Topic 08

Concurrency Control

When thousands of users are reading and writing the same data at the same moment, how does the database keep everything correct? If two people try to buy the last item in stock at the same time, only one should succeed. This topic explains the strategies databases use to manage that safely — from simple locks to multi-version snapshots.

PostgreSQL MVCCInnoDB 2PL OracleCockroachDB SSIMySQL

At a Glance

Core Concepts

Two-Phase Locking (2PL)

Two-Phase Locking is a set of rules that prevents concurrent transactions from producing incorrect results. Phase one (growing): the transaction acquires all the locks it needs — on every row it reads or writes. It never releases any locks during this phase. Phase two (shrinking): when the transaction is done with all its work, it releases all locks at once (on commit or rollback). This two-phase discipline guarantees that no two concurrent transactions can produce results that would be impossible in a sequential world. The version used in real databases holds all locks until commit to avoid a problem where one transaction's rollback forces another to also rollback.

MVCC

Multi-Version Concurrency Control (MVCC) is the reason you can read data quickly on a busy database without waiting for writers to finish. Instead of locking a row when a writer updates it, the database keeps the old version for readers and creates a new version for the writer. A reader sees a consistent snapshot of the database from the moment their transaction started — even if other transactions are updating rows at the same time. Readers never block writers. Writers never block readers. PostgreSQL and Oracle use MVCC for all reads — no read locks needed.

Deadlock

Imagine Transaction A is holding the lock on Row 1 and waiting for Row 2. At the same time, Transaction B is holding the lock on Row 2 and waiting for Row 1. Both are stuck waiting for each other — forever. This is a deadlock. The database detects it by checking for these circular waits. When found, it picks one transaction as the "victim," rolls it back (freeing its locks), and lets the other proceed. This is a normal occurrence in a busy database — your application code needs to catch the deadlock error and retry the transaction.

Optimistic Concurrency

Optimistic concurrency is based on the assumption that conflicts are rare. Instead of locking rows when you read them, you just read freely. When you are ready to save your changes, you check whether anyone else changed the same data while you were working. If no conflict — great, commit. If someone else changed it — abort and try again. This works very well when conflicts are unlikely (most reads), but is inefficient if many transactions are competing for the same rows (they all keep retrying). A common implementation is a "version number" column — you read the version, and when writing, check that the version is still the same.

Lock Types

Lock Compatibility Matrix

Different lock types have different compatibility rules. The matrix determines whether a lock request can be granted immediately or must wait. InnoDB adds intent locks (IS, IX) at the table level to enable efficient table-level lock checks without scanning all row locks.

Requester ↓ / Holder →ISIXSSIXX
Intent Shared (IS)YesYesYesYesNo
Intent Exclusive (IX)YesYesNoNoNo
Shared (S)YesNoYesNoNo
Shared + IX (SIX)YesNoNoNoNo
Exclusive (X)NoNoNoNoNo
Two-Phase Locking

2PL Protocol

2PL divides a transaction's lifetime into two strictly separated phases: a growing phase where locks are only acquired (never released), and a shrinking phase where locks are only released (never acquired). This ordering constraint is sufficient to guarantee that any concurrent execution schedule is serializable.

1

Growing phase

In the first phase, the transaction accumulates all the locks it needs. Reading a row requires a shared lock (others can also read, but no one can write). Writing requires an exclusive lock (no one else can read or write). If a transaction needs to upgrade from read to write on the same row, it waits until all other readers release their locks first. During this entire phase, no locks are ever released — only acquired.

2

Lock point

The moment a transaction releases its first lock is called the lock point. After this moment, the transaction cannot acquire any new locks. The order in which transactions reach their lock points effectively defines the order in which they "happened." Two transactions that never overlap in their lock sets can run truly in parallel. Two that do overlap must take turns, and their lock points determine which one went first in the equivalent sequential execution.

3

Strict 2PL (used by real databases)

