Bài mức cô lập kết bằng một mẫu code nguy hiểm: đọc số dư, kiểm tra, rồi trừ — giữa các bước một phiên khác có thể chen vào. Một cách chặn kịch bản đó là khóa hàng khi đọc: SELECT ... FOR UPDATE. Bài này đo chính xác khi nào một phiên phải chờ khóa và khi nào không, và vấp một giả định sai về việc "khóa thì chặn tất cả".

Khóa hàng và chờ khóa

Khóa hàng: ai chờ ai

SELECT ... FOR UPDATE khóa các hàng mà truy vấn chọn, với ý định sẽ ghi chúng. UPDATEDELETE cũng ngầm khóa hàng chúng sửa. Khóa được giữ tới khi giao dịch COMMIT (hoặc ROLLBACK) — không có lệnh "mở khóa" riêng. Trong lúc đó, một phiên khác muốn ghi cùng hàng phải chờ tới khi khóa nhả.

Điểm mấu chốt — và cũng là chỗ dễ hiểu nhầm — là ai phải chờ. Khóa của PostgreSQL ở mức hàng, không phải mức bảng: một phiên khác ghi hàng khác không hề bị ảnh hưởng. Và quan trọng hơn, khóa ghi không chặn người đọc: nhờ MVCC (bài phần 10), một SELECT thuần luôn đọc được phiên bản đã commit gần nhất mà không cần chờ khóa. Chỉ người ghi mới chặn người ghi trên cùng một hàng.

Đo: chờ hay không chờ

Tôi dựng hai phiên. Phiên A mở giao dịch, chạy SELECT ... FOR UPDATE trên hàng id=1, và giữ khóa vài giây. Trong lúc đó, phiên B thử làm nhiều việc khác nhau, và tôi bấm giờ B phải chờ bao lâu:

Phiên B làm gì Chờ Vì sao
UPDATE cùng hàng id=1 ~2s đợi khóa ghi của A nhả (COMMIT)
UPDATE hàng khác id=2 ~0,06s khóa mức hàng, không đụng nhau
SELECT thuần id=1 ~0,08s đọc không chờ khóa ghi (MVCC)

Dòng đầu là hành vi khóa kinh điển: B UPDATE đúng hàng A đang khóa, và bị chặn ~2 giây — treo cho tới khi A commit và nhả khóa. Dòng hai cho thấy khóa ở mức hàng: B UPDATE hàng id=2 (hàng khác) chạy ngay trong ~0,06s, không hề chờ. Dòng ba là bất ngờ tôi sẽ kể ở phần sau: B SELECT thuần đúng hàng id=1 mà A đang khóa, vẫn đọc được ngay trong ~0,08s.

Rồi tôi đo deadlock (khóa chết): phiên A khóa hàng 1 rồi định khóa hàng 2, trong khi phiên B khóa hàng 2 rồi định khóa hàng 1 — ngược thứ tự. Giờ A giữ 1 đợi 2, B giữ 2 đợi 1: vòng chờ khép kín, không ai nhả. PostgreSQL tự phát hiện sau khoảng một giây (deadlock_timeout) và hủy một bên với ERROR: deadlock detected; bên kia commit bình thường. Cơ sở dữ liệu không để hai giao dịch treo nhau vĩnh viễn — nó cắt vòng bằng cách hy sinh một nạn nhân.

Một lần tôi đo hớ: khóa không chặn người đọc

Cái bẫy nằm ở dòng thứ ba của bảng. Sau khi thấy A khóa hàng id=1 và B UPDATE cùng hàng bị chặn 2 giây, tôi muốn kiểm tra lại xem khóa có thật sự "ăn" không, bằng cách cho B đọc hàng đó — tôi tin chắc B sẽ bị chặn, vì "hàng đang bị khóa mà".

Nhưng B SELECT đọc được ngay lập tức (0,08 giây), trả về v=100, không chờ mảy may. Phản xạ đầu: "ủa, khóa không hoạt động à? Sao đọc được?". Theo kỷ luật, tôi không kết luận vội — kết quả này khác với dòng UPDATE cùng hàng (bị chặn 2s), và hai kết quả khác nhau cho "cùng một hàng bị khóa" nghĩa là có một biến tôi chưa để ý: loại thao tác của B.

