SQL & Database — Thực chiến PostgreSQL/Locks và Deadlock — row lock, table lock, deadlock detection, SKIP LOCKED
~22 phútTransactions, ACID & MVCC lượt xem

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òngPostgreSQL lock modeAcquired by
Đứng ngoài quan sát qua cửa kínhACCESS SHARESELECT
Đặt chỗ tạm 1 ghế, cho người khác ngồi ghế còn lạiROW SHARESELECT FOR UPDATE / FOR SHARE
Sửa nội thất 1 ghế, người khác vẫn dùng ghế khácROW EXCLUSIVEUPDATE / 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 EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLY
Đặt ghế cố định cho hội thảo — không ai được di chuyển ghếSHARECREATE 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ừngSHARE 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 raEXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLY
Sửa toàn phòng, phá trần — đuổi mọi người kể cả người quan sátACCESS EXCLUSIVEALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL
💡 Cách nhớ

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.status từ 'pending' sang 'done' cho id = 42.
  • T2 cũng muốn UPDATE tasks.status cho id = 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 ModeAcquired byKhông thể dùng cùng lúc với
ACCESS SHARESELECTACCESS EXCLUSIVE
ROW SHARESELECT FOR UPDATE, SELECT FOR SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT, UPDATE, DELETESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARECREATE 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
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYROW SHARE, ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
ACCESS EXCLUSIVEALTER 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:

  1. 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.

  2. 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 UPDATE dành khi bạn chắc chắn sẽ ghi sau khi đọc — lock mạnh hơn, chỉ cho 1 holder.
  • FOR SHARE dà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 LOCKED là 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.
  • NOWAIT phù 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_typewait_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_timeout dùng để tránh một transaction block indefinitely chỉ vì lock — fail fast, retry.
  • statement_timeout dùng để cap tổng thời gian query — bảo vệ database khỏi long-running query.
  • deadlock_timeout hầ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

Pitfall — Chon sai strategy lock gay thrashing hoac lien tuc retry

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

📚 Deep Dive — Lock internals và deadlock detection

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 SQLSTATE 40P01 và 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 SHARE cho phép concurrent reader. SKIP LOCKED bỏ qua row đang lock (queue pattern). NOWAIT fail ngay nếu phải chờ.
  • pg_locks + pg_stat_activity: debug ai đang block ai trong production. lock_timeout fail fast khi chờ lock quá lâu. deadlock_timeout khô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

Tự kiểm tra
Q1
Vì 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?

ALTER TABLE yêu cầu ACCESS EXCLUSIVE lock — conflict với mọi mode kể cả ACCESS SHARE (SELECT). Trước khi PostgreSQL cấp ACCESS EXCLUSIVE cho ALTER TABLE, nó phải chờ mọi transaction đang giữ bất kỳ lock nào trên table đó kết thúc.

Vấn đề compound: trong khi ALTER TABLE đang chờ ACCESS EXCLUSIVE, mọi query mới đến (SELECT, UPDATE) phải chờ sau ALTER TABLE trong hàng đợi lock — dù các query mới này không conflict với nhau. Kết quả: ALTER TABLE tạo ra bottleneck, tất cả query phía sau stack up. Hàng phút block có thể xảy ra kể cả giờ thấp điểm nếu có long-running transaction (monitoring, reporting query) đang chạy. Giải pháp production: dùng các migration pattern không cần ACCESS EXCLUSIVE toàn bộ thời gian (ví dụ: ADD COLUMN với DEFAULT NULL trong PG 11+ không cần rewrite table).

Q2
Deadlock 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.

Deadlock xảy ra khi: (1) Có ít nhất 2 transaction. (2) Mỗi transaction đang giữ ít nhất 1 lock và đang chờ lock của transaction khác. (3) Chuỗi chờ tạo thành cycle: A chờ B, B chờ A (hoặc A chờ B chờ C chờ A).

Cách 1 — Lock theo thứ tự nhất quán: trong hàm transfer(from_id, to_id), luôn lock account có id nhỏ hơn trước. BEGIN; SELECT ... FROM accounts WHERE id = MIN(from_id, to_id) FOR UPDATE; SELECT ... FROM accounts WHERE id = MAX(from_id, to_id) FOR UPDATE; — mọi transaction đều lock theo thứ tự id tăng dần, không có cycle.

