Transactions and contention: the last ticket problem
Ten thousand people want the last hundred tickets. Two bidders raise the same auction in the same millisecond. Two doctors both take themselves off call. Whenever many requests compete for the same data, a design that works perfectly one request at a time can oversell, double-book or lose updates. This module shows exactly how those bugs happen, and the toolbox that prevents them, from the simplest fix to the heaviest.
1. The idea
The bug is almost always the same shape: read → decide → write, where another request changes the data between the read and the write. The decision was made on information that's no longer true. This is a race condition, and the most common kind in databases is the lost update.
A transaction groups reads and writes so they commit together (atomicity), but on its own it doesn't stop two transactions reading the same value at the same time. How much transactions are protected from each other is the isolation level:
| Isolation level | Dirty read | Non-repeatable read | Phantom | Lost update / write skew |
|---|---|---|---|---|
| Read uncommitted | possible | possible | possible | possible |
| Read committed (PostgreSQL default) | no | possible | possible | possible |
| Repeatable read / snapshot (MySQL InnoDB default) | no | no | depends on the database | write skew possible |
| Serializable | no | no | no | no |
- Dirty read: seeing another transaction's uncommitted change.
- Non-repeatable read: reading the same row twice in a transaction and getting different values.
- Phantom: re-running a query and finding new rows that appeared meanwhile.
- Write skew: two transactions each read the same rows, each make a decision that's valid alone, and write different rows, breaking a rule together (both on-call doctors sign off).
At the default isolation of most databases, a plain read-then-write is not safe under contention. You must use one of the tools below.
2. How it works
A lost update, step by step
The toolbox, simplest first
| Tool | How | Use when |
|---|---|---|
| Conditional (atomic) write | UPDATE events SET left = left - 1 WHERE id = 7 AND left > 0, then check how many rows changed (0 = sold out) | The rule can be written as a condition on the row being changed. Start here. |
| Unique constraint | UNIQUE (seat_id) on bookings: the second insert fails | "At most one of these" rules: one booking per seat, one account per email |
| Pessimistic lock | SELECT … FOR UPDATE locks the rows; others wait until commit | You must read, decide in code (e.g. find 4 adjacent seats), then write; conflicts are frequent |
| Optimistic concurrency (OCC) | Read a version; write … WHERE id = 7 AND version = 41; if 0 rows changed, re-read and retry | Conflicts are rare; no locks are held while the user thinks |
| Serializable isolation | The database detects conflicting transactions and aborts one; the app retries | The rule spans several rows (write skew) and is hard to express as one condition |
| Hold with expiry (a lease) | UPDATE seats SET held_by = $u, held_until = now() + '10 min' WHERE id = $s AND (held_until IS NULL OR held_until < now()) | The claim must last longer than one transaction: a seat held while the user pays |
| Serialize through a queue | All requests for one hot item go to one queue and one worker, processed in order | Extreme contention on a single item (a huge flash sale); see Design D24 |
Locks without deadlocks
Transaction 1 locks account A then wants B; transaction 2 locks B then wants A. Each waits for the other forever: a deadlock. Databases detect it and abort one (the app must retry). Prevent it by always locking in the same order, for example by ascending ID, and keep lock-holding transactions short: never call a payment provider or wait for a user while holding a lock.
Guard the thing people actually fight over
A counter "seats left = 1" proves a seat is free, not that seat 14C is. If people pick specific seats, each seat needs its own row and its own guard. Model the contended thing as one row (or key) the database can protect, and keep it in one database: contention across databases or services becomes a distributed transaction (Module 22).
3. Try it yourself
Buyers arrive within a few milliseconds of each other for the last few tickets. Each one reads the stock, spends a few milliseconds deciding, then writes. Run it with each strategy.
Simulation: each buyer reads at a random moment in the window, then writes 5–30 ms later. With row locks, a buyer who finds the row locked waits until it's released. With OCC, a buyer whose version is stale re-reads and tries again (up to 5 times).
4. Trade-offs
Pessimistic vs optimistic
Locks make everyone wait in line: predictable, but slow under heavy contention and prone to deadlocks. OCC never waits, but under heavy contention most attempts fail and retry, wasting work.
Stronger isolation costs throughput
Serializable is the simplest to reason about, but aborts and retries rise with contention, and not every database offers it efficiently.
Holds improve the experience
Reserving a seat for 10 minutes means the conflict happens when the user picks a seat, not after they've typed in a card number. The expiry makes abandoned holds free themselves.
Change the problem first
A single row everyone writes to can't be scaled by more servers. Can likes be counted approximately? Can 10,000 identical tickets be 100 pools of 100? Remove the contention before managing it.
5. In real systems
- PostgreSQL defaults to Read Committed. Its Repeatable Read is snapshot isolation; its Serializable level uses Serializable Snapshot Isolation, which aborts transactions that would break serializability, with error code 40001 for the app to retry.
- MySQL's InnoDB defaults to Repeatable Read and uses gap locks to prevent some phantoms.
- Equivalents outside SQL: DynamoDB condition expressions and transactions, Redis
SET key value NX EX 30orWATCH/MULTI, Cassandra lightweight transactions (IF NOT EXISTS), etcd's compare-and-swap on revisions, and HTTP'sIf-Matchwith ETags (Module 07). - Distributed locks (Redis, ZooKeeper, etcd) coordinate work across services, but a lock that expires while its holder is paused can be held by two clients at once. For correctness, pair it with fencing tokens checked by the storage (Module 21).
- Ticketing at scale combines several tools: a virtual waiting room to limit concurrency, seat holds with expiry, and a conditional write at purchase (Design D15).
The tools up close
a conditional update on the seat row, and a 10-minute hold so payment doesn't race.Don't overcomplicate: no Redis lock where a row lock or OCC works.
| Prompt | Approach |
|---|---|
| Auction | Optimistic concurrency using the current high bid as the version (it only goes up, so no ABA) |
| Ticketmaster | A 10-minute seat hold with expiry, then a conditional write at purchase |
| Bank transfer (one database) | Pessimistic locks in ascending account-ID order, or OCC; across services → a saga |
| Ride dispatch | Set the driver to pending_request with a ~10-second expiry so two riders can't get the same driver |
| Flash sale | OCC or a conditional decrement on inventory, plus cart holds with a TTL |
| Ratings average | OCC with a version column when updating the average |
Conditional (atomic) write
The default · start herePut the rule in the WHERE clause of the write itself, so checking and changing happen as one step.
How it works
UPDATE events SET left = left - 1 WHERE id = 7 AND left > 0, or claim a seat with … WHERE status = 'available'. Then check rows affected: 0 means someone else won, which isn't an error. Chain follow-up inserts to it in the same statement or transaction (WITH … RETURNING).
Status today
Works everywhere: SQL, DynamoDB condition expressions, Redis SET NX, Cassandra lightweight transactions, HTTP If-Match.
Use it when
- The rule is a condition on the row being written: a counter, a status, a claim
Skip it when
- The decision needs application logic or other rows (find 4 adjacent seats)
In the interview
Purchase is
UPDATE seats SET status = 'sold' WHERE id = ? AND status = 'held' AND held_by = ?; zero rows means it's gone.
- Read, then decide in code, then write without a guard: the classic lost update.
- A counter ("seats left") guarding specific seats. Guard the thing people fight over: one row per seat.
Optimistic concurrency (OCC)
Situational · conflicts are rareRead a version, do your work without locks, and write only if the version hasn't changed; otherwise re-read and retry.
How it works
UPDATE … SET …, version = version + 1 WHERE id = 7 AND version = 41; 0 rows → someone else wrote first. The ABA problem: if you check a business value instead of a version, it can go A → B → A and your check wrongly passes. Use a dedicated counter that only increases, or a value that only moves one way (an auction's high bid).
Status today
Standard: version columns, HTTP ETag/If-Match, etcd revisions, DynamoDB version attributes, JPA/Hibernate @Version.
Use it when
- Conflicts are rare; users think for a while between read and write
- Auctions, edits to a document's metadata, rating averages
Skip it when
- High contention: most attempts fail and retry storms waste work
In the interview
Bidders compete, so OCC with the high bid as the version: a bid only succeeds if the high bid is still what you saw.
- OCC under heavy contention (a ticket drop): retries pile up.
- Using a value that can return to an old state as the version (ABA).
Pessimistic locks
Situational · high contention with app logicLock the rows first (SELECT … FOR UPDATE), decide, update, commit; others wait in line.
How it works
Lock as little as possible for as short as possible. Never call a payment provider or wait for a user while holding a lock. Avoid deadlocks by always locking in a fixed order (ascending ID); the database still detects deadlocks, so catch the error and retry.
Status today
Built into every relational database.
Use it when
- Read, decide in code, write, with frequent conflicts (find 4 adjacent seats, transfer between two accounts)
Skip it when
- Rare conflicts (every request pays for the lock)
- Holds that must outlast one transaction (use a lease)
In the interview
The transfer locks both accounts with
FOR UPDATE in ascending ID order, so two opposite transfers can't deadlock.
- Slow I/O inside the lock (a payment API call).
- Locking in "initiator first" order: two opposite transfers deadlock.
Serializable isolation
Situational · rules across rowsThe database makes concurrent transactions behave as if they ran one at a time, aborting one when they conflict.
How it works
Catches write skew: two on-call doctors each check "is someone else on call?", both see yes, both leave. No single row conflicts, so only Serializable (or moving the rule onto one row you can lock) catches it. The app must retry aborted transactions.
Status today
PostgreSQL's Serializable uses Serializable Snapshot Isolation (retry on error 40001); support and cost vary across databases, and it's mostly unavailable outside relational ones.
Use it when
- Rules that span several rows and are hard to express as one condition
Skip it when
- Hot paths: aborts and tracking get expensive under contention
In the interview
The on-call rule spans rows, so either Serializable with retries, or a count on the team row that we lock.
- Assuming the default isolation level catches write skew. Only Serializable does.
Distributed locks and leases
Situational · holds that outlast a transactionA lock with an expiry, held across steps or services: a 10-minute seat hold, a job only one worker may run.
How it works
| Where | How | Trade-off |
|---|---|---|
| Redis | SET key owner NX EX 600 | Fast and self-expiring; a stalled holder can briefly overlap another. Fine for soft holds, not correctness |
| Database columns | UPDATE seats SET held_by = ?, held_until = now() + '10 min' WHERE id = ? AND (held_until IS NULL OR held_until < now()) | No new infrastructure; expired holds count as free without cleanup |
| ZooKeeper / etcd | Consensus-backed leases; ephemeral nodes vanish when the client dies | Most robust; another cluster to run. Pair with fencing tokens (Module 21) |
Status today
All three are common; database-column holds are the simplest correct choice for seat-style reservations.
Use it when
- Reservations (seat or cart holds)
- Leader-only jobs, coordinating work across services
Skip it when
- A single-row guard inside one transaction already does the job
In the interview
I'll reserve the seat for 10 minutes with held_until on the seat row, so users don't lose it after typing a card number.
- A Redis lock where a row lock or OCC would do: more infrastructure, weaker guarantee.
- A lock without expiry: a crashed holder blocks everyone forever.
Queue-based serialization
Rare · everyone wants one itemSend every request for one hot item through one queue with a single worker, so they're processed strictly one at a time.
How it works
Sharding, replicas and load balancing don't help one row. First try to change the problem: split 10,000 identical tickets into 100 pools, or make likes and follows eventually consistent. If one item really must be serialised, a dedicated queue and worker absorb the spike, at the cost of a throughput ceiling and a standby for the worker.
Status today
Used for extreme single-item contention (limited drops, some exchanges' order books per instrument).
Use it when
- A single item gets a burst no database row could handle
Skip it when
- Normal contention: conditional writes are simpler
In the interview
For the drop, requests for that product go through one ordered queue and a single worker; everything else stays normal.
- Adding servers or replicas to fix one hot row. They all still write to that row.
6. Remember it
How this hook works: what "Check and change in one step" means
What the slogan means, phrase by phrase
- "Check"
- The decision: is a ticket left, is the bid higher, is the seat free?
- "and change"
- The write that acts on the decision.
- "in one step"
- Nothing else may change the data between the two. A conditional write does both in one statement; a lock makes others wait; OCC detects interference and starts again.
Why this picture fits: at the shop till, "is it still there?" and "it's yours" happen in one motion. That's the whole idea.
The story → the concept
| At the cake shop | In the database |
|---|---|
| Seeing the cake in the window | Reading the stock |
| Walking to the till | The gap where another transaction can act |
| "Sorry, just sold" at the till | A conditional write affecting 0 rows |
| Putting your hand on the cake | A row lock (FOR UPDATE) |
| "Is it still the same cake I saw?" | An optimistic version check |
| "I'll hold it for you for 10 minutes" | A hold with expiry |
Watch it on an example
- An auction service reads the high bid ($90), checks the new bid ($95) is higher, writes $95.
- A second bidder does the same with $92 at the same moment and writes after: the high bid is now $92, lower than an accepted bid.
- Fix in one step:
UPDATE auctions SET high_bid = 95, bidder = $u WHERE id = 7 AND high_bid < 95. - The $92 bid's update now affects 0 rows, and the service tells that bidder they've been outbid.
The trap it saves you from
Code that's correct when you test it alone and wrong in production, because tests rarely run two requests in the same millisecond.
It still works when…
The data is in a cache or another service: Redis SET NX, DynamoDB conditions and HTTP If-Match are all "check and change in one step".
7. Common mistakes
8. Design exercise (25 minutes)
- Find the races in a group-buy app: "the deal unlocks when 100 people join", "each person may join once", "stock is 500 units". Write the guard for each.
- Write skew: a hospital rule says at least one doctor must be on call. Two doctors sign off at the same time. Show how it breaks at Read Committed, and two ways to prevent it.
- Seat holds: write the SQL for holding a seat for 10 minutes, and for buying it only if you hold it.
- Use the simulator with 500 buyers, 20 tickets and a 20 ms window. Compare locks and OCC: which has more retries, which more waiting?
- Write it down: the toolbox table, in order.
9. Self-check quiz
Simplest safe way to sell one of the remaining tickets?
Conflicts are rare and users may edit a document for minutes. Which approach?
How do you prevent deadlocks between transfers?
Two transactions each read the same rows and update different rows, together breaking a rule. What is it?
An OCC check uses the item's status as the version. It goes available → held → available while you think, and your write succeeds. What's this called?
Ten thousand people want one limited-edition item at the same second. More app servers and replicas…
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Designing Data-Intensive Applications, chapter 7 (Transactions) | 2 h | Isolation levels, lost updates, write skew and serializability, with clear diagrams. |
| Transaction isolation and explicit locking (PostgreSQL docs) | 45 min | Exactly what each level and lock mode does in a real database. |
| Dealing with contention (Hello Interview) | 25 min | The interview version, with ticketing, auctions and flash sales. |
| Jepsen analyses | browse | Real databases tested against their isolation claims, and where they fell short. |
11. Under the hood (optional)
How databases really isolate and commit transactions, and why the same level name means different things. Not needed for most interviews; open it when you want to know how the products really do it. All the deep dives are listed on the Under the hood page.
Transactions and isolationIsolation levels product by product, 2PL vs MVCC vs SSI vs OCC, where old versions live, WAL and recovery, 2PC and Percolatorabout 40 min
"Repeatable read" means something different in PostgreSQL, MySQL and Oracle, and "serializable" in Oracle isn't serializable at all. This deep dive shows what each level really prevents, how databases implement it (locks, versions, validation), where old row versions are kept, how a commit survives a crash, and how a transaction spans several machines.
Three races, three isolation levels
Pick a race and a PostgreSQL isolation level to see what two concurrent transactions do. Each row of the timeline is one moment; T1 and T2 run at the same time.
Two lessons: snapshot isolation is not serializable (write skew and "check, then insert" phantoms slip through), and stronger levels work by aborting one transaction, so the application must retry on serialization errors. Constraints (unique, exclusion, foreign keys) protect specific rules at any level.
What the names really mean
| Product | Default | Its "repeatable read" | Its "serializable" |
|---|---|---|---|
| PostgreSQL | Read committed | Snapshot isolation; also blocks phantoms; aborts on a concurrent update of the same row | Serializable snapshot isolation (SSI): truly serializable, by aborting |
| MySQL InnoDB | Repeatable read | Plain SELECTs read a snapshot, but locking reads and UPDATEs see the latest committed rows and take next-key locks | Two-phase locking: plain SELECTs become shared-lock reads (when autocommit is off) |
| Oracle | Read committed | (Not offered) | Actually snapshot isolation: write skew is possible |
| SQL Server | Read committed with locks (row versioning with RCSI; on by default in Azure SQL Database) | Locks held until commit; phantoms possible | Range locks (two-phase locking); SNAPSHOT is a separate level |
| CockroachDB | Serializable | Read committed also available | Serializable, with automatic retries where it can |
| Spanner | Serializable | External consistency: serializable and in real-time order (TrueTime, Module 20) |
PostgreSQL accepts "read uncommitted" but treats it as read committed: dirty reads can't happen there at all.
How isolation is implemented
| Technique | How | Cost | Used by |
|---|---|---|---|
| Two-phase locking (2PL) | Take shared locks to read and exclusive locks to write; hold them until commit. Range or next-key locks cover rows that don't exist yet (phantoms). | Readers and writers block each other; deadlocks happen, so the database detects them and aborts one; many row locks may be escalated to a table lock | SQL Server (default), InnoDB serializable and locking reads, Db2 |
| MVCC + snapshot isolation | Each transaction reads a snapshot as of its start; writers create new versions. Two writers to the same row: the first wins, the second aborts or waits. | Readers never block writers; old versions must be cleaned up; write skew allowed | PostgreSQL, Oracle, InnoDB, MongoDB, SQL Server SNAPSHOT |
| Serializable snapshot isolation (SSI) | Snapshot isolation plus tracking of read-write dependencies (PostgreSQL's non-blocking SIRead locks); a "dangerous structure" of two dependencies in a cycle aborts one transaction | Some false-positive aborts; memory for tracking | PostgreSQL serializable, CockroachDB (with its own variant) |
| Optimistic concurrency control (OCC) | Run without locks, then validate at commit that nothing read has changed; abort if it has | Wasted work under contention; great when conflicts are rare | FoundationDB (resolvers check conflicts at commit), many in-memory engines; application-level version columns |
Where old versions live
- PostgreSQL: every version is a full row in the table, stamped with the transaction that created it (
xmin) and the one that deleted or replaced it (xmax). VACUUM later reclaims versions no snapshot can still see. A transaction left open for hours holds back cleanup for the whole database, so tables and indexes bloat. Transaction IDs are 32-bit, so VACUUM must also "freeze" old rows to prevent wraparound. - InnoDB: the row is updated in place and the previous values go to the undo log. A reader with an older snapshot rebuilds the version it needs by walking the undo chain; a purge thread discards undo nobody needs. Long transactions make the history list (and reads that walk it) grow.
- Oracle: undo segments, versioned by system change number (SCN); a long query can fail with "snapshot too old" once the undo it needs is overwritten.
- SQL Server: versions go to a version store (in tempdb, or in the database with accelerated database recovery) only when snapshot-based isolation is turned on.
Durability: the log, group commit and recovery
- Write-ahead logging: before a changed page is written, the log record describing the change must be on disk. Commit means "the commit record is durable in the log"; the data pages are written later by checkpoints.
- Group commit: an fsync costs tens of microseconds to milliseconds, so the database flushes the log for many transactions at once, which is how it reaches thousands of commits a second.
- Recovery (ARIES, 1992, the basis of most engines): after a crash, analysis finds what was in flight, redo replays the log from the last checkpoint to repeat history, and undo rolls back transactions that never committed. Checkpoints bound how much log must be replayed, and therefore recovery time.
| Setting | Safe default | Faster, riskier | What you can lose |
|---|---|---|---|
PostgreSQL synchronous_commit | on | off | The last few hundred milliseconds of commits on a crash (no corruption) |
MySQL innodb_flush_log_at_trx_commit | 1 (flush every commit) | 2 or 0 (flush about once a second) | About a second of commits (with 2: only if the operating system or machine crashes) |
MySQL sync_binlog | 1 | 0 | Binary log events, so replicas can diverge |
| MongoDB write concern | w: "majority" (journaled) | w: 1 | Writes acknowledged by a primary that fails before replicating |
Redis appendfsync | everysec | no | About a second (or more) of writes |
Transactions across machines
- Two-phase commit (2PC): the coordinator asks every participant to prepare (make the change durable and promise to commit); if all say yes, it tells them to commit. The weakness: a participant that has prepared can't decide alone, so if the coordinator dies it stays in doubt, holding locks, until the coordinator returns. XA is the standard API for it.
- Making 2PC survivable: replicate the coordinator and every participant with consensus. Spanner runs 2PC across Paxos groups (plus commit wait); CockroachDB's parallel commits write the intents and a "staging" transaction record together, so a commit needs one round of consensus instead of two.
- Percolator (Google, 2010): transactions on top of Bigtable, with a timestamp oracle and one "primary" lock per transaction that decides its fate; other locks point at it. TiDB/TiKV use this model.
- Avoid it when you can: across services, use sagas and the outbox pattern (Module 22) rather than distributed transactions.
Product by product
| Product | Old versions | Transactions | The detail worth knowing |
|---|---|---|---|
| PostgreSQL | In the table (xmin/xmax) | Read committed default; RR = snapshot; serializable = SSI | VACUUM and long transactions decide bloat |
| MySQL InnoDB | Undo log | Repeatable read default; next-key locks for locking reads | Gap locks can block inserts into ranges and cause deadlocks |
| Oracle | Undo segments (SCN) | Read committed default; "serializable" = snapshot | "Snapshot too old" on long queries |
| SQL Server | Version store, when enabled | Locking read committed; RCSI and SNAPSHOT optional | Turning on RCSI stops readers blocking writers |
| MongoDB | WiredTiger keeps versions in memory | Single-document writes are atomic; multi-document transactions since 4.0, across shards since 4.2 | 60-second default limit; design documents so most writes touch one |
| CockroachDB | MVCC with hybrid-logical-clock timestamps | Serializable default; read committed available | Clock uncertainty can force a read to restart |
| Spanner | MVCC with TrueTime timestamps | Externally consistent | Commit wait of a few milliseconds |
| DynamoDB | Not exposed | Conditional writes per item; transactions of up to 100 items | Transactions cost twice the capacity of plain writes |
| FoundationDB | A short MVCC window | Serializable, optimistic | Transactions are limited to about 5 seconds |
| Redis | None | MULTI/EXEC runs commands in a batch without interleaving; WATCH adds an optimistic check; Lua scripts are atomic | No rollback: a failing command inside EXEC doesn't undo the others |
Say it in the interview
- "Is repeatable read enough to stop double booking?" Not if the check is "no overlapping booking exists" and two transactions insert different rows: that's a phantom that snapshot isolation allows. Use an exclusion or unique constraint, lock a parent row, or run serializable and retry.
- "Why is our PostgreSQL table bloated?" Long-running or idle-in-transaction sessions keep old versions alive, so VACUUM can't reclaim them. Find them, set timeouts, then let VACUUM catch up.
- "Why not a distributed transaction across the order and payment services?" 2PC blocks when the coordinator fails and couples availability of both services; a saga with an outbox and idempotent steps is the usual answer.
Two doctors both check that two are on call, and each takes themselves off. Under snapshot isolation both commit. What is this anomaly called?
Where does InnoDB keep the old version of a row a long-running reader still needs?
In two-phase commit, a participant has voted yes and the coordinator crashes. What happens to the participant?
Sources
- PostgreSQL: transaction isolation
- PostgreSQL: routine vacuuming
- PostgreSQL: write-ahead logging
- MySQL 8.4: isolation levels
- MySQL 8.4: InnoDB multi-versioning
- MySQL 8.4: InnoDB startup options and variables
- SQL Server: SET TRANSACTION ISOLATION LEVEL
- CockroachDB: read committed
- CockroachDB: transaction layer
- Percolator paper (Google, 2010)
- Serializable Snapshot Isolation in PostgreSQL (Ports and Grittner, 2012)
- DynamoDB: transactions
- MongoDB: transactions in production
- FoundationDB: known limitations
- Redis: transactions
Money and ledgersA live double-entry ledger lab with refunds and idempotent retries, money-handling rules, reconciliation, PCI scope, ledger databasesabout 15 min
Money systems are where consistency mistakes become real losses. The accounting world solved the core data model centuries ago: double-entry bookkeeping with an immutable journal. Payment systems add idempotency, reconciliation and careful handling of card data.
Double entry, hands on
Every transaction is recorded as entries that move money between accounts; debits always equal credits, nothing is ever edited, and balances are derived by adding up entries. Post some transactions:
The rules that keep money correct
- Amounts in minor units (integer cents) or exact decimals, with a currency on every amount; never floating point. Round once, at defined points, with a stated rule.
- Immutable entries: mistakes are corrected by new, reversing entries, so history explains every balance. Balances are a cached sum, recomputable from the journal.
- Idempotency keys: the client sends a unique key with each payment request; the server stores the key with the result and returns the same result on a retry instead of charging again (Stripe keeps keys for 24 hours). Store the key in the same transaction as the ledger entries.
- Balance checks under concurrency: "don't let the wallet go negative" needs a conditional update or a lock on the account row (Module 17), or a ledger database that enforces it.
- Reconciliation: every day, match your ledger against the bank's and the card processor's settlement files. Differences are found by people or rules, never silently ignored.
- Card data: storing card numbers puts your systems in scope for PCI DSS. Use the processor's tokenization so raw card numbers never touch your servers.
| Option | When |
|---|---|
| Ledger tables in PostgreSQL | The default: journal + entries tables, constraints and serializable or locked updates |
| A purpose-built ledger database (TigerBeetle) | Very high transaction rates with debits/credits and balance limits enforced by the database |
| A ledger service from a payments provider | When you'd rather buy than build (Modern Treasury, Stripe Treasury and similar) |
Say it in the interview
- "How do you store a wallet balance?" As the sum of immutable double-entry ledger lines, with a cached balance updated in the same transaction and a constraint or lock that prevents overdrafts.
- "The client timed out; did the payment go through?" It retries with the same idempotency key; the server returns the original result, so there's never a second charge.
A payment was recorded with the wrong amount. What do you do in a ledger?
What invariant must every transaction in a double-entry ledger satisfy?