InterviewKata
System design · Module 17 of 29 · Level 3: Data

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

Two people look at the last cake in a shop window at the same moment. Both see one cake, both walk in, both say "I'll take it". If the shop decides by "whoever looked first", both are disappointed or both are charged. The shop needs a rule at the till: one cake, one sale, and the second person is told "sorry, just sold".

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 levelDirty readNon-repeatable readPhantomLost update / write skew
Read uncommittedpossiblepossiblepossiblepossible
Read committed (PostgreSQL default)nopossiblepossiblepossible
Repeatable read / snapshot (MySQL InnoDB default)nonodepends on the databasewrite skew possible
Serializablenononono

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

ToolHowUse when
Conditional (atomic) writeUPDATE 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 constraintUNIQUE (seat_id) on bookings: the second insert fails"At most one of these" rules: one booking per seat, one account per email
Pessimistic lockSELECT … FOR UPDATE locks the rows; others wait until commitYou 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 retryConflicts are rare; no locks are held while the user thinks
Serializable isolationThe database detects conflicting transactions and aborts one; the app retriesThe 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 queueAll requests for one hot item go to one queue and one worker, processed in orderExtreme 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.

200
20
100 ms

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

The tools up close

The default answer in an interviewRaise contention yourself whenever there's limited inventory, money, or "only one may win". Name the race, then the simplest correct guard, at the single source of truth: 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.
PromptApproach
AuctionOptimistic concurrency using the current high bid as the version (it only goes up, so no ABA)
TicketmasterA 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 dispatchSet the driver to pending_request with a ~10-second expiry so two riders can't get the same driver
Flash saleOCC or a conditional decrement on inventory, plus cart holds with a TTL
Ratings averageOCC with a version column when updating the average

Conditional (atomic) write

The default · start here

Put 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

In the interview

Purchase is UPDATE seats SET status = 'sold' WHERE id = ? AND status = 'held' AND held_by = ?; zero rows means it's gone.

🚩 Red flags
  • 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 rare

Read 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

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.

🚩 Red flags
  • 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 logic

Lock 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)

In the interview

The transfer locks both accounts with FOR UPDATE in ascending ID order, so two opposite transfers can't deadlock.

🚩 Red flags
  • Slow I/O inside the lock (a payment API call).
  • Locking in "initiator first" order: two opposite transfers deadlock.

Serializable isolation

Situational · rules across rows

The 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

In the interview

The on-call rule spans rows, so either Serializable with retries, or a count on the team row that we lock.

🚩 Red flags
  • Assuming the default isolation level catches write skew. Only Serializable does.

Distributed locks and leases

Situational · holds that outlast a transaction

A lock with an expiry, held across steps or services: a 10-minute seat hold, a job only one worker may run.

How it works

WhereHowTrade-off
RedisSET key owner NX EX 600Fast and self-expiring; a stalled holder can briefly overlap another. Fine for soft holds, not correctness
Database columnsUPDATE 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 / etcdConsensus-backed leases; ephemeral nodes vanish when the client diesMost 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

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.

🚩 Red flags
  • 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 item

Send 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

In the interview

For the drop, requests for that product go through one ordered queue and a single worker; everything else stays normal.

🚩 Red flags
  • Adding servers or replicas to fix one hot row. They all still write to that row.

6. Remember it

Memory hook: "Check and change in one step"The bug is a gap between reading and writing. Close it: make the check part of the write (a condition, a constraint), or hold the row while you decide (a lock), or detect that it changed and retry (a version).
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 shopIn the database
Seeing the cake in the windowReading the stock
Walking to the tillThe gap where another transaction can act
"Sorry, just sold" at the tillA conditional write affecting 0 rows
Putting your hand on the cakeA 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

  1. An auction service reads the high bid ($90), checks the new bid ($95) is higher, writes $95.
  2. 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.
  3. Fix in one step: UPDATE auctions SET high_bid = 95, bidder = $u WHERE id = 7 AND high_bid < 95.
  4. 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

Read, check in code, then writeAt the default isolation level this oversells under load, even inside a transaction. Use a conditional write, a lock or a version check.
Holding locks during slow workCalling a payment API or waiting for user input while holding a row lock blocks everyone else. Hold for milliseconds, not seconds; use expiring holds for longer claims.
Ignoring 0 rows affectedA conditional update that matched nothing isn't an error, it's the answer "you lost the race". Check the row count and tell the user.
Inconsistent lock orderLocking rows in different orders in different code paths causes deadlocks. Sort by ID before locking, and retry on deadlock errors.
Using a distributed lock for correctness aloneLocks in Redis can expire under a paused holder. Keep the database's own guard (a condition or version) as the final check.

8. Design exercise (25 minutes)

  1. 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.
  2. 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.
  3. Seat holds: write the SQL for holding a seat for 10 minutes, and for buying it only if you hold it.
  4. Use the simulator with 500 buyers, 20 tickets and a 20 ms window. Compare locks and OCC: which has more retries, which more waiting?
  5. Write it down: the toolbox table, in order.

9. Self-check quiz

