07. Locking Mechanics & Concurrency Hazards¶
PostgreSQL manages concurrency using a three-tier lock hierarchy: CPU spinlocks for nanosecond pointer updates, Lightweight Locks (LWLock) for shared-memory structures, and Heavyweight Locks (LOCK) for user-level table and row synchronization.
1. The Three-Tier Lock Architecture¶
1. Spinlocks (Hardware atomic instructions: CAS / TAS)
└── Duration: Microseconds. Used to protect primitive counters. Non-interruptible.
2. Lightweight Locks (LWLocks)
└── Duration: Duration of an operation (e.g. reading an 8KB buffer).
└── Modes: LW_SHARED (multiple readers), LW_EXCLUSIVE (single writer).
└── Protects: Shared Buffers, WAL buffers, lock tables.
3. Heavyweight Locks (Regular Locks)
└── Duration: Held until transaction COMMIT or ROLLBACK.
└── Managed by: Lock Manager hash tables in shared memory.
└── Participates in: Deadlock detection.
Fast-Path Locks¶
Checking the shared-memory lock table requires acquiring an LWLock on the lock hash bucket. To avoid this under high read volume, PostgreSQL allocates a Fast-Path Lock array (16 slots) per backend. When acquiring a weak lock (such as ACCESS SHARE), the backend registers it in its local fast-path array without touching shared memory!
2. Table-Level Lock Modes & Conflict Matrix¶
PostgreSQL defines eight distinct table lock modes arranged in increasing strength:
| Lock Mode | SQL Examples | Conflicts With |
|---|---|---|
| ACCESS SHARE | SELECT |
ACCESS EXCLUSIVE |
| ROW SHARE | SELECT ... FOR UPDATE |
EXCLUSIVE, ACCESS EXCLUSIVE |
| ROW EXCLUSIVE | INSERT, UPDATE, DELETE |
SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE UPDATE EXCLUSIVE | VACUUM (standard), ANALYZE, CREATE INDEX CONCURRENTLY |
SHARE UPDATE EXCLUSIVE through ACCESS EXCLUSIVE |
| SHARE | CREATE INDEX (standard) |
ROW EXCLUSIVE through ACCESS EXCLUSIVE |
| SHARE ROW EXCLUSIVE | Rare / custom triggers | ROW EXCLUSIVE through ACCESS EXCLUSIVE |
| EXCLUSIVE | REFRESH MATERIALIZED VIEW |
ROW SHARE through ACCESS EXCLUSIVE |
| ACCESS EXCLUSIVE | ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL |
ALL LOCK MODES (including SELECT) |
3. The Head-of-Line (HoL) Blocking Hazard¶
A common production outage occurs when a migration runs a DDL command such as ALTER TABLE users ADD COLUMN age int;:
sequenceDiagram
participant Q1 as Long Read (SELECT)
participant DDL as Migration (ALTER TABLE)
participant Q2 as New Reads (Incoming Web Traffic)
Note over Q1: Transaction holds ACCESS SHARE
DDL->>DDL: Requests ACCESS EXCLUSIVE
Note over DDL: Blocked behind Q1. Enters Lock Queue!
Q2->>Q2: Requests ACCESS SHARE
Note over Q2: BLOCKED! Because DDL is ahead in queue!
Note over Q2: Entire API connection pool exhausts!
- Because
ACCESS EXCLUSIVEconflicts withACCESS SHARE, the migration waits for the long-running read \(Q_1\) to finish. - The Lock Queue Priority Rule: To prevent starvation of write transactions, all incoming queries \(Q_2\) queue behind the migration request.
- Result: Even lightweight
SELECTqueries are blocked, causing connection pool exhaustion and application downtime!
Prevention: Aggressive Lock Timeouts¶
Always set lock_timeout before executing any DDL:
BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE users ADD COLUMN age integer;
COMMIT;
-- If the lock cannot be acquired within 2 seconds, the transaction aborts instantly
-- rather than queueing and taking down the API!
4. Row-Level Locks & MultiXactIds¶
Unlike table locks, row locks are not stored in shared memory (which would exhaust memory with millions of locks). Instead, row locks are recorded directly in the tuple header (t_xmax):
- Exclusive Row Lock (
FOR UPDATE,FOR NO KEY UPDATE): The locking backend writes its own XID into the row'st_xmaxand setsHEAP_XMAX_EXCL_LOCKint_infomask. - Shared Row Lock (
FOR SHARE,FOR KEY SHARE): If multiple transactions lock the same row simultaneously, PostgreSQL allocates a MultiXactId in$PGDATA/pg_multixact/and stores the MultiXact ID int_xmax.
5. Deadlock Detection Engine¶
PostgreSQL runs a background deadlock detector when a transaction waits longer than deadlock_timeout (default 1000ms):
1. Builds a directed Wait-For Graph of blocked transactions.
2. Runs a cycle detection algorithm (Tarjan's strongly connected components).
3. If a cycle is found, aborts the youngest transaction in the cycle with error 40P01 (deadlock_detected).
6. High-Throughput Queue Pattern: SKIP LOCKED¶
Building background task queues in PostgreSQL can cause catastrophic row lock serialization if workers block on the same rows.
Using FOR UPDATE SKIP LOCKED allows backends to skip over locked rows without waiting:
-- Atomically claim the top 5 available jobs without lock contention:
WITH claimed_jobs AS (
SELECT id
FROM job_queue
WHERE status = 'pending'
ORDER BY priority DESC, created_at ASC
LIMIT 5
FOR UPDATE SKIP LOCKED
)
UPDATE job_queue q
SET status = 'processing',
locked_at = NOW(),
worker_id = 'worker-node-01'
FROM claimed_jobs c
WHERE q.id = c.id
RETURNING q.*;
7. Production Diagnostics: Root Blocker Detection¶
-- Identify the root blocker PID and statement stalling the database:
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS blocking_statement,
now() - blocked_activity.query_start AS blocked_duration
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;