Các anomaly khi nhiều transaction cùng đọc và ghi, và cách chữa từng loại Trung cấp
Thời gian đọc ước tính: 9 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”Service kiểm tra “còn ít nhất một bác sĩ trực” trước khi cho nghỉ. Hai bác sĩ A và B cùng xin nghỉ. Mỗi request đọc thấy người kia còn trực, rồi cập nhật row của chính mình thành on_call = false. Ở Repeatable Read cả hai commit, không ai sửa cùng row, và cuối cùng không còn ai trực.
“Race condition” quá rộng để chọn cách chữa. Anomaly là tên gọi cụ thể của từng kiểu giao thoa, và mỗi kiểu có một cách chữa riêng: đổi SQL, thêm constraint, lock hay tăng isolation level.
Tóm tắt: anomaly nào, mức nào, chữa bằng gì
Phần tiêu đề “Tóm tắt: anomaly nào, mức nào, chữa bằng gì”| Anomaly | Read Committed | Repeatable Read | Serializable | Cách chữa thực dụng |
|---|---|---|---|---|
| Dirty read | Không | Không | Không | Không cần |
| Non-repeatable read | Có | Không | Không | Một statement, hoặc REPEATABLE READ READ ONLY cho báo cáo |
| Phantom read | Có | Không (chặt hơn chuẩn SQL) | Không | Như trên; muốn chặn ghi theo tập row thì dùng EXCLUDE/UNIQUE |
| Read skew | Có (nếu tách statement) | Không | Không | Một SELECT lấy cả hai row, hoặc snapshot chung |
| Lost update (đọc rồi ghi hằng số tính ở app) | Có | Lỗi 40001 |
Lỗi 40001 |
SQL atomic SET x = x + 10, FOR UPDATE, version column |
| Write skew | Có | Có | 40001 cho một bên |
EXCLUDE/UNIQUE, khoá row cha, Serializable + retry |
| Read-only transaction anomaly | Có | Có | Không | Serializable (hiếm gặp; xem tài liệu isolation) |
Cột Repeatable Read và Serializable của lost update khớp tài liệu PostgreSQL: transaction không được sửa hoặc khoá row đã bị transaction khác đổi sau snapshot của nó, nên PostgreSQL trả could not serialize access due to concurrent update (40001), app retry cả transaction.
Ba anomaly đọc, chạy từng bước
Phần tiêu đề “Ba anomaly đọc, chạy từng bước”Bảng dưới là thứ tự thời gian từ trên xuống. Read Committed, trừ khi nói khác.
Non-repeatable read: cùng một row, hai lần đọc, hai kết quả.
| T1 (report) | T2 (writer) |
|---|---|
SELECT balance (đọc 100) |
|
UPDATE ... SET balance = 70; COMMIT |
|
SELECT balance (đọc 70) |
Phantom: cùng điều kiện lọc, tập row đổi.
| T1 | T2 |
|---|---|
SELECT count(*) WHERE status = 'pending' (đọc 2) |
|
INSERT job pending, COMMIT |
|
SELECT count(*) WHERE status = 'pending' (đọc 3) |
Read skew: ghép hai snapshot thành một con số chưa từng tồn tại. A = 100, B = 50, tổng thật luôn 150.
| T1 (report) | T2 (chuyển 30 từ A sang B) |
|---|---|
SELECT balance WHERE id = A (đọc 100) |
|
UPDATE A = 70; UPDATE B = 80; COMMIT |
|
SELECT balance WHERE id = B (đọc 80) |
Report cộng ra 180. T2 vẫn atomic; lỗi nằm ở report tự ghép hai statement. Chữa cả ba cùng một cách: gom vào một statement, hoặc BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY để mọi câu đọc dùng một snapshot.
Lost update: ghi hằng số đã tính từ giá trị cũ
Phần tiêu đề “Lost update: ghi hằng số đã tính từ giá trị cũ”A đọc balance = 100 vào biến trong ứng dụng.
Ở Read Committed, kết quả này luôn xảy ra với lịch trên: lệnh thứ hai chờ lệnh thứ nhất commit, rồi gán đúng hằng số 110 mà app đã tính. PostgreSQL chỉ tuần tự hoá việc ghi, không biết con số 110 đã cũ.
Ba cách chữa, từ rẻ đến đắt:
- Atomic SQL:
UPDATE account SET balance = balance + 10 WHERE id = 1. Ở Read Committed, lệnh sau chờ rồi áp phép cộng lên phiên bản mới, nên ra 120. Đây là mặc định nên dùng. SELECT ... FOR UPDATEtrước khi đọc, trong cùng transaction (xem row-level locks).- Version column (optimistic):
UPDATE ... SET balance = 110, version = version + 1 WHERE id = 1 AND version = 7, row count bằng 0 nghĩa là có người sửa trước, đọc lại và thử lại.
Write skew: hai transaction, hai row, một invariant
Phần tiêu đề “Write skew: hai transaction, hai row, một invariant”A đọc: còn 2 người trực, đủ điều kiện nghỉ.
Khác lost update ở chỗ hai transaction ghi hai row khác nhau, nên không có write-write conflict để PostgreSQL bắt. Snapshot ổn định của Repeatable Read không giúp: cả hai quyết định đều đúng với snapshot của mình. Chạy tuần tự thì kết quả này không thể xảy ra, vì người thứ hai sẽ thấy người thứ nhất đã nghỉ.
Chọn cách chữa theo thứ tự ưu tiên:
| Cách | Khi nào dùng | Ví dụ |
|---|---|---|
UNIQUE / EXCLUDE constraint |
Invariant biểu diễn được trên các row | Không trùng username; không hai booking chồng giờ cùng phòng |
| Khoá row cha hoặc row điều phối | Có một thực thể chung cho các row | SELECT ... FROM shifts WHERE id = 7 FOR UPDATE rồi mới kiểm tra và sửa |
Serializable + retry 40001 |
Invariant phức tạp, chấp nhận abort | Quy tắc “còn ít nhất một người trực” |
| Advisory lock theo khoá nghiệp vụ | Không có row cha để khoá | pg_advisory_xact_lock(hashtext('shift:7')) |
EXCLUDE cho “không đặt trùng phòng” (cần extension btree_gist để dùng = trong GiST):
CREATE EXTENSION IF NOT EXISTS btree_gist;CREATE TABLE bookings ( room_id int NOT NULL, during tstzrange NOT NULL, EXCLUDE USING gist (room_id WITH =, during WITH &&));Lệnh INSERT thứ hai chồng giờ bị từ chối với SQLSTATE 23P01 (exclusion_violation) ở mọi isolation level, không cần Serializable. Ngược lại “kiểm tra username chưa tồn tại rồi insert” nên dùng UNIQUE (lỗi 23505) thay vì SELECT rồi INSERT.
Với Serializable, trong lịch ở trên bên commit sau (B) thường là bên nhận 40001, nhưng bên bị chọn có thể khác ở lịch khác. Wrapper retry phải chạy lại cả transaction, gồm phần đọc điều kiện (xem isolation levels).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Số liệu trong ví dụ là kịch bản minh hoạ, không phải đo đạc.
Thử trên máy
Phần tiêu đề “Thử trên máy”Tạo dữ liệu demo từ thư mục gốc:
docker compose -f examples/docker-compose.yml up -d postgresdocker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-anomalies/setup.sqlMở hai terminal psql (docker compose -f examples/docker-compose.yml exec postgres psql -U hub -d hub) và chạy đúng thứ tự, dừng cả hai sau câu SELECT:
-- Session ABEGIN ISOLATION LEVEL REPEATABLE READ;SELECT name, on_call FROM anomaly_doctors ORDER BY name;
-- Session B (terminal khác)BEGIN ISOLATION LEVEL REPEATABLE READ;SELECT name, on_call FROM anomaly_doctors ORDER BY name;
-- Quay lại AUPDATE anomaly_doctors SET on_call = false WHERE name = 'A';COMMIT;
-- Quay lại BUPDATE anomaly_doctors SET on_call = false WHERE name = 'B';COMMIT;SELECT name, on_call FROM anomaly_doctors ORDER BY name;Kết quả ở Repeatable Read: cả A và B đều false. Chạy lại setup.sql, đổi cả hai BEGIN thành BEGIN ISOLATION LEVEL SERIALIZABLE; và giữ nguyên thứ tự: một bên nhận 40001.
Khi test trong code, cần hai connection thật, điểm đồng bộ để hai bên cùng đọc trước khi ghi, và assertion trên trạng thái cuối. Nếu ORM tự mở transaction khác cho câu ghi, mô phỏng hai session không còn mô tả đúng code của bạn.
Trade-offs
Phần tiêu đề “Trade-offs”Nhận diện đúng anomaly
- Chọn cách chữa sát lỗi: atomic SQL cho lost update, một snapshot cho read skew, constraint cho invariant đơn giản.
- Test được thứ tự sự kiện thay vì chờ race xuất hiện ngẫu nhiên.
- Phân biệt lỗi app ghi giá trị cũ với hành vi isolation của database.
Chi phí khi xử lý
- Isolation mạnh hơn tăng retry và chi phí theo dõi conflict.
- Lock bảo vệ invariant nhưng có thể tạo chờ và deadlock nếu không khoá theo thứ tự thống nhất.
- Một câu
SELECTđúng hôm nay có thể bị ORM hoặc refactor tách thành nhiều câu và tạo read skew mới.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Checklist review code chống race
Phần tiêu đề “Checklist review code chống race”- Không có “đọc lên app, tính, rồi
SET x = hằng số”: đưa phép tính vàoUPDATE(SET balance = balance + 10) và kiểm tra row count. - Không có “
SELECTkiểm tra rồiINSERT” cho tính duy nhất hay chồng lấn: dùngUNIQUEhoặcEXCLUDE. - Invariant trải trên nhiều row (kiểu “còn ít nhất một người trực”): khoá row cha, advisory lock, hoặc Serializable + retry; Repeatable Read không đủ.
- Report ghép nhiều con số: một statement, hoặc
REPEATABLE READ READ ONLYđể dùng chung snapshot. - Wrapper retry
40001chạy lại cả transaction, gồm phần đọc điều kiện. - Test bằng hai connection thật, có điểm đồng bộ để hai bên cùng đọc trước khi ghi, và assert trên trạng thái cuối.
Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra:
- Hai request cùng
SET balance = 110từ giá trị 100 khác gì hai lầnSET balance = balance + 10ở Read Committed? - Vì sao report đọc A trước commit và B sau commit ra tổng chưa từng tồn tại?
- Trong ví dụ bác sĩ, khoá riêng row của mình có ngăn write skew không?