Bỏ qua để đến nội dung
Knowledge Hub

Row-level locks trong PostgreSQL: FOR UPDATE, FOR NO KEY UPDATE và cách tránh đặt trùng Trung cấp

Thời gian đọc ước tính: 8 phút

Hệ thống đặt vé có bảng seats (cột status: available hoặc booked). Hai request đặt cùng ghế 42 gần như đồng thời, mỗi request chạy: SELECT status, thấy available, rồi UPDATE ... SET status = 'booked'.

Anti-pattern: đọc rồi ghi, ghế 42 bị bán hai lần
Anti-pattern: đọc rồi ghi, ghế 42 bị bán hai lầnBước 1/4: Request 1 đọc status, thấy ghế còn trống. SELECT thường không giữ lock.thời gianSELECT status→ availableRequest 1đặt ghế 42Request 2đặt ghế 42
Bước 1/4

Request 1 đọc status, thấy ghế còn trống. SELECT thường không giữ lock.

Database tuần tự hoá hai lệnh UPDATE, nhưng lệnh thứ hai không kiểm tra lại điều kiện. SELECT thường chỉ quan sát, không giữ quyền gì. Có hai cách chữa:

  • Đưa điều kiện vào câu ghi: UPDATE seats SET status = 'booked' WHERE id = 42 AND status = 'available' RETURNING id, rồi kiểm tra có đúng một row trả về. Đây là mặc định cho điều kiện đơn giản.
  • Giữ lock từ lúc đọc đến lúc ghi bằng SELECT ... FOR UPDATE, khi quyết định cần nhiều bước và phải giữ ổn định row trong các bước đó.
Chữa bằng FOR UPDATE: request 2 chờ rồi thấy ghế đã booked
Chữa bằng FOR UPDATE: request 2 chờ rồi thấy ghế đã bookedBước 1/5: Request 1 chạy SELECT id FROM seats WHERE id = 42 AND status = available FOR UPDATE trong transaction và giữ lock row 42.thời giangiữ lock row 42 …SELECT FOR UPDATE→ 1 rowRequest 1FOR UPDATERequest 2FOR UPDATE
Bước 1/5

Request 1 chạy SELECT id FROM seats WHERE id = 42 AND status = available FOR UPDATE trong transaction và giữ lock row 42.

  • Row lock giữ đến hết transaction (COMMIT hoặc ROLLBACK). Chạy SELECT FOR UPDATE ở autocommit thì lock nhả ngay khi câu lệnh kết thúc và không bảo vệ lệnh sau.
  • Row lock chỉ chặn writer và locker cùng row; SELECT thường vẫn đọc được nhờ MVCC.
  • Lock giữ lâu nghĩa là người khác chờ lâu: đừng gọi HTTP hay tính toán nặng khi đang giữ lock.
  • Ở Read Committed, sau khi chờ xong PostgreSQL đánh giá lại WHERE trên phiên bản row mới nhất; ở Repeatable Read và Serializable, nếu row đã bị đổi sau snapshot thì báo lỗi 40001 (xem isolation levels).
Chế độ Dùng khi
FOR UPDATE Sắp DELETE, hoặc UPDATE đổi cột khoá
FOR NO KEY UPDATE Sắp UPDATE không đổi cột khoá. Nên ưu tiên hơn FOR UPDATE vì không chặn kiểm tra foreign key
FOR SHARE Đọc và cần đảm bảo row không bị sửa hoặc xoá đến hết transaction
FOR KEY SHARE Chỉ cần đảm bảo cột khoá không đổi; foreign key dùng chế độ này

Xung đột (X là chờ nhau, ok là cùng giữ được):

Đang giữ \ Muốn lấy KEY SHARE SHARE NO KEY UPDATE UPDATE
KEY SHARE ok ok ok X
SHARE ok ok X X
NO KEY UPDATE ok X X X
UPDATE X X X X
Câu lệnh Row lock tự động
SELECT thường Không có
DELETE, hoặc UPDATE đổi cột thuộc unique index dùng được cho foreign key FOR UPDATE
UPDATE còn lại FOR NO KEY UPDATE
INSERT/UPDATE bảng con có foreign key FOR KEY SHARE trên row cha

Hệ quả thực tế: sửa cột thường của bảng cha không chặn việc insert bảng con (từ PostgreSQL 9.3), nhưng DELETE row cha, hoặc SELECT ... FOR UPDATE trên row cha, sẽ chờ mọi insert con đang chạy và ngược lại. Đó là nguồn của nhiều lock wait và deadlock “bí ẩn” liên quan foreign key.

  • Row lock được ghi lên đĩa (vào chính row, qua xmax). SELECT FOR UPDATE làm dirty page và tăng WAL, kể cả khi bạn không sửa gì. Khi nhiều transaction cùng khoá chung một row bằng FOR SHARE, PostgreSQL dùng multixact, thứ cũng có tuổi cần freeze (xem wraparound).
  • Join: FOR UPDATE không có danh sách bảng khoá mọi bảng tham gia. Dùng FOR UPDATE OF orders để chỉ khoá orders.

Cách hoạt động: người chờ nằm ở đâu trong pg_locks

Phần tiêu đề “Cách hoạt động: người chờ nằm ở đâu trong pg_locks”

