Locks và Deadlock — row lock, table lock, deadlock detection, SKIP LOCKED
8 lock mode PostgreSQL, deadlock anatomy với 2 psql session, pg_locks debugging, lock_timeout vs deadlock_timeout, optimistic vs pessimistic locking, và SKIP LOCKED cho queue pattern.
Mở 2 terminal, kết nối vào cùng một database PostgreSQL, rồi chạy theo thứ tự:
-- Session A:
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- OK, dang giu lock tren row id=1
-- Session B:
BEGIN;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
-- OK, dang giu lock tren row id=2
-- Session A (tiep):
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
-- ... hang, cho Session B nha lock tren id=2
-- Session B (tiep):
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- ... hang, cho Session A nha lock tren id=1
Sau khoảng 1 giây, PostgreSQL phát hiện vòng lặp chờ và tự giải quyết:
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 789; blocked by process 12346.
Process 12346 waits for ShareLock on transaction 788; blocked by process 12345.
HINT: See server log for query details.
SQLSTATE: 40P01
Một transaction bị kill, một cái tiếp tục. Đây là deadlock — và nó xảy ra bất cứ khi nào 2 transaction cùng lock 2 tài nguyên theo thứ tự ngược nhau. Hiểu lock là hiểu cách tránh điều này.
1. Analogy — Phòng họp đa cấp
Hãy tưởng tượng một phòng họp lớn với từng ghế riêng biệt. Các activity trong phòng có mức độ can thiệp khác nhau:
| Activity trong phòng | PostgreSQL lock mode | Acquired by |
|---|---|---|
| Đứng ngoài quan sát qua cửa kính | ACCESS SHARE | SELECT |
| Đặt chỗ tạm 1 ghế, cho người khác ngồi ghế còn lại | ROW SHARE | SELECT FOR UPDATE / FOR SHARE |
| Sửa nội thất 1 ghế, người khác vẫn dùng ghế khác | ROW EXCLUSIVE | UPDATE / INSERT / DELETE |
| Thay sàn — cần không gian nhất định, nhưng cho người khác làm việc nhẹ | SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY |
| Đặt ghế cố định cho hội thảo — không ai được di chuyển ghế | SHARE | CREATE INDEX (no CONCURRENTLY) |
| Sắp xếp lại toàn bộ bàn ghế, hầu hết người khác phải dừng | SHARE ROW EXCLUSIVE | (ít dùng trực tiếp) |
| Trình chiếu màn hình lớn — cần tắt đèn, hầu hết người phải ra | EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY |
| Sửa toàn phòng, phá trần — đuổi mọi người kể cả người quan sát | ACCESS EXCLUSIVE | ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL |
Lock mạnh hơn thì conflict với nhiều lock khác hơn. ACCESS SHARE (SELECT) chỉ bị block bởi ACCESS EXCLUSIVE (ALTER TABLE / DROP). ACCESS EXCLUSIVE block tất cả mọi người — kể cả SELECT đơn giản. Đây là lý do ALTER TABLE trên bảng lớn cần làm cẩn thận trong production.
2. Vì sao cần lock — MVCC chưa đủ
Bài 4 của module này giải thích MVCC: mỗi version của row được giữ lại, read không block write, write không block read. MVCC giải quyết rất tốt read-write conflict — transaction đang SELECT không bị block bởi transaction đang UPDATE.
Nhưng MVCC không giải quyết write-write conflict trên cùng row:
- T1 muốn UPDATE
tasks.statustừ'pending'sang'done'choid = 42. - T2 cũng muốn UPDATE
tasks.statuschoid = 42. - Nếu cả 2 chạy đồng thời mà không có lock, kết quả cuối cùng phụ thuộc vào thứ tự commit — một UPDATE bị mất.
PostgreSQL giải quyết điều này bằng row-level lock: transaction đầu tiên ghi vào row sẽ giữ lock, transaction thứ hai phải chờ. Khi T1 commit hoặc rollback, T2 mới được tiếp tục.
DDL (Data Definition Language) phải lock toàn table vì ALTER TABLE đổi structure — drop column, thêm column, thay đổi kiểu dữ liệu. Mọi query đang chạy trên table đó cần biết structure hiện tại. Nếu structure thay đổi giữa chừng, query đó invalid. ACCESS EXCLUSIVE đảm bảo không ai đang đọc hoặc ghi khi DDL diễn ra.
3. 8 lock mode và conflict matrix
PostgreSQL định nghĩa 8 lock mode theo thứ tự tăng dần từ nhẹ đến nặng. Mode nào conflict với mode nào được document chính thức trong PG Ch.13.3 Table 13.2:
| Lock Mode | Acquired by | Không thể dùng cùng lúc với |
|---|---|---|
| ACCESS SHARE | SELECT | ACCESS EXCLUSIVE |
| ROW SHARE | SELECT FOR UPDATE, SELECT FOR SHARE | EXCLUSIVE, ACCESS EXCLUSIVE |
| ROW EXCLUSIVE | INSERT, UPDATE, DELETE | SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE | CREATE INDEX (no CONCURRENTLY) | ROW EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE ROW EXCLUSIVE | (ít dùng trực tiếp) | ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY | ROW SHARE, ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| ACCESS EXCLUSIVE | ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL, LOCK TABLE (default) | Mọi mode (kể cả ACCESS SHARE — block cả SELECT) |
Hai quan sát thực tế quan trọng nhất:
-
SELECT vs ALTER TABLE: SELECT giữ ACCESS SHARE. ALTER TABLE cần ACCESS EXCLUSIVE. Hai mode này conflict — ALTER TABLE phải chờ mọi SELECT đang chạy kết thúc. Trên production table có query liên tục, ALTER TABLE có thể block hàng chục giây đến vài phút.
-
VACUUM (regular) vs ALTER TABLE: VACUUM thường giữ SHARE UPDATE EXCLUSIVE — không conflict với ROW EXCLUSIVE (UPDATE/INSERT/DELETE), cho phép DML tiếp tục. Nhưng VACUUM FULL giữ ACCESS EXCLUSIVE — block tất cả. Đây là lý do tránh dùng VACUUM FULL trên production.
4. SELECT FOR UPDATE / FOR SHARE / SKIP LOCKED / NOWAIT
Khi cần đọc row với intent to update, dùng SELECT FOR UPDATE để lock row ngay từ lúc đọc — tránh race condition giữa đọc và ghi:
-- FOR UPDATE: lock row, block UPDATE/DELETE khac cung row
SELECT * FROM tasks WHERE id = 1 FOR UPDATE;
-- Row id=1 bi lock. Tx khac UPDATE id=1 phai cho.
-- FOR SHARE: shared read lock — allow concurrent FOR SHARE, block UPDATE/DELETE
SELECT * FROM tasks WHERE id = 1 FOR SHARE;
-- Nhieu tx co the cung FOR SHARE. UPDATE phai cho tat ca FOR SHARE release.
-- SKIP LOCKED: bo qua row dang bi lock, lay row tiep theo (queue pattern)
SELECT * FROM tasks WHERE status = 'pending' LIMIT 1 FOR UPDATE SKIP LOCKED;
-- Neu row dang lock boi tx khac: SKIP, khong wait. Lay row ke tiep available.
-- NOWAIT: error ngay neu phai wait, khong block
SELECT * FROM tasks WHERE id = 1 FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation "tasks" (neu row dang bi lock)
Tradeoff FOR UPDATE vs FOR SHARE:
FOR UPDATEdành khi bạn chắc chắn sẽ ghi sau khi đọc — lock mạnh hơn, chỉ cho 1 holder.FOR SHAREdành khi nhiều reader cùng cần đảm bảo row không bị ghi trong khi họ đọc — nhiều holder cùng lúc.SKIP LOCKEDlà chìa khóa của pattern queue công việc — bài này trình bày chi tiết ở mục 9.NOWAITphù hợp khi application logic cho phép "thử — nếu không được thì làm gì khác ngay", tránh block indefinitely.
5. Demo deadlock — 2 psql session theo từng bước
Tái hiện chính xác deadlock từ phần mở đầu, với output thực tế:
Bước 1 — Session A lock row id=1:
-- Session A:
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- UPDATE 1 (thanh cong, giu ROW EXCLUSIVE lock tren id=1)
Bước 2 — Session B lock row id=2:
-- Session B:
BEGIN;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
-- UPDATE 1 (thanh cong, giu ROW EXCLUSIVE lock tren id=2)
Bước 3 — Session A cố lock row id=2 (đang bị B giữ):
-- Session A:
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
-- [HANG -- waiting for Session B to release lock on id=2]
Bước 4 — Session B cố lock row id=1 (đang bị A giữ):
-- Session B:
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- [HANG -- waiting for Session A to release lock on id=1]
-- Tao ra cycle: A -> wait B, B -> wait A
Bước 5 — Sau ~1 giây, PostgreSQL deadlock detector chạy:
Session B nhận:
ERROR: deadlock detected
DETAIL: Process 23456 waits for ShareLock on transaction 1001; blocked by process 23457.
Process 23457 waits for ShareLock on transaction 1002; blocked by process 23456.
HINT: See server log for query details.
SQLSTATE: 40P01
Session A tiếp tục bình thường — UPDATE 1 thành công. Session B bị abort, transaction của nó bị rollback hoàn toàn. Application phía Session B phải catch 40P01 và retry.
Cách tránh deadlock này: Luôn lock resource theo thứ tự nhất quán — nếu cả 2 session đều UPDATE id=1 trước rồi id=2, sẽ không có cycle. Đây là nguyên tắc quan trọng nhất khi thiết kế code xử lý nhiều row trong 1 transaction.
6. pg_locks debugging — ai đang block ai
Khi production bị lock contention, query này cho biết ai đang chờ và ai đang giữ lock:
-- Ai dang wait lock, va ai dang giu lock do
SELECT
waiting.pid AS waiting_pid,
waiting.query AS waiting_query,
waiting.state AS waiting_state,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
age(now(), waiting.xact_start) AS wait_duration
FROM pg_stat_activity AS waiting
JOIN pg_locks AS wl
ON wl.pid = waiting.pid AND NOT wl.granted
JOIN pg_locks AS bl
ON bl.relation = wl.relation
AND bl.locktype = wl.locktype
AND bl.granted
JOIN pg_stat_activity AS blocking
ON blocking.pid = bl.pid
WHERE waiting.pid <> blocking.pid
ORDER BY wait_duration DESC;
Query đơn giản hơn để xem tất cả lock hiện tại (kể cả granted):
-- Tat ca lock dang ton tai, kem theo query va tx age
SELECT
l.pid,
l.mode,
l.granted,
l.relation::regclass AS relation_name,
a.query,
a.state,
age(now(), a.xact_start) AS tx_age
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.relation IS NOT NULL
ORDER BY tx_age DESC;
-- granted = false: tx nay dang WAIT
-- granted = true: tx nay dang GIU lock
Xem trong kết quả: nếu thấy nhiều row granted = false với tx_age lớn — có transaction đang block lâu. Tìm granted = true cùng relation_name để biết ai đang giữ.
View pg_stat_activity còn có cột wait_event_type và wait_event — khi một process đang chờ lock, sẽ thấy wait_event_type = 'Lock'.
7. lock_timeout, deadlock_timeout, statement_timeout
Ba GUC (Grand Unified Configuration) liên quan đến thời gian chờ lock:
-- lock_timeout: abort neu phai wait lock qua X thoi gian
-- Scope: co the set cho toan session hoac chi transaction hien tai
SET LOCAL lock_timeout = '5s';
-- Neu UPDATE phai wait lock > 5 giay: ERROR: canceling statement due to lock timeout
-- SQLSTATE: 55P03
-- statement_timeout: abort neu query chay qua X thoi gian (bat ke co wait hay khong)
SET LOCAL statement_timeout = '30s';
-- Bao gom ca thoi gian wait lock. Neu tong thoi gian > 30s: cancel query.
-- deadlock_timeout: khoang thoi gian PG bat dau chay deadlock detector
-- Default: 1s. Set trong postgresql.conf, khong nen doi thuong xuyen.
-- KHONG nen giam deadlock_timeout xuong duoi 1s:
-- deadlock detection algorithm la O(N^2) theo so luong lock -- expensive.
SHOW deadlock_timeout;
-- deadlock_timeout
-- ----------------
-- 1s
Thứ tự ưu tiên thực tế:
lock_timeoutdùng để tránh một transaction block indefinitely chỉ vì lock — fail fast, retry.statement_timeoutdùng để cap tổng thời gian query — bảo vệ database khỏi long-running query.deadlock_timeouthầu như không cần đổi — PG tự handle sau 1 giây, abort victim tx.
-- Pattern thuc te cho API endpoint co SLA 2 giay:
BEGIN;
SET LOCAL lock_timeout = '1500ms'; -- fail neu wait lock qua 1.5s
SET LOCAL statement_timeout = '2000ms'; -- fail neu toan query qua 2s
-- ... business logic ...
COMMIT;
8. Pitfall — Optimistic vs pessimistic locking
Pessimistic locking (SELECT FOR UPDATE): lock row ngay khi đọc, đảm bảo không ai khác có thể sửa row đó đến khi bạn commit. Phù hợp khi contention cao (nhiều transaction cùng muốn row đó) — tránh nhiều retry. Nhược điểm: lock time dài nếu business logic phức tạp → throughput giảm.
Optimistic locking (version column + compare-and-swap): không lock khi đọc, khi ghi kiểm tra xem row có bị thay đổi không. Phù hợp khi contention thấp (hiếm khi 2 tx đụng nhau row) — throughput cao. Nhược điểm: nếu contention cao → nhiều retry → thrashing.
-- Optimistic locking: version column
-- Schema: ALTER TABLE tasks ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
-- Doc voi version
SELECT id, title, status, version FROM tasks WHERE id = 42;
-- => version = 5
-- Ghi: chi update neu version chua thay doi
UPDATE tasks
SET status = 'done', version = version + 1
WHERE id = 42 AND version = 5;
-- affected rows = 0: nguoi khac da update (version da tang) -> retry voi version moi
-- affected rows = 1: thanh cong
-- Pessimistic locking: FOR UPDATE
BEGIN;
SELECT id, title, status FROM tasks WHERE id = 42 FOR UPDATE;
-- Row bi lock. Khong ai khac co the UPDATE row nay.
-- ... tinh toan trong app nếu can ...
UPDATE tasks SET status = 'done' WHERE id = 42;
COMMIT;
Retry pattern đúng — exponential backoff với jitter:
// Retry exponential backoff + jitter (tranh tight loop -> thrashing)
async function retryOnConflict(fn, maxAttempts = 5) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
// 40P01: deadlock detected
// 40001: serialization failure (Repeatable Read / Serializable)
if (err.code !== '40P01' && err.code !== '40001') throw err;
if (attempt === maxAttempts - 1) throw new Error('Max retry exceeded');
// Exponential backoff: 50ms, 100ms, 200ms, 400ms... cap 2000ms
// Jitter: cong them random de cac tx khong retry cung luc
const delay = Math.min(50 * 2 ** attempt, 2000) + Math.random() * 50;
await new Promise(r => setTimeout(r, delay));
}
}
}
// Dung:
// await retryOnConflict(async () => {
// await client.query('BEGIN');
// // ... business logic ...
// await client.query('COMMIT');
// });
Không retry trong tight loop (while (true) { try { ... } catch { } }) — nếu nhiều client cùng làm vậy, tất cả cùng retry cùng lúc, tạo lại conflict ngay lập tức. Jitter phân tán các retry theo thời gian.
9. Applied — TaskFlow worker queue pattern với SKIP LOCKED
TaskFlow có bảng tasks với nhiều worker xử lý song song. Vấn đề kinh điển của queue: làm sao để nhiều worker cùng pull task mà không pull cùng 1 task, không cần coordinator tập trung?
SKIP LOCKED giải quyết chính xác bài này:
-- Worker A pull next task tu queue:
BEGIN;
SELECT id, payload
FROM tasks
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- Worker A lay task id=1 (lock tren id=1)
-- Neu khong co row available: tra ve empty set (khong block)
UPDATE tasks SET status = 'processing' WHERE id = 1;
COMMIT;
-- Task id=1 bay gio la 'processing', lock duoc release
-- Worker B chay dong thoi:
BEGIN;
SELECT id, payload
FROM tasks
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- id=1 dang bi Worker A lock -> SKIP
-- Lay task id=2 (lock tren id=2, khong conflict voi Worker A)
UPDATE tasks SET status = 'processing' WHERE id = 2;
COMMIT;
Pattern này cho phép N worker chạy song song mà không conflict, không duplicate processing, không cần Redis hay message queue riêng. Mỗi worker pull 1 task khác nhau.
Sau khi xử lý xong:
-- Sau khi worker hoan thanh task:
UPDATE tasks
SET
status = 'done',
completed_at = NOW()
WHERE id = ?;
-- Neu worker crash giua chung (khong COMMIT):
-- Lock tu dong release khi connection cua worker bi dong
-- Task van la 'processing' -> can job rieng de reset timeout tasks
-- SELECT * FROM tasks WHERE status = 'processing' AND updated_at < NOW() - INTERVAL '10 minutes';
Lưu ý: ORDER BY created_at trong SELECT đảm bảo FIFO — task cũ được xử lý trước. Nếu không có ORDER BY, thứ tự không xác định và nhiều worker có thể cạnh tranh cùng tập nhỏ rows ở đầu heap page.
10. Deep Dive
-
PostgreSQL Documentation Ch.13.3 "Explicit Locking" — nguồn chính thức đầy đủ: 8 lock mode, conflict matrix Table 13.2, row-level lock, advisory lock, và deadlock detection. Đọc Table 13.2 để verify conflict matrix trong bài này.
-
PostgreSQL 14 Internals — Egor Rogov, Part III "Locks" — free PDF. Phần Locks (Chương 13–15) đi sâu vào lock manager internals: lightweight lock, heavy lock, deadlock detector algorithm, và lock wait graph. Nguồn PG-specific đầy đủ nhất về cơ chế lock thực sự.
-
The Internals of PostgreSQL — Suzuki, Ch.5 "Concurrency Control" (mục 5.4 "Heavy Locks") — web miễn phí, hình vẽ đẹp. Mục 5.4 giải thích heavy lock (table/row lock) và mục 5.5 deadlock detection — bổ sung tốt cho Rogov về visual flow.
11. Tóm tắt
- 2 loại lock chính: Row-level (UPDATE/DELETE/SELECT FOR UPDATE — per row, fine-grained) và Table-level (DDL như ALTER TABLE / DROP / VACUUM FULL — toàn bộ table, block mọi người).
- 8 lock mode PostgreSQL: từ ACCESS SHARE (SELECT) nhẹ nhất đến ACCESS EXCLUSIVE (ALTER TABLE) nặng nhất. Xem conflict matrix đầy đủ ở PG Ch.13.3 Table 13.2.
- Deadlock xảy ra khi: 2 transaction lock 2 resource theo thứ tự ngược nhau → cycle trong wait graph. PG tự detect sau
deadlock_timeout(default 1s) và abort 1 victim. Application phải catch SQLSTATE40P01và retry. - Phòng tránh deadlock: luôn lock resource theo thứ tự nhất quán trong toàn bộ codebase.
- SELECT FOR UPDATE: lock row ngay từ lúc đọc — tránh race condition giữa đọc và ghi.
FOR SHAREcho phép concurrent reader.SKIP LOCKEDbỏ qua row đang lock (queue pattern).NOWAITfail ngay nếu phải chờ. - pg_locks + pg_stat_activity: debug ai đang block ai trong production.
lock_timeoutfail fast khi chờ lock quá lâu.deadlock_timeoutkhông nên giảm dưới 1s. - Optimistic vs pessimistic: optimistic (version column) tốt khi contention thấp; pessimistic (FOR UPDATE) tốt khi contention cao. Retry phải dùng exponential backoff + jitter, không tight loop.
- SKIP LOCKED cho queue: N worker pull task song song mà không conflict — không cần coordinator, không duplicate processing.
12. Tự kiểm tra
- Q1Vì sao ALTER TABLE trên production table lớn có thể block hàng phút? Mô tả cụ thể lock mode nào bị conflict, và tại sao không thể avoid bằng cách chạy ALTER TABLE vào giờ thấp điểm?
- Q2Deadlock xảy ra khi nào? Mô tả điều kiện cần và đủ, và đề xuất 2 cách thiết kế code tránh deadlock trong một hàm transfer tiền giữa 2 account.
- Q3Phân biệt SKIP LOCKED và NOWAIT trong SELECT FOR UPDATE. Khi nào dùng cái nào? Đưa ra 1 ví dụ use case cụ thể cho mỗi cái.
- Q4VACUUM FULL vs VACUUM thường — lock mode khác nhau thế nào? Tại sao VACUUM FULL không được chạy trên production table đang có traffic?
- Q5Optimistic locking với version column — khi nào pattern này gây vấn đề? Mô tả scenario cụ thể trong TaskFlow nơi optimistic locking dẫn đến thrashing và cần chuyển sang pessimistic.
- Q6pg_locks và pg_stat_activity — query debug lock contention trả về nhiều row với granted=false và tx_age lớn. Mô tả bước tiếp theo để xác định root cause và cách resolve nhanh nhất mà không restart database.
Bài tiếp theo: Mini-challenge — race condition assign task (3 pattern fix)
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên