Skip to content

PostgreSQL — MVCC & Isolation Levels

1. Snapshot Isolation

PostgreSQL không đọc trực tiếp từ table mà đọc từ snapshot — một "ảnh chụp" trạng thái data tại thời điểm nhất định.

Quan trọng: Snapshot không phải bản copy vật lý của table. PostgreSQL chỉ ghi nhớ "XID nào đã commit tại thời điểm này" rồi dùng xmin/xmax để lọc row phù hợp → rất nhẹ, không tốn storage.

Thời điểm snapshot được chụp phụ thuộc vào isolation level:

  • READ COMMITTED → chụp mới mỗi câu SELECT
  • REPEATABLE READ / SERIALIZABLE → chụp 1 lần lúc BEGIN, dùng xuyên suốt transaction

2. xmin và xmax

Mỗi row trong PostgreSQL có 2 hidden column:

ColumnÝ nghĩa
xminTransaction ID đã tạo row này
xmaxTransaction ID đã xóa/update row này. Mặc định = 0 (row còn sống)

PostgreSQL không sửa row cũ — chỉ tạo row mới

UPDATE:

row cũ: xmin=100, xmax=200, tier='silver'   ← xmax được set = xid của UPDATE
row mới: xmin=200, xmax=0,  tier='gold'     ← row mới được tạo

DELETE:

row cũ: xmin=100, xmax=200, tier='silver'   ← chỉ set xmax, KHÔNG tạo row mới

xmax và rollback

xmax được set ngay khi UPDATE/DELETE thực thi, kể cả chưa commit. Nếu sau đó rollback → xmax vẫn còn trong tuple nhưng transaction đó chưa commit → các transaction khác check thấy "chưa commit" → coi row vẫn sống. Không cần xóa xmax đi.

Tại sao không cần xóa xmax khi rollback?

Các transaction khác không đọc xmax một mình — chúng phải kết hợp với transaction status (lưu trong pg_clog / pg_xact):

Row vẫn sống nếu:
  xmax = 0                               → chưa ai đụng vào
  HOẶC
  xmax != 0 AND status(xmax) = ABORTED   → kẻ đánh dấu đã rollback

Ví dụ cụ thể:

Txn 500 DELETE row → xmax = 500 (ghi ngay vào disk, chưa commit)

  Txn 501 muốn đọc row này:
    → check xmax = 500
    → check status của txn 500 trong pg_clog → "IN PROGRESS"
    → kết luận: row vẫn sống → đọc được ✓

Txn 500 ROLLBACK:
    → pg_clog ghi txn 500 = ABORTED
    → xmax = 500 vẫn nằm trong tuple, không ai xóa

  Txn 502 check sau đó:
    → xmax = 500, status = ABORTED
    → kết luận: row vẫn sống ✓

Tại sao thiết kế vậy? PostgreSQL theo triết lý "lazy cleanup" — ghi nhanh, dọn sau:

  • Xóa xmax khi rollback = tốn thêm write I/O vô ích
  • Để nguyên + check status = đủ để suy ra visibility, không cần modify tuple
  • VACUUM sau này dọn các dead tuple thật sự (khi xmax đã committed)

Tóm lại: xmax chỉ là hint — "có ai đó từng đánh dấu row này". Muốn biết row còn sống không, thì phải nhìn thêm pg_clog để biết kẻ đó có commit không.

Cách PostgreSQL quyết định row có visible không

xmax = 0                          → row còn sống → đọc được
xmax != 0, chưa commit            → coi như row vẫn sống
xmax != 0, đã commit trước BEGIN  → row đã chết → không đọc
xmax != 0, đã commit sau BEGIN    → tùy isolation level

Dead rows và VACUUM

Các row có xmax != 0 và không còn transaction nào cần đọc → dead rows. VACUUM dọn dẹp định kỳ, đánh dấu lại thành free space để tái sử dụng (không xóa khỏi disk ngay).

Nếu VACUUM không chạy kịp → dead rows tích tụ → table bloat.


3. Transaction ID (XID) và Wraparound

XID là gì

Mỗi transaction được cấp 1 số định danh tăng dần — gọi là XID. Đây chính là giá trị được lưu trong xmin/xmax.

sql
SELECT txid_current();  -- xem XID của transaction hiện tại

Vấn đề Wraparound

XID là số 32-bit → max ≈ 4 tỷ. Khi đếm đến 4 tỷ, quay về 0.

PostgreSQL so sánh XID theo vòng tròn (modular arithmetic), không phải tuyến tính:

XID hiện tại = 50 (sau wraparound)
Row cũ có xmin = 100

→ 100 nằm ở phía "tương lai" của 50
→ PostgreSQL nghĩ row này chưa được tạo
→ không cho đọc → DATA MẤT

Giải pháp: FREEZE

VACUUM định kỳ freeze các row cũ:

xmin = 100  →  đổi thành FrozenXID (= 2)

