Bài trước cho thấy Repeatable Read bắt được lost update khi hai giao dịch ghi cùng một hàng. Nhưng có một loại bug đồng thời tinh vi hơn mà Repeatable Read không bắt được: write skew. Nó xảy ra khi hai giao dịch cùng đọc một điều kiện chung, rồi mỗi cái ghi một hàng khác nhau theo điều kiện đó — và tổ hợp hai lần ghi phá vỡ một ràng buộc mà không giao dịch nào, nhìn riêng lẻ, làm sai.

Đây là lý do tồn tại của mức cô lập cao nhất: Serializable. Bài này (phần 4 loạt PostgreSQL) demo thật write skew trên pg-lab và cho thấy chỉ Serializable mới bắt được nó.

Write skew: ví dụ hai bác sĩ trực

Ví dụ kinh điển: hệ thống lịch trực bệnh viện, ràng buộc luôn phải có ít nhất 1 bác sĩ trực (on_call). Alice và Bob đều đang trực. Mỗi người chạy cùng một logic: "nếu còn ≥2 người trực thì tôi xin nghỉ".

  • Alice: đếm thấy 2 người trực (ok, còn người khác) → đặt alice nghỉ.
  • Bob: đếm thấy 2 người trực (ok, còn người khác) → đặt bob nghỉ.
  • Kết quả: cả hai nghỉ → 0 người trực → vi phạm ràng buộc.

Serializable và write skew: ví dụ hai bác sĩ trực ràng buộc phải còn ít nhất 1 bác sĩ on_call, alice và bob đều đang trực mỗi người nếu còn lớn hơn hoặc bằng 2 người tôi xin nghỉ, A đếm on_call 2 ok còn người khác set alice OFF, B đếm on_call 2 ok set bob OFF, kết quả cả hai nghỉ 0 người trực vi phạm ràng buộc; vì sao Repeatable Read không bắt được RR chỉ phát hiện khi 2 tx ghi cùng một hàng write skew A ghi hàng alice B ghi hàng bob khác hàng RR không thấy xung đột ghi cả hai commit thành công dữ liệu hỏng vấn đề ở chỗ cả hai cùng đọc chung một điều kiện count rồi hành động lệch nhau; Serializable SSI phát hiện phụ thuộc đọc ghi BEGIN ISOLATION LEVEL SERIALIZABLE PostgreSQL theo dõi phụ thuộc đọc ghi giữa các tx SSI nếu phát hiện chu trình không thể xếp tuần tự ERROR could not serialize access due to read write dependencies among transactions hủy một tx buộc thử lại đảm bảo kết quả như chạy tuần tự

Hình 1: Write skew — hai giao dịch đọc chung điều kiện (count≥2), ghi hai hàng khác nhau (alice, bob), tổ hợp phá vỡ ràng buộc. Repeatable Read không bắt vì hai hàng khác nhau; Serializable (SSI) theo dõi phụ thuộc đọc/ghi và hủy một giao dịch.

-- Moi giao dich lam cung logic nay:
BEGIN ISOLATION LEVEL SERIALIZABLE;   -- hoac REPEATABLE READ
SELECT count(*) FROM doctors WHERE on_call;  -- doc dieu kien chung
-- neu count >= 2:
UPDATE doctors SET on_call=false WHERE name='alice';  -- ghi HANG cua rieng minh
COMMIT;

Đo thật: Repeatable Read để lọt write skew

Mình chạy hai giao dịch đồng thời trên pg-lab, mỗi cái đếm số bác sĩ trực rồi (nếu ≥2) tự đặt mình nghỉ. Kết quả thật:

Bảng kết quả đo thật write skew RR vs Serializable trên pg-lab postgresql 16.15 với 2 tx đồng thời alice cộng bob đang trực ràng buộc lớn hơn hoặc bằng 1 người: REPEATABLE READ gây write skew ràng buộc bị vi phạm A đếm on_call 2 ok UPDATE alice OFF commit B đếm on_call 2 ok UPDATE bob OFF commit số người còn trực 0 vi phạm ràng buộc phải lớn hơn hoặc bằng 1, cả hai tx thành công không lỗi RR không bắt được vì A sửa hàng alice B sửa hàng bob hai hàng khác nhau không có xung đột ghi nhưng cả hai cùng dựa trên điều kiện đọc count 2 đã không còn đúng; SERIALIZABLE phát hiện hủy một tx ràng buộc giữ vững A ERROR could not serialize access due to read write dependencies among transactions B UPDATE bob OFF commit thành công số người còn trực 1 đúng ràng buộc giữ vững. Badge output thật màu xanh

Hình 2: Kết quả thật. Repeatable Read: cả A và B thành công, còn 0 người trực — write skew, ràng buộc bị vi phạm, không lỗi. Serializable: A bị ERROR could not serialize access due to read/write dependencies, B thành công, còn 1 người trực — ràng buộc giữ vững.

  • Repeatable Read: A đếm on_call=2 → UPDATE alice OFF (commit); B đếm on_call=2 → UPDATE bob OFF (commit). Số người còn trực = 0 — ràng buộc "≥1" bị vi phạm. Và như lost update ở bài trước, cả hai thành công, không lỗi gì.

