skip to content

Transactions in Practice

The application-facing craft: choosing optimistic or pessimistic concurrency for a workload, scoping transactions tightly with savepoints where partial undo helps, and knowing why long-running transactions quietly damage the whole database. This is where interviewers move from theory to 'what would you actually do'.

part ofRelational database conceptsoverview, primer and where to startread it →
on this pageshow

questions

13

A service opens a database transaction, calls an external payment API inside it, and commits only after that API responds. What costs does keeping the transaction open across the remote call impose on the database, and what would you do instead?

level: juniorimportance: must knowfreq 62%

answer

  1. duration × contention, not work done
  2. locks released at commit, not last statement
  3. oldest open txn = cleanup horizon
  4. never hold a txn across network I/O
  5. PENDING row + idempotency key + reconcile

basics

~20 s

For the whole API call the transaction keeps its locks, keeps its read snapshot, and holds a pooled connection. Other writers queue behind those locks and old row versions cannot be cleaned up. Move the remote call outside the transaction.

solid answer

~50 s

A transaction's cost is driven by its **duration**, not by how much work it does. While it is open the engine must keep every row and index lock it has taken, keep the read snapshot it was given, keep its undo/rollback data reachable, and keep a connection occupied. Wrapping a network call in that window lets an unrelated third party decide how long your locks are held. A slow provider turns a 5 ms write into a 30-second lock hold: sessions touching the same rows block, the connection pool drains, and the service stalls on one dependency. The fix is to shrink the transaction to local work: validate and read, commit, make the remote call, then open a second short transaction to record the outcome. The atomicity you gave up is recovered by design — an idempotency key, an outbox row, or a status state machine (PENDING then CONFIRMED) — never by holding the transaction open longer.

code

text · 12 lines
text
BAD
  BEGIN
    UPDATE accounts SET balance = balance - 100 WHERE id = 42;
    <-- HTTP POST to payment provider (up to 30s) -->
    INSERT INTO payments(...);
  COMMIT        -- row 42 locked for the whole HTTP call

GOOD
  BEGIN; INSERT INTO payments(id, status, idem_key) VALUES (...,'PENDING',...); COMMIT;
  <-- HTTP POST to payment provider, with timeout -->
  BEGIN; UPDATE payments SET status='CONFIRMED' WHERE idem_key = ...;
         UPDATE accounts SET balance = balance - 100 WHERE id = 42; COMMIT;

go deeper

for a junior

Say clearly that locks and the connection are held until commit, so a slow API call means a long lock hold, and that the call belongs outside the transaction.

for a middle

Add the MVCC snapshot and undo retention, name the pool-exhaustion cascade, and sketch the PENDING-then-confirm split.

for a senior

Talk about the failure cascade under provider slowdown, idempotency keys and the outbox pattern, and the timeouts (statement, lock wait, idle-in-transaction) that bound the blast radius.

for a principal

Frame it as a coupling problem: transaction scope must not span trust or availability boundaries, and discuss reconciliation, framework defaults that wrap HTTP clients in transactions, and how you enforce this across many services.

