Round two: “two requests, same wallet”
The interviewer has stopped asking what to build and started asking what breaks. This round is about two things happening at once, which is where most money bugs live. We will go down into MVCC far enough to say what the database is physically doing when it isolates a transaction, choose between pessimistic and optimistic control per operation rather than per system, and finish with the retry problem, which is where the phrase "exactly-once" gets retired for good.
The pressure: concurrent debits
“Two transfer requests hit the same wallet at the same millisecond. Walk me through what happens. And what if the client times out and retries?”
Two distinct problems wearing one coat, and separating them is the first move:
- Two different operations touch one row at once.
- The risk is a lost update or a write skew: both succeed when only one should.
- The tool is isolation, provided by locks or by version visibility.
- The same operation arrives twice, because a client retried.
- The risk is double posting: money moved twice for one intent.
- The tool is idempotency, provided by a unique key on the intent.
Requirements this round adds
- Every write operation accepts a client-supplied idempotency key.
- Replaying a request with the same key returns the original result and moves no money.
- Replaying a key with a different body is rejected as a conflict.
- Concurrent transfers on one wallet must serialise, never interleave destructively.
- A wallet must sustain ~20 concurrent writes without deadlocking.
- Lock hold time stays under 10 ms, so contention cannot cascade into timeouts.
Isolation levels, and what each one permits
Isolation is defined by the anomalies it forbids. The useful way to hold this in your head is as a ladder of things that stop being possible as you climb, at increasing cost.
Write skew, since it is the one people cannot name
Not hypothetical for a bank. Suppose a rule says a customer's combined balance across two wallets must stay at or above zero, and each wallet holds ₦500:
-- T1: withdraw 800 from wallet A -- T2: withdraw 800 from wallet B SELECT sum(bal) FROM wallets SELECT sum(bal) FROM wallets WHERE customer = 'c1'; -- 1000 WHERE customer = 'c1'; -- 1000 -- 1000 >= 800, allowed -- 1000 >= 800, allowed UPDATE wallets SET bal = bal - 800 UPDATE wallets SET bal = bal - 800 WHERE id = 'A'; WHERE id = 'B'; COMMIT; COMMIT; -- combined balance is now -600. Neither transaction did anything wrong -- individually. They each read a state the other then invalidated.
Note that no row was written by both transactions, so row locks do not help and REPEATABLE READ does not catch it. The fixes are SERIALIZABLE, an explicit lock on something both transactions must touch (the customer row), or restructuring the invariant so it lives on a single row.
Inside MVCC: how the snapshot is built
Isolation levels are the contract. MVCC is the mechanism. Being able to explain it is the difference between quoting documentation and understanding the system.
The core idea: an update never overwrites a row in place. It writes a new version of the row and marks the old one as expired. Readers then pick the version that was visible when their snapshot was taken, which is why readers never block writers and writers never block readers.
The three things stored on every row
| Field | Meaning | Set when |
|---|---|---|
xmin | The transaction id that created this version | On insert or update |
xmax | The transaction id that expired it, or 0 if still live | On update or delete |
ctid | Physical location, and the pointer to the newer version | Maintained by the engine |
What a snapshot actually is
Not a copy of the data. A snapshot is a small descriptor of which transactions to believe:
snapshot = { xmin: oldest still-running txid,
xmax: next txid to be assigned,
xip_list: txids in flight right now }
a row version is visible to me if:
its xmin committed, and xmin is not in my xip_list, and xmin < my xmax
and its xmax is unset, aborted, or still in flight per my listThis is why the isolation levels differ so cheaply. READ COMMITTED takes a new snapshot per statement. REPEATABLE READ takes one per transaction. Same machinery, different refresh point, which is a satisfying thing to be able to say out loud.
The costs MVCC brings, which you should name
| Cost | Mechanism | Consequence for a ledger |
|---|---|---|
| Bloat | Dead versions occupy pages until vacuumed. | Our entries table is append-only, so it produces almost no dead tuples. This is a real advantage of never updating rows. |
| Vacuum pressure | Autovacuum must reclaim space and advance the freeze horizon. | A long-running analytics query holds the horizon back and blocks reclamation, which is one more reason Part 11 moves analytics off this database entirely. |
| Transaction id wraparound | Txids are 32-bit and must be frozen before they lap. | At 10M transactions a day this is a real operational concern, and it belongs on the monitoring list in Part 13. |
| Update amplification | Every update rewrites the row and every index entry pointing at it. | Another argument for append-only entries over a mutable balance column: one insert instead of an insert plus an update. |
Pessimistic: SELECT FOR UPDATE and the lock manager
Pessimistic control assumes conflict is likely and takes the lock before doing the work.
BEGIN; -- acquires an exclusive row lock, held until COMMIT or ROLLBACK. -- a second transaction reaching this line blocks here. SELECT * FROM accounts WHERE id = $1 FOR UPDATE; -- now safe: nobody else can modify this row while we compute -- ... application logic, limit checks, fee derivation ... INSERT INTO entries (...) VALUES (...); COMMIT; -- lock released here
What the lock manager is doing
The lock is not stored on the row itself in the way people imagine. Postgres records the locking transaction id in the tuple header, and maintains a shared in-memory structure of lock requests:
1. T1 requests exclusive on row R → granted, xmax = T1 on the tuple
2. T2 requests exclusive on row R → conflict detected
3. T2 is put on R's wait queue and sleeps on a semaphore
4. T1 commits → lock released → T2 woken
5. T2 re-checks visibility and proceeds against the new version
step 5 is the important one: T2 does not act on the stale value it
would have read earlier, because it re-resolves the row afterwards
The variants, and when each is right
| Clause | Behaviour | Use for |
|---|---|---|
FOR UPDATE | Exclusive. Blocks other FOR UPDATE, FOR SHARE and writes. | The default when you will write the row. |
FOR NO KEY UPDATE | Weaker exclusive; permits foreign-key share locks. | Reduces contention when child rows reference this one. |
FOR SHARE | Shared. Blocks writers, permits other readers to share. | Reading a row you need to stay stable without intending to write it. |
NOWAIT | Raises an error immediately instead of queueing. | Interactive paths where a fast failure beats a slow success. |
SKIP LOCKED | Silently omits locked rows from the result. | Queue workers. Several consumers claim disjoint work with no coordination. We use this in Part 6 for the settlement outbox. |
The cost, stated honestly
A held lock serialises every other writer to that row for its entire duration, and the duration is your transaction, not your statement. Two rules keep this survivable:
- Never hold a lock across a network call. No HTTP request, no queue publish, no third-party API inside the transaction.
- Acquire as late as possible and commit as soon as possible. Validate, price and authorise before the lock.
- Set a statement timeout so a pathological lock wait fails fast rather than exhausting the connection pool.
SET statement_timeout = '3s'; -- kill runaway statements SET lock_timeout = '500ms'; -- do not queue behind a lock forever SET idle_in_transaction_session_timeout = '10s'; -- reap abandoned txns
Optimistic: version columns and CAS
Optimistic control assumes conflict is rare, takes no lock, and detects interference at write time.
-- 1. read, including the version SELECT id, balance, version FROM accounts WHERE id = $1; -- → balance 1000, version 7 -- 2. compute freely in the application. No lock is held. -- 3. write, asserting nothing changed underneath UPDATE accounts SET balance = 400, version = 8 WHERE id = $1 AND version = 7; -- 0 rows affected ⇒ somebody moved first ⇒ re-read and retry
This is compare-and-swap, the same primitive as an atomic CPU instruction, expressed in SQL. The write succeeds only if the world matches what you assumed.
The retry loop, written correctly
async function withRetry<T>(op: () => Promise<T>, tries = 3): Promise<T> {
for (let i = 0; i < tries; i++) {
try { return await op(); }
catch (e) {
if (!isConflict(e) || i === tries - 1) throw e;
// full jitter: avoids a retry stampede aligning on the same instant
await sleep(Math.random() * 50 * 2 ** i);
}
}
throw new Error('unreachable');
}The jitter matters more than the backoff. Without it, every conflicted transaction retries at the same moment and you have rebuilt the contention you were escaping.
Head to head
| Pessimistic | Optimistic | |
|---|---|---|
| Cost when uncontended | A lock acquisition, and blocking others | Nearly nothing |
| Cost when contended | Queueing, bounded and fair | Wasted work plus retries, potentially unfair |
| Behaviour under high contention | Degrades to a queue | Degrades badly: livelock risk |
| Works across service boundaries | No, you cannot hold a DB lock over HTTP | Yes |
| Application complexity | Low | Retry logic required |
| Deadlock risk | Real, needs write ordering | None |
Choosing per-operation, not per-system
The answer that earns credit is not "we use optimistic locking". It is a mapping from operation to mechanism, justified by expected contention.
| Operation | Contention | Mechanism | Why |
|---|---|---|---|
| Customer wallet debit | Low. One human, occasionally two devices. | Conditional write, no explicit lock | The check fuses into the insert. No round trip, no retry logic, and single-statement atomicity is guaranteed at every isolation level. |
| Company or fee account credit | Extreme. Every transaction touches it. | Neither. Restructure: sharded sub-accounts | No locking strategy fixes a row that 5,600 transactions a second want. The fix is to stop having one row. Chapter 39. |
| Loan repayment schedule update | Low, but multi-step with logic between read and write. | Optimistic version column | The computation is long and must not hold a lock. Conflicts are rare enough that retries are cheap. |
| Card authorisation hold | Moderate, and under a hard 2-second budget. | Conditional write with lock_timeout | Must fail fast rather than queue. A declined authorisation beats a timed-out one. Part 8. |
| Settlement batch claim | By design: many workers, one queue. | FOR UPDATE SKIP LOCKED | Workers claim disjoint rows with zero coordination and no double processing. |
| Cross-account group limit | Rare, but the invariant spans rows. | SERIALIZABLE for that transaction | Write skew cannot be caught by row locks. Scope the expensive level to the transaction that needs it. |
Deadlocks: how they form, how to order writes
A transfer touches two accounts, so a transfer can deadlock with another transfer going the other way. This is not exotic; it is the default outcome of unordered locking.
How the database resolves it
Postgres does not prevent deadlocks; it detects them. After a lock wait exceeds
deadlock_timeout (1 second by default), it builds the
wait-for graph of which transaction waits on which, searches for
a cycle, and if it finds one aborts the transaction that triggered the check with
error 40P01.
wait-for graph: T1 → T2 (T1 waits for a lock T2 holds)
T2 → T1 (T2 waits for a lock T1 holds)
cycle found → abort one victim → the other proceeds
detection costs a graph walk after a 1 second stall.
so a deadlock is not just an error, it is a second of latency
firstThe fix: a total order on lock acquisition
If every transaction acquires locks in the same order, a cycle is impossible. Any deterministic order works as long as everybody uses it, and account id sorts naturally:
// Never lock in "from, to" order. Lock in sorted order, always. const [first, second] = [fromId, toId].sort(); await tx.query( `SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE`, [[first, second]] ); // both directions of a transfer now queue rather than cycle
The ORDER BY inside the locking statement matters. Without it the
engine may acquire in whatever order it scans, which defeats the point.
Idempotency keys, and the fingerprint
Now the second half of the interviewer's question: the client timed out and retried. The request may have succeeded, failed, or still be in flight. The client cannot tell, and the server must make the ambiguity harmless.
The mechanism
The client generates a key per intent and reuses it on every retry of that intent. The server enforces uniqueness at the database level:
CREATE TABLE idempotency ( key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, -- SHA-256 of the canonical body state TEXT NOT NULL, -- in_flight | done | failed response JSONB, -- the original response, replayed verbatim journal_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL );
Why a fingerprint as well as a key
The key alone is not enough. A buggy or malicious client could reuse one key for two different transfers, and returning the first response for the second request would silently drop a payment. So the server hashes the canonical request body and compares:
function fingerprint(body: TransferRequest): string {
// canonical: sorted keys, no whitespace, amounts as exact strings.
// the hash must not change because a client reordered its JSON.
const canon = JSON.stringify({
from: body.from, to: body.to,
amount: body.amount.toString(), currency: body.currency
});
return sha256(canon);
}| Key | Fingerprint | Server response |
|---|---|---|
| New | n/a | Process it. Insert in_flight, do the work, store the response. |
Seen, done | Matches | 200 with the stored original response. No money moves. |
Seen, done | Differs | 422 idempotency key reused with a different payload. Refuse. |
Seen, in_flight | Matches | 409 with Retry-After. The original is still running; do not race it. |
Seen, failed | Matches | Depends: retry a transient failure, replay a terminal one. |
Where the key lives, and why ours needs no second table
A separate table means two writes that must agree, which is the dual-write problem
in miniature. Our round-one schema avoided it by putting the key
directly on the journal row under a UNIQUE
constraint, so the duplicate check and the money movement are the same atomic
commit:
BEGIN; -- a duplicate raises unique_violation right here, before any entry exists. INSERT INTO journal (id, kind, idempotency_key) VALUES ($jid, 'transfer', $key); INSERT INTO entries ... -- debit INSERT INTO entries ... -- credit COMMIT; -- on unique_violation: look up the original journal row by key and -- return its result. The retry is now indistinguishable from the original.
Keep the richer idempotency table for operations that are not a
single ledger posting, such as the external payouts in Part 6, where the response
must be replayed and the state machine has more than one terminal state.
DELETE.Exactly-once is a lie; here is the truth
A claim worth being able to defend, because it comes up in every distributed systems conversation and most people repeat the marketing.
Exactly-once delivery is impossible over an unreliable network. The sender cannot distinguish "the message was lost" from "the message arrived and the acknowledgement was lost". It must therefore either resend, risking a duplicate, or not resend, risking a loss. There is no third option, and no protocol removes it. This is the Two Generals problem.
at-most-once = send, never retry → may lose
at-least-once = send, retry until acked → may duplicate
exactly-once = not achievable in delivery
but: at-least-once delivery + idempotent processing
= exactly-once effectThat last line is the whole answer. You stop trying to make delivery exact and make processing tolerant of repetition. The duplicate still arrives; it just does not do anything the second time.
What "exactly-once" means when a vendor says it
| Claim | What is actually provided |
|---|---|
| Kafka exactly-once semantics | Idempotent producers (sequence numbers deduplicate retries per partition) plus transactions spanning consume, process and produce within Kafka. It does not extend to your database or an external API. |
| SQS FIFO exactly-once processing | Deduplication of identical messages inside a 5-minute window. Outside the window, a duplicate is delivered. |
| Stripe-style APIs | At-least-once delivery with idempotency keys, which is honest about being the pattern in this chapter. |
The three places our design makes repetition harmless
- The API boundary. Unique idempotency key on the journal row. A retried transfer posts once.
- Event consumers. Every consumer tracks processed event ids, so a redelivered Kafka message is dropped. Part 4.
- External calls. Every outbound payment carries our own reference, and providers deduplicate on it. Part 6.
Sketch v2: safe under concurrency
The design has not grown a single new box this round. It grew guarantees, which is worth pointing out explicitly, because it shows that not every round should add infrastructure.
| Concern | v1 | v2 |
|---|---|---|
| Concurrent debits | Conditional write, unexamined | Conditional write, justified at READ COMMITTED, with the isolation reasoning explicit |
| Multi-row invariants | Not considered | SERIALIZABLE, scoped to those transactions only |
| Long computations | Not considered | Optimistic versioning with jittered retry |
| Deadlocks | Possible | Prevented by sorted lock acquisition |
| Client retries | Duplicate posting | Idempotency key on the journal, plus body fingerprint |
| Lock hold time | Unbounded | lock_timeout, statement_timeout, no network calls inside a transaction |
The invariants now enforced, and by what
- Entries per journal sum to zero → in-transaction assertion plus a continuous check in Part 10.
- A balance never goes below its floor → condition inside the insert.
- One intent posts at most once → UNIQUE on idempotency_key.
- Entries are never modified → revoked UPDATE and DELETE privileges on the table.
-- make the append-only rule a database guarantee, not a code review habit REVOKE UPDATE, DELETE ON entries FROM ledger_app; -- corrections are new, opposite entries. history is never rewritten.
And the interviewer takes the invitation, because ten million transactions a day is exactly where one primary stops being enough.