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

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

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.

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

  • Read Committed: mỗi statement lấy snapshot mới. Hai SELECT trong cùng transaction có thể thấy hai trạng thái khác nhau. Với UPDATE cùng row, PostgreSQL chờ writer trước rồi đánh giá lại WHERE trê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áo 40001 thay 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)”
Session A thấy gì sau khi B commit: Read Committed so với Repeatable Read
Session A thấy gì sau khi B commit: Read Committed so với Repeatable ReadBước 1/5: 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.thời giansnapshot cố định …SELECT balance→ 100SELECT balance→ 100Session ARead CommittedSession Bngười ghiSession ARepeatable Read
Bước 1/5

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

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.

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.
  • SET TRANSACTION ISOLATION LEVEL phả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 DEFERRABLE cho 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.

Thử trên máy với bảng demo:

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-isolation/setup.sql

Mở hai terminal psql tới database hub và chạy đúng timeline ở trên:

-- A
BEGIN ISOLATION LEVEL READ COMMITTED; -- đổi thành REPEATABLE READ ở lần thử sau
SELECT balance FROM isolation_account WHERE id = 1; -- 100
-- B (terminal khác)
UPDATE isolation_account SET balance = 70 WHERE id = 1;
-- A
SELECT 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.

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.
  1. 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ố.
  2. Dùng constraint (UNIQUE, EXCLUDE, CHECK) và FOR UPDATE trước khi nghĩ tới Serializable.
  3. Mọi code chạy ở Repeatable Read hoặc Serializable đều đi qua wrapper retry 40001 và 40P01.
  4. Metric: tỷ lệ retry, số abort theo endpoint, thời gian transaction dài nhất.
  5. Đặt idle_in_transaction_session_timeout và statement_timeout để transaction treo không giữ snapshot.
  6. Side effect ngoài database đi qua outbox, không nằm trong transaction có thể retry.

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 đủ?