FrozenXID là giá trị đặc biệt có nghĩa: "row này luôn visible với mọi transaction, không cần so sánh XID".

Kiểm tra nguy cơ wraparound

sql
SELECT datname, age(datfrozenxid) FROM pg_database;
-- age > 1,500,000,000 → cần chạy VACUUM gấp
-- age > 2,000,000,000 → PostgreSQL tự chuyển read-only để bảo vệ data

Không bao giờ tắt autovacuum trên production — autovacuum có 2 nhiệm vụ chính:

  1. Dọn dead rows
  2. Freeze row cũ trước khi XID bị wraparound

4. Isolation Levels

READ COMMITTED (default)

Mỗi câu SELECT chụp snapshot mới nhất tại thời điểm câu đó chạy.

BEGIN
  SELECT tier  → snapshot tại T1 → 'silver'
  -- transaction khác UPDATE + COMMIT
  SELECT tier  → snapshot tại T2 → 'gold'   ← khác lần trước!
COMMIT

Non-repeatable read — cùng 1 transaction đọc 2 lần ra 2 giá trị khác nhau.

UPDATE/DELETE: sau khi chờ row lock release, re-fetch row mới nhất rồi ghi đè → có thể lost update âm thầm nếu không có guard.

REPEATABLE READ

Snapshot chụp 1 lần lúc BEGIN, dùng xuyên suốt transaction.

BEGIN ISOLATION LEVEL REPEATABLE READ
  SELECT tier  → snapshot tại BEGIN → 'silver'
  -- transaction khác UPDATE + COMMIT
  SELECT tier  → vẫn dùng snapshot cũ → 'silver'  ← không đổi
COMMIT

UPDATE/DELETE conflict: nếu transaction khác đã UPDATE + COMMIT cùng row → ERROR 40001, buộc app retry.

SERIALIZABLE

Cao nhất. Đảm bảo kết quả giống hệt như chạy tuần tự từng transaction một.

Thêm predicate locking — track "transaction A đã đọc tập data nào":

Session A:
  BEGIN ISOLATION LEVEL SERIALIZABLE
  SELECT COUNT(*) WHERE tier = 'gold'
  → predicate lock: "A đọc WHERE tier='gold'"

Session B:
  BEGIN ISOLATION LEVEL SERIALIZABLE
  UPDATE tier = 'gold' WHERE id = 2
  COMMIT ✓
  → row này thỏa predicate lock của A → đánh dấu conflict

Session A:
  COMMIT → ERROR 40001
  → B đã ghi vào đúng tập data A vừa đọc
  → dù A và B update 2 row khác nhau!

Không phải lúc nào cũng lỗi — chỉ lỗi khi có dependency (đọc rồi ghi dựa trên data đã đọc). Nếu 2 transaction độc lập nhau → cho qua bình thường.

So sánh

READ COMMITTEDREPEATABLE READSERIALIZABLE
Snapshotmỗi SELECTlúc BEGINlúc BEGIN
Row lock
Predicate lock
Non-repeatable read❌ xảy ra✅ ngăn được✅ ngăn được
Lost update❌ có thể xảy ra✅ báo lỗi 40001✅ báo lỗi 40001
Write skew✅ ngăn được
Performancecao nhấttrung bìnhthấp nhất

5. Thực tế chọn isolation level

Bài toánNên dùngLý do
Kho hàng, balance đơn giảnREAD COMMITTED + guard trong WHERERow lock + re-fetch tự xử lý
Report, thống kêREPEATABLE READKhông muốn data thay đổi giữa chừng
Logic nhiều bước phụ thuộc nhauREPEATABLE READ + retryĐảm bảo consistency
Tuyệt đối không được saiSERIALIZABLE + retryNgăn mọi anomaly

TypeORM — isolation level mặc định

TypeORM không set isolation level khi gọi dataSource.transaction() — tức là dùng default của PostgreSQL, là READ COMMITTED.

typescript
// Mặc định → READ COMMITTED (PostgreSQL default)
await dataSource.transaction(async (manager) => {
  // ...
});

// Chỉ định rõ khi cần level cao hơn
await dataSource.transaction("REPEATABLE READ", async (manager) => {
  // ...
});

await dataSource.transaction("SERIALIZABLE", async (manager) => {
  // ...
});

Pattern an toàn cho kho hàng

sql
-- 1 câu SQL duy nhất = implicit transaction = atomic
UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 1 AND warehouse_code = 'HN1' AND quantity >= 1;

-- Check rows affected trong app
-- 0 rows → out of stock

Không cần REPEATABLE READ, không cần pessimistic lock — row lock mặc định của PostgreSQL đã đủ.


6. Lỗi hay gặp

Error codeNghĩaXử lý
40001Serialization failure — conflict với transaction khácROLLBACK + retry
55P03Lock timeout — chờ row lock quá lâuROLLBACK + retry hoặc tăng timeout
sql
-- Set timeout để tránh treo vô thời hạn
SET lock_timeout = '3s';

Personal notes by thanhlt