## What an open transaction is holding A transaction is more than markers around some statements. From its first lock or first snapshot until it ends, the engine must keep several resources alive. **Locks.** Every row, page or index entry the transaction has written stays locked until commit or rollback. Two-phase locking means write locks are never released early — that is what makes the transaction atomic. Anyone who wants to write the same row waits. **A read snapshot.** In a multi-version (MVCC) engine, each transaction reads a consistent point-in-time view. The engine may not discard any row version that this snapshot could still need, so the *oldest open transaction* sets the cleanup horizon for the whole database. **Undo / rollback data.** The before-images needed to undo the transaction (and, in many engines, to reconstruct old versions for other readers) must remain available until it finishes. **A session and a connection.** Transactions are bound to a session; a pooled connection cannot be handed to anyone else while a transaction is in flight. None of these are released "when the last statement finishes". They are released **at commit or rollback**. So the resource bill is duration × contention, and a transaction that spends 30 seconds waiting on someone else's HTTP endpoint pays exactly as much as one doing 30 seconds of real work. ## Why a remote call inside a transaction is the classic mistake When you write `begin → UPDATE account → call payment provider → INSERT payment_record → commit`, you have handed control of your lock duration to a system you do not run. Its p99 latency, its retries, its timeouts and its outages become your lock-hold time. The failure mode is not gradual: when the provider slows from 50 ms to 5 s, lock waits pile up, every worker thread ends up blocked on the same rows, the pool is exhausted, and requests that never touch payments start failing too. A single slow dependency has become a database-wide incident. The same shape appears with a transaction held open across **user think-time** (open a transaction when the user opens an edit form, commit when they press Save), across a large in-application computation, or across a message-broker publish. ## The restructuring Split the unit of work at the network boundary: 1. Short transaction 1: read state, validate, write an intent row (`status = 'PENDING'`) with a unique idempotency key. Commit. 2. Outside any transaction: call the provider, with its own timeout. 3. Short transaction 2: record the outcome (`status = 'CONFIRMED'` or `'FAILED'`). Commit. You lost one-shot atomicity across the two systems — but you never really had it, because a remote side effect cannot be rolled back by a database `ROLLBACK` anyway. If the process dies between steps 2 and 3, a reconciliation job finds rows stuck in `PENDING` and asks the provider what happened; the idempotency key stops step 2 from being executed twice. When the second system is also a service you own, the **transactional outbox** pattern does the same thing: commit the business row and an outbox row atomically, then a relay publishes the message after commit. ## Practical rules - Keep transactions to local database work; do I/O to other systems outside them. - Acquire whatever you will contend on **as late as possible** so the hot row is locked for the shortest window. - Begin the transaction after the connection is checked out and cheap preparation is done; do not begin on request entry. - Set a bound so a mistake cannot become an outage: a statement timeout, a lock-wait timeout, and an idle-in-transaction timeout that kills sessions sitting inside an open transaction doing nothing. - Beware framework defaults: a service-layer transactional method that also calls an HTTP client reproduces this bug invisibly, and so does an ORM session opened for the whole web request. The general principle an interviewer wants to hear: **a transaction is a lock-and-snapshot lease; lease it for the shortest time you can, and never let a third party decide when you give it back.**

  • If you commit before the external call, how do you avoid charging a customer twice when the process crashes and retries?
    Generate an idempotency key when you write the PENDING row and send that same key to the provider on every attempt, so the provider collapses duplicates into one charge. On retry you first re-read the local row: if it is already CONFIRMED you stop. A reconciliation job sweeps rows left PENDING past a threshold and queries the provider for the authoritative outcome.
  • A colleague says the answer is just to raise the lock-wait timeout. Why is that wrong?
    A longer timeout does not shorten the lock hold; it only makes the victims wait longer before failing, so queues and pool usage grow instead of shrinking. Timeouts are a blast-radius control, not a fix. The real fix is reducing how long the transaction holds the lock, which means taking the network call out of the transaction.

Holding a transaction across an API call is like taking the only library copy of a book to the counter, then standing there on the phone with your travel agent. You are not reading it, but nobody else can have it until you hang up.

saying these in an interview costs you the question

  • Thinking locks are released when the statement finishes rather than at commit or rollback
  • Believing an idle transaction is harmless because it is doing no work
  • Proposing a bigger connection pool or a longer lock timeout as the fix
  • Assuming database ROLLBACK can undo an external side effect such as a charge
  • Opening the transaction at the start of the web request and committing at the end by default

context

open as a page

Two users load the same customer record and both submit an edit. Compare optimistic concurrency control (a version column checked at write time) with pessimistic concurrency control (locking the row at read time via SELECT ... FOR UPDATE): how does each stop one edit from silently overwriting the other, and when would you choose each?

level: juniorimportance: must knowfreq 62%

basics

~20 s

Pessimistic locks the row when you read it, so the second writer waits — safe, but it holds locks and blocks. Optimistic takes no lock: you remember a version, and the update applies only if the version is unchanged; otherwise you re-read and retry. Rare conflicts favor optimistic; hot contended rows favor pessimistic.

open as a page

A service inserts an order row and then updates a stock row, running on a connection left in autocommit mode. Explain what autocommit does to those two statements, what can go wrong, and how the transaction scope should be set instead.

level: juniorimportance: must knowfreq 50%

basics

~20 s

In autocommit each statement is its own transaction, committed the moment it succeeds. So the insert is durable even if the update then fails, leaving an order with no stock deducted, and nothing can roll it back. Both statements must run inside one explicit transaction on the same connection.

open as a page

In a database that uses multi-version concurrency control (MVCC), why can one transaction that stays open for hours cause tables and undo/version storage to keep growing, even when that transaction only reads and never writes?

level: middleimportance: must knowfreq 58%

basics

~20 s

An open transaction pins a snapshot, and the engine may not reclaim any row version that the oldest live snapshot could still need. So while it sits there, superseded versions from every other transaction accumulate: table and index bloat, growing undo storage, slower scans.

open as a page

Your write is an UPDATE guarded by a version column, and it sometimes reports zero affected rows. Walk through how you would build the retry loop around that failure: what must be redone, how many attempts, what backoff, and what would make a retry unsafe.

