SQL & Database — Tư tưởng & Nguyên lý/Isolation levels — 4 anomaly và cách ngăn chặn
40/51
Bài 40 / 51~28 phútTransactions & consistency — nguyên lýMiễn phí lượt xem

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 DocIsolation LevelÝ nghĩa
Thấy từng ký tự người khác gõ realtime, kể cả khi họ đang xoá điRead 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 CommittedChỉ đọ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ửaRepeatable ReadSnapshot 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 slotSerializablePhá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ườiSerializable chặn write skewSSI detect dependency cycle, abort 1 tx
Có ai đó xoá cả trang bạn đang đọc mà bạn không hayPhantom readQuery 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
💡 Cách nhớ

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

Ghi chú dialect — Read Uncommitted

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ĩaVí dụ TaskFlow
Dirty readTx đọc data của tx khác chưa commitT1 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 nhauT1 SELECT priority FROM tasks WHERE id=42 ra 'high'; T2 UPDATE+COMMIT đổi thành 'low'; T1 đọc lại → 'low'
Phantom readQuery cùng điều kiện 2 lần trong tx, tập kết quả khác nhauT1 SELECT COUNT(*) WHERE status='todo' ra 5; T2 INSERT task status='todo' COMMIT; T1 đếm lại → 6
Write skew2 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ỡ invariantT1 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.

💡 Berenson 1995 — tại sao paper này quan trọng

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 LevelDirty readNon-repeatable readPhantom readWrite skewThroughput
READ UNCOMMITTEDCho phépCho phépCho phépCho phépCao nhất
READ COMMITTED (default nhiều engine)CấmCho phépCho phépCho phépCao
REPEATABLE READCấmCấmCho phép (ANSI) / Cấm (SI engine)Cho phépTrung bình-cao
SERIALIZABLECấmCấmCấmCấmTrung 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;
Ghi chú dialect — cú pháp isolation level

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:

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

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

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

Pitfall — RC default không đủ cho read-modify-write pattern

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

📚 Deep Dive — Isolation levels và Snapshot Isolation
  • 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ặc SELECT FOR UPDATE hoặ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 NULL cho case đơn giản; Serializable + retry cho business logic phức tạp nhiều bảng.

13. Tự kiểm tra

Tự kiểm tra
Q1
Vì 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.

Q2
Phâ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.

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

Q4
SSI (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.

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

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

Q7
Jepsen 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

Đặt 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