In theory, a transaction could release some locks early while still running. But this creates a danger: another transaction could read the partially-released data, and if the first transaction later rolls back, that second transaction made decisions based on data that never officially existed. This chain of failures is called a cascading abort. To prevent it, real databases use Strict 2PL: all locks are held until the very end (commit or rollback), then released all at once. This is slightly more conservative but prevents cascading failures entirely.

4

Lock granularity

Locks can be applied at different scales: the whole table, a page of rows, or a single row. Locking an entire table is simple but heavy — it blocks everyone while one transaction is running. Locking individual rows is more precise and allows multiple transactions to work on different rows at the same time. To avoid scanning every row-level lock when someone wants to lock the whole table, databases use intent locks — a quick signal at the table level that says "there are row locks in here," allowing fast compatibility checks without looking at every row.

InnoDB Locks

Gap Locks and Next-Key Locks

MySQL InnoDB at REPEATABLE READ uses gap locks to prevent phantom reads without requiring SERIALIZABLE isolation. A gap lock locks the gap between two index values — preventing any INSERT that would fall into that gap.

1

Record lock

A record lock targets exactly one row. When you run SELECT * FROM t WHERE id = 10 FOR UPDATE, the database places a lock on the row with id=10. No other transaction can update or delete that specific row while your transaction holds the lock. It does not block inserts of unrelated rows — only this one record is protected.

2

Gap lock

A gap lock protects the empty space between existing index values. Imagine you are reading all orders with id between 10 and 20. A gap lock on that range prevents any other transaction from inserting a new order that would fall inside it. This stops phantom rows — rows that would appear if you re-ran the same query. Importantly, two transactions can both hold gap locks on the same range at the same time without conflicting, because they are both just preventing inserts, not modifying anything.

3

Next-key lock

A next-key lock is a record lock and a gap lock combined into one. It protects a specific existing row AND the empty space just before it. When InnoDB scans a range of rows, it places next-key locks on each row it touches, plus the gap after the last one. The result: no other transaction can modify any of those rows, and no transaction can insert a new row that would appear in that range. It is InnoDB's default strategy for range scans.

4

Deadlocks with gap locks

Gap locks can create surprising deadlocks. Imagine two transactions each trying to insert a row next to the other's gap lock. Transaction A holds a gap lock that blocks B's insert, and transaction B holds a gap lock that blocks A's insert. Neither can move forward — a deadlock. InnoDB detects this circular standoff automatically, picks the younger transaction as the victim, rolls it back, and lets the other proceed. Your application should catch error 1213 (ER_LOCK_DEADLOCK) and retry the transaction.

Deadlocks

Deadlock Detection and Prevention

StrategyHow It WorksProsCons
Deadlock detectionMaintain wait-for graph; detect cycles periodically; abort victimNo unnecessary aborts; maximum concurrencyCycles can exist briefly; detection adds overhead
TimeoutAbort any transaction waiting for a lock longer than N msSimple; no graph maintenanceFalse positives (slow transactions aborted unnecessarily)
Wait-DieOlder Tx waits for younger; younger Tx dies instead of waitingNo cycles possible; deterministicExtra aborts of young transactions under high load
Wound-WaitOlder Tx forces younger Tx to abort (wound); younger Tx waits for olderOlder (more important) transactions rarely abortYounger transactions are aggressively killed
Lock orderingApplication always acquires locks in a fixed global orderEliminates deadlocks entirely; no overheadRequires disciplined coding across all code paths
SQL — Locking Patterns
-- Explicit row lock: SELECT FOR UPDATE (pessimistic)
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE; -- X lock on row 42
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;

-- Shared lock: FOR SHARE (read-with-lock, no exclusive needed)
SELECT * FROM orders WHERE id = 99 FOR SHARE;
-- Other transactions can also take FOR SHARE; only FOR UPDATE blocks

-- Skip locked rows: non-blocking job queue pattern
BEGIN;
SELECT id, payload FROM job_queue
  WHERE status = 'pending'
  ORDER BY priority DESC, created_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED; -- skip rows locked by other workers
