Một người đang mượn cuốn sổ ghi chép để sửa một trang — họ giữ cuốn sổ, ai muốn sửa cùng trang đó phải đợi họ trả. Nhưng chuyện lạ: người khác vẫn đọc được nội dung trang đó bất cứ lúc nào, vì thư viện luôn giữ sẵn một bản photo của phiên bản mới nhất đã hoàn chỉnh. "Khóa" ở đây chỉ chặn người muốn cầm bút sửa, không chặn người đọc. Khóa hàng trong cơ sở dữ liệu hoạt động đúng như vậy, và bài này là lần tôi tưởng "hàng bị khóa thì cấm cả đọc", rồi ngã ngửa khi thấy nó đọc được ngay.

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: 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. UPDATE và DELETE 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 — đúng cái bản photo trang sổ luôn có sẵn. 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.

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 có 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 — đúng cái bản photo phiên bản hoàn chỉnh. Chỉ khi B ghi — UPDATE, 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 trật 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ó. 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ề đến code hằng ngày

Điều đầ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 đó.

Điều 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 detected và thử 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ờ.

Và mọi thứ trong bài này — khóa chỉ chặn người ghi, deadlock từ khóa ngược thứ tự, cách chữa bằng thứ tự khóa nhất quán — không phải đặc sản của SQL mà là toàn bộ lý thuyết khóa mà bạn gặp lại y nguyên khi lập trình đa luồng. Trong Java, một ReadWriteLock cho phép nhiều người đọc song song nhưng người ghi độc quyền — đúng cùng nguyên tắc "đọc không chặn đọc, chỉ ghi mới độc quyền" mà MVCC hiện thực hóa ở tầng CSDL. Deadlock giữa hai synchronized/mutex khóa ngược thứ tự là bài học vỡ lòng của concurrency, và cách chữa kinh điển giống hệt: áp một thứ tự khóa toàn cục. Ngay cả SKIP LOCKED cũng có họ hàng là tryLock() — "lấy được thì làm, không thì bỏ qua". Mẫu số chung đáng mang theo: khóa là công cụ để "đọc-rồi-ghi" an toàn dưới đồng thời, nhưng nó luôn kèm ba câu hỏi — khóa ở mức nào (hàng/bảng, object/lớp), nó chặn ai (chỉ ghi, hay cả đọc), và có nguy cơ deadlock do thứ tự không; trả lời sai câu nào cũng dẫn tới hoặc nghẽn hoặc bug đua tranh. Gói lại bằng số: 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ờ bị khóa ghi chặn, nhờ MVCC); khóa hai hàng ngược thứ tự gây deadlock mà PostgreSQL tự hủy một bên.

Muốn tự thấy thì 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.