Bỏ qua để đến nội dung
Knowledge Hub

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

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.

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ũ”
Lost update: hai request cùng cộng 10, kết quả 110 thay vì 120
Lost update: hai request cùng cộng 10, kết quả 110 thay vì 120Bước 1/5: A đọc balance = 100 vào biến trong ứng dụng.thời gianSELECT balance→ 100Request Acộng 10Request Bcộng 10
Bước 1/5

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:

  1. 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.
  2. SELECT ... FOR UPDATE trước khi đọc, trong cùng transaction (xem row-level locks).
  3. 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”
Write skew: hai bác sĩ cùng rời ca ở Repeatable Read
Write skew: hai bác sĩ cùng rời ca ở Repeatable ReadBước 1/5: A đọc: còn 2 người trực, đủ điều kiện nghỉ.thời gianCOUNT on_call→ 2Bác sĩ Axin nghỉBác sĩ Bxin nghỉ
Bước 1/5

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).

Số liệu trong ví dụ là kịch bản minh hoạ, không phải đo đạc.

Tạo dữ liệu demo từ thư mục gốc:

Terminal window
docker compose -f examples/docker-compose.yml up -d postgres
docker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-anomalies/setup.sql

Mở 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 A
BEGIN 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 A
UPDATE anomaly_doctors SET on_call = false WHERE name = 'A';
COMMIT;
-- Quay lại B
UPDATE 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.

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.
  1. Không có “đọc lên app, tính, rồi SET x = hằng số”: đưa phép tính vào UPDATE (SET balance = balance + 10) và kiểm tra row count.
  2. Không có “SELECT kiểm tra rồi INSERT” cho tính duy nhất hay chồng lấn: dùng UNIQUE hoặc EXCLUDE.
  3. 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 đủ.
  4. Report ghép nhiều con số: một statement, hoặc REPEATABLE READ READ ONLY để dùng chung snapshot.
  5. Wrapper retry 40001 chạy lại cả transaction, gồm phần đọc điều kiện.
  6. 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ự kiểm tra:

  1. Hai request cùng SET balance = 110 từ giá trị 100 khác gì hai lần SET balance = balance + 10 ở Read Committed?
  2. Vì sao report đọc A trước commit và B sau commit ra tổng chưa từng tồn tại?
  3. Trong ví dụ bác sĩ, khoá riêng row của mình có ngăn write skew không?