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
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”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'.
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 đó.
Request 1 chạy SELECT id FROM seats WHERE id = 42 AND status = available FOR UPDATE trong transaction và giữ lock row 42.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”- Row lock giữ đến hết transaction (
COMMIThoặcROLLBACK). ChạySELECT 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;
SELECTthườ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
WHEREtrê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ỗi40001(xem isolation levels).
Bốn chế độ và ma trận xung đột
Phần tiêu đề “Bốn chế độ và ma trận xung đột”| 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 nào tự lấy lock nào
Phần tiêu đề “Câu lệnh nào tự lấy lock nào”| 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.
Hai điều ít ai biết
Phần tiêu đề “Hai điều ít ai biết”- Row lock được ghi lên đĩa (vào chính row, qua
xmax).SELECT FOR UPDATElà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ằngFOR SHARE, PostgreSQL dùng multixact, thứ cũng có tuổi cần freeze (xem wraparound). - Join:
FOR UPDATEkhông có danh sách bảng khoá mọi bảng tham gia. DùngFOR 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:
- Tìm dòng
granted = false: đó là session đang chờ (session_b,ShareLocktrêntransactionid). - 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.
- Dòng
tuplevớigranted = truelà chỗ B xếp hàng; đừng chỉ tìmlocktype = 'tuple'. - Số dòng và thứ tự có thể khác, vì
pg_lockshiện thêm lock nội bộ. Đặtapplication_namecho 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.
Chọn công cụ: lock hay thứ khác?
Phần tiêu đề “Chọn công cụ: lock hay thứ khác?”| 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í dụ thực tế
Phần tiêu đề “Ví dụ thực tế”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.
Checklist production
Phần tiêu đề “Checklist production”- Điều kiện nghiệp vụ nằm trong câu ghi, và code kiểm tra row count hoặc
RETURNING. - 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.
- Dùng
FOR NO KEY UPDATEkhi không đổi cột khoá; dùngFOR UPDATE OF <bảng>khi join. - Transaction giữ lock phải ngắn: không gọi API ngoài, không chờ người dùng.
- Đặt
lock_timeout(vàidle_in_transaction_session_timeout) cho app để lock wait không treo vô hạn. - Khoá nhiều row theo thứ tự cố định (xem deadlock).
- Theo dõi thời gian chờ lock bằng
pg_stat_activity(wait_event_type = 'Lock') vàlog_lock_waits.
Trade-offs
Phần tiêu đề “Trade-offs”Ư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 UPDATEtrong 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.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”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?