Part 2 · 10 chapters · ~20 min

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.

21

The pressure: concurrent debits

interviewer

“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:

problem one: concurrency
  1. Two different operations touch one row at once.
  2. The risk is a lost update or a write skew: both succeed when only one should.
  3. The tool is isolation, provided by locks or by version visibility.
problem two: duplication
  1. The same operation arrives twice, because a client retried.
  2. The risk is double posting: money moved twice for one intent.
  3. The tool is idempotency, provided by a unique key on the intent.
why the distinction matters
Perfect isolation does not prevent duplicates, and perfect idempotency does not prevent lost updates. They are orthogonal defences against different failures, and a design needs both. Candidates who conflate them tend to add a lock and believe the retry problem is solved.

Requirements this round adds

functional, new
  1. Every write operation accepts a client-supplied idempotency key.
  2. Replaying a request with the same key returns the original result and moves no money.
  3. Replaying a key with a different body is rejected as a conflict.
non-functional, new
  1. Concurrent transfers on one wallet must serialise, never interleave destructively.
  2. A wallet must sustain ~20 concurrent writes without deadlocking.
  3. Lock hold time stays under 10 ms, so contention cannot cascade into timeouts.
22

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.

READ UNCOMMITTEDweakest
Permits dirty reads: you can see writes from a transaction that has not committed and may yet abort. Postgres does not actually implement this level; asking for it gives you READ COMMITTED. Never appropriate for money.
READ COMMITTEDdefault in Postgres, Oracle, SQL Server
No dirty reads. Each statement sees a fresh snapshot of committed data. Permits non-repeatable reads (the same row read twice in one transaction can differ) and lost updates on read-then-write. This is the level round one ran at, which is why the conditional write mattered so much.
REPEATABLE READdefault in MySQL InnoDB
No dirty or non-repeatable reads. The whole transaction shares one snapshot. In Postgres this also blocks phantoms and aborts conflicting writers with a serialization failure. Still permits write skew: two transactions each read a condition, each act on it, and together they violate it.
SERIALIZABLEstrongest
No anomalies at all. The result is equivalent to having run the transactions one after another in some order. Postgres implements this as SSI, which tracks read and write dependencies and aborts a transaction that would create a cycle. Costs tracking overhead and forces your application to handle retries.

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:

code
-- 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.

the choice for our ledger
READ COMMITTED, plus conditional writes and explicit locking where needed. The ledger's critical invariant is per-account, so it can be enforced on a single row, and single-row atomicity is guaranteed at every isolation level. We pay nothing for SSI and we never handle serialization-failure retries on the hot path. If Z changed, if we grew an invariant spanning multiple accounts such as a group credit limit, I would move those specific transactions to SERIALIZABLE rather than raising the level globally.
23

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

FieldMeaningSet when
xminThe transaction id that created this versionOn insert or update
xmaxThe transaction id that expired it, or 0 if still liveOn update or delete
ctidPhysical location, and the pointer to the newer versionMaintained 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:

worked numbers
          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 list

This 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

CostMechanismConsequence for a ledger
BloatDead 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 pressureAutovacuum 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 wraparoundTxids 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 amplificationEvery 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.
the connection worth drawing
Our design choice in round one, append-only entries with derived balances, turns out to be the pattern MVCC is best at: inserts only, no dead tuples, no update amplification, no hot row rewritten thousands of times a second. The correctness argument and the performance argument point the same way, and saying so shows you understand why the model fits the engine.
MVCC
row versions and snapshot visibility
swipe the figure sideways, or tap expand for full screen
1/7
one version
A row starts as a single version, created by transaction 100. Its xmax is 0, meaning nothing has expired it, so it is the live version.
24

Pessimistic: SELECT FOR UPDATE and the lock manager

Pessimistic control assumes conflict is likely and takes the lock before doing the work.

code
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:

worked numbers
          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

ClauseBehaviourUse for
FOR UPDATEExclusive. Blocks other FOR UPDATE, FOR SHARE and writes.The default when you will write the row.
FOR NO KEY UPDATEWeaker exclusive; permits foreign-key share locks.Reduces contention when child rows reference this one.
FOR SHAREShared. Blocks writers, permits other readers to share.Reading a row you need to stay stable without intending to write it.
NOWAITRaises an error immediately instead of queueing.Interactive paths where a fast failure beats a slow success.
SKIP LOCKEDSilently 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:

lock discipline
  1. Never hold a lock across a network call. No HTTP request, no queue publish, no third-party API inside the transaction.
  2. Acquire as late as possible and commit as soon as possible. Validate, price and authorise before the lock.
  3. Set a statement timeout so a pathological lock wait fails fast rather than exhausting the connection pool.
code
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
why rule one is absolute
A lock held across a call to a payment provider that takes 30 seconds to time out means 30 seconds of every other transfer on that account queueing behind you. At 20M customers that is how one slow dependency becomes a full outage. Part 6 exists largely to structure external calls so this never happens.
25

Optimistic: version columns and CAS

Optimistic control assumes conflict is rare, takes no lock, and detects interference at write time.

code
-- 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

code
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.

the trap
A retry loop around a non-idempotent operation is a money bug generator. If the first attempt actually committed and the failure was in reading the response, the retry posts again. Optimistic concurrency and idempotency keys are a package, which is exactly why chapter 28 follows this one.

