Transactions and Concurrency: ACID, Isolation, and Locking
A staff-engineer guide to ACID guarantees, isolation levels, locking, and deadlocks in production relational databases.
Transactions and Concurrency
Transactions are how a database protects correctness when hundreds of requests hit the same rows at the same time. Getting isolation levels and locking wrong doesn't show up in a unit test — it shows up three months into production as a race condition that corrupts an account balance or double-books a seat. This guide covers ACID, every standard isolation level, how locking actually works in InnoDB, and how to reason about deadlocks instead of just retrying and hoping.
1. ACID Properties
| Property | Guarantee | What breaks it if missing |
|---|---|---|
| Atomicity | A transaction's writes all happen, or none do | A payment debits one account but a crash prevents crediting the other |
| Consistency | A transaction moves the database from one valid state to another, respecting constraints | A CHECK constraint, foreign key, or application invariant is violated mid-write |
| Isolation | Concurrent transactions don't see each other's uncommitted intermediate state | One transaction reads another's half-finished write, acting on data that's about to be rolled back |
| Durability | Once committed, data survives a crash, power loss, or restart | A "successful" payment vanishes after a server restart |
START TRANSACTION;
UPDATE accounts SET balance = balance - 5000 WHERE id = 1 AND balance >= 5000;
-- Application checks affected row count here; if 0, insufficient funds, ROLLBACK
UPDATE accounts SET balance = balance + 5000 WHERE id = 2;
COMMIT; -- Atomicity: both updates happen together, or neither doesConsistency is partly your job. The database enforces the constraints you declare (CHECK, NOT NULL, FOREIGN KEY, UNIQUE), but application-level invariants — "a transfer can't leave a balance negative," "an order total must equal the sum of its line items" — are only as consistent as the code and transaction boundaries you write. ACID gives you the mechanism; you still have to use it correctly.
2. Concurrency Anomalies
Before isolation levels make sense, you need to understand the specific ways concurrent transactions can corrupt what a transaction "sees."
| Anomaly | Definition | Example |
|---|---|---|
| Dirty read | Reading another transaction's uncommitted (possibly to-be-rolled-back) changes | You see a balance mid-transfer, before the transaction commits or aborts |
| Non-repeatable read | Re-reading the same row within a transaction returns different committed values | You read a price twice in one transaction and get two different values because another transaction committed a change in between |
| Phantom read | Re-running the same range query within a transaction returns a different set of rows | You count "orders with status = PENDING" twice and get a different count because a row was inserted in between |
| Lost update | Two transactions read the same value, both write based on it, and one overwrite silently discards the other's change | Two requests both read stock = 10, both compute 9, both write 9 — one decrement is lost |
3. Isolation Levels
The SQL standard defines four isolation levels, each permitting a strict subset of the anomalies above in exchange for better concurrency.
| Isolation level | Dirty read | Non-repeatable read | Phantom read | Typical use |
|---|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible | Almost never used in production; analytics scans that tolerate imprecision |
| READ COMMITTED | Prevented | Possible | Possible | PostgreSQL's default; most OLTP web apps |
| REPEATABLE READ | Prevented | Prevented | Possible in standard SQL (InnoDB actually prevents most phantoms via gap locks/MVCC) | MySQL/InnoDB's default |
| SERIALIZABLE | Prevented | Prevented | Prevented | Financial ledgers, inventory reservation, anything requiring full correctness |
-- Set isolation level for the current session (MySQL)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- ... business logic ...
COMMIT;MySQL and PostgreSQL default to different isolation levels. MySQL/InnoDB defaults to REPEATABLE READ; PostgreSQL defaults to READ COMMITTED. Code that "just works" against a local PostgreSQL instance can behave subtly differently — different anomaly exposure, different locking behavior — when the same queries run against MySQL in production. Always set isolation level explicitly for correctness-critical transactions instead of relying on the engine default.
InnoDB's REPEATABLE READ is stronger than the SQL standard requires — it uses MVCC (multi-version concurrency control) snapshots plus gap locks to prevent most phantom reads too, which the standard doesn't strictly guarantee at that level. This is a well-known InnoDB-specific enhancement; don't assume the same phantom-read protection exists on every database claiming REPEATABLE READ.
How MVCC actually prevents dirty/non-repeatable reads
MVCC avoids the classic "reader blocks writer, writer blocks reader" problem entirely for SELECT statements — readers see a consistent snapshot instead of blocking on locks held by writers. This is why InnoDB and PostgreSQL can offer strong isolation without grinding read-heavy workloads to a halt: plain reads don't take row locks at all under MVCC.
4. Locking in InnoDB
Isolation levels describe the guarantee; locks are the mechanism that enforces it for writes (and for SELECT ... FOR UPDATE reads).
Lock types
| Lock type | Acquired by | Blocks |
|---|---|---|
| Shared lock (S) | SELECT ... LOCK IN SHARE MODE / FOR SHARE | Other transactions' exclusive locks on the same row |
| Exclusive lock (X) | UPDATE, DELETE, SELECT ... FOR UPDATE | Any other lock (shared or exclusive) on the same row |
| Record lock | Locks a single index record | Other transactions modifying that exact row |
| Gap lock | Locks the space between index records | Insertions into that gap (prevents phantoms) |
| Next-key lock | Record lock + gap lock combined (InnoDB's default for REPEATABLE READ) | Both modifying the row and inserting adjacent to it |
-- Explicit locking read: reserve rows for update within a transaction
START TRANSACTION;
SELECT quantity FROM inventory
WHERE product_id = 55 AND warehouse_id = 1
FOR UPDATE; -- exclusive lock held until COMMIT/ROLLBACK
-- Application checks quantity >= requested amount here
UPDATE inventory SET quantity = quantity - 3
WHERE product_id = 55 AND warehouse_id = 1;
COMMIT; -- lock releasedSELECT ... FOR UPDATE is how you prevent the classic lost update race — two concurrent requests both reading quantity = 10, both deciding there's enough stock, and both decrementing, leaving stock at 9 when it should be 8. Without the lock, both SELECTs succeed and both UPDATEs apply, silently overselling inventory. An alternative that avoids holding a lock across a round trip: a single atomic UPDATE inventory SET quantity = quantity - 3 WHERE product_id = 55 AND quantity >= 3, then checking the affected row count — this is usually the better pattern where it fits the business logic.
Optimistic vs pessimistic locking
-- Optimistic locking: no lock held, conflict detected at write time
-- Table has a `version` column
UPDATE products
SET price_cents = 45000, version = version + 1
WHERE id = 55 AND version = 7;
-- If another transaction already bumped version to 8, this affects 0 rows.
-- Application detects 0 rows affected and retries or surfaces a conflict.| Aspect | Pessimistic locking | Optimistic locking |
|---|---|---|
| Mechanism | Acquire lock before reading/modifying | Compare a version/timestamp column on write |
| Contention cost | Blocks concurrent transactions | No blocking; conflicts detected, not prevented |
| Best for | High write contention on the same rows | Low contention, long user-think-time between read and write |
| Failure mode | Waiting transactions queue up (or deadlock) | Failed writes must be retried by the application |
| Typical implementation | SELECT ... FOR UPDATE | version column, or updated_at timestamp check |
Optimistic locking is the right default for anything involving human think-time — e.g., a user editing a document form for two minutes before saving. Holding a database lock for the duration of a human interaction is a reliable way to exhaust your connection pool. Reserve pessimistic locking (FOR UPDATE) for short, tight, machine-speed critical sections.
5. Deadlocks
A deadlock happens when two transactions each hold a lock the other needs, and neither can proceed.
-- Transaction A
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ... (some delay) ...
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- may deadlock
COMMIT;
-- Transaction B (running concurrently, opposite order)
START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- ... (some delay) ...
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- may deadlock
COMMIT;InnoDB automatically detects deadlocks and kills one of the two transactions (typically the one that has done less work, or based on the deadlock detection heuristic) with error 1213 (ER_LOCK_DEADLOCK). This is not a bug you can prevent at the database level entirely — your application must catch this error and retry the transaction. A service that doesn't handle deadlock errors will surface them as unexplained 500s under concurrent load, usually only noticed once traffic is high enough for lock ordering to actually collide.
Preventing deadlocks
| Technique | How it helps |
|---|---|
| Consistent lock ordering | Always acquire locks on rows in the same order (e.g., always by ascending id) across every transaction that touches the same tables |
| Keep transactions short | Fewer statements between BEGIN and COMMIT means less time holding locks |
| Reduce lock scope | Index the columns in your WHERE clause — an unindexed UPDATE can lock far more rows (or even a full table scan's worth of gap locks) than necessary |
| Retry with backoff | Catch deadlock errors in application code and retry the transaction, usually with jittered backoff |
| Lower isolation where safe | READ COMMITTED takes fewer gap locks than REPEATABLE READ, reducing deadlock surface — but re-evaluate anomaly exposure before switching |
// Typical application-level retry pattern for deadlocks (pseudocode structure)
int attempts = 0;
while (attempts < 3) {
try {
transactionTemplate.execute(status -> {
accountRepository.debit(fromId, amount);
accountRepository.credit(toId, amount);
return null;
});
break;
} catch (DeadlockLoserDataAccessException e) {
attempts++;
if (attempts == 3) throw e;
sleepWithJitter(attempts);
}
}Consistent lock ordering is the single most effective deadlock prevention technique. If every transaction that touches accounts 1 and 2 always locks the lower id first, the circular-wait condition that causes a deadlock simply can't form. This is worth enforcing as a code-review rule for any multi-row transactional update path (fund transfers, seat reservations, inventory swaps).
6. Transaction Scope in Application Code
Never make a network call (payment gateway, email service, another microservice) inside an open database transaction. If that call is slow or hangs, every lock your transaction holds stays held for as long as the network call takes — potentially seconds, potentially never returning. This is one of the most common causes of a database-wide outage: one slow external dependency, called from inside a transaction, cascades into connection pool exhaustion for the entire service. Do the external call before opening the transaction (validate first) or after committing (fire the side effect once data is durably saved, ideally via an outbox pattern for reliability).
// BAD: external call inside the transaction boundary
@Transactional
public void placeOrder(OrderRequest request) {
Order order = orderRepository.save(toOrder(request));
paymentGateway.charge(order); // network call while DB locks are held!
inventoryRepository.decrement(order.getItems());
}
// BETTER: keep the transaction to DB work only; use an outbox for the side effect
@Transactional
public void placeOrder(OrderRequest request) {
Order order = orderRepository.save(toOrder(request));
inventoryRepository.decrement(order.getItems());
outboxRepository.save(new OutboxEvent("PAYMENT_REQUESTED", order.getId()));
// A separate async worker reads the outbox and calls paymentGateway.charge(...)
// outside of any open DB transaction, with its own retry logic.
}Key takeaways
- ACID's four properties aren't abstract — atomicity and isolation are what make concurrent writes to the same row safe; durability is what a
COMMITactually promises. - MySQL/InnoDB defaults to
REPEATABLE READ; PostgreSQL defaults toREAD COMMITTED— never assume the default, set isolation explicitly for correctness-critical code. - InnoDB's MVCC lets readers see a consistent snapshot without blocking writers, which is why strong isolation doesn't have to mean poor read throughput.
- Use
SELECT ... FOR UPDATE(pessimistic) for short, high-contention critical sections; use aversioncolumn (optimistic) when there's human think-time between read and write. - Deadlocks are normal under concurrency, not a sign of a broken system — the fix is application-level retry logic plus consistent lock ordering, not trying to eliminate them entirely.
- Never hold a database transaction open across a network call — it's one of the most common causes of cascading production outages under load.
- An atomic conditional
UPDATE ... WHERE quantity >= ?is often simpler and safer thanSELECT ... FOR UPDATEfollowed by a separateUPDATE. - Keep transactions as short as possible; every statement inside
BEGIN/COMMITextends how long your locks block everyone else.
Interview Questions
- Explain each of the four ACID properties with a concrete example of what breaks if it's missing.
- What is a dirty read, and which isolation level prevents it?
- What is the difference between a non-repeatable read and a phantom read?
- Why do MySQL and PostgreSQL have different default isolation levels, and why does that matter for portability?
- How does MVCC allow readers to avoid blocking on writer locks?
- What is the difference between a record lock, a gap lock, and a next-key lock in InnoDB?
- When would you choose optimistic locking over pessimistic locking? Give a concrete example of each.
- Walk through how a lost update happens with two concurrent
SELECT+UPDATEsequences, and how you'd prevent it. - What causes a deadlock, and how does InnoDB resolve one once detected?
- What is the single most effective technique for preventing deadlocks in a multi-row transactional workflow?
- Why is it dangerous to call an external HTTP API inside an open database transaction?
- How would you implement a reliable "charge payment after order is saved" flow without holding locks across the payment call?
- What does
SELECT ... FOR UPDATEactually do, and what's an alternative single-statement pattern that avoids holding a lock across a round trip? - Why might lowering isolation from
REPEATABLE READtoREAD COMMITTEDreduce deadlocks, and what do you give up by doing so?