Transaction Isolation — 4 mức cô lập trong PostgreSQL, chọn sao cho đúng?
Khi dính lỗi dữ liệu "đọc chết" hay trùng lặp vắng mặt giữa request, đa số nghi ngờ đến đoạn code — nhưng thiệt ra thủ phạm thường nằm ở mức cô lập giao dịch (transaction isolation level). PostgreSQL mặc định dùng Read Committed, và nhiều lập trình viên không biết đây mới là điểm dễ đi sai nhất khi viết logic đọc-ghi cạnh tranh.
Mức cô lập là gì?
Khi nhiều transaction chạy song song trên cùng dữ liệu, chúng có thể đụng nhau. Tiêu chuẩn SQL định nghĩa 4 vấn đề mà mức cô lập cần chống:
- Dirty read: đọc dữ liệu của transaction chưa commit.
- Non-repeatable read: đọc hai lần trong cùng transaction mà ra kết quả khác nhau, vì bản ghi bị transaction khác sửa giữa chừng.
- Phantom read: chạy cùng một câu query filter được hai lần, lần sau thấy thêm bản ghi mới lọt vào.
PostgreSQL chỉ có 3 mức (bỏ Read Uncommitted vì nó chạy giống Read Committed): Read Committed, Repeatable Read, Serializable.
1. Read Committed (mặc định)
Mỗi câu lệnh SELECT bên trong transaction nhìn snapshot tại thời điểm câu lệnh chạy, không phải tại lúc bắt đầu transaction. Điều này cực kỳ dễ gây non-repeatable read: hai lần đọc trong một transaction cho số liệu khác nhau.
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 100
-- user khác commit +100 -> balance = 200
SELECT balance FROM accounts WHERE id = 1; -- 200 (đổi bất ngờ!)
COMMIT;
Với phần lớn workload OLTP (đọc-giá, đếm số, update đơn lẻ) thì Read Committed là chuẩn và nhanh. Đừng nâng cấp "cho an toàn" khi chưa cần — nâng cao = tốn lock, dễ chết nghẽn.
2. Repeatable Read
Transaction nhìn một snapshot cố định từ lệnh đầu tiên. Hai SELECT luôn cho kết quả giống nhau — hết non-repeatable read. NHƯNG nó không chống phantom read trong mọi ngữ cảnh, và nguy hiểm thật sự nằm ở chuyện write conflict: khi cố sửa một bản ghi transaction khác đã thay đổi, bạn nhận lỗi could not serialize access due to concurrent update.
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A';
-- nếu bị transaction khác sửa qty giữa chừng -> ERROR + rollback
COMMIT;
Kinh nghiệm thực tế: mặc định Postgres trả về lỗi ngay chứ không chờ — nên pattern phổ biến là bắt lỗi 40001 serialization_failure và chạy lại transaction nguyên khối. App của em từng ghi sai nghiệp vụ chỉ vì không RETURNING giá trị cũ trước khi update trừ kho.
3. Serializable
Mạnh nhất — mô phỏng transaction chạy tuần tự, chống mọi loại xung đột kể cả phantom. Postgres dùng kỹ thuật SSI (Serializable Snapshot Isolation): không lock cả bảng, mà tự phát hiện xung đột rồi báo lỗi để app retry.
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- toàn bộ logic nghiệp vụ đặt trong try/catch, retry khi gặp 40001
COMMIT;
Chỉ nên dùng cho chốt kỳ, tính tổng đa bảng, hoặc logic "thêm nếu chưa tồn tại" cần đúng tuyệt đối. Đừng áp tràn cho endpoint có traffic cao — tỉ lệ serialization failure tăng nhanh, đổ hết lên đầu retry logic.
Quy tắc rút ra cho dự án thật
- Giữ
Read Committedở mức mặc định, đừng nâng level "phòng ngừa". - Muốn đọc-ổn-định trong một báo cáo dài thì bọc trong
Repeatable Read. - Logic ghi phức tạp (trừ kho, đếm giới hạn) thì dùng
Serializable+ retry toàn bộ transaction. - Đọc bảng
pg_stat_activityđể xem transaction nào mở lâu (long-running) — mở transaction im lặng để hở là cách chết lock kinh điển mà không ai nhìn thấy.
Kiểm tra mức cô lập hiện tại bằng:
SHOW transaction_isolation; -- read committed
alter system set default_transaction_isolation = 'repeatable read';
Mức cô lập không phải "càng nghiêm ngặt càng tốt" — nó là một sự đánh đổi giữa tính nhất quán và hiệu năng. Chọn đúng mức cho từng nghiệp vụ, tập trung xử lý lỗi 40001 bằng retry đúng cách, bài toán "dữ liệu chạy lung tung" sẽ tự khỏi.