Hai giao dịch cùng cộng 100, số dư chỉ tăng 100: isolation level qua thí nghiệm thật
Isolation level quyết định một giao dịch nhìn thấy gì khi có giao dịch khác chạy song song. Ở mức mặc định của hầu hết database là READ COMMITTED, hai phiên cùng đọc số dư 1000 rồi cùng ghi 1100 sẽ cho kết quả cuối là 1100 — một lần cộng biến mất, không có lỗi nào được ném. Nâng lên REPEATABLE READ thì phiên thứ hai nhận ERROR: could not serialize access due to concurrent update: câu trả lời sai âm thầm biến thành một lỗi rõ ràng mà ứng dụng phải thử lại. Toàn bộ số liệu dưới đây đo bằng hai session psql song song trên PostgreSQL 16.11.
Đây là loại lỗi gần như không thể tái hiện trên máy dev, vì ở đó bạn chỉ có một người dùng. Nó chỉ xuất hiện khi hai request thật chạm vào cùng một dòng trong cùng một khoảnh khắc — và lúc đó nó không sập, không log, chỉ làm sai số liệu.
Bài này đào sâu bài Transactions và ACID trong series học SQL 30 ngày.
Tóm tắt nhanh (TL;DR)
READ COMMITTED(mặc định) không chống được lost update trong mẫu đọc-tính-ghi.REPEATABLE READbiến lỗi âm thầm thành exception, nên ứng dụng bắt buộc phải có cơ chế thử lại.- PostgreSQL chặn phantom read ngay ở
REPEATABLE READ, dù chuẩn SQL-92 cho phép nó xảy ra ở mức này. READ UNCOMMITTEDtrong PostgreSQL chạy y nhưREAD COMMITTED— không có dirty read.- Nếu không muốn đụng isolation level: dùng
SELECT ... FOR UPDATE, hoặc ghi bằng một câuUPDATEduy nhất.
Thí nghiệm 1: non-repeatable read
Một bảng tài khoản, số dư 1000. Phiên A mở transaction và đọc, phiên B ghi 2000 rồi commit, sau đó A đọc lại trong cùng transaction.
CREATE TABLE tk(id int primary key, so_du int);
INSERT INTO tk VALUES (1, 1000);
Kết quả đo:
----- READ COMMITTED -----
A đọc lần 1: 1000
A đọc lần 2: 2000 ← đổi giữa chừng
----- REPEATABLE READ -----
A đọc lần 1: 1000
A đọc lần 2: 1000 ← ổn định
Ở READ COMMITTED, mỗi câu lệnh nhìn thấy trạng thái đã commit tại thời điểm câu lệnh đó chạy. Nên trong cùng một transaction, hai lần đọc cùng một dòng có thể ra hai giá trị khác nhau. Đó gọi là non-repeatable read.
Hậu quả thực tế: một báo cáo đọc bảng nhiều lần trong một transaction có thể tự mâu thuẫn với chính nó — tổng ở phần đầu không khớp chi tiết ở phần sau.
Ở REPEATABLE READ, transaction làm việc trên một ảnh chụp dữ liệu tại thời điểm nó bắt đầu, nên mọi lần đọc đều nhất quán.
Thí nghiệm 2: lost update
Đây là thí nghiệm quan trọng nhất, vì nó mô phỏng đúng thứ code ứng dụng vẫn làm hằng ngày:
var tk = await db.TaiKhoan.FindAsync(id); // đọc: 1000
tk.SoDu = tk.SoDu + 100; // tính: 1100
await db.SaveChangesAsync(); // ghi: 1100
Hai phiên cùng chạy đoạn đó. Cả hai đọc 1000, cả hai ghi 1100.
----- LOST UPDATE @ READ COMMITTED -----
B đọc thấy: 1000
số dư cuối cùng: 1100 ← mất một lần cộng, KHÔNG có lỗi
----- LOST UPDATE @ REPEATABLE READ -----
B đọc thấy: 1000
ERROR: could not serialize access due to concurrent update
số dư cuối cùng: 1100 ← B bị huỷ, ứng dụng phải thử lại
Trình tự hai phiên, và chỗ hai mức isolation rẽ nhánh:
