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