Isolation levels và 4 anomalies — dirty read, non-repeatable read, phantom, write skew
4 isolation level SQL chuẩn, 4 anomaly Berenson 1995, demo từng anomaly với 2 psql session, Snapshot Isolation vs SSI trong PostgreSQL, và write skew trong TaskFlow.
Mở 2 terminal, kết nối vào cùng một database PostgreSQL, rồi cùng chạy lệnh sau trên cả hai session:
-- Session A va B cung chay dong thoi, khong BEGIN, khong LOCK
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
Không có lỗi. Không có cảnh báo. Cả hai session commit thành công. Nhưng khi kiểm tra kết quả, số dư chỉ giảm 10, không phải 20. Một trong hai lần trừ tiền bị mất — đây là lost update, một trong 4 anomaly mà hệ quản trị cơ sở dữ liệu phải đối mặt khi nhiều transaction chạy đồng thời.
Vì sao xảy ra? Vì cả hai session đọc balance = 100, tính 100 - 10 = 90, rồi cùng ghi 90 vào row — không phải 80. Đây là bài toán race condition kinh điển. Và mỗi database, tùy vào isolation level được chọn, sẽ cho phép hoặc cấm các anomaly khác nhau. Hiểu đúng từng level và từng anomaly là điều kiện bắt buộc để viết code transaction an toàn trong môi trường production.
Bài này mổ xẻ 4 isolation level theo SQL standard, 4 anomaly được định nghĩa trong paper Berenson 1995, hành vi cụ thể của PostgreSQL (khác với SQL Server và Oracle ở một số điểm quan trọng), demo 2-session cho từng anomaly, và áp dụng vào TaskFlow với bài toán assign task race condition.
1. Analogy — Google Doc phòng họp nhiều tác giả
Hãy tưởng tượng một Google Doc dùng chung trong phòng họp, nhiều người cùng chỉnh sửa một lúc:
| Cách dùng Google Doc | Isolation Level | Ý nghĩa |
|---|---|---|
| Thấy từng ký tự người khác gõ realtime, kể cả khi họ đang xoá đi | Read Uncommitted | Đọc data chưa commit của tx khác — kể cả data sẽ bị rollback |
| Chỉ thấy nội dung khi người khác bấm "Lưu" | Read Committed | Chỉ đọc data đã commit; mỗi lần đọc lại thấy version mới nhất |
| Mở doc lúc 9h, đóng lúc 10h; suốt 1 tiếng thấy đúng nội dung 9h dù ai đó đã sửa | Repeatable Read | Snapshot chụp lúc tx bắt đầu; mọi lần đọc trong tx đều thấy cùng dữ liệu |
| Ngoài việc thấy nội dung 9h, hệ thống còn chủ động ngăn 2 tác giả cùng đặt phòng 1 slot | Serializable | Phát hiện và ngăn xung đột logic giữa các tx, dù không cùng sửa 1 row |
| Bạn và đồng nghiệp cùng đọc lịch "slot 14h trống", cùng quyết định đặt → hệ thống ngăn 1 người | Serializable chặn write skew | SSI detect dependency cycle, abort 1 tx |
| Có ai đó xoá cả trang bạn đang đọc mà bạn không hay | Phantom read | Query range 2 lần trong tx, thấy số row khác nhau |
| Sửa slide 3 rồi xem lại thấy nội dung cũ vẫn ở đó | Non-repeatable read | Đọc cùng row 2 lần trong tx, thấy giá trị khác nhau |
Bốn level là bốn điểm trên trục correctness–throughput: Read Uncommitted (tối đa throughput, tối thiểu đảm bảo) → Read Committed (mặc định PG, an toàn cho đa số CRUD) → Repeatable Read (snapshot isolation, PG mạnh hơn SQL standard) → Serializable (an toàn nhất, có overhead). PostgreSQL không thực sự implement Read Uncommitted — nó được xử lý như Read Committed vì PG không bao giờ để đọc dirty data.
2. Tại sao cần isolation — lost update và dirty money
Concurrency không tự nhiên đúng. Khi nhiều transaction cùng đọc và ghi data, kết quả có thể sai theo nhiều cách không rõ ràng. Demo kinh điển nhất là read-modify-write pattern:
-- Schema accounts (tu M06.1, re-declare cho self-contained)
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
balance NUMERIC(15,2) NOT NULL CHECK (balance >= 0)
);
-- Seed du lieu
INSERT INTO accounts(user_id, balance) VALUES (1, 100.00), (2, 500.00);
Sau đây là timeline của lost update khi 2 session chạy đồng thời ở Read Committed:
-- Session A -- Session B
BEGIN;
SELECT balance
FROM accounts WHERE id = 1;
-- => 100
BEGIN;
SELECT balance
FROM accounts WHERE id = 1;
-- => 100 (cung snapshot)
UPDATE accounts
SET balance = 90 -- tru 10
WHERE id = 1;
COMMIT;
-- balance = 90
UPDATE accounts
SET balance = 80 -- B nghi balance=100, tru 20
WHERE id = 1;
COMMIT;
-- balance = 80 (ghi de ket qua cua A)
-- Ket qua cuoi: 80 thay vi 80 dung, nhung mat logic:
-- A tru 10 (100->90), B tru 20 (100->80) -- tong tru 30 nhung balance chi giam 20
-- Lost update: 10 bien mat
Đây là lý do tại sao dùng pattern balance = balance - 10 (atomic expression) thay vì đọc ra app rồi ghi lại. Nhưng ngay cả vậy, vẫn tồn tại những anomaly phức tạp hơn mà chỉ level cao hơn mới ngăn được.
3. Bốn anomaly — Berenson 1995
Paper "A Critique of ANSI SQL Isolation Levels" (Berenson et al., 1995) định nghĩa chặt chẽ các anomaly mà SQL standard muốn ngăn. Đây là bộ từ vựng chuẩn của ngành:
| Anomaly | Định nghĩa | Ví dụ TaskFlow |
|---|---|---|
| Dirty read | Tx đọc data của tx khác chưa commit | T1 sửa tasks.title = 'X', T2 đọc thấy 'X', T1 rollback → T2 đã thao tác trên data không tồn tại |
| Non-repeatable read | Đọc cùng một row 2 lần trong tx, giá trị khác nhau | T1 SELECT priority FROM tasks WHERE id=42 ra 'high'; T2 UPDATE+COMMIT đổi thành 'low'; T1 đọc lại → 'low' |
| Phantom read | Query cùng điều kiện 2 lần trong tx, tập kết quả khác nhau | T1 SELECT COUNT(*) WHERE status='todo' ra 5; T2 INSERT task status='todo' COMMIT; T1 đếm lại → 6 |
| Write skew | 2 tx cùng đọc một tập data, cùng đưa ra quyết định dựa trên tập đó, cùng ghi vào 2 row khác nhau → phá vỡ invariant | T1 và T2 cùng đọc "slot 14h trống", cùng ghi "đặt slot 14h" → 2 booking cùng slot |
Write skew tinh tế nhất: T1 và T2 không ghi vào cùng row (nên không có write conflict), nhưng kết quả hợp lại vi phạm một invariant của business. Chỉ Serializable mới ngăn được.
SQL-92 standard định nghĩa isolation level theo kiểu "cấm dirty read/non-repeatable read/phantom read". Nhưng định nghĩa đó không đầy đủ — bỏ sót write skew và một số anomaly khác. Berenson et al. 1995 chỉ ra lỗ hổng này và đề xuất định nghĩa chặt chẽ hơn. Paper này là nền tảng lý thuyết mà PostgreSQL SSI và nhiều database hiện đại dựa vào.
4. Bốn isolation level và ma trận anomaly
SQL standard định nghĩa 4 level. PostgreSQL implement theo cách riêng — ở một số điểm mạnh hơn standard yêu cầu:
| Isolation Level | Dirty read | Non-repeatable read | Phantom read | Write skew | Throughput |
|---|---|---|---|---|---|
| READ UNCOMMITTED | Cho phép (PG: cấm) | Cho phép | Cho phép | Cho phép | Cao nhất |
| READ COMMITTED (PG default) | Cấm | Cho phép | Cho phép | Cho phép | Cao |
| REPEATABLE READ (PG: Snapshot Isolation) | Cấm | Cấm | Cấm (PG mạnh hơn standard) | Cho phép | Trung bình-cao |
| SERIALIZABLE (PG: SSI) | Cấm | Cấm | Cấm | Cấm | Trung bình |
Hai điểm đặc biệt của PostgreSQL:
Read Uncommitted = Read Committed trong PG: PG không có cơ chế để đọc dirty data — kiến trúc MVCC của PG đảm bảo mọi read đều thấy committed version. Nếu set SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED, PG xử lý y hệt Read Committed.
Repeatable Read trong PG là Snapshot Isolation: SQL standard cho phép phantom read ở Repeatable Read. Nhưng PG implement RR bằng Snapshot Isolation — snapshot chụp lúc transaction bắt đầu, nên phantom read không xảy ra. PG RR mạnh hơn ANSI RR.
-- Dat isolation level cho transaction hien tai
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED; -- mac dinh
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Hoac dung SET TRANSACTION sau BEGIN
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... statements ...
COMMIT;
-- Hoac dung default cua session
SET default_transaction_isolation = 'repeatable read';
5. Snapshot Isolation — PG Repeatable Read hoạt động thế nào
Hiểu Snapshot Isolation (SI) là hiểu tại sao PG RR mạnh hơn ANSI RR và tại sao nó vẫn không ngăn được write skew.
Snapshot là gì? Mỗi transaction trong PG được gán một txid (transaction ID). Khi transaction bắt đầu, PG ghi lại:
txidhiện tại của transaction này.- Danh sách tất cả
txidđang active (chưa commit, chưa rollback) tại thời điểm đó.
Đây là snapshot. Mọi read trong transaction sẽ chỉ thấy data được ghi bởi các transaction có txid nhỏ hơn snapshot và đã committed — không thấy data của transaction nào trong danh sách active, không thấy data mới được commit sau khi snapshot được chụp.
txid timeline:
100 (committed) 101 (active khi T103 bat dau) 102 (committed) 103 (T hien tai)
T103 snapshot: thay data tu txid <= 102, trong do txid=101 dang active -> khong thay
=> T103 thay: txid 100 + txid 102 (da commit, < 103, khong trong active list)
=> T103 KHONG thay: txid 101 (con active), txid 104+ (chua ton tai)
Read Committed vs Repeatable Read — khác biệt snapshot:
- Read Committed: snapshot chụp lại mỗi statement. Hai câu SELECT liên tiếp trong cùng transaction có thể thấy data khác nhau nếu có commit xảy ra giữa chúng.
- Repeatable Read: snapshot chụp một lần lúc statement đầu tiên của transaction. Mọi statement tiếp theo dùng cùng snapshot → cùng một view của data.
-- Demo: RC lay snapshot moi moi statement
-- Session A (READ COMMITTED -- mac dinh):
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- => 100 (snapshot tai thoi diem nay)
-- [Session B: UPDATE accounts SET balance = 200 WHERE id = 1; COMMIT;]
SELECT balance FROM accounts WHERE id = 1;
-- => 200 (snapshot moi, thay commit cua B)
COMMIT;
-- Demo: RR giu snapshot tu dau den cuoi transaction
-- Session A (REPEATABLE READ):
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- => 100 (snapshot chup luc nay)
-- [Session B: UPDATE accounts SET balance = 200 WHERE id = 1; COMMIT;]
SELECT balance FROM accounts WHERE id = 1;
-- => 100 (van la snapshot cu -- khong thay thay doi cua B)
COMMIT;
Write conflict ở Repeatable Read: Nếu T1 (RR) muốn UPDATE một row mà T2 đã UPDATE và committed sau khi T1 chụp snapshot, PG sẽ abort T1 với ERROR: could not serialize access due to concurrent update (SQLSTATE 40001). Application phải retry transaction.
6. Serializable SSI — Cahill 2008
Serializable Snapshot Isolation (SSI) là cơ chế PG dùng cho isolation level Serializable — không phải lock-based serialization như SQL Server hay Oracle, mà là snapshot + detection conflict.
Vấn đề của Snapshot Isolation: SI ngăn được nhiều anomaly nhưng không ngăn write skew. Lý do: 2 transaction có thể cùng đọc tập data không overlap, cùng ghi vào 2 row khác nhau — không có write conflict — nhưng hợp lại thì phá invariant.
SSI giải quyết thế nào? Thêm một lớp tracking trên SI:
-
SIREAD locks (predicate locks): Mỗi khi transaction đọc data (kể cả đọc theo điều kiện WHERE — predicate), PG ghi lại "transaction này đã đọc tập data X". Đây không phải lock thực — không block ai. Chỉ là dấu theo dõi.
-
Read-write dependency tracking: PG xây dựng đồ thị phụ thuộc giữa các transaction: T1 đọc data mà T2 sau đó ghi (rw-dependency T1→T2), T2 đọc data mà T3 sau đó ghi (rw-dependency T2→T3).
-
Cycle detection: Nếu đồ thị phụ thuộc có chu trình (ví dụ T1→T2→T1), PG biết rằng không thể sắp xếp các transaction này theo thứ tự serial nào đó mà kết quả tương đương. Một transaction bị abort với SQLSTATE
40001.
Write skew detection qua SSI:
T1: READ tasks WHERE assignee IS NULL -- SIREAD lock tren tap nay
T2: READ tasks WHERE assignee IS NULL -- SIREAD lock tren tap nay (overlap)
T1: WRITE tasks SET assignee=userA WHERE id=42 -- rw-dep T2->T1
T2: WRITE tasks SET assignee=userB WHERE id=42 -- write conflict! T2 abort
Hoac write skew khong cung row:
T1: READ oncall WHERE status='on' -- SIREAD lock
T2: READ oncall WHERE status='on' -- SIREAD lock (overlap)
T1: INSERT oncall (doctor_id=1, status='off') -- rw-dep T2->T1
T2: INSERT oncall (doctor_id=2, status='off') -- rw-dep T1->T2
-- Cycle: T1->T2->T1 -> PG abort 1 tx voi SQLSTATE 40001
Lưu ý quan trọng về false positive: SSI đôi khi abort transaction dù không có anomaly thực sự (false positive). Khi retry, transaction sẽ thành công. App phải implement retry logic cho SQLSTATE 40001.
-- Pattern retry cho Serializable
-- App code (pseudo):
-- for attempt := 1 to MAX_RETRY:
-- BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE
-- ... business logic ...
-- COMMIT
-- if success: break
-- if SQLSTATE 40001: continue (retry)
-- else: raise error
7. Demo từng anomaly với 2 psql session
7a. Dirty read — PG không bao giờ cho phép
-- Session A:
BEGIN;
UPDATE accounts SET balance = 9999 WHERE id = 1;
-- CHUA COMMIT
-- Session B (du o READ UNCOMMITTED, PG van khong thay):
BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- => 100 (khong thay 9999 chua commit cua A)
COMMIT;
-- Session A:
ROLLBACK;
-- 9999 bien mat, khong ai doc duoc
PG không có dirty read ở bất kỳ isolation level nào — MVCC ngăn điều này ở tầng kiến trúc.
7b. Non-repeatable read — demo ở Read Committed
-- Session A (READ COMMITTED -- mac dinh):
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- => 100
-- [Dung o day, sang Session B]
-- Session B:
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;
-- Session B ket thuc
-- Session A tiep tuc (van con trong tx):
SELECT balance FROM accounts WHERE id = 1;
-- => 200 (KHAC lan dau -- non-repeatable read xay ra)
COMMIT;
-- Fix: dung Repeatable Read
-- Session A (REPEATABLE READ):
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- => 100
-- [Session B commit UPDATE balance=200]
SELECT balance FROM accounts WHERE id = 1;
-- => 100 (van la snapshot cu -- non-repeatable read KHONG xay ra)
COMMIT;
7c. Phantom read — demo ở Read Committed
-- Gia su co bang tasks (schema goc cua TaskFlow)
-- Session A (READ COMMITTED):
BEGIN;
SELECT COUNT(*) FROM tasks WHERE status = 'todo';
-- => 5
-- Session B:
BEGIN;
INSERT INTO tasks(project_id, assignee_id, title, status)
VALUES (1, NULL, 'Task moi', 'todo');
COMMIT;
-- Session A tiep tuc:
SELECT COUNT(*) FROM tasks WHERE status = 'todo';
-- => 6 (THEM 1 -- phantom read xay ra)
COMMIT;
-- Fix: Repeatable Read trong PG (Snapshot Isolation) can duoc phantom:
-- Session A (REPEATABLE READ):
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM tasks WHERE status = 'todo';
-- => 5
-- [Session B INSERT + COMMIT]
SELECT COUNT(*) FROM tasks WHERE status = 'todo';
-- => 5 (snapshot cu -- phantom read KHONG xay ra)
COMMIT;
Nhớ: đây là điểm PG mạnh hơn SQL standard — ANSI Repeatable Read cho phép phantom, nhưng PG RR (SI) cấm phantom.
7d. Write skew — demo ở Repeatable Read và Serializable
Scenario: TaskFlow có invariant "mỗi task chỉ được assign cho 1 user". Hai user cùng assign cùng một task đang trống:
-- Setup: task 42 chua duoc assign
UPDATE tasks SET assignee_id = NULL WHERE id = 42;
-- Session A (REPEATABLE READ):
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT assignee_id FROM tasks WHERE id = 42;
-- => NULL (task trong)
-- A quyet dinh: se assign cho user 1
-- Session B (REPEATABLE READ) -- bat dau SAU A:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT assignee_id FROM tasks WHERE id = 42;
-- => NULL (van la NULL trong snapshot cua B)
-- B quyet dinh: se assign cho user 2
-- Session A:
UPDATE tasks SET assignee_id = 1 WHERE id = 42;
COMMIT;
-- Thanh cong -- assignee_id = 1
-- Session B:
UPDATE tasks SET assignee_id = 2 WHERE id = 42;
COMMIT;
-- Thanh cong (!!) -- assignee_id = 2 (ghi de A)
-- Write skew: ca A va B deu nghi task trong, ca 2 assign, ket qua cuoi sai
-- Fix: dung Serializable (SSI detect conflict, abort 1 tx)
-- Session A (SERIALIZABLE):
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT assignee_id FROM tasks WHERE id = 42; -- SIREAD lock
-- => NULL
-- Session B (SERIALIZABLE):
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT assignee_id FROM tasks WHERE id = 42; -- SIREAD lock (overlap)
-- => NULL
-- Session A:
UPDATE tasks SET assignee_id = 1 WHERE id = 42;
COMMIT;
-- Thanh cong
-- Session B:
UPDATE tasks SET assignee_id = 2 WHERE id = 42;
COMMIT;
-- ERROR: could not serialize access due to concurrent update
-- SQLSTATE: 40001
-- App retry -> lan nay thay assignee_id = 1 -> tu choi hoac xu ly khac
Ở Serializable, SSI phát hiện read-write dependency cycle và abort một transaction. App nhận SQLSTATE 40001 và phải retry.
8. Jepsen-aware — distributed SQL và isolation bugs
Isolation anomaly không chỉ là lý thuyết — chúng xuất hiện trong sản phẩm production khi implementation sai.
Jepsen là framework kiểm thử chaos cho distributed database — inject network partition, clock skew, node crash, và kiểm tra xem database có vi phạm isolation guarantee của nó không. Kyle Kingsbury (aphyr) đã phát hiện nhiều bug nghiêm trọng:
-
Jepsen — CockroachDB beta-20160829: CockroachDB claim Serializable, nhưng Jepsen tìm thấy lost update và write skew ở thời điểm đó. Đội ngũ CockroachDB đã sửa và cải thiện isolation guarantee sau report này.
-
Jepsen — TiDB 2.1.7: TiDB (MySQL-compatible distributed SQL) có anomaly ở Snapshot Isolation implementation — stale read, lost update, và write skew xuất hiện trong các điều kiện concurrent nhất định.
Bài học từ Jepsen: distributed SQL khó hơn single-node rất nhiều. Network partition, clock drift giữa các node, và distributed commit protocol tạo ra vô số corner case. Nếu bạn chạy multi-region hoặc distributed SQL cluster, đừng tin mù quáng vào isolation claim của vendor — hỏi xem họ có Jepsen report không, và report đó từ version nào.
Với PostgreSQL single-node, isolation guarantee đã được kiểm chứng rất kỹ. Nhưng nếu dùng Citus, Aurora, hoặc Neon (distributed PG variants), cần kiểm tra documentation về isolation behavior ở distributed transactions.
9. Pitfall — Read Committed mặc định không an toàn cho counter và money
Anti-pattern phổ biến nhất: Đọc giá trị ra application, tính toán trong code, rồi ghi lại. Ở Read Committed, đây là race condition chờ xảy ra.
// ANTI-PATTERN: read-modify-write o RC
const result = await client.query(
'SELECT balance FROM accounts WHERE id = $1',
[accountId]
);
const currentBalance = result.rows[0].balance;
const newBalance = currentBalance - withdrawAmount;
// Race condition o day: 2 request dong thoi doc cung balance,
// ca 2 tinh newBalance, ca 2 ghi -> 1 lan rut tien bi mat
await client.query(
'UPDATE accounts SET balance = $1 WHERE id = $2',
[newBalance, accountId]
);
Hai request đến cùng lúc với balance = 1000, cùng tính 1000 - 500 = 500, cùng ghi 500. Kết quả: rút 1000 nhưng chỉ mất 500.
Fix tier theo mức độ phức tạp:
Tier 1 — Atomic UPDATE expression (đủ cho most cases ở RC):
-- Atomic: expression duoc evaluate trong cung statement, khong race
UPDATE accounts
SET balance = balance - 100
WHERE id = 1
AND balance >= 100; -- check du tien cung trong 1 statement
-- Kiem tra rows_affected: neu = 0 thi khong du tien
Tier 2 — SELECT FOR UPDATE (lock row truoc modify):
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Row bi lock, tx khac phai cho den khi tx nay COMMIT hoac ROLLBACK
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- Chi tot khi can doc gia tri truoc de tinh toan phuc tap
-- Chi tiet o bai 6 cua module nay (Locks va Deadlock)
Tier 3 — Serializable + retry on SQLSTATE 40001:
-- Dung khi co write skew risk -- tx phuc tap, nhieu bang
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ... business logic phuc tap ...
COMMIT;
-- App retry neu nhan 40001
Không bao giờ đọc giá trị ra app code rồi ghi lại ở RC mà không có cơ chế lock. Đây là nguồn gốc của nhiều bug tài chính production.
10. Applied — TaskFlow: 2 user assign cùng 1 task đồng thời
TaskFlow có feature "assign task": user mở task dashboard, thấy task chưa có assignee, bấm "Assign to me". Nếu 2 user làm điều này trong cùng khoảnh khắc — và app dùng Read Committed — cả 2 sẽ thành công, nhưng task chỉ thuộc về 1 người (người commit sau).
Đây là write skew: cả 2 đọc assignee_id IS NULL, cả 2 quyết định assign, cả 2 ghi. Kết quả cuối sai.
-- Schema tasks (goc TaskFlow):
-- tasks(id, project_id, assignee_id, title, status, ...)
-- assignee_id REFERENCES users(id) -- co the NULL
-- Pattern SAI (Read Committed, race condition):
BEGIN;
SELECT assignee_id FROM tasks WHERE id = $1;
-- App check: neu NULL thi assign
UPDATE tasks SET assignee_id = $current_user WHERE id = $1;
COMMIT;
-- 2 user cung lam buoc nay dong thoi: ca 2 deu update thanh cong
-- Pattern DUNG -- Option A: Serializable + retry
-- App code:
-- MAX_RETRY = 3
-- for attempt in 1..MAX_RETRY:
-- try:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT assignee_id FROM tasks WHERE id = $1;
-- Neu assignee_id IS NOT NULL: raise "da duoc assign", ROLLBACK
UPDATE tasks SET assignee_id = $current_user
WHERE id = $1 AND assignee_id IS NULL;
-- Kiem tra affected rows
COMMIT;
-- catch SQLSTATE 40001: retry
-- catch other: raise
-- Pattern DUNG -- Option B: Atomic UPDATE + check affected rows (RC, don gian hon)
BEGIN;
UPDATE tasks
SET assignee_id = $current_user
WHERE id = $1
AND assignee_id IS NULL; -- chi update neu con trong
-- rows_affected = 0: task da duoc assign boi nguoi khac
-- rows_affected = 1: assign thanh cong
COMMIT;
-- Khong can Serializable vi WHERE assignee IS NULL la dieu kien atomic trong UPDATE
Option B đơn giản hơn và đúng trong trường hợp này — điều kiện AND assignee_id IS NULL trong UPDATE là atomic ở mọi isolation level. Option A cần thiết khi business logic phức tạp hơn và cần đọc nhiều bảng trước khi quyết định.
Retry pattern trong Node.js:
// Serializable + retry cho workflow phuc tap
async function assignTaskSafe(client, taskId, userId) {
const MAX_RETRY = 3;
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
await client.query(
'BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE'
);
const { rows } = await client.query(
'SELECT assignee_id FROM tasks WHERE id = $1 FOR UPDATE',
[taskId]
);
if (rows[0].assignee_id !== null) {
await client.query('ROLLBACK');
return { success: false, reason: 'already_assigned' };
}
await client.query(
'UPDATE tasks SET assignee_id = $1 WHERE id = $2',
[userId, taskId]
);
await client.query('COMMIT');
return { success: true };
} catch (err) {
await client.query('ROLLBACK');
// SQLSTATE 40001: serialization failure -- retry
if (err.code === '40001' && attempt < MAX_RETRY) {
continue;
}
throw err;
}
}
}
Pattern này sẽ được mở rộng trong bài 7 của module này (mini-challenge TaskFlow end-to-end với Serializable).
11. Deep Dive
-
Berenson et al. 1995 — "A Critique of ANSI SQL Isolation Levels" — paper gốc định nghĩa lại 4 anomaly và chỉ ra lỗ hổng của SQL-92. Microsoft Research. Đọc Section 3 (anomaly definitions) và Section 4 (isolation levels matrix) để hiểu bộ từ vựng chuẩn ngành.
-
Cahill et al. 2008 — "Serializable Isolation for Snapshot Databases" (SIGMOD) — paper gốc SSI, University of Washington course reading. Mô tả thuật toán detect serialization conflict bằng predicate lock + dependency graph, nền tảng của PG Serializable hiện tại.
-
PostgreSQL 14 Internals — Egor Rogov, Part I (MVCC + Isolation) — free PDF. Chapter 12–13 đi sâu snapshot mechanics (xmin/xmax, pg_xact, visibility check), isolation level implementation, và SSI predicate lock internals. Nguồn PG-specific đầy đủ nhất hiện có.
-
Jepsen Analyses — jepsen.io/analyses — danh sách đầy đủ tất cả Jepsen test report. Mỗi report là case study thực tế về isolation bug trong distributed database. Đặc biệt: CockroachDB beta-20160829 (write skew) và TiDB 2.1.7 (SI anomaly).
12. Tóm tắt
- 4 isolation level SQL standard: Read Uncommitted → Read Committed (PG default) → Repeatable Read → Serializable. PG không thực sự implement Read Uncommitted — xử lý như Read Committed.
- 4 anomaly (Berenson 1995): Dirty read (đọc chưa commit), Non-repeatable read (cùng row khác giá trị), Phantom read (cùng range khác tập kết quả), Write skew (cùng đọc + cùng ghi khác row → phá invariant).
- PG Repeatable Read = Snapshot Isolation: snapshot chụp lúc tx bắt đầu, mạnh hơn ANSI RR — cấm cả phantom read. ANSI RR chỉ cấm non-repeatable read.
- PG Serializable = SSI (Cahill 2008): SIREAD lock theo dõi read set, detect dependency cycle, abort 1 tx với SQLSTATE
40001. App phải retry. - Write skew chỉ Serializable cấm: 2 tx cùng đọc tập data, cùng ghi vào 2 row khác nhau → không có write conflict nhưng phá invariant. Invisible với locking thông thường.
- Đọc ra app rồi ghi lại ở RC là anti-pattern: dùng atomic UPDATE expression (
SET col = col - x) hoặcSELECT FOR UPDATEhoặc Serializable + retry. - Distributed SQL cần audit thực tế: isolation claim trên giấy và implementation thực tế có thể khác nhau — Jepsen report là bằng chứng. Single-node PG an toàn hơn distributed variants.
- TaskFlow assign task: dùng atomic UPDATE +
WHERE assignee_id IS NULLcho case đơn giản; Serializable + retry cho business logic phức tạp nhiều bảng.
13. Tự kiểm tra
Q1Vì sao Read Committed (PG default) không an toàn cho pattern đọc balance ra app, tính toán, rồi ghi lại? Mô tả timeline cụ thể của race condition dẫn đến lost update.▸
Read Committed lấy snapshot mới cho mỗi statement — không cho mỗi transaction. Nên 2 transaction đồng thời ở RC đều đọc được giá trị hiện tại của row ngay tại thời điểm SELECT của chúng.
Timeline lost update: T1 SELECT balance = 1000 → T2 SELECT balance = 1000 (cùng lúc) → T1 tính 1000-300=700, ghi 700, commit → T2 tính 1000-400=600, ghi 600, commit. Kết quả: 600, nhưng đáng lẽ 1000-300-400=300. T1 trừ 300 bị mất hoàn toàn.
Fix: dùng atomic expression SET balance = balance - 300 — PG evaluate expression này trong cùng statement với lock implicit, không có window để race.
Q2Phân biệt Non-repeatable read và Phantom read. Cho ví dụ cụ thể với bảng tasks của TaskFlow. Tại sao PG Repeatable Read (Snapshot Isolation) cấm được cả phantom dù ANSI RR chỉ yêu cầu cấm non-repeatable read?▸
Non-repeatable read: đọc cùng một row 2 lần, giá trị khác nhau vì tx khác UPDATE+COMMIT giữa 2 lần đọc. Ví dụ: T1 SELECT priority FROM tasks WHERE id=42 → 'high'; T2 UPDATE priority='low' COMMIT; T1 SELECT lại → 'low'.
Phantom read: query cùng điều kiện 2 lần, tập kết quả (số row) khác nhau vì tx khác INSERT/DELETE+COMMIT. Ví dụ: T1 SELECT COUNT(*) WHERE status='todo' → 5; T2 INSERT task status='todo' COMMIT; T1 đếm lại → 6. Không có row nào bị sửa, nhưng tập kết quả thay đổi.
PG SI cấm phantom: snapshot chụp một lần lúc transaction bắt đầu. Mọi query trong T1 đều chạy trên snapshot đó — không thấy INSERT mới dù đã commit. ANSI RR dùng lock-based: lock từng row đã đọc, nhưng không lock "khoảng trống" giữa các row → row mới INSERT vào khoảng trống vẫn visible → phantom. SI không lock, dùng snapshot → phantom không xảy ra tự nhiên.
Q3Write skew là gì? Tại sao nó invisible với lock-based concurrency control thông thường? Đưa ra một ví dụ ngoài TaskFlow (không phải assign task).▸
Write skew: 2 transaction cùng đọc một tập data, cùng đưa ra quyết định dựa trên tập đó, cùng ghi vào 2 row khác nhau — không có write conflict — nhưng hợp lại kết quả phá invariant của business.
Invisible với lock thông thường vì: lock protect row bạn đang ghi, không protect row bạn đã đọc nhưng không ghi. T1 đọc R1+R2, ghi W1. T2 đọc R1+R2, ghi W2. Không có tx nào ghi vào row tx kia đang ghi → không có write conflict → không có lock contention. Nhưng quyết định của mỗi tx dựa trên assumption về tổng thể đang bị vô hiệu bởi tx kia.
Ví dụ — hệ thống oncall: invariant "phải có ít nhất 1 bác sĩ trực". T1 đọc "còn 2 bác sĩ trực", quyết định T1 có thể nghỉ, UPDATE doctor1 OFF. T2 đọc "còn 2 bác sĩ trực" (cùng snapshot), quyết định T2 cũng có thể nghỉ, UPDATE doctor2 OFF. Cả 2 commit thành công. Kết quả: 0 bác sĩ trực — vi phạm invariant. Giải pháp: Serializable.
Q4SSI (Serializable Snapshot Isolation) trong PostgreSQL dùng SIREAD lock như thế nào? Phân biệt SIREAD lock với lock thông thường (blocking lock).▸
SIREAD lock (predicate lock): khi transaction đọc data (kể cả qua WHERE predicate), PG ghi lại "tx này đã đọc tập X". Đây KHÔNG phải blocking lock — không ngăn tx khác đọc hay ghi vào cùng tập data. Chỉ là dấu tracking để build dependency graph.
Blocking lock (regular lock): khi tx ghi vào row/page, PG đặt lock thực sự — tx khác muốn ghi vào cùng row phải chờ đến khi tx đầu commit/rollback. Blocking lock ngăn concurrent write.
SSI dùng SIREAD lock để track read-write dependency: nếu T1 SIREAD-lock tập X, sau đó T2 ghi vào X, PG ghi rw-dependency T1→T2. Nếu T2 cũng SIREAD-lock tập Y mà T1 đã ghi vào → rw-dependency T2→T1. Cycle T1→T2→T1 → abort 1 tx với SQLSTATE 40001.
Q5App nhận SQLSTATE 40001 từ PostgreSQL khi COMMIT. Đây là loại lỗi gì, và app phải xử lý thế nào? Phân biệt với SQLSTATE 40P01 (deadlock).▸
SQLSTATE 40001: serialization failure — PG phát hiện rằng execution history của tập transactions hiện tại không thể được sắp xếp thành một thứ tự serial nào tương đương. Xảy ra ở Repeatable Read (write conflict trên cùng row) và Serializable (dependency cycle).
App phải: rollback transaction hiện tại, chờ một khoảng ngắn (optional, để giảm contention), rồi retry toàn bộ transaction từ đầu. Sau khi retry, các transaction kia đã committed nên transaction mới sẽ thấy state mới nhất và có thể thành công hoặc từ chối hợp lý.
SQLSTATE 40P01: deadlock detected — PG phát hiện deadlock (cycle trong blocking lock wait graph) và chủ động abort 1 tx để phá cycle. Xử lý tương tự: rollback + retry. Khác 40001 ở chỗ: 40001 là về serialization guarantee, 40P01 là về lock wait cycle. Cả 2 đều cần retry logic.
Q6PostgreSQL Repeatable Read mạnh hơn ANSI Repeatable Read ở điểm nào? Điều này có phải lúc nào cũng tốt không — có trường hợp nào cần biết sự khác biệt này?▸
PG Repeatable Read dùng Snapshot Isolation — snapshot chụp lúc transaction bắt đầu, bao gồm cả predicate query. Nên phantom read không xảy ra: INSERT mới dù đã commit cũng không visible trong snapshot. ANSI RR dùng lock-based per-row, không lock predicate → phantom read có thể xảy ra.
Không phải lúc nào cũng tốt hơn — có 2 điểm cần biết:
1. Write conflict abort: PG RR sẽ abort transaction nếu cố UPDATE row mà tx khác đã UPDATE+COMMIT sau snapshot. ANSI RR lock-based sẽ block và chờ. Tùy workload, abort+retry có thể kém hiệu quả hơn blocking nếu conflict rate cao.
2. Portability: code viết cho PG RR (không thấy phantom) chạy trên Oracle hay SQL Server RR có thể bị phantom. Nếu cần đảm bảo cross-database, phải dùng Serializable hoặc tự lock predicate. Đây là lý do code production nên document rõ isolation level assumption.
Q7Jepsen tests phát hiện isolation bug trong CockroachDB và TiDB. Điều này có ý nghĩa gì với kỹ sư đang dùng distributed SQL (Citus, Aurora PostgreSQL, PlanetScale)? Nên làm gì để verify isolation guarantee thực tế?▸
Ý nghĩa: distributed SQL là một engineering problem khác hoàn toàn với single-node SQL. Clock drift giữa node, network partition, distributed commit protocol (2PC, Paxos, Raft) tạo ra corner case mà single-node MVCC không gặp. Vendor có thể claim "Serializable" nhưng implementation có bug ở specific scenario.
Việc cần làm khi chọn distributed SQL:
1. Tìm Jepsen report cho database đó (jepsen.io/analyses). Xem report mới nhất — bug cũ có thể đã fix, nhưng fix đúng version nào?
2. Đọc isolation level documentation kỹ — một số hệ thống chỉ offer "Snapshot Isolation" và gọi là "Serializable". Phân biệt thực sự SSI vs SI.
3. Chạy txn correctness test trên staging environment — Elle (Jepsen sub-framework) có thể detect anomaly mà không cần chaos injection phức tạp.
4. Với critical financial workflow: thiết kế application-level idempotency và validation thay vì chỉ dựa vào isolation level — defense in depth.
Q8TaskFlow mở rộng: ngoài assign task, team bạn đề xuất dùng Read Committed cho feature 'đặt chỗ họp' (booking room, mỗi slot chỉ có 1 booking). Analyze lỗ hổng và thiết kế fix đúng.▸
Lỗ hổng: đây là write skew kinh điển. Flow RC sai: T1 SELECT count(*) WHERE slot='14h' AND date='2025-01-15' → 0 (trống); T2 SELECT tương tự → 0; T1 INSERT booking slot='14h'; T2 INSERT booking slot='14h'; Cả 2 commit thành công → 2 booking cùng slot.
Fix Option A — UNIQUE constraint + upsert:
Thêm UNIQUE(slot, date) vào bảng bookings. INSERT sẽ fail với unique violation nếu slot đã tồn tại. Đây là database-enforced invariant, không cần Serializable. App catch unique violation (SQLSTATE 23505) và return "slot đã được đặt".
Fix Option B — SELECT FOR UPDATE:
BEGIN; SELECT id FROM slots WHERE slot='14h' AND date='2025-01-15' FOR UPDATE; — lock slot row. Chỉ 1 tx acquire lock tại 1 thời điểm. Check booking count sau khi giữ lock. INSERT nếu còn trống. COMMIT.
Fix Option C — Serializable + retry:
Phù hợp nếu booking logic phức tạp (nhiều bảng, nhiều điều kiện). SSI tự detect conflict, abort 1 tx. App retry — lần retry thấy slot đã có booking, từ chối đúng cách.
Trong thực tế, Option A (UNIQUE constraint) là đơn giản và đáng tin nhất — encode invariant trực tiếp vào schema, không phụ thuộc isolation level hay application logic.
Bài tiếp theo: MVCC internals — xmin/xmax, snapshot, visibility check, và so sánh với MySQL InnoDB
Bài này có giúp bạn hiểu bản chất không?