level: middleimportance: must knowfreq 55%

basics

~20 s

Zero affected rows means someone else wrote the row, so your in-memory copy is stale. Retry the whole unit of work — new transaction, fresh read, recompute, write again — not just the UPDATE. Cap attempts (3–5), use jittered backoff, and keep external side effects out of the retried block or make them idempotent.

open as a page

Inside a long transaction you want to undo the last few statements without losing everything done so far. Explain what a savepoint gives you, what it does not undo, and how it interacts with locks the transaction already holds.

level: middleimportance: must knowfreq 45%

basics

~20 s

A savepoint is a named marker inside a transaction. Rolling back to it undoes data changes made after it while keeping earlier work and the transaction itself alive. It does not commit anything, does not make earlier work visible to others, and generally does not release locks already acquired.

open as a page

You need to update or delete roughly 200 million rows in a table that is serving live OLTP traffic. Compare doing it in a single transaction with committing in chunks, and describe how you would make the chunked version safe to re-run after a failure.

level: seniorimportance: must knowfreq 55%

basics

~20 s

One transaction means hours of held locks, huge undo and log growth, blocked version cleanup, replication lag, and a rollback as long as the run. Chunked commits keep each transaction short but lose all-or-nothing semantics, so make the work idempotent, resumable via a stored cursor, and throttled.

open as a page

Writes against one table have started timing out, and your monitoring shows several database sessions sitting idle inside an open transaction. How would you confirm that long-running transactions are the cause, and what controls would you put in place so it cannot recur?

level: seniorimportance: must knowfreq 52%

basics

~20 s

Find the blocking chain: which session holds the lock the timed-out writers wait for, how old its transaction is, and what it is doing. Then bound it: statement timeout, lock-wait timeout, idle-in-transaction timeout, alerts on oldest-transaction age, and no network I/O inside transactions.

open as a page

You are implementing a job-queue table where many workers claim pending rows. Explain the difference between a locking read that waits, one that fails immediately (NOWAIT), and one that skips already-locked rows (SKIP LOCKED), and which one a queue claim should use and why.

level: seniorimportance: should knowfreq 45%

basics

~20 s

A plain locking read waits for the lock holder, so all workers queue behind the same row. NOWAIT raises an error instead of waiting. SKIP LOCKED silently omits rows locked by other transactions, so each worker claims a different row. A queue claim should use SKIP LOCKED; it turns a convoy into parallel workers.

open as a page

You must import 100,000 rows and a small unknown number of them will violate constraints. Describe how you would structure the transaction scope so that bad rows are rejected and recorded while good rows still land, and explain the tradeoffs of the approach you choose.

level: seniorimportance: should knowfreq 36%

basics

~20 s

Do not run the import as one transaction. Process in chunks with a commit per chunk; inside a chunk, take a savepoint per item (or re-run a failed chunk item-by-item), roll back to the savepoint on error, record the row as rejected, and continue. That bounds lock hold time, redo cost and rollback loss.

open as a page

Analysts want to run multi-hour reporting queries against a physical replica of your OLTP primary. Explain how long-running readers on a replica interact with replication and with the primary's version cleanup, and how you would design for it.

level: principalimportance: should knowfreq 38%

basics

~20 s

A long reader on a replica needs old row versions that the primary's cleanup removes. Either the replica cancels the query when conflicting cleanup arrives, or it delays applying changes and lags, or it makes the primary hold garbage back and bloat. You choose which one you pay for.

open as a page

You own a service where several write paths behave very differently: per-user profile edits, a shared inventory counter for a flash sale, and a nightly bulk reconciliation. How do you decide, per path, between optimistic version checks and pessimistic locking — and what measurements would change your mind?

level: principalimportance: should knowfreq 32%

basics

~20 s

Decide per path, not per system. Estimate conflict probability, the window between read and write, and the cost of redoing the work. Rare conflicts and long windows favour optimistic; hot rows with short transactions favour pessimistic. Measure conflict rate, lock-wait time and end-to-end latency, and prefer designs that remove the contention entirely.

open as a page

In a service that persists to a relational database and also calls external systems (a payment provider, a message broker, a search index), how do you decide where transaction boundaries begin and end, and how do you keep the database and those systems consistent without stretching a transaction across them?

level: principalimportance: should knowfreq 30%

basics

~20 s

Put the boundary around the smallest unit that covers a database invariant, and keep external calls out of it. Record intent inside the transaction (an outbox row) and dispatch after commit; make external calls idempotent so retries are safe. Reconcile asynchronously rather than pretending remote systems can join the transaction.

open as a page