Head to head

PessimisticOptimistic
Cost when uncontendedA lock acquisition, and blocking othersNearly nothing
Cost when contendedQueueing, bounded and fairWasted work plus retries, potentially unfair
Behaviour under high contentionDegrades to a queueDegrades badly: livelock risk
Works across service boundariesNo, you cannot hold a DB lock over HTTPYes
Application complexityLowRetry logic required
Deadlock riskReal, needs write orderingNone
26

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.

OperationContentionMechanismWhy
Customer wallet debitLow. One human, occasionally two devices.Conditional write, no explicit lockThe 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 creditExtreme. Every transaction touches it.Neither. Restructure: sharded sub-accountsNo 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 updateLow, but multi-step with logic between read and write.Optimistic version columnThe computation is long and must not hold a lock. Conflicts are rare enough that retries are cheap.
Card authorisation holdModerate, and under a hard 2-second budget.Conditional write with lock_timeoutMust fail fast rather than queue. A declined authorisation beats a timed-out one. Part 8.
Settlement batch claimBy design: many workers, one queue.FOR UPDATE SKIP LOCKEDWorkers claim disjoint rows with zero coordination and no double processing.
Cross-account group limitRare, but the invariant spans rows.SERIALIZABLE for that transactionWrite skew cannot be caught by row locks. Scope the expensive level to the transaction that needs it.
the sentence that lands
"Concurrency control is a per-operation decision driven by expected contention. Low contention gets a conditional write. Long computations get optimistic versioning. Queue semantics get SKIP LOCKED. And for the genuinely hot row, the company account, no locking strategy helps and the answer is to change the data model."
27

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.

worked numbers
          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
          first

The 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:

code
// 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.

the deeper reason this design avoids most of it
Our ledger inserts entries rather than updating balances, and two inserts into an append-only table do not conflict with each other at all. The deadlock scenario above is what you get with a mutable balance column. So round one's model already removed most of this class of bug, and the ordering rule covers the cases where we do take explicit locks.
deadlock
and the ordering that prevents it
swipe the figure sideways, or tap expand for full screen
1/8
two transfers
Two transfers between the same pair of accounts, running in opposite directions. Each locks its source account first, which feels natural and is the bug.
28

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:

code
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:

code
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);
}
KeyFingerprintServer response
Newn/aProcess it. Insert in_flight, do the work, store the response.
Seen, doneMatches200 with the stored original response. No money moves.
Seen, doneDiffers422 idempotency key reused with a different payload. Refuse.
Seen, in_flightMatches409 with Retry-After. The original is still running; do not race it.
Seen, failedMatchesDepends: 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:

code
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.

two details interviewers probe
Who generates the key? The client, because only the client knows that two requests represent one intent. A server-generated key cannot deduplicate a retry. How long do you keep them? Longer than any client will retry, and long enough to cover a support investigation. 24 hours to 7 days is the usual range; expire them with a partitioned table or a TTL sweep rather than a giant DELETE.
29

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.

worked numbers
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 effect

That 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

ClaimWhat is actually provided
Kafka exactly-once semanticsIdempotent 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 processingDeduplication of identical messages inside a 5-minute window. Outside the window, a duplicate is delivered.
Stripe-style APIsAt-least-once delivery with idempotency keys, which is honest about being the pattern in this chapter.

The three places our design makes repetition harmless

idempotency, layer by layer
  1. The API boundary. Unique idempotency key on the journal row. A retried transfer posts once.
  2. Event consumers. Every consumer tracks processed event ids, so a redelivered Kafka message is dropped. Part 4.
  3. External calls. Every outbound payment carries our own reference, and providers deduplicate on it. Part 6.
the answer to give
"I would not claim exactly-once delivery, because it does not exist. I would build at-least-once delivery with idempotent handlers, which produces an exactly-once effect. Concretely that is a unique constraint on the intent at the API, processed-event tracking in every consumer, and our own reference on every outbound call so the provider can deduplicate. The duplicate still arrives; it just cannot move money twice."
30

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.

Concernv1v2
Concurrent debitsConditional write, unexaminedConditional write, justified at READ COMMITTED, with the isolation reasoning explicit
Multi-row invariantsNot consideredSERIALIZABLE, scoped to those transactions only
Long computationsNot consideredOptimistic versioning with jittered retry
DeadlocksPossiblePrevented by sorted lock acquisition
Client retriesDuplicate postingIdempotency key on the journal, plus body fingerprint
Lock hold timeUnboundedlock_timeout, statement_timeout, no network calls inside a transaction

The invariants now enforced, and by what

enforcement, not convention
  1. Entries per journal sum to zero → in-transaction assertion plus a continuous check in Part 10.
  2. A balance never goes below its floor → condition inside the insert.
  3. One intent posts at most once → UNIQUE on idempotency_key.
  4. Entries are never modified → revoked UPDATE and DELETE privileges on the table.
code
-- 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.
how to close round two
"v2 adds no boxes and four guarantees. Concurrency is handled per operation rather than by one global setting, retries are harmless because the intent is unique at the database level, and deadlocks are structurally prevented by lock ordering. The thing I have not fixed is throughput: this is still one primary, and the company account is still a single row every transaction wants to touch. That is what breaks next."

And the interviewer takes the invitation, because ten million transactions a day is exactly where one primary stops being enough.