- Published on |
- 10 min read
The Magic of Database Transactions: Understanding ACID
The $92 Million Bug: When Race Conditions Cost Real Money
In 2012, Knight Capital's trading algorithm had a race condition bug. For 45 minutes, it executed 4 million trades without proper transaction isolation. The result? 92 million in actual losses, and the company nearly went bankrupt.
The root cause? A lack of understanding about database transactions and ACID properties.
Transactions aren't just theoretical computer science—they're the invisible force that keeps your bank balance correct, prevents double booking airline seats, and ensures e commerce inventory is accurate. Get them wrong, and you lose money, users, or both.
This is your complete guide to mastering ACID transactions.
Part 1: What Are Transactions? The Foundation
1.1 The Restaurant Analogy
Imagine ordering dinner at a restaurant:
- You order a steak ($30)
- Restaurant deducts 1 steak from inventory
- Restaurant charges your card $30
- Kitchen starts cooking
What if the power goes out after step 2?
- ❌ Without transactions: Inventory decremented, but you weren't charged. Restaurant loses $30.
- ✅ With transactions: All 4 steps happen together or none happen. Either you get charged and get your steak, or nothing happens.
1.2 The Technical Definition
A transaction is a sequence of database operations that are treated as a single logical unit. Either all operations succeed (COMMIT), or all operations fail (ROLLBACK).
-- Start transaction
BEGIN;
-- Step 1: Deduct from inventory
UPDATE inventory SET quantity = quantity - 1 WHERE item = 'steak';
-- Step 2: Charge customer
INSERT INTO charges (customer_id, amount) VALUES (123, 30.00);
-- Step 3: Create order
INSERT INTO orders (customer_id, item, status) VALUES (123, 'steak', 'pending');
-- If everything succeeds
COMMIT;
-- If anything fails (power outage, constraint violation, etc.)
ROLLBACK;
Part 2: The A in ACID - Atomicity (All or Nothing)
2.1 The Core Principle
Atomicity guarantees that a transaction is treated as an indivisible unit. It either completes entirely or has no effect at all.
Real World Example: Bank Transfer
Scenario: Transfer $500 from Account A to Account B.
Without Atomicity (Disaster):
-- Step 1: Deduct from Account A
UPDATE accounts SET balance = balance - 500 WHERE account_id = 'A';
-- ✅ SUCCESS: Account A now has $500 less
-- Step 2: Add to Account B
UPDATE accounts SET balance = balance + 500 WHERE account_id = 'B';
-- ❌ DATABASE CRASH! (Power outage, server failure)
-- Result: $500 vanished into thin air. Account A lost money, Account B never received it.
With Atomicity (Safe):
BEGIN TRANSACTION;
-- Step 1: Deduct from Account A
UPDATE accounts SET balance = balance - 500 WHERE account_id = 'A';
-- Step 2: Add to Account B
UPDATE accounts SET balance = balance + 500 WHERE account_id = 'B';
-- If BOTH succeed → Money transfers correctly
COMMIT;
-- If EITHER fails → Everything rolls back, no money is lost
ROLLBACK;
2.2 How It Works: Write Ahead Logging (WAL)
Databases use a Write Ahead Log to ensure atomicity:
- Before changing data on disk, write the change to a log file
- If the database crashes mid transaction, replay the log on restart
- If the log shows an incomplete transaction, undo all changes
PostgreSQL Example:
-- Enable statement logging to see WAL in action
SET log_statement = 'all';
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE account_id = 'A';
-- Internally: PostgreSQL writes to pg_wal/000000010000000000000001
UPDATE accounts SET balance = balance + 500 WHERE account_id = 'B';
-- More WAL entries...
COMMIT;
-- WAL marks transaction as "committed"
Part 3: The C in ACID - Consistency (Rules Are Enforced)
3.1 The Core Principle
Consistency ensures that a transaction can only bring the database from one valid state to another. All rules, constraints, and triggers are enforced.
Real World Example: Inventory Management
Scenario: You can't have negative inventory.
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
name VARCHAR(255),
quantity INT CHECK (quantity >= 0) -- Constraint: No negative stock
);
-- Try to oversell
BEGIN;
UPDATE products SET quantity = quantity - 10 WHERE product_id = 1;
-- Current quantity: 5
-- Attempted new quantity: -5
-- Database enforces consistency
ERROR: new row for relation "products" violates check constraint "products_quantity_check"
ROLLBACK;
3.2 Foreign Key Constraints
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(user_id), -- Must exist in users table
product_id INT REFERENCES products(product_id)
);
-- Try to create order for non existent user
BEGIN;
INSERT INTO orders (user_id, product_id) VALUES (99999, 1);
-- user_id 99999 doesn't exist
ERROR: insert or update on table "orders" violates foreign key constraint
ROLLBACK;
3.3 Application Level Consistency: The Two Phase Pattern
Problem: Business rules are too complex for database constraints.
Solution: Check conditions before committing.
BEGIN;
-- Check: User has enough balance
SELECT balance FROM users WHERE user_id = 123 FOR UPDATE;
-- Returns: 100.00
-- Business rule: Can't spend more than you have
IF balance < 150.00 THEN
ROLLBACK;
RETURN 'Insufficient funds';
END IF;
-- Proceed with purchase
UPDATE users SET balance = balance - 150.00 WHERE user_id = 123;
INSERT INTO purchases (user_id, amount) VALUES (123, 150.00);
COMMIT;
Part 4: The I in ACID - Isolation (Transactions Don't Interfere)
4.1 The Core Principle
Isolation ensures that concurrent transactions don't interfere with each other. Even if 1,000 users are updating the database simultaneously, each transaction sees a consistent snapshot.
The Classic Race Condition: Double Booking
Scenario: Two users try to book the last airline seat simultaneously.
Without Isolation (Disaster):
-- User A's transaction (Time: 10:00:00.000)
SELECT seats_available FROM flights WHERE flight_id = 'AA100';
-- Returns: 1 seat available
-- User B's transaction (Time: 10:00:00.002)
SELECT seats_available FROM flights WHERE flight_id = 'AA100';
-- Returns: 1 seat available (same!)
-- User A books the seat (Time: 10:00:00.010)
UPDATE flights SET seats_available = seats_available - 1 WHERE flight_id = 'AA100';
INSERT INTO bookings (user_id, flight_id) VALUES ('A', 'AA100');
COMMIT;
-- User B books the seat (Time: 10:00:00.012)
UPDATE flights SET seats_available = seats_available - 1 WHERE flight_id = 'AA100';
INSERT INTO bookings (user_id, flight_id) VALUES ('B', 'AA100');
COMMIT;
-- Result: 2 people booked the same seat. seats_available = -1. Airline disaster.
With Isolation (Safe):
-- User A's transaction
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT seats_available FROM flights WHERE flight_id = 'AA100' FOR UPDATE;
-- Locks the row. User B must wait.
-- Returns: 1
UPDATE flights SET seats_available = seats_available - 1 WHERE flight_id = 'AA100';
INSERT INTO bookings (user_id, flight_id) VALUES ('A', 'AA100');
COMMIT;
-- Lock released
-- User B's transaction (now executes)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT seats_available FROM flights WHERE flight_id = 'AA100' FOR UPDATE;
-- Returns: 0 (User A already booked it)
-- Business logic: "No seats available"
ROLLBACK;
4.2 Isolation Levels: The Trade off Spectrum
SQL defines 4 isolation levels, each with different trade offs between consistency and performance:
| Level | Dirty Reads | Non-Repeatable Reads | Phantom Reads | Performance |
|---|---|---|---|---|
| Read Uncommitted | ✅ Possible | ✅ Possible | ✅ Possible | 🚀 Fastest |
| Read Committed | ❌ Prevented | ✅ Possible | ✅ Possible | ⚡ Fast |
| Repeatable Read | ❌ Prevented | ❌ Prevented | ✅ Possible | 🐢 Slower |
| Serializable | ❌ Prevented | ❌ Prevented | ❌ Prevented | 🐌 Slowest |
4.2.1 Read Uncommitted: The Wild West
Problem: You can see changes from other transactions before they commit.
-- Transaction A
BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
UPDATE accounts SET balance = balance + 1000000 WHERE user_id = 1;
-- Not committed yet!
-- Transaction B (concurrent)
BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM accounts WHERE user_id = 1;
-- Returns: 1,000,500 (includes uncommitted change!)
-- Transaction A rolls back
ROLLBACK; -- Oops, that $1M was an error
-- Transaction B now has invalid data (dirty read)
Use Case: Rarely used. Maybe for approximate analytics where exact accuracy doesn't matter.
4.2.2 Read Committed: The Default
Guarantee: You only see committed data. Most databases default to this.
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE user_id = 1;
-- Returns: 500
-- Another transaction commits a change to user 1's balance
-- (balance is now 600)
SELECT balance FROM accounts WHERE user_id = 1;
-- Returns: 600 (non repeatable read - value changed during transaction)
COMMIT;
Use Case: General purpose web applications, most CRUD operations.
4.2.3 Repeatable Read: Snapshot Isolation
Guarantee: All reads within a transaction see the same snapshot.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE user_id = 1;
-- Returns: 500
-- Another transaction changes balance to 600 and commits
SELECT balance FROM accounts WHERE user_id = 1;
-- Still returns: 500 (repeatable read - same as first read)
COMMIT;
Use Case: Report generation, financial calculations where consistency matters.
4.2.4 Serializable: Total Isolation
Guarantee: Transactions execute as if they ran one at a time (serially).
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 1;
-- Returns: 5
-- Another transaction inserts a new order for user 1 and commits
SELECT COUNT(*) FROM orders WHERE user_id = 1;
-- Still returns: 5 (prevents phantom reads)
COMMIT;
Use Case: Banking transactions, inventory management where accuracy is critical.
Trade off: Can cause serialization failures (transactions abort and must retry).
Part 5: The D in ACID - Durability (Permanent Storage)
5.1 The Core Principle
Durability guarantees that once a transaction is committed, it survives crashes, power failures, or hardware failures.
Real World Example: E Commerce Purchase
BEGIN;
-- User completes checkout
INSERT INTO orders (user_id, total) VALUES (123, 299.99);
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 456;
COMMIT;
-- At this moment, the database GUARANTEES this data is on disk
-- 1 millisecond later: Server catches fire 🔥
-- When database restarts: Order is still there!
5.2 How It Works: WAL and fsync
Step by step durability process:
- Write to WAL (Write Ahead Log): Transaction changes are written to a sequential log file
- fsync to Disk: Call
fsync()to ensure WAL is physically on disk (not just in OS cache) - Return "COMMIT" to client: Only after fsync succeeds
- Later: Apply WAL changes to actual data files (can be done lazily)
PostgreSQL Configuration:
-- Check durability settings
SHOW fsync; -- Should be 'on' in production
SHOW synchronous_commit; -- 'on' for full durability
-- Trade off: Disable for testing (NEVER in production)
SET synchronous_commit = off;
-- Commits return faster, but data could be lost in a crash
5.3 Durability Trade offs: Performance vs. Safety
Full Durability (Safe but Slow):
SET synchronous_commit = on;
-- Every COMMIT waits for fsync
-- Throughput: ~1,000 commits/second
Async Commit (Fast but Risky):
SET synchronous_commit = off;
-- COMMIT returns immediately (fsync happens in background)
-- Throughput: ~50,000 commits/second
-- Risk: Last few seconds of commits could be lost in a crash
Part 6: Real World Case Studies
6.1 Case Study: Stripe Payment Processing
Requirement: Process credit card charge + create invoice. Both must succeed or fail together.
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Step 1: Charge the credit card (external API call)
-- (In practice, this is an idempotent API request)
INSERT INTO charges (customer_id, amount, status)
VALUES ('cust_123', 5000, 'processing');
-- Step 2: Create invoice record
INSERT INTO invoices (customer_id, charge_id, amount)
VALUES ('cust_123', 'ch_456', 5000);
-- Step 3: Update customer balance
UPDATE customers SET balance = balance + 5000 WHERE customer_id = 'cust_123';
-- If any step fails (network error, constraint violation, etc.)
-- Everything rolls back. No partial charges.
COMMIT;
Key Insight: Even external API calls need idempotency tokens to work with transactions.
6.2 Case Study: Twitter's Follower Count Race Condition
Problem (Pre-2010): Follower counts were inconsistent due to race conditions.
Race Condition:
-- User A's follower count: 1000
-- Transaction 1: User B follows A
SELECT followers FROM users WHERE user_id = 'A'; -- Returns: 1000
UPDATE users SET followers = 1000 + 1 WHERE user_id = 'A';
-- Transaction 2: User C follows A (concurrent)
SELECT followers FROM users WHERE user_id = 'A'; -- Returns: 1000 (stale!)
UPDATE users SET followers = 1000 + 1 WHERE user_id = 'A';
-- Result: 2 new followers, but count only increased by 1
Solution: Atomic Increment:
BEGIN;
UPDATE users SET followers = followers + 1 WHERE user_id = 'A';
-- No race condition - database locks the row during increment
COMMIT;
6.3 Case Study: Uber's Ride Matching Deadlock
Problem: Two drivers accepting the same ride request simultaneously.
Deadlock Scenario:
-- Driver A's transaction
BEGIN;
UPDATE ride_requests SET status = 'matched', driver_id = 'A' WHERE request_id = 1;
UPDATE drivers SET current_ride = 1 WHERE driver_id = 'A';
-- Waiting for lock on drivers table...
-- Driver B's transaction (concurrent)
BEGIN;
UPDATE drivers SET current_ride = 1 WHERE driver_id = 'B';
-- Got the lock first!
UPDATE ride_requests SET status = 'matched', driver_id = 'B' WHERE request_id = 1;
-- Waiting for lock on ride_requests table...
-- DEADLOCK! Both transactions stuck waiting for each other
ERROR: deadlock detected
Solution: Consistent Lock Order:
-- Always lock tables in the same order
BEGIN;
-- Lock ride_requests FIRST (alphabetically)
UPDATE ride_requests SET status = 'matched', driver_id = 'A' WHERE request_id = 1;
-- Then lock drivers
UPDATE drivers SET current_ride = 1 WHERE driver_id = 'A';
COMMIT;
Part 7: Advanced Patterns and Anti Patterns
7.1 The SELECT FOR UPDATE Pattern
Use Case: Read a value, perform calculation, then update based on that value.
BEGIN;
-- Lock the row for update
SELECT balance FROM accounts WHERE user_id = 123 FOR UPDATE;
-- No other transaction can modify this row until we commit
-- Perform calculation
IF balance >= 100 THEN
UPDATE accounts SET balance = balance - 100 WHERE user_id = 123;
INSERT INTO withdrawals (user_id, amount) VALUES (123, 100);
END IF;
COMMIT;
7.2 Optimistic Locking: The Version Number Pattern
Problem: You don't want to lock rows for long (e.g., user edits a form for 5 minutes).
Solution: Use a version column.
-- Table with version tracking
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
name VARCHAR(255),
price DECIMAL(10,2),
version INT DEFAULT 0
);
-- User reads product at version 5
SELECT product_id, name, price, version FROM products WHERE product_id = 1;
-- Returns: version = 5
-- User submits update
UPDATE products
SET name = 'New Name', price = 29.99, version = version + 1
WHERE product_id = 1 AND version = 5;
-- If 0 rows updated, someone else modified it (version is now 6)
-- Return "Conflict: Please refresh and try again"
7.3 The Two Phase Commit (Distributed Transactions)
Use Case: Transaction spans multiple databases (e.g., microservices).
-- Phase 1: Prepare
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE account_id = 'A';
PREPARE TRANSACTION 'transfer_123';
-- On second database
BEGIN;
UPDATE accounts SET balance = balance + 500 WHERE account_id = 'B';
PREPARE TRANSACTION 'transfer_123';
-- Phase 2: Commit (if both prepared successfully)
COMMIT PREPARED 'transfer_123'; -- On database 1
COMMIT PREPARED 'transfer_123'; -- On database 2
Trade off: Slow and complex. Modern systems prefer eventual consistency (Saga pattern).
Part 8: Performance Optimization
8.1 Keep Transactions Short
Bad (Long Transaction):
BEGIN;
SELECT * FROM orders WHERE user_id = 123;
-- Send data to application
-- Application processes for 30 seconds
-- (Database holds locks the entire time!)
UPDATE orders SET status = 'processed' WHERE order_id = 456;
COMMIT;
Good (Short Transaction):
-- Fast read (no transaction needed)
SELECT * FROM orders WHERE user_id = 123;
-- Application processes data (no DB locks held)
-- Quick transactional update
BEGIN;
UPDATE orders SET status = 'processed' WHERE order_id = 456;
COMMIT;
8.2 Use the Lowest Isolation Level Possible
-- For analytics (no consistency needed)
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- For most web apps
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Only for critical operations
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Conclusion: The ACID Mindset
Transactions aren't just a database feature—they're a way of thinking about data integrity.
The Golden Rules:
- ✅ Use transactions for multi step operations
- ✅ Choose the right isolation level for your use case
- ✅ Keep transactions as short as possible
- ✅ Always handle rollback scenarios
- ✅ Lock resources in consistent order to avoid deadlocks
- ✅ Use optimistic locking for long user interactions
- ❌ Don't hold locks during network I/O or user input
- ❌ Don't use SERIALIZABLE unless absolutely necessary
The Final Truth: ACID transactions are the foundation of data integrity. Master them, and you'll never lose money to race conditions again.
Mustafiz Kaifee
@mustafiz_kaifee