Isolation levels trong PostgreSQL: chọn mức nào cho transaction? Trung cấp
Thời gian đọc ước tính: 8 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”Một API đọc số dư bằng hai câu SELECT rồi quyết định có cho chuyển tiền không. Giữa hai câu, transaction khác commit. Cùng một đoạn code:
- ở Read Committed đọc hai mốc dữ liệu khác nhau;
- ở Repeatable Read đọc một mốc ổn định;
- ở Serializable có thể bị PostgreSQL từ chối nếu kết quả không tương đương bất kỳ thứ tự chạy tuần tự nào.
Isolation level không phải thang “an toàn hơn”. Nó quyết định khi nào lấy snapshot, xung đột nào bị phát hiện và ứng dụng phải xử lý lỗi nào.
PostgreSQL nhận bốn tên theo SQL standard nhưng chỉ triển khai ba hành vi: READ UNCOMMITTED cư xử như READ COMMITTED (mặc định); REPEATABLE READ là snapshot isolation; SERIALIZABLE là Serializable Snapshot Isolation (SSI). Bài áp dụng cho PostgreSQL 17 trên primary; đừng mang bảng hành vi này sang MySQL hay hệ khác.
Chọn mức nào? (tóm tắt trước)
Phần tiêu đề “Chọn mức nào? (tóm tắt trước)”| Tình huống | Mức nên dùng | Điều kiện đi kèm |
|---|---|---|
| CRUD ngắn, SQL tự đặt điều kiện trong câu ghi | Read Committed (mặc định) | Viết SQL atomic, kiểm tra row count, FOR UPDATE khi cần |
| Báo cáo đọc nhiều bảng phải cùng một mốc | Repeatable Read, READ ONLY |
Giữ truy vấn đủ ngắn để không cản VACUUM |
| Invariant nhiều row không biểu diễn được bằng constraint hay lock | Serializable | Bắt buộc có retry toàn transaction, theo dõi tỷ lệ abort |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Ma trận anomaly theo mức (PostgreSQL)
Phần tiêu đề “Ma trận anomaly theo mức (PostgreSQL)”| Anomaly | Read Committed | Repeatable Read | Serializable |
|---|---|---|---|
| Dirty read | Không | Không | Không |
| Non-repeatable read | Có | Không | Không |
| Phantom read | Có | Không (mạnh hơn chuẩn SQL) | Không |
| Lost update (đọc-sửa-ghi trong app) | Có | Bị chặn bằng lỗi 40001 |
Bị chặn bằng lỗi 40001 |
| Write skew | Có | Có | Bị chặn: một transaction bị huỷ 40001 |
Xem từng anomaly chạy thật ở bài các anomaly.
Ba mức, ba cách lấy snapshot
Phần tiêu đề “Ba mức, ba cách lấy snapshot”- Read Committed: mỗi statement lấy snapshot mới. Hai
SELECTtrong cùng transaction có thể thấy hai trạng thái khác nhau. VớiUPDATEcùng row, PostgreSQL chờ writer trước rồi đánh giá lạiWHEREtrên phiên bản mới. - Repeatable Read: snapshot cố định từ câu lệnh đầu tiên không phải lệnh điều khiển transaction (không phải từ chữ
BEGIN). Nếu cố cập nhật row đã bị transaction khác commit sau snapshot, PostgreSQL báo40001thay vì âm thầm ghi đè. - Serializable: vẫn đọc từ snapshot, nhưng SSI theo dõi các phụ thuộc đọc–ghi (predicate lock). Khi thấy cấu hình có thể vi phạm tính tuần tự, một transaction bị huỷ với
40001. Không có nghĩa các transaction xếp hàng chạy lần lượt.
Cùng một lịch chạy, hai mức isolation (balance ban đầu = 100)
Phần tiêu đề “Cùng một lịch chạy, hai mức isolation (balance ban đầu = 100)”Hai biến thể của session A cùng mở transaction và đọc balance = 100. Repeatable Read giữ snapshot này đến hết transaction.
Ở Serializable, ví dụ chỉ có B ghi và A đọc sẽ không có lỗi nào: 40001 chỉ xuất hiện ở lịch có nguy cơ vi phạm tính tuần tự.
Mức cao hơn không tự làm logic đúng
Phần tiêu đề “Mức cao hơn không tự làm logic đúng”Nếu ứng dụng tính sai (cộng 20 thay vì 30), Serializable vẫn commit một transaction “tuần tự” nhưng sai nghiệp vụ. Constraint vẫn cần cho luật biểu diễn được trong schema; idempotency vẫn cần khi client không chắc COMMIT đã thành công; transaction vẫn phải ngắn.
Cách retry đúng
Phần tiêu đề “Cách retry đúng”Với Repeatable Read và Serializable, 40001 là tín hiệu chạy lại toàn bộ quyết định, gồm cả phần đọc. Retry riêng câu UPDATE là sai vì snapshot cũ không còn dùng được. 40P01 (deadlock) cũng nên đi qua cùng wrapper retry.
async function withRetry<T>(fn: (c: PoolClient) => Promise<T>, maxAttempts = 5): Promise<T> { for (let attempt = 1; ; attempt++) { const client = await pool.connect(); try { await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE'); const result = await fn(client); await client.query('COMMIT'); // COMMIT cũng có thể ném 40001 return result; } catch (err: any) { await client.query('ROLLBACK').catch(() => {}); const retryable = err.code === '40001' || err.code === '40P01'; if (!retryable || attempt >= maxAttempts) throw err; await sleep(Math.random() * 50 * 2 ** attempt); // chờ ngắn có jitter } finally { client.release(); } }}Lưu ý:
- Chỉ retry lỗi đã phân loại; đừng retry mù mọi lỗi SQL.
- Giới hạn số lần và deadline, và theo dõi tỷ lệ retry thành metric.
- Nếu transaction gửi email hoặc gọi API ngoài trước
COMMIT, retry sẽ lặp side effect. Đẩy side effect qua outbox hoặc cơ chế idempotent sau commit.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”SET TRANSACTION ISOLATION LEVELphải chạy trước truy vấn đầu tiên. Không thể đọc ở Read Committed rồi đổi cùng transaction sang Repeatable Read để “sửa” snapshot.- Đừng mở transaction rồi chờ người dùng xác nhận trên giao diện: transaction giữ lock, giữ snapshot cũ và cản VACUUM.
- Serializable có chi phí: SSI dùng predicate lock, và Seq Scan lấy khoá cấp bảng nên dễ gây false positive (abort không cần thiết). Index tốt giảm abort.
- Serializable không dùng được trên hot standby (replica). Báo cáo trên replica dùng Repeatable Read.
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLEcho báo cáo dài trên primary: chờ một snapshot an toàn rồi chạy mà không bao giờ bị huỷ vì40001.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Thử trên máy với bảng demo:
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-isolation/setup.sqlMở hai terminal psql tới database hub và chạy đúng timeline ở trên:
-- ABEGIN ISOLATION LEVEL READ COMMITTED; -- đổi thành REPEATABLE READ ở lần thử sauSELECT balance FROM isolation_account WHERE id = 1; -- 100
-- B (terminal khác)UPDATE isolation_account SET balance = 70 WHERE id = 1;
-- ASELECT balance FROM isolation_account WHERE id = 1; -- 70 (RC) hoặc 100 (RR)COMMIT;Chạy lại setup.sql để đưa số dư về 100 trước mỗi lần thử. Ở Repeatable Read, thử UPDATE ... SET balance = 90 sau lần đọc thứ hai sẽ nhận 40001; chạy ROLLBACK trước khi thử tiếp. Ví dụ này không chứng minh write skew; xem bài anomaly.
Trade-offs
Phần tiêu đề “Trade-offs”Chọn mức cô lập theo yêu cầu
- Read Committed giữ transaction ngắn, phù hợp nhiều thao tác đơn giản khi SQL đặt điều kiện ngay trong câu ghi.
- Repeatable Read cho nhiều truy vấn một góc nhìn ổn định, hữu ích cho báo cáo.
- Serializable phát hiện lịch chạy vi phạm tính tuần tự mà snapshot isolation bỏ sót.
Chi phí cần quản lý
- Repeatable Read và Serializable trả
40001; code phải chạy lại được an toàn. - Serializable thêm chi phí theo dõi và có thể tăng tỷ lệ abort khi contention cao.
- Transaction giữ snapshot lâu cản dọn version cũ; isolation không thay timeout.
Checklist production
Phần tiêu đề “Checklist production”- Mặc định Read Committed; viết SQL atomic (
UPDATE ... SET x = x + 10,WHERE status = 'pending') thay vì đọc rồi ghi hằng số. - Dùng constraint (
UNIQUE,EXCLUDE,CHECK) vàFOR UPDATEtrước khi nghĩ tới Serializable. - Mọi code chạy ở Repeatable Read hoặc Serializable đều đi qua wrapper retry
40001và40P01. - Metric: tỷ lệ retry, số abort theo endpoint, thời gian transaction dài nhất.
- Đặt
idle_in_transaction_session_timeoutvàstatement_timeoutđể transaction treo không giữ snapshot. - Side effect ngoài database đi qua outbox, không nằm trong transaction có thể retry.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) A chỉ BEGIN rồi chờ B commit, sau đó A SELECT lần đầu ở Repeatable Read: A thấy mốc nào? (2) Vì sao hai SELECT khác kết quả ở Read Committed không phải dirty read? (3) Khi nhận 40001, vì sao retry riêng câu UPDATE là không đủ?