Read Committed (mặc định) và Repeatable Read trông như chỉ khác nhau một chút — một cái lấy snapshot mỗi câu lệnh, một cái đóng băng snapshot từ đầu giao dịch. Nghe có vẻ hàn lâm. Nhưng khác biệt đó trở nên chí mạng trong một tình huống cực kỳ phổ biến: read-modify-write (đọc giá trị, tính toán, ghi lại). Đây là mẫu đằng sau "cộng điểm", "trừ tồn kho", "chuyển tiền", "tăng bộ đếm" — và ở Read Committed, nó có thể âm thầm làm hỏng dữ liệu.

Bài này (phần 3 loạt PostgreSQL) đo thật mẫu này trên pg-lab, cho thấy Read Committed gây lost update không báo lỗi, còn Repeatable Read phát hiện và buộc thử lại.

Vấn đề: lost update trong read-modify-write

Xét bộ đếm bắt đầu = 100. Hai giao dịch cùng làm một việc: đọc giá trị, cộng 10, ghi lại. Kết quả đúng phải là 100+10+10 = 120. Nhưng nếu cả hai đọc 100 trước khi ai đó ghi, cả hai sẽ tính 110 và ghi 110 — một lần +10 bị mất.

Read Committed vs Repeatable Read và lost update: vấn đề read-modify-write hai giao dịch cùng làm đọc value cộng 10 ghi lại, A đọc value 100 tính 100 cộng 10 ghi 110, B đọc value 100 tính 100 cộng 10 ghi 110 mất một lần cộng 10 kỳ vọng đúng 120 thực tế nếu ghi đè âm thầm 110; READ COMMITTED ghi đè âm thầm không báo lỗi BEGIN ISOLATION LEVEL READ COMMITTED SELECT value cả A và B đọc 100 UPDATE value 110 cả hai ghi 110 lost update RC cho phép ghi đè dữ liệu hỏng mà không có lỗi gì báo; REPEATABLE READ phát hiện xung đột báo lỗi buộc thử lại A ghi 110 commit trước B ghi hàng đã đổi sau snapshot của B ERROR could not serialize access due to concurrent update B phải thử lại đọc giá trị mới 110 ghi 120 an toàn nhưng code phải bắt lỗi serialization_failure và làm lại giao dịch

Hình 1: Mẫu read-modify-write gây lost update. Read Committed cho phép hai giao dịch cùng đọc 100 rồi cùng ghi 110 (mất một +10, không báo lỗi). Repeatable Read phát hiện hàng đã đổi sau snapshot và báo lỗi serialization, buộc thử lại.

-- Moi giao dich: doc, +10, ghi (gia tri doc duoc dua vao app roi ghi lai)
BEGIN ISOLATION LEVEL READ COMMITTED;   -- hoac REPEATABLE READ
SELECT value FROM counter WHERE id = 1;  -- doc (vd: 100)
-- ... app tinh 100 + 10 = 110 ...
UPDATE counter SET value = 110 WHERE id = 1;  -- ghi gia tri da tinh
COMMIT;

Đo thật: Read Committed gây lost update âm thầm

Mình chạy hai giao dịch đồng thời trên pg-lab, mỗi cái đọc value, chờ một chút (để cả hai cùng đọc 100), rồi ghi value+10. Kết quả thật:

Bảng kết quả đo thật lost update RC vs RR trên pg-lab postgresql 16.15 với 2 tx đồng thời counter 100 mỗi tx đọc-tính-ghi cộng 10: READ COMMITTED gây lost update âm thầm A đọc 100 ghi 110 commit B đọc 100 ghi 110 commit giá trị cuối trong DB 110 đúng phải là 120 mất một lần cộng 10, cả hai tx chạy thành công không lỗi gì nhưng dữ liệu sai đây là bug nguy hiểm nhất hỏng dữ liệu âm thầm không ngoại lệ nào báo; REPEATABLE READ phát hiện báo lỗi thử lại ra đúng A ghi 110 commit B ghi ERROR could not serialize access due to concurrent update sau vòng đầu value 110 chỉ A B bị từ chối B thử lại đọc lại 110 ghi 120 value 120 đúng. Badge output thật màu xanh

Hình 2: Kết quả thật. Read Committed: cả A và B ghi 110, giá trị cuối = 110 (đúng phải là 120) — lost update âm thầm, không lỗi. Repeatable Read: B bị ERROR could not serialize access; sau khi B thử lại (đọc 110, ghi 120) → kết quả đúng 120.

  • Read Committed: A đọc 100 → ghi 110 (commit); B đọc 100 → ghi 110 (commit). Giá trị cuối trong DB = 110, đúng phải là 120. Một lần +10 bị mất. Và điều nguy hiểm nhất: cả hai giao dịch chạy thành công, không lỗi gì. Dữ liệu hỏng âm thầm, không ngoại lệ nào báo cho bạn biết.