UPDATE job_queue SET status = 'processing', worker_id = :wid
  WHERE id = :id;
COMMIT;

-- Lock timeout: abort if lock not acquired within 2 seconds
SET lock_timeout = '2s';  -- PostgreSQL
SET innodb_lock_wait_timeout = 2;  -- MySQL

-- Advisory lock: application-level mutex (PostgreSQL)
SELECT pg_advisory_xact_lock(12345); -- acquired until COMMIT/ROLLBACK
SELECT pg_try_advisory_xact_lock(12345); -- non-blocking; returns false if busy

-- Optimistic locking with version column
BEGIN;
SELECT balance, version FROM accounts WHERE id = 42;
-- application computes new_balance, reads version=7
UPDATE accounts
  SET balance = :new_balance, version = version + 1
  WHERE id = 42 AND version = 7;
-- if 0 rows updated, someone else modified it — retry
COMMIT;
MVCC vs 2PL

MVCC Compared to Locking

Property2PL (Strict)MVCC (Snapshot Isolation)SSI (Serializable SI)
Reads block writers?Yes (shared lock)NoNo
Writers block readers?Yes (excl. lock)NoNo
Prevents write skew?Yes (X lock)NoYes (anti-dep tracking)
Prevents phantom reads?Yes (predicate lock)Not alwaysYes
Deadlocks possible?YesNo (read-only paths)Serialization aborts
Garbage collectionN/AVACUUM neededVACUUM needed
Used byInnoDB (for writes), DB2PostgreSQL, Oracle, SQL Server RCSIPostgreSQL SERIALIZABLE, CockroachDB
Interactive Demo

Two-Phase Locking Visualizer

Step through a 3-transaction, 5-resource locking scenario. Watch Shared and Exclusive locks fill the table. A deadlock cycle forms and triggers rollback of one victim.

Lock Phases GROWING PHASE acquire locks, never release Lock Point SHRINKING PHASE release locks, never acquire Lock Table Resource T1 T2 T3 Row A X WAIT Row B X WAIT Wait-For Graph T1 T2 DEADLOCK

2PL prevents non-serializable schedules by ensuring all locks are acquired before any are released. The deadlock is a cycle in the wait-for graph — T1 holds Row A and waits for Row B; T2 holds Row B and waits for Row A. The DBMS picks a victim and rolls back.

2PL Lock Table

S = Shared lock, X = Exclusive lock. Red = deadlock cycle. One transaction will be rolled back as the victim.
Anti-patterns

Acquiring locks in inconsistent order

If T1 locks row A then row B, and T2 locks row B then row A, a deadlock is guaranteed under concurrent execution. Always acquire locks in a consistent global order across all code paths — ascending primary key, alphabetical table name, or any deterministic ordering. This is the single most effective deadlock prevention technique.

Table-level locks for row-level operations

LOCK TABLE orders IN EXCLUSIVE MODE when a row-level FOR UPDATE would suffice blocks every reader and writer on the table. Row-level locking has dramatically higher concurrency. Use table-level locks only for DDL operations or bulk imports that genuinely need exclusive table access.

Not handling deadlock errors in application code

Deadlocks are a normal outcome of concurrent transactions, not a bug. The DBMS will abort one transaction with SQLSTATE 40P01 (PostgreSQL) or error 1213 (MySQL). Application code must catch this specific error and retry the entire transaction — not crash, not surface a 500 error to the user. Implement exponential backoff and a retry limit.

Long gaps between SELECT and UPDATE

SELECT FOR UPDATE locks the row. If application code runs long logic (API call, file I/O) between the SELECT and the UPDATE, the lock is held for the entire duration. Every other transaction waiting for that row queues up. Keep the locked read-modify-write operation in a single short transaction. Prepare all inputs before BEGIN.

Using advisory locks as a substitute for proper ACID transactions

