Chương 06 · ACID & Isolation

Transactions & ACID Isolation Levels

Tính nguyên tử, nhất quán, cô lập, bền vững. Bốn isolation level và các anomaly tương ứng. Postgres implementation và hands-on demo.

1. Transaction là gì?

Một transaction (giao dịch) là một chuỗi các thao tác được DB xử lý như một đơn vị nguyên tử duy nhất. Hoặc tất cả thành công và được commit, hoặc tất cả thất bại và rollback.

Ví dụ kinh điển: chuyển tiền từ A sang B.

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- A
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- B
COMMIT;

Nếu giữa 2 UPDATE server crash, hoặc UPDATE thứ 2 lỗi (account #2 không tồn tại), cả 2 phải hoàn tác. Không thể có trường hợp tiền A bị trừ mà B không nhận.

1.1. Vòng đời transaction

┌─────────┐ ┌──────────┐ │ IDLE │ ─── BEGIN ───► │ COMMITTED│ ← thành công └─────────┘ └──────────┘ ▲ ┌──── BEGIN ────► ACTIVE ─── COMMIT │ │ │ └─── ROLLBACK │ │ │ ▼ │ ┌────────────┐ │ │ ABORTED │ ← rollback │ └────────────┘ │ │ │ ▼ │ ┌──────────────┐ └────────────────────── │ FAILED │ ← lỗi tự động └──────────────┘

1.2. Cú pháp

-- Bắt đầu:
BEGIN;          -- hoặc START TRANSACTION;

-- Trong transaction:
UPDATE ...;
INSERT ...;

-- Kết thúc:
COMMIT;         -- xác nhận, dữ liệu lưu
-- HOẶC
ROLLBACK;       -- hủy, mọi thay đổi hoàn tác

-- Đánh mốc:
SAVEPOINT s1;
ROLLBACK TO SAVEPOINT s1;
RELEASE SAVEPOINT s1;

2. ACID — bốn chữ định nghĩa "đáng tin"

ChữTênĐảm bảo
AAtomicityTất-cả-hoặc-không-gì-cả. Không có "thành công nửa chừng".
CConsistencySau transaction, mọi constraint (FK/CHECK/UNIQUE) thỏa mãn.
IIsolationTransactions chạy đồng thời như chạy tuần tự (ở mức cao nhất).
DDurabilitySau COMMIT, dữ liệu sống sót qua mọi crash.

4 tính chất này không độc lập — chúng phụ thuộc nhau. Hệ phân tán phải đánh đổi (CAP). Single-node Postgres/MySQL/Oracle đều hỗ trợ ACID đầy đủ.

Ngược lại, nhiều NoSQL chỉ BASE: Basically Available, Soft state, Eventual consistency — nhanh hơn, scale tốt hơn, nhưng dữ liệu có thể "hơi sai" trong khoảng ngắn (xem Ch9).

3. Atomicity — implementation

Postgres triển khai atomicity qua WAL (Write-Ahead Log) + MVCC:

  1. Mỗi transaction có 1 transaction ID (XID).
  2. UPDATE thực ra tạo phiên bản mới của row (dead tuple cũ giữ lại tạm).
  3. Mọi thay đổi ghi WAL trước khi áp vào data file.
  4. COMMIT ghi 1 record "commit record" vào WAL → fsync để đảm bảo durability.
  5. ROLLBACK chỉ cần không ghi commit record → các phiên bản mới sẽ bị vacuum dọn.

Ý nghĩa: ROLLBACK không "undo" theo nghĩa sửa lại data. Nó chỉ đánh dấu xid là aborted — các tuple version có xid đó tự động bị bỏ qua.

3.1. Implicit transaction

Mỗi câu SQL đơn lẻ ngoài BEGIN tự động chạy trong 1 transaction ngầm + auto-commit. Nghĩa là:

UPDATE users SET name = 'X' WHERE id = 1;
-- Tương đương:
-- BEGIN; UPDATE ...; COMMIT;

Để bao 1 nhóm thao tác, phải explicit BEGIN.

4. Consistency — vai trò của constraint

Consistency nói: "Sau khi transaction commit, mọi ràng buộc dữ liệu (FK, UNIQUE, CHECK, NOT NULL) đều thỏa."

Đây là tính chất nửa app, nửa DB:

  • DB enforce mọi constraint declaratively. Nếu vi phạm → tự động ROLLBACK transaction.
  • App có nhiệm vụ chỉ commit khi data hợp lý nghiệp vụ.

Constraint enforcement có thể là IMMEDIATE (default — check sau mỗi statement) hoặc DEFERRED (check ở COMMIT) cho FK:

CREATE TABLE orders (
  id INT PRIMARY KEY,
  user_id INT REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED
);

BEGIN;
INSERT INTO orders (id, user_id) VALUES (1, 100);  -- user 100 chưa tồn tại, OK
INSERT INTO users (id, ...) VALUES (100, ...);     -- giờ tạo user
COMMIT;  -- ở đây mới check FK

Hữu ích cho seed data / circular FK.

5. Durability — Write-Ahead Log (WAL)

Khi DB nói "COMMIT thành công", dữ liệu phải tồn tại sau khi mất điện ngay sau đó.

5.1. WAL là gì?

WAL (Write-Ahead Log) = log file ghi trước mọi thay đổi vào data file. Trình tự:

Client Postgres Disk │ COMMIT │ │ │ ────────────────►│ │ │ │ 1. Append commit │ │ │ record vào WAL │ │ │ 2. fsync(WAL) ─────────►│ ← phải xuống disk thật │ │ 3. fsync trả về │ │ │ 4. Ack COMMIT │ │ ◄────────────────│ │ │ │ 5. Async: bg writer │ │ │ flush data page │ │ │ sang disk (sau) │

Sau crash, restart:

  1. Đọc lại WAL từ điểm checkpoint cuối.
  2. Replay mọi commit record vào data file.
  3. Ignore mọi xid chưa commit.
  4. DB lên trạng thái nhất quán.

5.2. Vì sao WAL nhanh?

  • WAL ghi tuần tự (sequential write) — disk fast nhất.
  • Data files có thể flush asynchronous sau, không cản đường commit.
  • 1 fsync WAL có thể "đóng gói" commit của nhiều transaction (group commit).

5.3. Replication và backup dùng WAL

WAL stream sang server khác → replication realtime. Lưu WAL archive → Point-in-Time Recovery. Sẽ học ở Ch10 và Ch12.

5.4. synchronous_commit tradeoff

-- Default ON: chờ fsync trước khi ack COMMIT (an toàn)
SET synchronous_commit = on;

-- OFF: ack COMMIT trước, fsync sau (nhanh, có nguy cơ mất vài ms commit gần nhất nếu crash)
SET synchronous_commit = off;

Trade-off: nếu tolerate mất vài commit (log, analytics), set off để tăng throughput. Tài chính/đặt hàng — luôn để on.

6. Read Anomalies — vì sao cần Isolation

Khi nhiều transaction chạy đồng thời, nếu không có isolation, các anomaly sau có thể xảy ra:

6.1. Dirty Read

T1 đọc dữ liệu T2 đã ghi nhưng chưa commit. Sau đó T2 rollback → T1 đã đọc dữ liệu "ma".

T1 T2 │ │ │ UPDATE balance = 100 │ SELECT balance → 100 │ (chưa commit) │ (đọc dirty) │ │ ROLLBACK │ │ ▼ ▼ ← thật ra balance là cũ T1 đã quyết định dựa trên giá trị KHÔNG TỒN TẠI

6.2. Non-Repeatable Read (Fuzzy Read)

T1 đọc cùng row 2 lần trong 1 transaction, kết quả khác nhau vì T2 đã commit update giữa chừng.

T1 T2 │ │ SELECT balance → 50 │ │ UPDATE balance = 100 │ COMMIT SELECT balance → 100 │ │ │ ▼ ▼ T1 đọc 50 và 100 trong cùng transaction — không nhất quán

6.3. Phantom Read

T1 chạy cùng SELECT 2 lần với điều kiện range, lần 2 thấy dòng MỚI xuất hiện do T2 INSERT + commit.

T1 T2 │ │ SELECT * WHERE age > 18 → 5 dòng │ INSERT (age=25) │ COMMIT SELECT * WHERE age > 18 → 6 dòng │ │ ▼ ▼ "Phantom" xuất hiện

6.4. Lost Update

2 transaction cùng read-modify-write 1 row. Update sau đè lên update trước — cập nhật đầu tiên bị mất.

T1 T2 │ │ SELECT balance → 50 │ │ SELECT balance → 50 UPDATE balance = 50 + 10 │ COMMIT (balance = 60) │ │ UPDATE balance = 50 + 20 │ COMMIT (balance = 70 — chèn lên 60) ▼ ▼ Mất 10$ của T1!

6.5. Write Skew

Phức tạp hơn: 2 transaction read overlap rồi write disjoint, mỗi cái đúng riêng nhưng tổ hợp vi phạm constraint.

Ví dụ: bệnh viện yêu cầu "luôn có ≥ 1 bác sĩ trực". 2 bác sĩ cùng nghỉ:

Dr.A Dr.B │ │ SELECT COUNT(*) on_duty → 2 │ │ SELECT COUNT(*) on_duty → 2 "OK, mình có thể nghỉ" │ UPDATE A.on_duty = false │ │ "OK, mình có thể nghỉ" │ UPDATE B.on_duty = false COMMIT │ │ COMMIT ▼ ▼ Không còn bác sĩ trực — vi phạm constraint!

7. Bốn Isolation Level (SQL Standard)

Level Dirty Read Non-Repeatable Phantom Lost Update
READ UNCOMMITTED✗ có thể✗ có thể✗ có thể✗ có thể
READ COMMITTED✓ ngăn✗ có thể✗ có thể✗ có thể
REPEATABLE READ✓ ngăn✓ ngăn✗ có thể*✓ ngăn (Postgres)
SERIALIZABLE✓ ngăn✓ ngăn✓ ngăn✓ ngăn

* Postgres REPEATABLE READ cũng ngăn được Phantom (snapshot isolation chặt). Standard nói "có thể có".

7.1. READ UNCOMMITTED

Cho phép đọc dữ liệu chưa commit. Không có DB phổ biến nào thực sự dùng level này:

  • Postgres KHÔNG hỗ trợ — set thành READ COMMITTED.
  • MySQL hỗ trợ nhưng ít ai dùng.

7.2. READ COMMITTED (default đa số DB)

Mỗi statement chỉ thấy dữ liệu đã commit ở thời điểm statement đó bắt đầu.

Dirty read: ngăn. Non-repeatable read: VẪN CÓ (2 SELECT trong 1 transaction có thể khác nhau).

Đây là default của Postgres và Oracle. Đủ cho 90% workload OLTP.

7.3. REPEATABLE READ

Snapshot xác lập ở thời điểm transaction bắt đầu. Mọi SELECT trong transaction thấy cùng snapshot, dù người khác commit gì.

Postgres dùng Snapshot Isolation: lấy snapshot xid ở BEGIN, chỉ thấy version row có xid < snapshot và đã commit.

7.4. SERIALIZABLE

"As if" các transaction chạy tuần tự. Ngăn mọi anomaly bao gồm write skew.

Postgres triển khai bằng SSI (Serializable Snapshot Isolation) — phát hiện chu trình rw-conflict, abort 1 transaction.

Đảm bảo cao nhất, nhưng có thể có nhiều ROLLBACK do conflict → app phải retry.

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- hoặc:
BEGIN ISOLATION LEVEL SERIALIZABLE;

8. Postgres reality — MVCC & SSI

8.1. MVCC — Multi-Version Concurrency Control

Postgres KHÔNG dùng read locks. Thay vào đó:

  • Mỗi UPDATE tạo version mới của tuple (old version còn).
  • Mỗi transaction có 1 snapshot xid → chỉ thấy version visible với snapshot.
  • Read và write không block nhau.
  • Vacuum định kỳ dọn dead tuple.

MVCC là lý do Postgres có concurrency cực mạnh nhưng cần vacuum để tránh table bloat. Sẽ học sâu ở Ch7.

8.2. Postgres isolation thực tế

Level PostgresTương đươngPhantom?Write skew?
READ COMMITTED (default)SQL READ COMMITTEDCó thểCó thể
REPEATABLE READSnapshot Isolation (chặt hơn standard)NgănCó thể
SERIALIZABLESSINgănNgăn

8.3. Set isolation

-- Mức transaction:
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ...
COMMIT;

-- Mức session:
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Read-only optimize:
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;

9. Hands-on demo — mở 2 client psql

Setup:

CREATE TABLE accounts (id INT PRIMARY KEY, balance INT);
INSERT INTO accounts VALUES (1, 100), (2, 100);

9.1. Demo non-repeatable read (READ COMMITTED)

Session A

BEGIN;
SELECT balance FROM accounts WHERE id=1;
-- 100




-- (sau B commit)
SELECT balance FROM accounts WHERE id=1;
-- 200  ← Khác kết quả lần đầu!
COMMIT;

Session B



BEGIN;
UPDATE accounts SET balance = 200
  WHERE id = 1;
COMMIT;




9.2. Demo Snapshot Isolation (REPEATABLE READ)

Thay đổi: Session A dùng BEGIN ISOLATION LEVEL REPEATABLE READ;

Session A

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id=1;
-- 100




-- (sau B commit)
SELECT balance FROM accounts WHERE id=1;
-- 100  ← Vẫn snapshot ban đầu ✓
COMMIT;

Session B



BEGIN;
UPDATE accounts SET balance = 200
  WHERE id = 1;
COMMIT;




9.3. Demo lost update (READ COMMITTED) → fix bằng SELECT FOR UPDATE

Session A — chưa fix

BEGIN;
SELECT balance FROM accounts WHERE id=1;
-- 100

-- App tính: new = 100 + 10 = 110

UPDATE accounts SET balance = 110
  WHERE id = 1;
COMMIT;
-- balance bây giờ là 110 ❌
-- nhưng B vừa cộng 20 đã mất

Session B — chưa fix


BEGIN;
SELECT balance FROM accounts WHERE id=1;
-- 100
-- App tính: 100 + 20 = 120
UPDATE accounts SET balance = 120
  WHERE id = 1;
COMMIT;  -- giả sử commit TRƯỚC A
-- balance = 120, sau đó A đè lên 110

Fix bằng SELECT FOR UPDATE (xem Ch7) hoặc UPDATE atomic:

-- Tốt nhất nếu logic đơn giản:
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 20 WHERE id = 1;
-- Mỗi UPDATE đều atomic, kết quả: 130 đúng

9.4. Demo Write Skew (cần SERIALIZABLE)

Setup constraint: "ít nhất 1 bác sĩ trực".

Dr A — REPEATABLE READ

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM doctors
  WHERE on_duty = TRUE;
-- 2

UPDATE doctors SET on_duty = FALSE
  WHERE id = 1;
COMMIT;

Dr B — REPEATABLE READ

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM doctors
  WHERE on_duty = TRUE;
-- 2

UPDATE doctors SET on_duty = FALSE
  WHERE id = 2;
COMMIT;
-- Cả 2 commit thành công, 0 bác sĩ trực ❌

Với SERIALIZABLE, Postgres sẽ phát hiện rw-conflict và abort 1 transaction. App retry.

10. Anti-patterns & Best practices

10.1. Long-running transactions

Giữ transaction mở 30 phút → giữ snapshot, ngăn vacuum, gây bloat. Giữ transaction ngắn nhất có thể. Đừng:

  • Mở transaction trước khi gọi API ngoài.
  • Mở transaction để đọc/sleep/render UI.
  • Bỏ quên transaction trong code (rồi exception).

10.2. Wrap mỗi câu trong BEGIN/COMMIT thừa thãi

Nếu chỉ chạy 1 SELECT, không cần BEGIN/COMMIT — auto-commit đã đủ.

10.3. Không retry trên SERIALIZABLE

SERIALIZABLE có thể abort do conflict → app phải retry với backoff. Code phải có wrap retry.

async function withRetry<T>(fn: () => Promise<T>, maxRetry = 3): Promise<T> {
  for (let i = 0; i < maxRetry; i++) {
    try { return await fn(); }
    catch (e: any) {
      if (e.code === '40001') {  // serialization_failure
        await sleep(2 ** i * 50);
        continue;
      }
      throw e;
    }
  }
  throw new Error('max retry');
}

10.4. Đếm sai trong transaction

-- ❌ Race condition (lost update style):
BEGIN;
SELECT balance FROM accounts WHERE id=1;  -- 100
-- app tính 100 + 50 = 150
UPDATE accounts SET balance = 150 WHERE id=1;
COMMIT;

-- ✓ Atomic update:
UPDATE accounts SET balance = balance + 50 WHERE id=1;

10.5. Transaction lồng (nested)

SQL không có transaction lồng thật. BEGIN ... BEGIN ... bỏ qua BEGIN thứ 2. Dùng SAVEPOINT thay thế.

Quy tắc vàng Mặc định READ COMMITTED — đủ cho 90% workload. Nâng lên REPEATABLE READ khi cần snapshot ổn định trong báo cáo. Dùng SERIALIZABLE khi có constraint kiểu "tổng/đếm" mà không thể enforce qua DB constraint thường.

11. Bài tập

Cài Postgres local, mở 2 cửa sổ psql:

  1. Reproduce non-repeatable read ở READ COMMITTED. Sau đó chuyển sang REPEATABLE READ và verify ngăn được.
  2. Tạo bảng seats(id, available BOOLEAN). 2 user cùng book ghế cuối cùng. Demo lost update. Sửa bằng SELECT FOR UPDATE.
  3. Demo write skew với 2 doctors như mục 9.4. Chuyển sang SERIALIZABLE, observe abort.
  4. Viết hàm Node.js (hoặc TS) chạy chuyển tiền giữa 2 account, retry tự động khi gặp serialization failure.
  5. Vì sao long-running transaction nguy hiểm với MVCC? Hệ quả về vacuum, table bloat?

12. Quiz

Quiz cuối Chương 6

Atomicity nghĩa là:

  • Mỗi câu SQL không tách được
  • Dữ liệu lưu trong RAM
  • Tất cả hoặc không gì cả — transaction commit hết hoặc rollback hết
  • DB không có constraint
Atomicity = "indivisible". Mọi thao tác trong 1 transaction là 1 đơn vị nguyên tử. Postgres triển khai bằng cách đánh dấu xid commit/abort, các tuple version có xid aborted bị coi như chưa từng có.

Default isolation level của Postgres và Oracle là:

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE
Postgres và Oracle mặc định READ COMMITTED — đủ cho hầu hết OLTP. MySQL mặc định REPEATABLE READ.

Phantom Read xảy ra khi:

  • App crash
  • Read uncommitted data
  • Cùng row đọc 2 lần khác nhau
  • Cùng query với range condition đọc 2 lần, lần 2 thấy dòng MỚI xuất hiện
Phantom = dòng "ma" mới xuất hiện do INSERT của transaction khác commit giữa chừng. Ngăn được bằng REPEATABLE READ ở Postgres (snapshot isolation), hoặc SERIALIZABLE chuẩn.

Postgres dùng MVCC nghĩa là:

  • Không hỗ trợ ACID
  • Read và write block nhau
  • Mỗi UPDATE tạo version mới của tuple, snapshot quyết định version nào visible
  • DB là single-threaded
MVCC giữ nhiều phiên bản (version) của row. Transaction có snapshot xid → chỉ thấy version có xid hợp lệ. Read không cần lock, write chỉ lock row đang update. Vacuum dọn version cũ.

Cách an toàn để cộng thêm 10 vào balance:

  • UPDATE accounts SET balance = balance + 10 WHERE id = 1;
  • SELECT balance; rồi UPDATE balance = old + 10;
  • Thực hiện trong app rồi UPDATE absolute value
  • Dùng READ UNCOMMITTED
UPDATE atomic balance = balance + N tránh lost update vì DB tự lock row trong UPDATE. Read-then-write trong app gặp race nếu không có FOR UPDATE.

Write skew chỉ ngăn được hoàn toàn ở:

  • READ COMMITTED
  • REPEATABLE READ
  • Snapshot Isolation
  • SERIALIZABLE (qua SSI ở Postgres)
Write skew là rw-anti-dependency cycle. Snapshot Isolation thuần (REPEATABLE READ Postgres) KHÔNG ngăn. Cần SSI (Serializable Snapshot Isolation) — phát hiện cycle, abort 1 transaction.

WAL (Write-Ahead Log) đảm bảo tính:

  • Atomicity duy nhất
  • Durability — sau crash có thể replay để khôi phục state
  • Isolation
  • Encryption
WAL ghi mọi thay đổi vào log file trước khi áp vào data file. Sau crash, replay WAL từ checkpoint cuối → DB nhất quán. Đó là Durability. WAL cũng hỗ trợ replication và PITR.

Transaction kéo dài 30 phút trong Postgres gây vấn đề gì?

  • Tăng performance
  • Không có vấn đề
  • Giữ snapshot, ngăn vacuum dọn dead tuple → table bloat, query chậm dần
  • DB tự kill
Vacuum chỉ dọn được tuple version mà KHÔNG transaction nào còn đang giữ snapshot tham chiếu. Long-running tx → snapshot cũ hold mãi → bloat tăng. Đặt timeout, dùng idle_in_transaction_session_timeout.

Hoàn thành Chương 6. Tiếp theo: Chương 7 — Concurrency Control: Locking & MVCC →