MVCC (bài 1) cho phép đọc không chặn ghi — tuyệt vời cho tải đọc. Nhưng có những tình huống bạn cần tuần tự hóa: "đọc số dư, kiểm tra đủ tiền, rồi trừ" phải đảm bảo không ai chen vào giữa. Bài 3 và 4 cho thấy nâng mức cô lập là một cách (nhưng cần retry). Cách trực tiếp hơn, thường đơn giản hơn, là khóa hàng tường minh bằng SELECT ... FOR UPDATE.

Bài này (phần 5 loạt PostgreSQL) đo thật cơ chế khóa hàng: một phiên giữ khóa, phiên kia chờ bao lâu, cách đọc pg_locks và pg_stat_activity để chẩn đoán — kỹ năng cứu bạn khi "query bỗng treo" trên production.

FOR UPDATE: khóa độc quyền hàng đã đọc

SELECT ... FOR UPDATE đọc hàng và khóa nó độc quyền cho tới khi giao dịch kết thúc. Mọi giao dịch khác muốn FOR UPDATE, UPDATE, hay DELETE hàng đó phải chờ. Đây là cách tuần tự hóa chính xác chỉ các hàng liên quan.

Khóa hàng tường minh PostgreSQL FOR UPDATE FOR SHARE lock wait: SELECT FOR UPDATE khóa độc quyền hàng đã đọc BEGIN SELECT từ seat WHERE id 1 FOR UPDATE khóa hàng id 1 giao dịch khác muốn FOR UPDATE hoặc UPDATE hoặc DELETE hàng này phải chờ UPDATE seat set taken true COMMIT nhả khóa dùng cho mẫu đọc rồi ghi an toàn khóa trước khi kiểm tra và ghi; các biến thể FOR UPDATE khóa độc quyền chặn cả đọc-khóa lẫn ghi của tx khác, FOR SHARE khóa chia sẻ nhiều tx FOR SHARE cùng được nhưng chặn ghi, FOR UPDATE NOWAIT không chờ nếu hàng đang bị khóa báo lỗi ngay, FOR UPDATE SKIP LOCKED bỏ qua hàng đang bị khóa hàng đợi công việc; chẩn đoán chờ khóa pg_locks ai đang giữ khóa gì SELECT pid mode granted FROM pg_locks WHERE relation seat, pg_stat_activity tiến trình nào đang chờ SELECT pid state wait_event_type wait_event

Hình 1: FOR UPDATE khóa độc quyền hàng đã đọc; giao dịch khác muốn ghi/khóa hàng đó phải chờ tới khi COMMIT. Các biến thể: FOR SHARE (khóa chia sẻ), NOWAIT (báo lỗi thay vì chờ), SKIP LOCKED (bỏ qua hàng bị khóa). Chẩn đoán bằng pg_locks + pg_stat_activity.

BEGIN;
SELECT * FROM seat WHERE id = 1 FOR UPDATE;   -- khoa hang id=1
-- ... kiem tra dieu kien, tinh toan ...
UPDATE seat SET taken = true WHERE id = 1;
COMMIT;   -- nha khoa

Đo thật: phiên thứ hai chờ bao lâu

Mình chạy hai phiên trên pg-lab: S1 FOR UPDATE hàng id=1 rồi giữ 4 giây; S2 cũng FOR UPDATE cùng hàng và đo thời gian chờ. Kết quả thật:

Bảng kết quả đo thật FOR UPDATE lock wait trên pg-lab postgresql 16.15 với 2 phiên seat id 1 wall-clock cộng pg_locks cộng pg_stat_activity: S2 bị chặn chờ S1 nhả khóa, S1 BEGIN SELECT id 1 FOR UPDATE giữ 4s UPDATE COMMIT, S2 cùng SELECT id 1 FOR UPDATE chờ đợi 2.535 ms rồi mới lấy được khóa, S2 không chạy được cho tới khi S1 COMMIT nhả khóa thời gian chờ bằng phần còn lại của 4s S1 giữ khóa; quan sát khóa thật trong lúc chờ pg_locks pid 18631 mode AccessExclusiveLock granted t, pg_stat_activity pid 18631 state active wait_event_type Lock wait_event transactionid đang chờ giao dịch giữ hàng kết thúc; NOWAIT không chờ báo lỗi ngay SELECT FOR UPDATE NOWAIT ERROR could not obtain lock on row in relation seat. Badge output thật màu xanh