Advisory locks (pg_advisory_lock) are application-level mutual exclusion that the database does not understand in terms of data correctness. Relying on advisory locks instead of proper row locking for data mutations means the DBMS cannot help with deadlock detection or automatic abort. Use row-level locks for data protection; use advisory locks only for coarse-grained application-level serialization (e.g., "only one migration runs at a time").

Quiz
Question 1 of 5
Two-phase locking (2PL) guarantees:
AComplete deadlock freedom
BSerializability of concurrent transactions
CMaximum possible write throughput
DZero transaction rollbacks
Question 2 of 5
MVCC (Multi-Version Concurrency Control) primarily eliminates:
AAll deadlocks in the system
BReader-writer blocking — readers don't block writers and writers don't block readers
CExtra storage usage from row versioning
DAll write-write conflicts
Question 3 of 5
A phantom read occurs when:
AA transaction reads data not yet committed by another transaction
BA specific row returns a different value when re-read within the same transaction
CA range query returns different rows on re-execution within the same transaction
DTwo transactions form a mutual wait cycle
Question 4 of 5
In MySQL InnoDB, a gap lock prevents:
AUpdates to rows within the locked range
BInsertion of new rows into the gap between locked index values
CReads from any row in the table
DAny access to the table by other transactions
Question 5 of 5
Which strategy completely eliminates deadlocks without aborting transactions unnecessarily?
ADeadlock detection (wait-for graph cycle detection)
BLock timeout (abort after N seconds)
CConsistent global lock ordering across all transactions
DWound-Wait protocol
Interview Q&A
Explain 2PL and why it guarantees serializability.
Two-Phase Locking divides a transaction's lifetime into two phases: (1) Growing phase: acquire locks (S or X), never release. (2) Shrinking phase: release locks, never acquire. The lock point is the instant the first lock is released. The serialization order of concurrent transactions equals their lock-point order. Why? Because no transaction can acquire new locks after its lock point. If T1's lock point comes before T2's, T2 must have waited for T1 to release at least one lock before it could proceed — the schedule is equivalent to T1 running before T2. Strict 2PL: hold all locks until commit. This additionally prevents dirty reads and cascading aborts. Most databases use Strict 2PL.
How does deadlock detection work and how do you prevent deadlocks in application code?
Detection: the DBMS maintains a wait-for graph — nodes are transactions, a directed edge T1→T2 means T1 is waiting for a lock held by T2. A cycle = deadlock. PostgreSQL runs a lightweight cycle check before sleeping on a lock wait. MySQL InnoDB checks for deadlocks on every lock acquisition. Victim selection: typically the youngest transaction (minimum work lost) or smallest number of locks held. Prevention in application code: (1) Consistent lock ordering across all code paths — always lock rows in ascending PK order. (2) Keep transactions short — reduce the overlap window. (3) Retry on error 40P01/1213 with exponential backoff. (4) Never wait for external I/O while holding locks. (5) Set lock_timeout to fail fast rather than wait indefinitely.
What is the difference between optimistic and pessimistic concurrency control?
Pessimistic: assume conflicts will happen — prevent them upfront with locks. Acquire locks before reading or writing data. No conflict possible during execution because other transactions are blocked. Release locks on commit. Best under high contention. Optimistic: assume conflicts are rare. Execute without any locking — read data, compute result, validate at commit that no conflict occurred (i.e., no read row was modified by another committed transaction since you read it). If conflict: abort and retry. Best under low contention. Wasted work under high contention. Implementations: SELECT FOR UPDATE is pessimistic; version-column optimistic locking (UPDATE … WHERE version = :v, fail if 0 rows updated) is optimistic. MVCC snapshot isolation is conceptually optimistic for reads (free reads), but pessimistic for write-write conflicts (still uses row locks). SSI is fully optimistic — even write conflicts are validated at commit.
What is SELECT FOR UPDATE SKIP LOCKED and when would you use it?
SELECT … FOR UPDATE SKIP LOCKED acquires an exclusive lock on the selected rows but, instead of blocking on rows already locked by other transactions, silently skips them. Use case: database-backed job queues. Multiple workers each run: SELECT id, payload FROM jobs WHERE status='pending' LIMIT 1 FOR UPDATE SKIP LOCKED. Each worker atomically claims a different job — no two workers claim the same job, and they don't block each other. Without SKIP LOCKED, all workers would queue waiting for the first pending job, creating a thundering herd when that job is released. Supports throughput of roughly 1K–5K jobs/sec from PostgreSQL. For higher throughput, use a dedicated queue (Redis, Kafka, SQS).
What is Serializable Snapshot Isolation (SSI) and how does it differ from MVCC snapshot isolation?
MVCC Snapshot Isolation (SI): each transaction reads a snapshot frozen at start time. Writers create new versions. Provides: no dirty reads, no non-repeatable reads, no lost updates. Vulnerability: write skew (two transactions read overlapping data, write non-overlapping data, violate an invariant together). SSI (used in PostgreSQL's SERIALIZABLE, CockroachDB): builds on SI and adds tracking of read-write anti-dependencies. If T1's snapshot includes rows later written by T2, and T2's snapshot includes rows later written by T1, a dangerous rw-anti-dependency cycle exists — one must abort. Implemented via "predicate locks" (SIREAD locks) that track what was read. Benefit: reads never block; no row-level lock acquisition for reads. Cost: some false-positive aborts (transactions aborted conservatively when a cycle might exist). Performance overhead is low under low-conflict workloads.
Explain intent locks and why databases need them.
Without intent locks, checking whether a table-level lock can be granted requires scanning all existing row-level locks on that table — O(n) in the number of locked rows. Intent locks solve this with a two-level hierarchy. Before acquiring a row-level shared lock, a transaction acquires an Intent Shared (IS) lock on the table. Before acquiring a row-level exclusive lock, it acquires an Intent Exclusive (IX) lock on the table. To grant a full table S lock, the DBMS only checks if any IX lock exists on the table — O(1). IS and IX are compatible with each other (multiple transactions can have row-level locks on the same table simultaneously), but IX conflicts with S (can't take a table-level read lock when rows have exclusive locks) and X conflicts with everything. This makes lock management at scale efficient without sacrificing correctness.
How does PostgreSQL advisory lock work and when should you use it?
PostgreSQL advisory locks are application-defined locks using 64-bit integer keys — not tied to specific rows or tables. They exist only in the lock manager, not in data. Two forms: session-level (pg_advisory_lock(key)) — held until explicitly released or session ends; transaction-level (pg_advisory_xact_lock(key)) — automatically released at commit/rollback. Use cases: distributed mutex for background jobs ("only one instance of the nightly job runs at a time"); application-level resource locking when DB row locking is too granular; preventing concurrent schema migrations. Non-blocking variant: pg_try_advisory_xact_lock(key) returns false if already locked. Do NOT use advisory locks for row-level data protection — the DBMS won't enforce it and you bypass all deadlock detection and ACID guarantees for that data.
What happens when a lock timeout occurs versus a deadlock?
Lock timeout: a transaction waits for a lock longer than the configured timeout (e.g., SET lock_timeout = '2s'). The waiting transaction is aborted with error code 55P03 (lock_timeout, PostgreSQL) — not the lock holder. The lock holder continues normally. Lock timeout is a blunt instrument — it aborts potentially non-deadlocked transactions. Deadlock: two or more transactions are mutually waiting with no way to proceed. The DBMS detects the cycle and aborts one transaction (the "victim") with 40P01. Application response to both: catch the error and retry the transaction. The distinction matters for which transaction to retry: on timeout, only the waiting transaction needs retry; on deadlock, only the victim transaction needs retry (the winner continues). Both are transient failures — retry with exponential backoff is the correct pattern.
Further Reading
Previous
Query Processing