On a database engine using strict two-phase locking, throughput on a contended workload rises as you add concurrent transactions and then collapses past a certain point. Explain the mechanism behind that collapse, and how you would design and operate the system to stay on the healthy side of the curve.
answer
- Waiters keep their locks → self-amplifying contention
- Throughput knee, then collapse; CPU looks idle
- Conflicts ~ MPL × length²; deadlocks ~ length⁴
- Touch the hot row last; commit fast
- Past the knee, admission control beats a bigger pool
basics
~20 sBlocked transactions keep the locks they already hold, so each new conflict creates more blocking — lock thrashing. Fix it by shortening lock hold time (short transactions, no remote calls inside them), reducing hot-row conflict, and capping concurrent writers with admission control rather than raising the pool.
solid answer
~50 sUnder strict 2PL every lock is held until commit, so a transaction that **waits** does so while still holding its own locks. Add concurrency and you get a positive feedback loop: more active transactions → more conflicts → more waiters holding locks → still more conflicts. Throughput climbs, flattens, then falls off a cliff. Conflict probability grows roughly with concurrency times the square of transaction length, and deadlock rate grows faster still. How to stay left of the knee: - **Shorten hold time** — the dominant lever. No network calls, user think time or slow computation inside a transaction; touch the hottest row **last**; commit promptly. - **Reduce conflict** — partition hot counters into shards, aggregate rather than increment, order updates consistently, batch small writes. - **Cap concurrency** — bounded connection pool and admission control for writers; past the knee, adding workers subtracts throughput. - **Move readers off the lock path** — snapshot reads for reporting. - **Measure lock-wait time, not CPU** — a thrashing system looks idle.
code
text · 13 linesBEFORE (hot row locked for the whole transaction)
BEGIN
UPDATE account_totals ... <- hot row X-locked here
... several reads, validation, joins ...
INSERT INTO ledger ...
COMMIT <- hot lock held across everything above
AFTER (hot row locked for microseconds)
BEGIN
... reads, validation, joins ...
INSERT INTO ledger ...
UPDATE account_totals ... <- last statement before commit
COMMITgo deeper
Know that transactions should be short and that locks are held until commit, so slow work inside a transaction blocks others.
Explain that blocked transactions keep their locks, and name the standard fixes: short transactions, no remote calls inside them, contended update last.
Diagnose from lock-wait time rather than CPU, identify the hot objects, and apply hot-row sharding, consistent access ordering and a bounded pool.
Reason about the throughput-versus-concurrency curve and its knee, argue for admission control and load shedding against the instinct to scale out, and weigh transaction chopping, granularity, durability settings and moving readers to snapshots as a coherent contention budget.
## The shape of the curve Plot throughput against the number of concurrently active transactions (the multiprogramming level, MPL) on a lock-based engine with a contended workload. Three regions appear: 1. **Rising** — resources are underused, conflicts are rare, each added transaction adds throughput. 2. **Flat knee** — conflicts become common; added concurrency mostly buys waiting. 3. **Collapse** — throughput falls, sometimes to near zero, while CPU and I/O sit idle. This is **lock thrashing**. The collapse is not gradual saturation; it is a feedback loop, which is why systems fall off it suddenly and why the usual reflex — add more workers — makes it worse. ## The mechanism Under strict 2PL a transaction holds every exclusive lock (and under the rigorous variant, every shared lock too) until it commits. Now note the crucial detail: **a blocked transaction does not release anything while it waits.** So each waiter is simultaneously a victim and an obstacle. As MPL rises: - the chance that a new transaction requests a lock someone already holds rises, - each new waiter extends the lock hold time of nothing — but it removes itself from the set of transactions making progress while keeping its own locks live, - which lengthens the *effective* hold time seen by everyone else, raising conflict probability again. Waits also **chain**: T3 waits on T2 which waits on T1, so T1's commit latency propagates to everyone behind it. Convoys form behind a single hot row, and one slow commit — a stalled fsync, a paused JVM, a slow replica ack — stalls the whole line. The classic analytical result (Gray and Reuter) is worth carrying: for small conflict probabilities, the expected number of conflicts grows roughly **linearly in MPL and quadratically in transaction length**, and deadlock rate grows far faster — roughly with the fourth power of transaction size. The practical reading is blunt: **transaction length is the most dangerous variable in the system**, and it appears squared. ## Design levers, in order of impact **1. Shorten the time locks are held.** This is the whole game. - Never make a network call, an external API request, a queue publish or a user interaction from inside an open transaction. A 200 ms remote call inside a transaction is 200 ms of held locks per execution. - Do reads, validation and computation *before* opening the write transaction where possible. - Because exclusive locks are held to commit anyway, **acquire the hottest row as late as possible** in the transaction. Reordering statements so the contended update is the last one before COMMIT cuts its hold time to nearly zero — a change that often buys more than any tuning knob. - Keep the commit path fast: group commit, sensible durability settings, and awareness that synchronous replication puts a network round trip *inside* the critical section. **2. Reduce the conflict itself.** - **Shard hot aggregates**: a single "total" row updated by every transaction serializes the whole workload. Split into N counter rows, update a random one, sum on read. This trades read cost for a factor-of-N reduction in conflict. - Prefer **inserting facts and aggregating** over updating a shared running total; inserts to distinct rows rarely conflict. - **Order access consistently** across code paths — it does not reduce blocking but sharply reduces deadlocks, which are the most expensive form of conflict since they waste all work done so far. - **Batch** many tiny transactions into fewer medium ones where the domain allows — but remember length is squared, so batching too aggressively pushes you back up the conflict curve. There is an optimum, and it is empirical. **3. Cap concurrency deliberately.** Past the knee, admission control *increases* throughput: bound the connection pool, and specifically bound the **writer** concurrency, letting queued work wait outside the lock manager rather than inside it. Waiting in a queue holds no locks; waiting on a lock does. This is the counter-intuitive move to be able to argue for, because the instinct under load is always to raise the pool size. **4. Take readers off the lock path.** If the engine offers snapshot reads, route reporting and dashboard queries there or to a replica so long analytical scans never hold shared locks against the OLTP writers. On a pure locking engine, a single long report can stall the write path entirely. **5. Choose granularity with eyes open.** Finer locks reduce false conflicts but cost lock-manager memory and CPU, and coarse escalation under a big statement can convert a manageable workload into a table-wide stall. The interaction to watch is a bulk update escalating and colliding with the OLTP path. **6. Transaction chopping, carefully.** Splitting one long transaction into a sequence of shorter ones is a large win for contention, and is only safe when the resulting pieces still preserve the invariants under interleaving — the formal condition involves checking that the chopping graph has no cycle mixing conflict and sibling edges. Doing this by intuition is how correctness bugs are introduced; treat it as a design exercise with explicit reasoning, and make each piece idempotent or independently retryable. ## Operating it Instrument the right things. A thrashing system shows **low CPU, low I/O, high lock-wait time, growing lock-wait queue depth, rising deadlock/rollback rate, and climbing p99 with a flat p50**. Utilization dashboards will report an idle, healthy machine. Alert on lock-wait time per transaction and on average lock hold time, not on CPU. Track the top contended objects continuously, since contention usually concentrates on a handful of rows. And have a load-shedding answer ready: when the system is past the knee, the correct emergency action is to *reduce* admitted concurrency, kill the longest-running transactions, and only then investigate. Scaling out application instances against a contended lock manager adds load to the exact resource that is failing. ## The framing that lands Say explicitly: under 2PL, a blocked transaction is still an obstacle, so contention is self-amplifying. The design goal is not "more concurrency" but **minimum lock hold time × minimum conflict probability**, with admitted concurrency capped just below the knee — and lock hold time is the term you can move by an order of magnitude with ordinary application changes.
- Throughput has collapsed and CPU is at 15%. Why might increasing the connection pool make it worse?Because the bottleneck is the lock manager, not the CPU. Every extra admitted transaction raises the probability of conflicting with a held lock, and each new waiter keeps its own locks while blocked, which raises everyone else's conflict probability again. Adding workers moves the queue from outside the database, where waiting holds nothing, to inside it, where waiting holds locks. The correct move is to reduce admitted concurrency until throughput recovers.
- When is splitting one long transaction into several shorter ones unsafe?When the intermediate states between the pieces are observable and violate an invariant, or when a failure between pieces leaves the data inconsistent with no compensating path. Formally, chopping is safe only when the chopping graph, containing conflict edges between pieces of different transactions and sibling edges within a transaction, has no cycle mixing both edge types. Practically, split at points where the partial state is a legitimate state of the system, and make each piece retryable or idempotent.
A single-lane checkout where anyone who cannot finish stays at the register holding the queue. Adding more shoppers past a point does not add sales — it just adds people frozen at the front while everyone behind them waits, and the store looks empty of activity even though it is completely jammed.
saying these in an interview costs you the question
- Assuming more concurrency always increases throughput
- Blaming deadlocks for a collapse that is actually plain lock waiting
- Diagnosing by CPU and I/O utilization, which look healthy while the system thrashes
- Holding a transaction open across an external API call or user think time
- Treating transaction length as harmless because each statement is fast