Simplest safe way to sell one of the remaining tickets?

The check is part of the write; if 0 rows change, it's sold out.

Conflicts are rare and users may edit a document for minutes. Which approach?

No lock is held while the user edits; on save, a version check detects the rare conflict.

How do you prevent deadlocks between transfers?

If every transaction locks accounts in ascending ID order, no cycle of waiting can form.

Two transactions each read the same rows and update different rows, together breaking a rule. What is it?

Only serializable isolation (or moving the rule onto one row you lock) prevents write skew.

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?

The value returned to what you saw, hiding a change. Use a version counter that only increases.

Ten thousand people want one limited-edition item at the same second. More app servers and replicas…

Change the problem (split into pools) or serialise requests for that item through one queue.

10. Go deeper

ResourceTimeWhy
Designing Data-Intensive Applications, chapter 7 (Transactions)2 hIsolation levels, lost updates, write skew and serializability, with clear diagrams.
Transaction isolation and explicit locking (PostgreSQL docs)45 minExactly what each level and lock mode does in a real database.
Dealing with contention (Hello Interview)25 minThe interview version, with ticketing, auctions and flash sales.
Jepsen analysesbrowseReal 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

ProductDefaultIts "repeatable read"Its "serializable"
PostgreSQLRead committedSnapshot isolation; also blocks phantoms; aborts on a concurrent update of the same rowSerializable snapshot isolation (SSI): truly serializable, by aborting
MySQL InnoDBRepeatable readPlain SELECTs read a snapshot, but locking reads and UPDATEs see the latest committed rows and take next-key locksTwo-phase locking: plain SELECTs become shared-lock reads (when autocommit is off)
OracleRead committed(Not offered)Actually snapshot isolation: write skew is possible
SQL ServerRead committed with locks (row versioning with RCSI; on by default in Azure SQL Database)Locks held until commit; phantoms possibleRange locks (two-phase locking); SNAPSHOT is a separate level
CockroachDBSerializableRead committed also availableSerializable, with automatic retries where it can
SpannerSerializableExternal 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

TechniqueHowCostUsed 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 lockSQL Server (default), InnoDB serializable and locking reads, Db2
MVCC + snapshot isolationEach 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 allowedPostgreSQL, 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 transactionSome false-positive aborts; memory for trackingPostgreSQL 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 hasWasted work under contention; great when conflicts are rareFoundationDB (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.
SettingSafe defaultFaster, riskierWhat you can lose
PostgreSQL synchronous_commitonoffThe last few hundred milliseconds of commits on a crash (no corruption)
MySQL innodb_flush_log_at_trx_commit1 (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_binlog10Binary log events, so replicas can diverge
MongoDB write concernw: "majority" (journaled)w: 1Writes acknowledged by a primary that fails before replicating
Redis appendfsynceverysecnoAbout 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

ProductOld versionsTransactionsThe detail worth knowing
PostgreSQLIn the table (xmin/xmax)Read committed default; RR = snapshot; serializable = SSIVACUUM and long transactions decide bloat
MySQL InnoDBUndo logRepeatable read default; next-key locks for locking readsGap locks can block inserts into ranges and cause deadlocks
OracleUndo segments (SCN)Read committed default; "serializable" = snapshot"Snapshot too old" on long queries
SQL ServerVersion store, when enabledLocking read committed; RCSI and SNAPSHOT optionalTurning on RCSI stops readers blocking writers
MongoDBWiredTiger keeps versions in memorySingle-document writes are atomic; multi-document transactions since 4.0, across shards since 4.260-second default limit; design documents so most writes touch one
CockroachDBMVCC with hybrid-logical-clock timestampsSerializable default; read committed availableClock uncertainty can force a read to restart
SpannerMVCC with TrueTime timestampsExternally consistentCommit wait of a few milliseconds
DynamoDBNot exposedConditional writes per item; transactions of up to 100 itemsTransactions cost twice the capacity of plain writes
FoundationDBA short MVCC windowSerializable, optimisticTransactions are limited to about 5 seconds
RedisNoneMULTI/EXEC runs commands in a batch without interleaving; WATCH adds an optimistic check; Lua scripts are atomicNo rollback: a failing command inside EXEC doesn't undo the others

Say it in the interview

Answers that show depth
  • "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?

Each wrote a different row based on a read the other invalidated. SSI or an explicit lock prevents it.

Where does InnoDB keep the old version of a row a long-running reader still needs?

PostgreSQL keeps versions in the table; InnoDB updates in place and keeps the past in undo.

In two-phase commit, a participant has voted yes and the coordinator crashes. What happens to the participant?

Having promised to commit, it can't decide alone. That blocking is why 2PC coordinators are replicated with consensus.

Sources

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.
OptionWhen
Ledger tables in PostgreSQLThe 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 providerWhen you'd rather buy than build (Modern Treasury, Stripe Treasury and similar)

Say it in the interview

Answers that show depth
  • "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?

History stays complete and auditable; the balance is still the sum of entries.

What invariant must every transaction in a double-entry ledger satisfy?

Money is only ever moved between accounts, never created or lost.

Sources