Giả sử A đã FOR UPDATE row 1 chưa commit:

Session B làm gì Kết quả
SELECT thường Đọc bản đã commit, không chờ
SELECT ... FOR UPDATE Chờ A kết thúc, rồi đọc phiên bản mới theo quy tắc isolation
UPDATE cùng row Chờ; sau đó xét lại điều kiện WHERE

Chạy bằng hai kết nối psql (hoặc examples/src/postgres-locks/row-lock-demo.ts), quan sát pg_locks khi B đang chờ:

┌─────────┬──────────────────┬─────────────────┬──────────────────┬─────────┬──────────────────┐
│ (index) │ application_name │ locktype │ mode │ granted │ wait_event_type │
├─────────┼──────────────────┼─────────────────┼──────────────────┼─────────┼──────────────────┤
│ 0 │ 'session_a' │ 'relation' │ 'RowShareLock' │ true │ 'Client' │
│ 3 │ 'session_a' │ 'transactionid' │ 'ExclusiveLock' │ true │ 'Client' │
│ 6 │ 'session_b' │ 'tuple' │ 'AccessExclusiveLock' │ true │ 'Lock' │
│ 8 │ 'session_b' │ 'transactionid' │ 'ShareLock' │ false │ 'Lock' │
└─────────┴──────────────────┴─────────────────┴──────────────────┴─────────┴──────────────────┘

Cách đọc:

  1. Tìm dòng granted = false: đó là session đang chờ (session_b, ShareLock trên transactionid).
  2. Transaction ID đó chính là transaction của người giữ lock (A). B không chờ trực tiếp trên dòng dữ liệu mà chờ A kết thúc.
  3. Dòng tuple với granted = true là chỗ B xếp hàng; đừng chỉ tìm locktype = 'tuple'.
  4. Số dòng và thứ tự có thể khác, vì pg_locks hiện thêm lock nội bộ. Đặt application_name cho từng session để nối log app với trạng thái database.

Khi A COMMIT, B được cấp lock ngay. Ở Read Committed B làm việc với phiên bản mới của row; nếu A đã xoá row hoặc đổi cột trong điều kiện lọc, B có thể nhận 0 row.

Tình huống Dùng Lý do
Điều kiện một row, đơn giản UPDATE ... WHERE điều_kiện RETURNING Không cần giữ lock lâu
Quyết định nhiều bước trên cùng row SELECT ... FOR NO KEY UPDATE trong transaction ngắn Giữ ổn định row
Xung đột hiếm, người dùng sửa lâu Version column (optimistic) Không giữ lock lúc chờ người dùng
Không có row để khoá (tài nguyên logic) Advisory lock (pg_advisory_xact_lock) Ứng dụng tự quy ước khoá
Quy tắc “duy nhất” / “không chồng” UNIQUE / EXCLUDE Database cưỡng chế, không cần lock tay
Nhiều worker nhận việc từ queue FOR UPDATE SKIP LOCKED Xem NOWAIT và SKIP LOCKED

Với SKIP LOCKED, kết quả không đầy đủ theo thiết kế: hợp cho nhiều worker tranh job, không hợp cho màn hình cần liệt kê mọi job đang chờ. NOWAIT báo lỗi ngay thay vì chờ. Dùng lock_timeout (mặc định 0, tức chờ vô hạn) làm lưới an toàn cho mọi câu có thể chờ lock.

  1. Điều kiện nghiệp vụ nằm trong câu ghi, và code kiểm tra row count hoặc RETURNING.
  2. Khoá và ghi cùng một transaction, cùng một connection. Với ORM, truyền đúng transaction handle qua mọi hàm trong luồng.
  3. Dùng FOR NO KEY UPDATE khi không đổi cột khoá; dùng FOR UPDATE OF <bảng> khi join.
  4. Transaction giữ lock phải ngắn: không gọi API ngoài, không chờ người dùng.
  5. Đặt lock_timeout (và idle_in_transaction_session_timeout) cho app để lock wait không treo vô hạn.
  6. Khoá nhiều row theo thứ tự cố định (xem deadlock).
  7. Theo dõi thời gian chờ lock bằng pg_stat_activity (wait_event_type = 'Lock') và log_lock_waits.

Ưu điểm

  • Tuần tự hoá các thao tác xung đột trên đúng row cần bảo vệ.
  • Dùng cơ chế transaction sẵn có, kết hợp được với SKIP LOCKED/NOWAIT.
  • Cho phép kiểm tra lại điều kiện sau khi lấy lock.

Nhược điểm

  • Lock giữ càng lâu, người khác chờ càng lâu; dễ thành nút thắt nếu transaction chứa logic chậm.
  • Khó thấy khi review: một FOR UPDATE trong hàm dùng chung có thể khoá nhiều hơn tưởng.
  • Hot row nhiều ghi thành điểm chờ; nếu xung đột ít, optimistic locking rẻ hơn.

Tự kiểm tra: (1) Vì sao hai request cùng thấy available vẫn có thể đặt trùng dù PostgreSQL đã tuần tự hoá hai UPDATE? (2) UPDATE đổi cột thường và UPDATE đổi primary key lấy chế độ lock nào? (3) Trong pg_locks, dòng nào cho biết ai đang chờ ai?