Sự thật: khóa hoạt động hoàn hảo — nó chỉ chặn người ghi, không chặn người đọc. Một SELECT thuần không bao giờ chờ khóa ghi, nhờ MVCC: nó đọc phiên bản đã commit gần nhất của hàng (ở đây v=100, giá trị trước khi A định sửa) mà không cần đụng vào khóa. Chỉ khi B ghiUPDATE, DELETE, hoặc SELECT ... FOR UPDATE — nó mới cần khóa hàng đó và mới phải chờ. Cái tôi đo hớ là cho rằng "hàng bị khóa thì mọi thao tác đều chờ", trong khi câu hỏi đúng phải là "B đọc hay ghi?". Nếu tôi dùng một SELECT thuần để kiểm tra khóa, tôi đã kết luận sai rằng khóa hỏng — trong khi nó đang làm đúng việc của nó. Bài học đo lường: khi hai phép đo trên "cùng đối tượng" cho kết quả trái nhau, tìm biến khác nhau giữa chúng (ở đây: đọc so với ghi) trước khi nghi công cụ.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: dùng SELECT ... FOR UPDATE để "đọc-rồi-ghi" an toàn. Đây là lời giải cho mẫu code nguy hiểm ở bài trước: thay vì SELECT số dư rồi UPDATE, hãy SELECT ... FOR UPDATE — khóa hàng ngay khi đọc, để không phiên nào khác kịp sửa giữa lúc bạn quyết định và lúc bạn ghi. Nhưng nhớ đánh đổi: khóa giữ tới khi commit, nên giữ giao dịch càng ngắn càng tốt; một giao dịch mở lâu ôm khóa sẽ khiến các phiên khác xếp hàng chờ, biến thành nút thắt. Và luôn COMMIT/ROLLBACK — quên đóng giao dịch là để khóa treo mãi, làm cả hệ thống nghẽn ở hàng đó.

Hệ quả thứ hai: deadlock là chuyện có thật, và cách tránh là khóa theo thứ tự nhất quán. Deadlock xảy ra khi các giao dịch khóa nhiều hàng theo thứ tự khác nhau. PostgreSQL tự phát hiện và hủy một bên (nên không treo vĩnh viễn), nhưng bên bị hủy phải bắt lỗi deadlock detectedthử lại — nếu code không có vòng retry, nó sẽ ném lỗi lạ dưới tải cao. Quy tắc phòng tránh đơn giản mà hiệu quả: khi một giao dịch cần khóa nhiều hàng, luôn khóa chúng theo cùng một thứ tự (ví dụ theo id tăng dần) ở mọi nơi trong code. Ngoài ra NOWAIT (lỗi ngay thay vì chờ), SKIP LOCKED (bỏ qua hàng đang khóa — hữu ích cho hàng đợi công việc), và lock_timeout cho bạn kiểm soát hành vi chờ.

Hệ quả thứ ba là bài học đo lường. Con số mang theo: SELECT ... FOR UPDATE khóa ở mức hàng và chỉ chặn người GHI cùng hàng (B UPDATE id=1 chờ 2s tới khi A commit); ghi hàng khác hay ĐỌC THUẦN không chờ (đọc không bao giờ chặn bởi khóa ghi, nhờ MVCC); khóa hai hàng ngược thứ tự gây deadlock mà PostgreSQL tự hủy một bên. Khi hỏi "thao tác này có bị khóa chặn không", câu trả lời phụ thuộc nó là đọc hay ghi và có đụng đúng hàng không — đo đúng loại thao tác, đừng gộp tất cả thành "bị khóa".

Thử ba mươi giây

Mở hai psql. Cửa sổ 1: BEGIN; SELECT * FROM ban WHERE id = 1 FOR UPDATE; (giữ, đừng commit). Cửa sổ 2: thử SELECT * FROM ban WHERE id = 1; — nó chạy ngay, đọc được dữ liệu (khóa không chặn đọc). Rồi thử UPDATE ban SET ... WHERE id = 1; ở cửa sổ 2 — lần này nó treo, chờ khóa. Quay lại cửa sổ 1 gõ COMMIT; và nhìn cửa sổ 2 chạy tiếp ngay lập tức. Trong lúc cửa sổ 2 đang treo, mở cửa sổ 3 và chạy SELECT * FROM pg_locks WHERE NOT granted; để thấy khóa nào đang bị chờ. Đó chính là hành vi chờ khóa mà bài này đo — và lý do luôn giữ giao dịch có khóa thật ngắn.