Two-Phase Locking (2PL)
Two-Phase Locking, usually abbreviated 2PL, is a concurrency-control protocol for making interleaved transactions behave like some serial execution.
The central problem it solves is this:
Two transactions may each perform individually valid reads and writes, but their operations can interleave in an order that produces a result that could never occur if the transactions had run one at a time.
2PL prevents that class of problem by controlling when a transaction may acquire and release conflicting locks.
The name has nothing to do with distributed commit.
Two-Phase Locking (2PL) controls concurrent access to data.
Two-Phase Commit (2PC) coordinates commit/rollback across multiple participants in a distributed transaction.
A distributed system can use both, but they solve completely different problems.
The problem 2PL is trying to solve
Suppose two transactions operate on two values, A and B.
A serial execution would look like:
T1 completely finishes
then T2 runs
or:
T2 completely finishes
then T1 runs
Real databases want more concurrency, so they interleave operations:
T1 reads A
T2 reads B
T1 writes B
T2 writes A
Some interleavings are harmless.
Others create contradictory ordering requirements.
For example, imagine this schedule:
T1 reads A
T2 writes A
T2 reads B
T1 writes B
Because T1 read A before T2 changed it, that interaction suggests:
T1 β T2
But because T2 read B before T1 changed it, the second interaction suggests:
T2 β T1
Together:
T1 β T2 β T1
There is a cycle.
There is therefore no way to say:
T1 happened entirely before T2
or:
T2 happened entirely before T1
while preserving those conflicting operations.
The schedule is not conflict serializable.
Two-Phase Locking was designed to prevent exactly these cycles.
Locks first: Shared and Exclusive
The textbook 2PL model usually begins with two lock types.
| Lock | Meaning | Compatible with another S lock? | Compatible with an X lock? |
|---|---|---|---|
Shared (S) |
Transaction wants to read the item | Yes | No |
Exclusive (X) |
Transaction wants to modify the item | No | No |
For a data item A:
T1: S(A)
means:
T1 intends to read A.
Another reader can also hold:
T2: S(A)
at the same time.
But:
T1: X(A)
means another transaction cannot obtain a conflicting S(A) or X(A) until that lock becomes available.
Conceptually:
S + S β allowed
S + X β conflict
X + S β conflict
X + X β conflict
Real database engines have many additional lock modes, such as update locks, intention locks, range locks, key-share locks, and metadata locks.
Those are implementation details built around the same general idea:
operations that cannot safely occur concurrently must be made incompatible.
The two phases
A transaction following Basic 2PL moves through two phases.
Growing phase
The transaction may:
acquire new locks
upgrade existing locks
but it does not release locks.
For example:
S(A)
X(B)
S(C)
is still in the growing phase.
Shrinking phase
The shrinking phase begins when the transaction releases its first lock.
From that point onward, it may release additional locks, but it may not acquire a new lock or upgrade an existing one.
For example:
S(A)
X(B)
S(C)
release S(A)
release X(B)
release S(C)
is valid.
But this is not:
S(A)
X(B)
release S(A)
X(C) <-- illegal under 2PL
Once shrinking has started, lock acquisition is finished.
The lock point
The lock point is the instant at which a transaction obtains its final lock.
For example:
Acquire S(A)
Acquire X(B)
Acquire S(C) <-- lock point
do work
Release S(A) <-- shrinking begins
Release X(B)
Release S(C)
Notice that:
lock point
and:
first unlock
are not necessarily the same instant.
There can be work between them.
The important fact is simply:
after the first unlock, the transaction can never obtain another lock.
Why this strange rule guarantees conflict serializability
This is the part that is usually omitted from short 2PL notes.
Suppose two transactions conflict on some data item.
For example:
T1 holds X(A)
T2 needs S(A)
T2 cannot obtain its conflicting lock until T1 releases its lock.
Because T1 follows 2PL, by the time it releases A, T1 has already obtained every lock it will ever need.
Its lock point has therefore already occurred.
T2 obtains its conflicting lock later.
So for this dependency:
T1 β T2
we also know:
lockPoint(T1) < lockPoint(T2)
Now imagine the conflict graph contained a cycle:
T1 β T2
T2 β T3
T3 β T1
The lock-point ordering would require:
lockPoint(T1) < lockPoint(T2)
lockPoint(T2) < lockPoint(T3)
lockPoint(T3) < lockPoint(T1)
Combining them would imply:
lockPoint(T1)
<
lockPoint(T2)
<
lockPoint(T3)
<
lockPoint(T1)
which is impossible.
Therefore the conflict graph cannot contain a cycle.
An acyclic conflict graph means the schedule is conflict serializable.
This is the fundamental reason 2PL works.
What goes wrong if the two-phase rule is broken
Consider this schedule:
T1: acquire S(A)
T1: read A
T1: release S(A)
T2: acquire X(A)
T2: write A
T2: acquire S(B)
T2: read B
T1: acquire X(B) <-- T1 acquires a lock after releasing one
T1: write B
T1 violates 2PL because it released A and later acquired a new lock on B.
Now look at the conflicts.
On A:
T1 read A before T2 wrote A
T1 β T2
On B:
T2 read B before T1 wrote B
T2 β T1
Therefore:
T1 β T2 β T1
The schedule is not conflict serializable.
The rule:
never acquire another lock after releasing your first lock
is therefore not arbitrary bookkeeping.
It prevents transactions from creating contradictory ordering relationships late in their execution.
Basic 2PL is serializable, but recovery can still be unpleasant
Basic 2PL guarantees conflict serializability.
It does not require every lock to be held until commit.
That creates another problem.
Imagine T1 does:
T1:
X(A)
write A = 500
release X(A)
T1 has entered its shrinking phase and will not acquire another lock, so Basic 2PL has not been violated.
Before T1 commits, T2 obtains:
S(A)
and reads:
A = 500
Then T1 discovers an error and rolls back.
The value T2 read never really committed.
T2 has consumed dirty data and may itself need to roll back.
If another transaction depended on T2, the rollback could cascade further.
This is the motivation for Strict 2PL.
The main 2PL variants
| Variant | Rule | Main purpose |
|---|---|---|
| Basic 2PL | Acquire locks during growing phase; after first release, acquire no more locks | Conflict serializability |
| Strict 2PL | Basic 2PL plus hold exclusive/write locks until commit or rollback | Prevent dirty reads/writes of uncommitted changes through conflicting lock access; avoid cascading aborts |
| Rigorous 2PL | Hold both shared and exclusive locks until transaction end | Makes transaction order and recovery particularly simple |
| Conservative / Static 2PL | Obtain the complete required lock set before execution proceeds | Prevent lock deadlocks caused by gradually acquiring locks |
These variants address different problems.
They should not be thought of as four completely unrelated protocols.
For example, strictness is about how long write locks are retained, while conservative 2PL is about obtaining the required lock set before execution instead of discovering locks incrementally.
The ideas can therefore overlap.
Strict 2PL
Under Strict Two-Phase Locking, a transaction does not release its exclusive locks until:
COMMIT
or:
ROLLBACK
Return to the earlier example.
T1 writes:
A = 500
but has not committed.
Because T1 still holds:
X(A)
T2 cannot obtain a conflicting lock allowing it to read or overwrite that value.
Conceptually:
T1:
X(A)
write A = 500
...
COMMIT
release X(A)
T2:
waits...
S(A)
read A
If T1 rolls back instead:
T2 never observes the uncommitted value.
This makes recovery much easier.
A transaction cannot build committed work on top of another transaction's still-uncommitted write through conflicting locked access.
Strict 2PL still has a growing and shrinking rule
A subtle point:
Strict 2PL does not simply mean:
hold X locks until commit
and do whatever you want with every other lock
The transaction still follows Basic 2PL.
For example:
Acquire S(A)
Acquire X(B)
Release S(A)
Acquire X(C)
is not valid Strict 2PL.
The release of S(A) started the shrinking phase.
Acquiring X(C) afterward violates 2PL.
Holding X(B) until commit does not repair that violation.
Rigorous 2PL
Rigorous 2PL goes further.
Instead of holding only write locks until the transaction ends, it holds both:
S locks
X locks
until commit or rollback.
Conceptually:
BEGIN
S(A)
X(B)
S(C)
read/write work
COMMIT
release S(A)
release X(B)
release S(C)
There is effectively no normal shrinking phase before transaction completion.
This has a useful property:
if T1 releases a conflicting lock before T2 obtains it, T1 must already have finished.
That makes the committed transaction order especially easy to reason about.
However, keeping read locks until transaction completion can increase blocking.
That tradeoff is one reason modern database systems often combine locking with MVCC rather than using textbook rigorous 2PL for every ordinary read.
Conservative 2PL
Ordinary 2PL can deadlock because transactions usually discover the locks they need gradually.
For example:
T1 locks A
T2 locks B
T1 requests B β waits
T2 requests A β waits
Neither transaction can continue.
Conservative 2PL avoids this specific problem by requiring a transaction to obtain its complete lock set before execution proceeds.
For example, T1 determines that it needs:
X(A)
X(B)
S(C)
and asks for all of them.
If the complete set is unavailable, it does not start while holding only part of that set.
Therefore the classic pattern:
hold one resource
while waiting for another
does not develop.
The disadvantage is obvious.
Many database transactions do not know their complete lock set in advance.
A query might first read:
customer_id = 42
then discover from that row which invoices, accounts, jobs, or child records must be accessed.
Predeclaring everything would be difficult or impossible.
It can also reduce concurrency by acquiring resources earlier than actually needed.
Deadlocks are not evidence that 2PL is broken
Deadlocks are a normal consequence of lock-based concurrency.
Suppose two bank transfers execute simultaneously.
Transaction T1:
transfer from account 1 to account 2
Transaction T2:
transfer from account 2 to account 1
If they lock their source account first:
T1: X(account 1)
T2: X(account 2)
then:
T1 asks for account 2 β waits
T2 asks for account 1 β waits
We now have:
T1 waits for T2
T2 waits for T1
That is a deadlock.
The database normally detects the cycle, aborts one participant, and lets the other continue.
The application then retries the aborted transaction.
PostgreSQL automatically detects lock deadlocks and aborts one transaction; its documentation recommends acquiring multiple objects in a consistent order where possible. PostgreSQL uses SQLSTATE 40P01 for deadlock_detected.
MySQL/InnoDB similarly detects deadlocks. Error 1213, ER_LOCK_DEADLOCK, uses SQLSTATE 40001, and MySQL explicitly tells applications to restart the transaction.
SQL Server chooses a deadlock victim, rolls its transaction back, and returns error 1205.
Concrete solution: always lock accounts in the same order
A common way to reduce the bank-transfer deadlock is to define a canonical lock order.
Instead of:
lock source
then lock destination
every transfer uses:
lock lower account_id
then lock higher account_id
For example, suppose accounts contains:
| account_id | balance |
|---|---|
| 1 | 1000 |
| 2 | 1000 |
In PostgreSQL, the transaction first locks and reads both rows:
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN (1, 2)
ORDER BY account_id
FOR UPDATE;
| account_id | balance |
|---|---|
| 1 | 1000 |
| 2 | 1000 |
Then it verifies the rows and applies the transfer:
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
COMMIT;
| account_id | balance |
|---|---|
| 1 | 900 |
| 2 | 1100 |
A concurrent reverse transfer should use the same account ordering.
Now both transactions attempt:
account 1
then account 2
instead of:
T1: 1 β 2
T2: 2 β 1
This greatly reduces the possibility of a cycle.
It does not mean an application can ignore deadlock handling entirely: databases can acquire additional internal/index locks and larger transactions can involve other resources.
Retry logic is still necessary.
Lock ordering and 2PL solve different problems
These concepts are often mixed together.
2PL prevents non-serializable conflict schedules.
Consistent lock ordering reduces deadlocks.
A system can follow perfect 2PL and still deadlock.
For example:
T1: lock A
T2: lock B
T1: request B
T2: request A
Neither transaction has released anything.
Both are still completely inside the growing phase.
They are obeying 2PL.
They are also deadlocked.
So:
2PL β serializability
does not imply:
2PL β no deadlocks
Rows are not the only things that may need locking
Textbook explanations often talk about:
lock(A)
lock(B)
as though every data item were one database row.
Real databases may lock or protect:
| Resource | Why it may matter |
|---|---|
| Row / tuple | Protect one existing record |
| Index entry | Protect access to indexed data |
| Key range | Prevent matching keys from appearing |
| Gap between keys | Prevent inserts into a predicate range |
| Page | Protect a larger storage unit |
| Table | Protect many rows at once |
| Metadata/schema object | Protect table definitions and other metadata |
| Logical transaction resource | Used by some modern lock implementations |
This becomes especially important for queries based on predicates.
Row locks cannot protect a row that does not exist
Consider a reservation system with this rule:
only one active reservation may exist
for resource 42 at 10:00
The reservations table has no row for resource 42 at 10:00:
| reservation_id | resource_id | start_time |
|---|
Transaction T1 executes:
SELECT *
FROM reservations
WHERE resource_id = 42
AND start_time = '10:00'
FOR UPDATE;
| rows returned |
|---|
| 0 |
Suppose no row exists.
What exactly did T1 row-lock?
Nothing.
There is no existing row corresponding to:
resource 42 at 10:00
to lock.
Another transaction might also find no row and attempt to insert one.
This is the predicate / phantom problem.
The logical thing that needs protection is:
"all current and future rows satisfying this condition"
rather than only:
"these rows that currently exist"
Different database systems solve this in different ways.
How SQL Server solves predicate protection
SQL Server's lock-based SERIALIZABLE isolation can use key-range locks.
Suppose reservations contains these rows:
| reservation_id | resource_id | start_time |
|---|---|---|
| 1 | 42 | 09:30 |
| 2 | 42 | 10:15 |
| 3 | 42 | 11:00 |
A query reads the indexed range:
SELECT *
FROM reservations
WHERE resource_id = 42
AND start_time >= '10:00'
AND start_time < '11:00';
| reservation_id | resource_id | start_time |
|---|---|---|
| 2 | 42 | 10:15 |
Protecting only currently existing rows is insufficient.
Another transaction could insert a new index key that falls into that interval.
Under locking Serializable behavior, SQL Server can protect the relevant index range so another transaction cannot insert a qualifying key until the first transaction finishes. Microsoft explicitly describes key-range locks as the mechanism used at Serializable to prevent phantom insertions.
Conceptually:
Existing keys:
09:00
09:30
[ protected range ]
11:00
The protected object is partly the space between keys, not only existing rows.
This is how lock-based serializable implementations extend the 2PL idea from individual rows to predicates.
How MySQL/InnoDB solves range conflicts
InnoDB combines MVCC with record and index-range locking.
Suppose bookings has these rows for event 42:
| booking_id | event_id |
|---|---|
| 501 | 42 |
| 502 | 42 |
A locking query such as:
SELECT booking_id
FROM bookings
WHERE event_id = 42
FOR UPDATE;
| booking_id |
|---|
| 501 |
| 502 |
can acquire locks on encountered index records.
At isolation levels and access patterns where range protection is needed, InnoDB also uses gap locks and next-key locks so that concurrent transactions cannot simply insert a new qualifying record between existing index entries.
This is how InnoDB protects situations in which:
the dangerous row does not exist yet
or the logical resource is a range rather than one row.
MySQL's InnoDB documentation explicitly separates ordinary consistent MVCC reads from locking reads and documents next-key locking as its phantom-protection mechanism.
PostgreSQL does something importantly different
PostgreSQL is an excellent example of why:
"database locking" is not automatically the same thing as "the database uses textbook 2PL for everything. "
Suppose accounts contains (account_id=1, balance=1000). An ordinary PostgreSQL read is:
SELECT *
FROM accounts
WHERE account_id = 1;
| account_id | balance |
|---|---|
| 1 | 1000 |
It uses MVCC.
It does not normally acquire an S row lock that blocks a concurrent writer.
If another transaction has modified the row but not yet committed, PostgreSQL can often read an older committed version instead.
This differs fundamentally from a pure textbook scheme where every read must acquire an S lock conflicting with a writer's X lock.
PostgreSQL row locks such as:
SELECT ...
FOR UPDATE;
do exist.
They block other writers and conflicting lockers until the transaction ends, but ordinary MVCC readers can continue reading suitable committed row versions. PostgreSQL explicitly documents that row-level locks do not prevent ordinary data querying.
Therefore:
PostgreSQL uses locks
does not imply:
PostgreSQL implements all transaction isolation
using classical 2PL.
PostgreSQL Serializable is not lock-based 2PL
PostgreSQL's Serializable level is particularly important.
It is implemented using:
Serializable Snapshot Isolation (SSI).
PostgreSQL begins with snapshot isolation and monitors read/write dependencies between concurrent transactions.
When it detects a dependency structure that could allow a non-serializable result, it aborts one of the transactions.
Conceptually:
Traditional lock-based approach:
prevent dangerous access
by blocking it with locks
versus PostgreSQL SSI:
allow substantial concurrent snapshot execution
track dependencies
detect dangerous serialization structure
abort one transaction if necessary
PostgreSQL does use what its documentation calls predicate locks for SSI, represented as SIReadLock, but these are not ordinary blocking locks.
They exist to record read dependencies.
PostgreSQL explicitly states that its Serializable predicate locks do not themselves block other transactions and therefore do not participate in lock deadlocks.
That is very different from SQL Server Serializable key-range locks.
"Predicate lock" means different things operationally
The terminology can therefore be confusing.
SQL Server Serializable:
range protection
β conflicting insert can be blocked
PostgreSQL Serializable SSI:
predicate/read tracking
β conflicting work can continue
β dangerous dependency can later cause serialization failure
Both protect serializability.
They do not protect it in the same way.
This is exactly why database isolation should be understood in terms of guarantees and mechanisms rather than assuming that every engine implements one textbook algorithm.
InnoDB is also not "just 2PL"
InnoDB is a hybrid.
Ordinary consistent reads use MVCC.
Suppose the products row is (id=100, name='Desk lamp', stock=8). An ordinary query is:
SELECT *
FROM products
WHERE id = 100;
| id | name | stock |
|---|---|---|
| 100 | Desk lamp | 8 |
It can read a historical committed row version rather than waiting on a current writer.
But a locking read returns and locks the matching current row:
SELECT *
FROM products
WHERE id = 100
FOR UPDATE;
| id | name | stock |
|---|---|---|
| 100 | Desk lamp | 8 |
It behaves differently.
It acquires locks on relevant records/index entries.
Those locks remain until commit or rollback.
MySQL documents that FOR SHARE and FOR UPDATE locks are released when the transaction commits or rolls back.
So InnoDB combines:
MVCC snapshot reading
+
pessimistic locking
+
record locks
+
gap / next-key locks
+
deadlock detection
It should not be reduced to:
"MySQL uses 2PL."
without explaining which operations and isolation mode are being discussed.
SQL Server is the closest of the three to the textbook locking model
Traditional SQL Server isolation is easier to map onto lock-based theory.
Under pessimistic locking:
READ COMMITTED
normally releases read locks after the statement.
Under:
REPEATABLE READ
read locks are held longer so rows already read cannot be changed by another transaction before the current transaction ends.
Under:
SERIALIZABLE
SQL Server additionally protects key ranges so new qualifying rows cannot appear.
Microsoft's locking documentation describes Repeatable Read as retaining read/write locks and Serializable as adding range locks to prevent phantom reads.
That behavior resembles the textbook progression from row-level locking toward a strict/range-aware locking protocol.
However, modern SQL Server adds an important complication.
Modern SQL Server and optimized locking
Recent SQL Server versions and Azure SQL can use optimized locking.
Instead of necessarily retaining every physical row/page X lock until transaction completion, SQL Server can release many row/page locks earlier and retain a transaction-ID (TID) lock representing the uncommitted transaction.
Microsoft describes, for example, an update of many rows where individual row X locks may be released after modification while one transaction-level X lock remains until the transaction ends.
This means statements such as:
"SQL Server implements rigorous 2PL by holding every S and X row lock until commit. "
are too simplistic for modern SQL Server.
The observable isolation guarantee matters more than whether each internal physical lock exactly matches the textbook protocol.
This is an important general lesson:
Textbook 2PL is a model for reasoning about concurrency. Production engines may implement equivalent guarantees using MVCC, dependency tracking, transaction-ID locks, range locks, or combinations of these mechanisms.
Technology summary
| Technology | Ordinary read mechanism | Explicit locking | Predicate/range protection | Serializable strategy |
|---|---|---|---|---|
| PostgreSQL | MVCC snapshot/read committed versions | FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE |
SSI predicate-read tracking rather than ordinary blocking range locks | Serializable Snapshot Isolation; dangerous dependency can abort a transaction |
| MySQL InnoDB | MVCC consistent reads | FOR UPDATE, FOR SHARE |
Record, gap, and next-key locks depending on operation/isolation | Stronger locking behavior combined with InnoDB MVCC |
| SQL Server | Locking or row versioning depending on configuration | S/U/X locks and locking hints | Key-range locks at locking Serializable | Primarily lock/range-lock based; newer versions can use optimized TID locking |
| Textbook 2PL | Reads acquire S locks | Writes acquire X locks | Requires locking the appropriate logical data item/range | Serializability follows from the growing/shrinking rule |
PostgreSQL's current documentation explicitly identifies its Serializable implementation as SSI rather than traditional blocking serializable locking. MySQL documents its combination of consistent MVCC reads and explicit locking reads. SQL Server documents both its traditional lock-based isolation behavior and its newer optimized-locking mechanism.
A concrete application example: bank transfer
Consider:
account 1 = β¬500
account 2 = β¬300
We want to transfer:
β¬100 from account 1 to account 2
The business operation is:
read/check source
debit source
credit destination
Both account changes must belong to one transaction.
Suppose the two accounts rows each have a balance of 1000. A PostgreSQL implementation reads and locks them in order:
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN (1, 2)
ORDER BY account_id
FOR UPDATE;
| account_id | balance |
|---|---|
| 1 | 1000 |
| 2 | 1000 |
After checking the balances, the transaction performs both updates and commits:
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
COMMIT;
| account_id | balance |
|---|---|
| 1 | 900 |
| 2 | 1100 |
The important design decisions are not merely:
"we used FOR UPDATE"
but:
both rows are locked before the decision is completed
all transfer code uses the same lock ordering
the debit and credit occur in one transaction
the transaction remains short
A useful database constraint may also be appropriate:
CHECK (balance >= 0)
if negative balances are never legal.
Constraints and locks solve different layers of the problem:
locking coordinates concurrent decisions
constraints reject impossible final states
Using both can be valuable.
Why an ordinary PostgreSQL reader can still see the account
Suppose T1 holds:
FOR UPDATE
on account 1.
Suppose account 1 has a committed balance of 1000 while T1 holds FOR UPDATE. Another transaction executes:
SELECT balance
FROM accounts
WHERE account_id = 1;
| balance |
|---|
| 1000 |
That ordinary PostgreSQL reader is not automatically blocked by the row lock.
Because PostgreSQL uses MVCC, it can read an appropriate committed version.
What is blocked are operations requiring a conflicting row lock or modification.
PostgreSQL documents exactly this distinction: row-level locks block writers and lockers of the same row, but do not prevent ordinary querying.
Therefore the intuition:
X lock exists
β nobody can even read the row
is a textbook-locking intuition that does not directly describe MVCC engines.
Another concrete example: reserving a seat
Suppose an application stores seats individually:
seat 1
seat 2
seat 3
...
Suppose seats contains:
| seat_id | reserved_by |
|---|---|
| 42 | NULL |
To reserve seat 42, the query reads and locks the existing row:
SELECT *
FROM seats
WHERE seat_id = 42
FOR UPDATE;
| seat_id | reserved_by |
|---|---|
| 42 | NULL |
Then the transaction updates it:
UPDATE seats SET reserved_by = 1001 WHERE seat_id = 42;
| seat_id | reserved_by |
|---|---|
| 42 | 1001 |
This maps naturally to row locking because the logical resource already exists as one row.
The lockable object:
seat 42
is explicit.
Now compare a different data model:
event capacity = 100
bookings are separate rows
The rule is:
COUNT(bookings WHERE event_id = 5) <= 100
There is no single booking row representing:
"the last remaining unit of capacity"
Two transactions can both count 99 and insert booking number 100 independently.
The invariant is now predicate-based.
Possible designs include:
lock one event/capacity row and update a counter atomically
use database Serializable isolation
use suitable range locking where the engine supports it
redesign the invariant as a directly lockable or constrained resource
This shows why concurrency design depends heavily on the schema.
A useful design trick: create a row that represents the logical resource
Predicate locking can be complicated.
Sometimes the easiest solution is to transform an abstract invariant into a concrete lockable row.
Suppose bookings currently has rows for event 42:
| booking_id | event_id |
|---|---|
| 501 | 42 |
| 502 | 42 |
Instead of repeatedly checking:
SELECT COUNT(*)
FROM bookings
WHERE event_id = 42;
| count |
|---|
| 2 |
the events table might contain:
event_id
capacity
reserved_count
Then reservation can use one atomic statement:
UPDATE events
SET reserved_count = reserved_count + 1
WHERE event_id = 42
AND reserved_count < capacity;
If:
rows affected = 1
capacity was successfully reserved.
If:
rows affected = 0
the event was already full.
This may be simpler than protecting:
all current and future booking rows
with a complex locking protocol.
The broader lesson is:
Good schema and statement design can remove the need for complicated locking.
FOR UPDATE is not a universal magic phrase
Adding:
FOR UPDATE
does not automatically make every business rule safe.
Suppose the users table has no alice@example.com row:
| user_id |
|---|
SELECT *
FROM users
WHERE email = 'alice@example.com'
FOR UPDATE;
| rows returned |
|---|
| 0 |
If no user exists, there may be no existing row to lock.
Two transactions may both conclude:
email is available
and both attempt the insert.
The correct solution for uniqueness is normally:
UNIQUE(email)
not relying on a pre-check plus row locking.
Likewise, use:
FOREIGN KEY
for referential integrity where appropriate rather than manually reproducing all parent/child locking behavior in application code.
Locking is most useful when the business decision itself cannot be represented directly by a database constraint or atomic statement.
2PL versus MVCC
The cleanest contrast is:
| Traditional 2PL intuition | MVCC intuition |
|---|---|
| Reader takes an S lock | Reader often reads a version |
| Writer takes X lock | Writer creates/changes a newer version and uses write-related locks |
| Reader and writer may block each other | Reader may continue from an older committed version |
| Serializability comes from lock ordering | Serializability may come from locks, validation, dependency tracking, or a hybrid |
Neither technique is inherently "better. "
They make different tradeoffs.
Locking tends to discover some conflicts by:
waiting before proceeding
Optimistic/MVCC techniques may instead allow more concurrency and discover problems through:
validation
serialization failure
transaction retry
Modern databases frequently combine both approaches.
Blocking versus aborting
Consider two transactions that cannot both safely complete.
A pessimistic lock-based system may behave as:
T1 obtains resource
T2 requests resource
T2 waits
T1 commits
T2 continues
An optimistic/SSI-style system can behave more like:
T1 proceeds
T2 proceeds concurrently
database detects incompatible dependency pattern
one transaction aborts
application retries it
The application experiences very different operational behavior:
locking β latency from waiting
optimistic detection β retry cost
Yet both approaches can provide serializable outcomes.
Deadlock versus serialization failure
These are also different concepts.
A deadlock means transactions are waiting in a cycle:
T1 waits for T2
T2 waits for T1
The database must break the cycle.
A serialization failure means allowing all participating transactions to commit could violate serializable ordering.
There does not have to be a lock-wait cycle.
PostgreSQL Serializable SSI is the clearest example: its dependency tracking can abort a transaction with SQLSTATE 40001 even though its predicate locks themselves do not block.
Therefore:
deadlock retry
and:
serialization-failure retry
belong to the same broad application pattern but arise for different reasons.
Retry the complete transaction
When the database aborts a transaction because of a deadlock or serialization conflict, the application should normally retry the transaction from the beginning.
Not:
retry only the UPDATE that failed
but:
BEGIN
repeat the reads
repeat the business decision
repeat the writes
COMMIT
The earlier reads may no longer be valid.
For example:
read balance = 500
decide β¬400 debit is safe
followed by an aborted transaction cannot safely become:
retry only debit β¬400
because another transaction may have changed the balance before the retry.
PostgreSQL's documentation explicitly recommends retrying the whole transaction for serialization failures and identifies 40P01 deadlock failures as another case applications may retry.
MySQL likewise tells applications encountering InnoDB deadlock error 1213 to run all operations in the transaction again.
Keep locked transactions short
Locks are held resources.
A dangerous pattern is:
BEGIN
lock customer row
call external HTTP service
wait 5 seconds
ask user for confirmation
perform more queries
COMMIT
During the network call or user wait, other transactions may be blocked.
Long lock durations increase:
contention
deadlock probability
tail latency
lock memory consumption
transaction timeout risk
PostgreSQL explicitly advises against leaving transactions holding locks while waiting for external/user interaction.
A better design is generally:
perform slow external work outside the database transaction
BEGIN
perform short concurrency-sensitive work
COMMIT
When external side effects must be coordinated with database state, patterns such as an outbox or idempotency mechanism are often more appropriate than keeping a database transaction open during the external operation.
Indexes affect locking behavior
Indexes are not only performance structures.
They can also determine:
which records are scanned
which index keys are touched
which gaps or ranges need protection
how many locks are acquired
InnoDB explicitly recommends good indexes as one method of reducing the number of index records scanned and therefore the amount of locking and deadlock exposure.
SQL Server's documented key-range locking also depends on index-based range protection.
Therefore a query changing from:
targeted index lookup
to:
large scan
can alter not only query cost but also concurrency behavior.
This is one reason concurrency testing must use realistic schema, indexes, and execution plans.
2PL and 2PC are completely different
The similar names cause constant confusion.
| Two-Phase Locking | Two-Phase Commit |
|---|---|
| Concurrency-control protocol | Distributed atomic-commit protocol |
| Concerned with acquiring/releasing locks | Concerned with prepare/commit decisions |
| Prevents non-serializable conflicting schedules | Ensures distributed participants agree to commit or abort |
| Phases: growing β shrinking | Phases: prepare β commit/abort |
| Can deadlock | Can suffer coordinator/participant availability problems |
| Usually discussed inside database concurrency control | Usually discussed across multiple resource managers/nodes |
A distributed transaction may use:
2PL for isolation
+
2PC for distributed atomic commit
so the presence of one tells you nothing about whether the other is being used.
What applications control and what databases control
The application typically controls:
where a transaction begins and ends
which statements execute inside it
which explicit locking clauses are requested
which isolation level is chosen
the order in which logical resources are accessed
how deadlocks/serialization failures are retried
The database engine typically controls:
the concrete lock manager
lock compatibility
physical row/page/index/range locks
MVCC version visibility
wait queues
deadlock detection
lock escalation or optimized locking
which transaction becomes a deadlock victim
The division matters.
An application saying:
SELECT ... FOR UPDATE;
expresses a locking requirement.
It does not generally dictate every internal lock object or storage-level mechanism the engine will use.
NOLOCK is not "use smaller locks"
SQL Server's:
WITH (NOLOCK)
is often badly named in informal explanations.
It does not mean:
please use more lightweight row locks
It changes the read semantics approximately to READ UNCOMMITTED for the referenced data.
That can allow dirty/inconsistent reads.
It is therefore an isolation choice, not a lock-granularity tuning switch.
Microsoft's own locking documentation shows that using NOLOCK can bypass the key-range locks that would otherwise be needed for Serializable behavior, meaning serializability is no longer guaranteed for that access.
Practical comparison of concurrency strategies
| Problem | Common mechanism |
|---|---|
| Two transactions update exactly the same row | Exclusive/write lock or optimistic version check |
| Read a row, then make a decision before updating it | Locking read such as FOR UPDATE, or atomic conditional update |
| Prevent duplicate logical value | UNIQUE constraint |
| Prevent negative quantity | CHECK, atomic conditional update, or appropriate locking |
| Protect a range against new matching rows | Range/gap/next-key locks or Serializable predicate protection |
| Multi-row business invariant | Explicit locking protocol or Serializable isolation |
| Deadlock | Database aborts a victim; application retries |
| Serializable dependency conflict | Database rejects an execution; application retries |
| Long reader should not block writers | MVCC / row-versioning approach |
| Need deterministic resource acquisition | Canonical lock ordering |
The most useful mental model
Do not memorize 2PL as:
phase 1 = lock
phase 2 = unlock
That is technically correct but not very useful.
Remember:
A transaction is allowed to expand
the set of resources it depends on.
Once it begins giving resources back,
it may never expand that dependency set again.
Why?
Because that creates a single ordering point for the transaction.
Conflicting transactions can then be ordered by their lock points.
That prevents dependency cycles.
Then remember the variants:
Basic 2PL
solves conflict serializability
Strict 2PL
additionally keeps uncommitted writes protected
Rigorous 2PL
holds all read/write locks until transaction end
Conservative 2PL
obtains the required lock set up front
to avoid incremental lock deadlocks
Finally, remember that real systems are hybrids:
PostgreSQL
MVCC ordinary reads
explicit row locks when requested
SSI for Serializable
MySQL InnoDB
MVCC consistent reads
row/index locking
gap and next-key locking
SQL Server
traditional pessimistic locking
key-range locking at Serializable
row versioning options
modern optimized TID locking
The important question is therefore not:
"Does this database use 2PL?"
but:
For this operation and isolation level, what is being protected, how is it protected, when is that protection released, and what happens when two transactions conflict?
That question transfers directly from textbook theory to production systems.
References
PostgreSQL's explicit-locking documentation describes its table and row lock modes, FOR UPDATE behavior, transaction-end lock release, and automatic deadlock detection.
PostgreSQL's transaction-isolation documentation explains that Serializable uses Serializable Snapshot Isolation, including nonblocking predicate locks used to detect dangerous dependencies rather than traditional blocking range locking.
MySQL's InnoDB locking documentation describes its combination of MVCC, record locks, gap/next-key locking, locking reads, and deadlock handling.
Microsoft's SQL Server locking documentation describes shared/exclusive locking, Repeatable Read behavior, Serializable key-range protection, deadlock handling, and modern optimized TID locking.