Hình 2: Kết quả thật. S2 chờ 2.535 ms cho S1 nhả khóa cùng hàng. Trong lúc chờ, pg_locks hiện AccessExclusiveLock đang giữ, pg_stat_activity hiện wait_event_type=Lock, wait_event=transactionid. NOWAIT báo lỗi ngay thay vì chờ.

  • S2 bị chặn, chờ 2.535 ms: S2 không chạy được cho tới khi S1 COMMIT nhả khóa. Thời gian chờ chính là phần còn lại của 4 giây S1 giữ khóa (S2 bắt đầu muộn ~1,5 giây nên chờ ~2,5 giây). Đọc không bị chặn (MVCC), nhưng khóa thì chặn.
  • Quan sát khóa thật: trong lúc S2 chờ, pg_locks cho thấy một khóa AccessExclusiveLock đang được giữ (granted=t); pg_stat_activity cho thấy tiến trình chờ ở trạng thái active với wait_event_type=Lock, wait_event=transactionid — nghĩa là nó đang chờ giao dịch giữ hàng kết thúc. Đây là hai view vàng để chẩn đoán "query bị treo vì chờ khóa".
  • NOWAIT báo lỗi ngay: SELECT ... FOR UPDATE NOWAIT không chờ mà trả ERROR: could not obtain lock on row in relation "seat" tức thì — hợp khi bạn muốn "thử lấy, không được thì làm việc khác" thay vì treo.

Ứng dụng: đọc-rồi-ghi an toàn mà không cần nâng mức cô lập

Nhớ bài 3 (lost update) và bài 4 (write skew)? FOR UPDATE là cách giải trực tiếp cho cả hai: khóa đúng hàng trước khi đọc giá trị để tính, thì hai giao dịch buộc tuần tự trên hàng đó — không lost update, không write skew, mà không cần nâng lên Repeatable Read/Serializable (vốn cần vòng retry). Ví dụ "trừ tồn kho": SELECT stock FROM product WHERE id=1 FOR UPDATE khóa hàng, rồi kiểm tra và UPDATE — giao dịch thứ hai chờ, đọc giá trị mới, nên luôn đúng.

Đánh đổi cần cân nhắc

Giữ khóa lâu = tắc nghẽn — luôn giữ ngắn và COMMIT sớm. Khi một hàng bị khóa, mọi giao dịch muốn ghi hàng đó xếp hàng chờ. Nếu bạn khóa hàng rồi làm việc chậm (gọi API ngoài, chờ người dùng, xử lý nặng) trước khi commit, cả hàng đợi đó treo theo — một kết nối chậm có thể làm nghẽn cả hệ thống. Nguyên tắc vàng: khóa càng muộn càng tốt, nhả càng sớm càng tốt; đừng bao giờ giữ khóa qua một thao tác I/O bên ngoài hay chờ đợi người dùng.

FOR UPDATE vs FOR SHARE: chọn đúng mức độ. FOR UPDATE khóa độc quyền (chỉ một giao dịch giữ, chặn cả ghi lẫn khóa khác) — dùng khi bạn sẽ ghi. FOR SHARE khóa chia sẻ (nhiều giao dịch cùng giữ, nhưng chặn ghi) — dùng khi bạn chỉ muốn đảm bảo hàng không đổi trong lúc đọc mà không độc chiếm. Dùng FOR UPDATE khi chỉ cần FOR SHARE làm giảm song song không cần thiết; dùng FOR SHARE khi thực sự sẽ ghi thì không đủ bảo vệ.

NOWAIT và SKIP LOCKED cho các mẫu không-chặn. NOWAIT trả lỗi ngay nếu không lấy được khóa — hợp cho giao diện cần phản hồi nhanh ("chỗ này đang có người xử lý, thử lại sau"). SKIP LOCKED bỏ qua các hàng đang bị khóa và chỉ lấy hàng rảnh — nền tảng của hàng đợi công việc trong SQL (nhiều worker lấy việc không đụng nhau, bài 11 sẽ đo). Ngoài ra, nhớ đặt lock_timeout ở ứng dụng để một query chờ khóa không treo vô hạn — chờ mãi thường tệ hơn thất bại nhanh và thử lại.

Ba ý mang về

  1. FOR UPDATE khóa hàng độc quyền; giao dịch khác phải chờ. Đo thật: S2 chờ 2.535 ms cho S1 nhả khóa cùng hàng trước khi lấy được FOR UPDATE. Đọc không bị chặn (MVCC) nhưng khóa thì chặn — đây là cách tuần tự hóa chính xác các hàng liên quan.
  2. pg_locks và pg_stat_activity để chẩn đoán treo vì khóa. Đo thật: trong lúc chờ, pg_locks hiện AccessExclusiveLock granted, pg_stat_activity hiện wait_event_type=Lock, wait_event=transactionid. Khi query "bỗng treo" trên production, mở hai view này ra xem ai giữ khóa và ai đang chờ.
  3. FOR UPDATE giải lost update/write skew mà không cần nâng mức cô lập — nhưng giữ khóa ngắn. Khóa đúng hàng trước khi đọc-tính-ghi buộc tuần tự, không cần retry như Serializable. Giữ khóa lâu gây tắc nghẽn; dùng NOWAIT/SKIP LOCKED cho mẫu không-chặn, đặt lock_timeout để tránh treo vô hạn.

Nguồn

Phần sau ta tạo một deadlock thật: hai giao dịch khóa chéo nhau, PostgreSQL tự phát hiện và hủy một cái với thông báo deadlock detected — và cách sắp thứ tự khóa để tránh.