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
Hai kết quả này cùng một con số nhưng khác nhau hoàn toàn về bản chất.
Ở READ COMMITTED, database làm đúng những gì được yêu cầu: B ghi đè giá trị của A. Không ai sai luật, và cũng không ai biết một giao dịch vừa bốc hơi. Nếu đây là số dư ví hay tồn kho thì bạn vừa mất tiền hoặc bán quá số lượng.
Ở REPEATABLE READ, PostgreSQL phát hiện B đang định ghi đè lên một dòng đã bị sửa sau khi ảnh chụp của B được tạo, và từ chối. Số dư vẫn là 1100 vì chỉ có A thành công — nhưng lần này hệ thống biết là có chuyện, và ứng dụng có cơ hội chạy lại.
Điểm đáng nhớ: nâng isolation level không làm bug biến mất. Nó đổi một câu trả lời sai âm thầm lấy một exception ồn ào. Nếu code của bạn không bắt và thử lại exception đó, bạn chỉ vừa đổi kiểu hỏng.
Thí nghiệm 3: phantom read, và chỗ PostgreSQL khác chuẩn
Phantom read là khi cùng một câu truy vấn theo điều kiện chạy hai lần trong một transaction lại trả về số dòng khác nhau, vì có transaction khác chèn thêm dòng khớp điều kiện.
----- PHANTOM READ @ READ COMMITTED -----
A đếm lần 1: 2
A đếm lần 2: 3 ← có bóng ma
----- PHANTOM READ @ REPEATABLE READ -----
A đếm lần 1: 2
A đếm lần 2: 2 ← không có
Kết quả thứ hai đáng chú ý, vì nó không khớp với bảng lý thuyết mà hầu hết tài liệu vẫn dạy:
| Hiện tượng | READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| Dirty read | chuẩn: có thể | không | không | không |
| Non-repeatable read | có thể | có thể | không | không |
| Phantom read | có thể | có thể | chuẩn: có thể | không |
Chuẩn SQL-92 nói REPEATABLE READ được phép có phantom read. Nhưng PostgreSQL hiện thực mức này bằng snapshot isolation — toàn bộ transaction làm việc trên một ảnh chụp cố định — nên phantom read không xảy ra được.
Điều đó nghĩa là bảng lý thuyết ở trên mô tả giới hạn tối thiểu mà chuẩn đòi hỏi, không phải hành vi thật của database bạn đang dùng. MySQL InnoDB ở REPEATABLE READ cũng chặn phantom cho câu đọc thuần nhờ MVCC, nhưng hành vi với câu đọc có khoá lại khác. Đừng suy ra hành vi từ tên mức isolation — hãy thử trên chính database của bạn.
Tương tự, READ UNCOMMITTED trong PostgreSQL chạy y hệt READ COMMITTED: kiến trúc MVCC của nó không có cách nào đọc dữ liệu chưa commit, nên dirty read đơn giản là không tồn tại.