Hãy hình dung đây là "trừ tồn kho": hai đơn hàng cùng đọc tồn=100, cùng trừ 1, cùng ghi 99 — thực tế đã bán 2 món nhưng tồn chỉ giảm 1. Lost update là nguồn gốc của cả một lớp bug dữ liệu khó lần ra.

Đo thật: Repeatable Read phát hiện và buộc thử lại

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

  • A ghi 110 (commit trước). B ghi → ERROR: could not serialize access due to concurrent update. PostgreSQL thấy hàng đã bị đổi sau snapshot của B, nên từ chối ghi đè. Sau vòng đầu, value = 110 (chỉ của A), B bị từ chối.
  • B thử lại: giao dịch mới của B đọc giá trị mới (110), tính 110+10, ghi 120. Kết quả đúng.

Khác biệt cốt lõi: Read Committed im lặng ghi đè (hỏng dữ liệu), Repeatable Read phát hiện xung đột và báo lỗi (buộc thử lại, bảo toàn tính đúng). Đây là lý do với logic read-modify-write nhạy cảm, nâng lên Repeatable Read (hoặc dùng khóa) là cần thiết.

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

Repeatable Read an toàn hơn nhưng code PHẢI xử lý retry. Lỗi could not serialize access (SQLSTATE 40001) không phải lỗi logic — nó là tín hiệu "hãy thử lại giao dịch này". Ứng dụng phải bắt mã lỗi này và lặp lại toàn bộ giao dịch (đọc giá trị mới, tính lại, ghi lại). Nếu không bắt, giao dịch đó thất bại và thao tác bị mất — khác với lost update (dữ liệu sai nhưng "thành công"), đây là "thất bại rõ ràng". Cả hai đều cần xử lý; RR biến bug âm thầm thành lỗi thấy được, nhưng không tự sửa giúp bạn.

Thường có cách đơn giản hơn nâng mức cô lập: UPDATE nguyên tử. Thay vì đọc value vào app rồi ghi lại, hãy để database tự đọc-tính-ghi trong một câu: UPDATE counter SET value = value + 10 WHERE id = 1. Câu này khóa hàng và đọc giá trị mới nhất tại thời điểm ghi, nên không bao giờ lost update kể cả ở Read Committed — vì không có khoảng hở giữa đọc và ghi ở tầng app. Với các phép cộng/trừ đơn giản, đây là giải pháp tốt nhất: đúng, nhanh, không cần retry. Chỉ khi logic phức tạp (tính toán ở app, nhiều bảng) mới cần Repeatable Read hoặc SELECT ... FOR UPDATE (bài 5).

Read Committed vẫn là mặc định đúng cho hầu hết ứng dụng. Đừng vội nâng mọi thứ lên Repeatable Read vì sợ lost update — RR giữ snapshot lâu hơn (cản vacuum, bài 8) và tăng tỉ lệ phải retry. Phần lớn truy vấn không có mẫu read-modify-write nguy hiểm. Chỉ nâng mức (hoặc dùng UPDATE nguyên tử / FOR UPDATE) cho đúng những giao dịch có đọc-rồi-ghi dựa trên giá trị vừa đọc. Dùng công cụ đúng cho đúng chỗ, không đổi mặc định toàn cục.

Ba ý mang về

  1. Read Committed gây lost update âm thầm trong read-modify-write. Đo thật: hai giao dịch cùng đọc 100, cùng +10, cùng ghi 110 → giá trị cuối 110 thay vì 120, một +10 bị mất, và KHÔNG có lỗi nào báo. Đây là bug dữ liệu nguy hiểm vì "thành công" nhưng sai.
  2. Repeatable Read phát hiện xung đột và báo lỗi, buộc thử lại. Đo thật: B bị ERROR could not serialize access; sau khi thử lại (đọc 110, ghi 120) cho kết quả đúng. RR biến bug âm thầm thành lỗi thấy được — nhưng code phải bắt serialization_failure và lặp lại giao dịch.
  3. Cách đơn giản nhất thường là UPDATE nguyên tử. UPDATE SET value = value + 10 không bao giờ lost update kể cả ở Read Committed, vì database tự đọc-ghi không có khoảng hở. Chỉ nâng lên Repeatable Read / FOR UPDATE cho logic phức tạp; giữ Read Committed làm mặc định cho phần còn lại.

Nguồn

Phần sau ta lên mức cao nhất: Serializable — khi nào PostgreSQL hủy một giao dịch vì không thể bảo đảm thứ tự tuần tự, và cái giá của việc có được sự an toàn tuyệt đối.