Vì sao Repeatable Read để lọt? Vì cơ chế của nó chỉ phát hiện xung đột khi hai giao dịch ghi cùng một hàng (first-updater-wins). Ở write skew, A ghi hàng 'alice', B ghi hàng 'bob' — khác hàng, không có xung đột ghi trực tiếp. Vấn đề nằm ở chỗ cả hai cùng đọc điều kiện count=2 rồi hành động dựa trên nó, nhưng điều kiện đó đã không còn đúng sau khi cái kia ghi.

Đo thật: Serializable bắt được

Chạy lại cùng kịch bản ở Serializable:

  • A → ERROR: could not serialize access due to read/write dependencies among transactions. B → UPDATE bob OFF thành công. Số người còn trực = 1 — ràng buộc giữ vững.

PostgreSQL Serializable dùng SSI (Serializable Snapshot Isolation): nó theo dõi phụ thuộc đọc/ghi giữa các giao dịch — "A đọc cái B ghi, B đọc cái A ghi". Khi phát hiện một chu trình phụ thuộc không thể xếp thành bất kỳ thứ tự tuần tự nào, nó hủy một giao dịch, buộc thử lại. Kết quả đảm bảo giống như thể các giao dịch chạy lần lượt, không chồng nhau — đó là ý nghĩa của "serializable".

Chú ý thông báo lỗi khác bài trước: đây là "read/write dependencies among transactions" (SSI phát hiện phụ thuộc), còn lost update ở Repeatable Read là "concurrent update" (ghi cùng hàng). Hai cơ chế khác nhau cho hai loại anomaly khác nhau.

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

Serializable an toàn nhất nhưng tốn và tỉ lệ bị hủy cao — code BẮT BUỘC có retry. SSI phải theo dõi tập đọc/ghi của mọi giao dịch song song (dùng predicate lock), tốn bộ nhớ và CPU hơn. Quan trọng hơn, nó hủy nhiều giao dịch hơn Repeatable Read (kể cả các ca "có vẻ" không xung đột), nên mọi giao dịch Serializable phải được bọc trong vòng lặp thử lại khi gặp SQLSTATE 40001. Không có retry, ứng dụng sẽ thất bại ngẫu nhiên dưới tải cao. Serializable không miễn phí — nó đổi throughput và độ phức tạp code lấy sự đúng đắn tuyệt đối.

Thường có lựa chọn rẻ hơn: ràng buộc ở database hoặc khóa tường minh. Write skew thường có thể ngăn bằng cách vật chất hóa ràng buộc: thêm một CHECK, một hàng tổng hợp có khóa, hoặc dùng SELECT ... FOR UPDATE để khóa đúng các hàng liên quan (bài 5) khiến hai giao dịch không chạy song song được. Ví dụ bác sĩ: khóa một hàng "lịch trực" chung trước khi kiểm tra, thì hai giao dịch buộc tuần tự. Những cách này rẻ hơn Serializable (không hủy/retry), nhưng đòi bạn nhận ra ràng buộc và khóa đúng chỗ. Serializable là lưới an toàn cho khi ràng buộc phức tạp khó khóa thủ công.

Serializable chỉ bảo vệ trong phạm vi các giao dịch Serializable. Nếu một giao dịch chạy Serializable nhưng giao dịch khác chạy Read Committed trên cùng dữ liệu, bảo đảm serializable không còn đúng — SSI chỉ suy luận được về các giao dịch cùng ở mức Serializable. Để có bảo đảm thật, tất cả giao dịch chạm vào dữ liệu nhạy cảm đó phải cùng dùng Serializable. Trộn lẫn mức cô lập phá vỡ bảo đảm mà bạn tưởng mình có.

Ba ý mang về

  1. Write skew là anomaly mà Repeatable Read không bắt được. Đo thật: hai giao dịch cùng đếm 2 bác sĩ trực rồi mỗi người tự xin nghỉ (ghi hàng khác nhau) → còn 0 người trực, vi phạm ràng buộc, không lỗi. RR chỉ bắt xung đột ghi cùng hàng, không bắt được ghi khác hàng dựa trên điều kiện đọc chung.
  2. Serializable (SSI) phát hiện phụ thuộc đọc/ghi và hủy một giao dịch. Đo thật: cùng kịch bản, A bị ERROR could not serialize access due to read/write dependencies, B thành công → còn 1 người trực, ràng buộc giữ vững. Kết quả đảm bảo như chạy tuần tự.
  3. Serializable an toàn nhất nhưng đắt — có lựa chọn rẻ hơn. Code phải có vòng retry (tỉ lệ hủy cao); mọi giao dịch chạm dữ liệu nhạy cảm phải cùng mức Serializable. Thường ngăn write skew rẻ hơn bằng ràng buộc CHECK, hàng tổng hợp có khóa, hoặc SELECT FOR UPDATE (bài 5).

Nguồn

Phần sau ta chuyển sang khóa tường minh: FOR UPDATE và FOR SHARE, cách một giao dịch chờ khóa của giao dịch khác, và đo thật thời gian chờ cùng cách đọc pg_locks để chẩn đoán tranh chấp khóa.