Cách 2 — Lock cả 2 trong 1 statement: SELECT * FROM accounts WHERE id = ANY(ARRAY[from_id, to_id]) ORDER BY id FOR UPDATE; — PostgreSQL lock 2 row cùng lúc theo thứ tự id, không có window để tx khác xen vào giữa 2 lần lock.

Q3
Phâ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.

SKIP LOCKED: bỏ qua row đang bị lock, trả về row tiếp theo available. Không block, không error. Phù hợp khi có nhiều row tương đương và bạn chỉ cần 1 cái bất kỳ chưa bị xử lý. Ví dụ: job queue — worker cần pull 1 task chưa bị worker khác xử lý. SKIP LOCKED giúp N worker tự động phân tán mà không conflict.

NOWAIT: nếu row đang bị lock, lập tức raise error (không block, không skip). Phù hợp khi bạn cần row cụ thể đó và không thể dùng row khác thay thế, nhưng cũng không muốn block. Ví dụ: user bấm "edit" trên 1 record cụ thể trong UI — nếu người khác đang edit cùng record, hiển thị ngay "record đang được chỉnh sửa bởi người khác" thay vì để user ngồi chờ.

Q4
VACUUM 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?

VACUUM thường giữ SHARE UPDATE EXCLUSIVE — conflict với SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE. Nhưng KHÔNG conflict với ROW EXCLUSIVE (UPDATE/INSERT/DELETE) và ACCESS SHARE (SELECT). Nghĩa là DML thông thường vẫn chạy bình thường trong khi VACUUM đang chạy.

VACUUM FULL giữ ACCESS EXCLUSIVE — conflict với mọi mode kể cả SELECT. VACUUM FULL thực ra rebuild toàn bộ table (copy vào file mới, compact), cần exclusive access trong suốt quá trình đó. Trên table 100GB, VACUUM FULL có thể chạy hàng giờ — block mọi query trong suốt thời gian đó. Trên production với traffic liên tục: không thể chấp nhận. Thay thế: dùng tool `pg_repack` — rebuild table mà không giữ ACCESS EXCLUSIVE suốt quá trình.

Q5
Optimistic 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.

Optimistic locking gây thrashing khi contention cao: nhiều client cùng muốn update cùng 1 row, cùng read version=5, cùng cố ghi version=6, chỉ 1 thành công, còn lại phải retry. Nếu có 20 worker, 19 worker retry với version mới nhất (6), lại chỉ 1 thành công, 18 retry tiếp... Trong worst case, throughput gần bằng sequential dù có 20 worker.

Scenario TaskFlow: bảng project_stats tổng hợp số task của mỗi project (tasks_total, tasks_done). Mỗi khi task được hoàn thành, app UPDATE project_stats bằng optimistic lock. Nếu 50 task hoàn thành đồng thời (bulk import), 50 transaction cùng đọc stats row, cùng cố update — chỉ 1 win mỗi vòng. Giải pháp: dùng UPDATE project_stats SET tasks_done = tasks_done + 1 (atomic expression) thay vì read-modify-write, hoặc SELECT FOR UPDATE để serialize tất cả update vào row stats đó.

Q6
pg_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ước 1 — Xác định blocking transaction: tìm row có cùng relation với granted=true. PID đó là kẻ đang giữ lock. Xem querystate của nó trong pg_stat_activity.

Bước 2 — Đánh giá: nếu state = idle in transaction với tx_age lớn (nhiều phút) — transaction đang bị treo (application crash, connection bị drop nhưng chưa cleanup). Đây là nguyên nhân phổ biến nhất của lock contention bất thường.

Bước 3 — Terminate nếu an toàn: SELECT pg_terminate_backend(blocking_pid); — terminate connection của blocking transaction. Transaction đó bị rollback, lock được release, mọi waiter tiếp tục. Dùng pg_cancel_backend trước (gửi cancel signal, nhẹ nhàng hơn) — nếu không đủ thì dùng pg_terminate_backend. Sau đó điều tra tại sao application để lại idle-in-transaction — thường là lỗi missing COMMIT/ROLLBACK trong error handler hoặc connection pool không reclaim connection đúng cách.

Bài tiếp theo: Mini-challenge — race condition assign task (3 pattern fix)

Bài này có giúp bạn hiểu bản chất không?