Isolation levels — 4 anomaly và cách ngăn chặn
4 isolation level ANSI SQL, 4 anomaly Berenson 1995, demo từng anomaly với 2 session, Snapshot Isolation vs SSI, write skew trong TaskFlow.
TL;DR: SQL chuẩn định nghĩa 4 isolation level (Read Uncommitted → Read Committed → Repeatable Read → Serializable) và 4 anomaly cần ngăn (dirty read, non-repeatable read, phantom read, write skew). Mỗi level cho phép một tập anomaly nhất định để đổi lấy throughput cao hơn. Write skew — anomaly tinh tế nhất — chỉ Serializable mới ngăn được. Hiểu đúng từng level là điều kiện bắt buộc để viết code transaction an toàn.
Mở 2 terminal, kết nối vào cùng một database, 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 của nhiều engine, an toàn cho đa số CRUD) → Repeatable Read (snapshot isolation) → Serializable (an toàn nhất, có overhead).
PostgreSQL không thực sự implement Read Uncommitted — xử lý như Read Committed vì kiến trúc MVCC không bao giờ để đọc dirty data. MySQL InnoDB cũng ít dùng Read Uncommitted trong thực tế. Mặc định của PostgreSQL và MySQL là Read Committed; Oracle mặc định Read Committed; SQL Server mặc định Read Committed. Serializable thường cần opt-in tường minh.
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 | Cho phép | Cho phép | Cho phép | Cao nhất |
| READ COMMITTED (default nhiều engine) | Cấm | Cho phép | Cho phép | Cho phép | Cao |
| REPEATABLE READ | Cấm | Cấm | Cho phép (ANSI) / Cấm (SI engine) | Cho phép | Trung bình-cao |
| SERIALIZABLE | Cấm | Cấm | Cấm | Cấm | Trung bình |
Lưu ý quan trọng về Repeatable Read: SQL standard cho phép phantom read ở RR, nhưng nhiều engine implement RR bằng Snapshot Isolation (SI) — snapshot chụp lúc transaction bắt đầu, nên phantom read cũng không xảy ra. PostgreSQL và một số engine khác mạnh hơn ANSI RR ở điểm này.
flowchart LR
RU[READ UNCOMMITTED\nCho phep dirty read\nnon-repeatable\nphantom\nwrite skew]
RC[READ COMMITTED\nCam dirty read\nCho phep non-repeatable\nphantom, write skew]
RR[REPEATABLE READ\nCam dirty + non-repeatable\nPG cam ca phantom\nCho phep write skew]
SER[SERIALIZABLE\nCam tat ca 4 anomaly\nSSI detect cycle\nAbort + retry 40001]
RU --> RC --> RR --> SER
style RU fill:#ef4444,color:#fff
style RC fill:#f59e0b,color:#fff
style RR fill:#3b82f6,color:#fff
style SER fill:#22c55e,color:#fff-- Syntax SQL chuan (ho tro boi PostgreSQL, MySQL, SQL Server, Oracle)
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Hoac dung SET TRANSACTION sau BEGIN (PostgreSQL, SQL Server)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... statements ...
COMMIT;
Cú pháp BEGIN TRANSACTION ISOLATION LEVEL ... là SQL standard. MySQL dùng SET TRANSACTION ISOLATION LEVEL ... trước BEGIN. Oracle không có BEGIN explicit — dùng SET TRANSACTION ISOLATION LEVEL SERIALIZABLE. Ngữ nghĩa mỗi level giống nhau giữa các engine; chỉ cú pháp set khác nhau.
5. Snapshot Isolation — cơ chế bên dưới Repeatable Read
Hiểu Snapshot Isolation (SI) là hiểu tại sao một số engine implement RR mạnh hơn ANSI và tại sao SI vẫn không ngăn được write skew.
Snapshot là gì? Khi transaction bắt đầu (ở RR hoặc Serializable), engine chụp một "snapshot" trạng thái database tại thời điểm đó. Mọi read trong suốt transaction chỉ thấy data đã committed trước khi snapshot được chụp — không thấy thay đổi của transaction khác dù chúng commit sau đó.
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 (SI): snapshot chụp một lần lúc transaction bắt đầu. Mọi statement tiếp theo dùng cùng snapshot → cùng một view của data.
-- Demo: Read Committed lay snapshot moi moi statement
-- Session A (READ COMMITTED):
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- => 100
-- [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 -- non-repeatable read xay ra)
COMMIT;
-- Demo: Repeatable Read giu snapshot tu dau den cuoi
-- Session A (REPEATABLE READ):
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- => 100
-- [Session B: UPDATE accounts SET balance = 200 WHERE id = 1; COMMIT;]
SELECT balance FROM accounts WHERE id = 1;
-- => 100 (van la snapshot cu -- non-repeatable read KHONG xay ra)
COMMIT;
Write conflict ở Repeatable Read (SI): Nếu T1 (RR) muốn UPDATE một row mà T2 đã UPDATE và committed sau khi T1 chụp snapshot, engine sẽ abort T1 với lỗi serialization failure (SQLSTATE 40001 ở PostgreSQL và các engine tương thích). Application phải retry transaction.
Cơ chế implement SI tùy engine: PostgreSQL dùng MVCC với transaction ID (txid) và danh sách active transactions. MySQL InnoDB dùng undo log + read view. SQL Server dùng row versioning trong tempdb. Chi tiết implement khác nhau nhưng đều đảm bảo cùng semantic: snapshot nhất quán từ đầu đến cuối 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-dependency 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-dependency T2->T1
T2: INSERT oncall (doctor_id=2, status='off') -- rw-dependency 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. Agnostic, áp dụng cho mọi RDBMS.
-
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. PostgreSQL implement SSI dựa trên paper này; khái niệm áp dụng cho mọi engine muốn đạt Serializable qua snapshot.
-
DDIA Ch.7 — "Transactions" — Martin Kleppmann giải thích 4 isolation level và anomaly agnostic, so sánh implement giữa các engine, và phân tích khi nào cần level nào. Cách tiếp cận phù hợp nhất với bài này.
-
Jepsen Analyses — jepsen.io/analyses — danh sách đầy đủ 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. Liên hệ các bài khác
- Bài 01 — ACID deep dive: Isolation là chữ I trong ACID — bài này đi sâu vào những gì bài 01 chỉ tóm tắt ở tầng khái niệm.
- Bài 02 — BEGIN, COMMIT, SAVEPOINT:
SET TRANSACTION ISOLATION LEVELđặt isolation level cho transaction — cú pháp và cách sử dụng ở bài 02.
13. Tóm tắt
- 4 isolation level SQL standard: Read Uncommitted → Read Committed (default nhiều engine) → Repeatable Read → Serializable.
- 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).
- Repeatable Read với Snapshot Isolation: snapshot chụp lúc tx bắt đầu, mạnh hơn ANSI RR — cấm cả phantom read. PostgreSQL, MySQL InnoDB implement RR theo cách này.
- 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.
- 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 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 — database evaluate expression này trong cùng statement, không có window để race. Đây là pattern an toàn ở mọi isolation level.
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 Repeatable Read với 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.
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 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 dùng snapshot → phantom không xảy ra tự nhiên. PostgreSQL và MySQL InnoDB implement RR theo cách này.
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.
Bài tiếp theo: Concurrency control concepts — 2PL, MVCC, OCC (bài 04 của module này)
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên