MVCC internals — xmin, xmax, snapshot, và vì sao SELECT không block UPDATE
Đi sâu vào cơ chế MVCC của PostgreSQL: hidden columns xmin/xmax/ctid, cách UPDATE tạo tuple mới thay vì sửa tại chỗ, snapshot visibility rule, hint bits, CLOG, và so sánh với MySQL InnoDB undo log.
Bạn mở hai psql session. Session A chạy UPDATE tasks SET title = 'X' WHERE id = 1 và chưa COMMIT. Session B chạy SELECT title FROM tasks WHERE id = 1 ngay lúc đó — và trả kết quả tức thì, không chờ, không block. Đồng thời cả hai đều nhìn thấy trạng thái nhất quán của row đó. Nghe như magic — làm sao một SELECT có thể đọc row đang bị UPDATE mà không nhìn thấy dữ liệu nửa chừng?
Câu trả lời là MVCC — Multi-Version Concurrency Control. Không có lock nào bảo vệ reader khỏi writer. Thay vào đó, PostgreSQL lưu nhiều phiên bản của cùng một row trên heap page, mỗi phiên bản có metadata về transaction nào tạo ra và transaction nào xoá nó đi. Reader chọn phiên bản đúng với "thời điểm logic" của mình — gọi là snapshot — mà hoàn toàn không cần block writer.
Bài này mở từng lớp của cơ chế đó: hidden columns, cách UPDATE thực sự hoạt động, snapshot visibility rule, hint bits để tối ưu hoá, CLOG để lưu commit status, và so sánh với cách MySQL InnoDB làm khác đi.
1. Analogy — Thư viện lưu mọi phiên bản sách
Hãy hình dung một thư viện không bao giờ xoá sách cũ — thay vào đó, mỗi lần sách được sửa đổi, thư viện tạo một bản in mới và giữ nguyên bản cũ trên kệ.
| Thư viện | PostgreSQL MVCC |
|---|---|
| Mỗi cuốn sách có số phiên bản (in lần 1, lần 2...) | Mỗi tuple có xmin (tạo bởi tx nào) và xmax (thay thế bởi tx nào) |
| Bạn đọc vào thư viện lúc 9h nhận "phiếu thời điểm 9h" | Transaction nhận snapshot lúc BEGIN (hoặc lúc statement đầu) |
| Bạn chỉ được đọc sách đã in trước 9h | Chỉ thấy tuple xmin committed trước snapshot |
| Người sửa sách lúc 9h30 tạo bản in mới — không ảnh hưởng bạn | UPDATE tạo tuple mới với xmin mới — reader cũ không thấy |
| Thủ thư định kỳ dọn bản cũ không ai cần nữa | VACUUM reclaim dead tuple — bài 5 của module này |
| Thủ thư biết bản nào "không còn ai cần" qua danh sách bạn đọc | VACUUM dùng xmin của oldest active snapshot để quyết định |
Mỗi row trong PostgreSQL không phải một chỗ dữ liệu duy nhất — nó là một chuỗi phiên bản có dấu thời gian transaction. Transaction ID (txid) là "thời gian" trong thế giới MVCC. Snapshot là "lúc bạn bước vào thư viện". Chỉ thấy sách đã in trước khi bạn bước vào.
2. Vấn đề concurrency cũ — lock-based isolation
Trước MVCC, database dùng lock-based isolation: SELECT đặt shared lock, UPDATE đặt exclusive lock. Reader block writer, writer block reader. Throughput đổ vỡ ngay khi có nhiều user đồng thời.
Oracle và SQL Server truyền thống (trước khi thêm MVCC) dùng kiểu này: SELECT lấy shared lock trên row đọc, UPDATE phải chờ hết shared lock mới lấy được exclusive lock. Kết quả: read workload và write workload block lẫn nhau.
PostgreSQL từ version đầu đã chọn hướng khác: reader không bao giờ block writer, writer không bao giờ block reader. Cơ chế cho phép điều đó là MVCC — lưu nhiều phiên bản tuple, mỗi transaction chọn phiên bản phù hợp theo snapshot của mình.
Trade-off: không có lock-based blocking → throughput cao → nhưng phải quản lý dead tuple, cần VACUUM. Đây là trade-off cốt lõi của thiết kế PostgreSQL.
3. xmin, xmax, ctid — hidden columns trên mỗi tuple
PostgreSQL lưu metadata MVCC trực tiếp trong header của mỗi tuple trên heap page. Bốn cột ẩn quan trọng nhất:
| Column | Kiểu | Ý nghĩa |
|---|---|---|
xmin | xid (32-bit) | Transaction ID của tx đã INSERT tuple này |
xmax | xid | Transaction ID của tx đã DELETE hoặc UPDATE tuple này (0 nếu chưa) |
cmin | cid | Command ID trong transaction tạo tuple (phân biệt nhiều INSERT trong 1 tx) |
ctid | tid | Vị trí vật lý: (page_number, offset_within_page) |
Truy vấn trực tiếp các hidden column này — PostgreSQL cho phép gọi tên chúng trong SELECT:
-- Truy van hidden columns truc tiep
SELECT xmin, xmax, ctid, id, title, status
FROM tasks
WHERE id IN (1, 2);
-- xmin | xmax | ctid | id | title | status
-- 100 | 0 | (0,1) | 1 | Deploy API | todo
-- 100 | 0 | (0,2) | 2 | Write docs | todo
-- xmax = 0 nghia la chua bi delete hoac update
Bây giờ chạy UPDATE và xem điều gì xảy ra:
-- Trong session A (txid = 101):
BEGIN;
UPDATE tasks SET title = 'Deploy API v2' WHERE id = 1;
-- CHUA COMMIT
-- Trong session B, doc hidden column:
SELECT xmin, xmax, ctid, id, title
FROM tasks
WHERE id = 1;
-- Session B thay tuple cu (xmax = 101 nhung 101 chua commit):
-- xmin | xmax | ctid | id | title
-- 100 | 101 | (0,1) | 1 | Deploy API
-- Session A commit:
-- COMMIT; (trong session A)
-- Gio session B doc lai:
SELECT xmin, xmax, ctid, id, title FROM tasks WHERE id = 1;
-- xmin | xmax | ctid | id | title
-- 101 | 0 | (0,3) | 1 | Deploy API v2
-- Tuple moi: xmin=101, xmax=0, ctid khac (page 0, offset 3)
-- Tuple cu xmin=100 xmax=101 van con tren heap -- "dead tuple", doi VACUUM reclaim
Hai điểm quan trọng: (1) UPDATE tạo tuple mới với xmin = txid hiện tại, không sửa tuple cũ tại chỗ. (2) Tuple cũ có xmax được đặt = txid của UPDATE — nó vẫn tồn tại trên heap và sẽ invisible với các snapshot sau khi xmax committed.
4. UPDATE = INSERT tuple mới + set xmax tuple cũ
Đây là điểm trái ngược hoàn toàn với cách ta thường nghĩ về UPDATE. Trong PostgreSQL, UPDATE tasks SET title = 'X' WHERE id = 1 thực sự làm hai việc nguyên tử:
- Set
xmaxtrên tuple cũ = txid của transaction đang UPDATE. - INSERT một tuple mới với
xmin= txid hiện tại,xmax= 0, chứa giá trị mới.
Tuple cũ không bị xoá ngay. Nó vẫn nằm trên heap page, chờ VACUUM reclaim sau khi không còn snapshot nào cần nhìn thấy nó. Kết quả: tại bất kỳ thời điểm nào trong khi UPDATE đang chạy (chưa commit), cả hai phiên bản đều tồn tại trên heap.
Heap page truoc UPDATE:
+------------------+------------------+
| Tuple v1 | |
| xmin=100 xmax=0 | (free space) |
| ctid=(0,1) id=1 | |
| title="Deploy" | |
+------------------+------------------+
Sau UPDATE (txid=101, chua commit):
+------------------+------------------+
| Tuple v1 | Tuple v2 |
| xmin=100 xmax=101| xmin=101 xmax=0 |
| ctid=(0,1) id=1 | ctid=(0,2) id=1 |
| title="Deploy" | title="Deploy v2"|
+------------------+------------------+
Snapshot truoc txid 101 commit: chi thay v1
Snapshot sau txid 101 commit: chi thay v2 (v1 la dead tuple)
HOT update (Heap-Only Tuple): nếu indexed column không thay đổi và cùng heap page còn chỗ, PG có thể làm HOT update — tuple mới nằm cùng page với tuple cũ, không cần update B-tree index. Index vẫn trỏ tới tuple cũ; tuple cũ có ctid trỏ sang tuple mới (chain pointer). Đây là tối ưu quan trọng cho throughput UPDATE trên bảng có nhiều index.
5. Snapshot — xmin, xip_list, xmax
Khi transaction bắt đầu (hoặc khi statement đầu tiên chạy ở READ COMMITTED), PostgreSQL chụp một snapshot mô tả "trạng thái thế giới lúc này":
Snapshot = (snap_xmin, snap_xmax, xip_list)
- snap_xmin: txid nhỏ nhất đang còn active lúc snapshot taken.
Mọi tuple xmin < snap_xmin: đã committed "lâu rồi", safe to see.
- snap_xmax: txid tiếp theo sẽ được assign (chưa tồn tại).
Mọi tuple xmin >= snap_xmax: không tồn tại theo snapshot này.
- xip_list: danh sách các txid đang active lúc snapshot taken.
Tuple có xmin trong xip_list: đang in-progress, chưa commit.
Visibility rule (đầy đủ cho tuple thường):
Một tuple (xmin, xmax) visible theo snapshot S khi và chỉ khi:
xminđã committed AND (xmin < S.snap_xminORxminkhông có trongS.xip_list)- AND (
xmax = 0ORxmaxchưa committed ORxmax >= S.snap_xmaxORxmaxcó trongS.xip_list)
Điều kiện 1: đảm bảo tuple đã được tạo bởi một committed transaction trước hoặc ngoài snapshot (tức là nằm trong "quá khứ" của snapshot). Điều kiện 2: đảm bảo tuple chưa bị xoá/update bởi committed transaction trong "quá khứ" của snapshot.
Ví dụ cụ thể: txid 100 committed, txid 101 đang active trong xip_list. Tuple (xmin=101, xmax=0) → xmin=101 có trong xip_list → điều kiện 1 fail → tuple invisible. Đúng: đây là row đang UPDATE chưa commit, không được thấy.
6. Timeline T1/T2 — snapshot stable per Repeatable Read
Scenario: hai session đồng thời, T1 update, T2 đọc hai lần:
sequenceDiagram
participant T1 as Session T1 (txid 100)
participant T2 as Session T2 (txid 101)
participant H as Heap page
T1->>H: BEGIN — snapshot {snap_xmin=99, xip=[100]}
T2->>H: BEGIN (RR) — snapshot {snap_xmin=99, xip=[100,101]}
T1->>H: UPDATE tasks SET title='X' WHERE id=1
Note over H: Tuple v1: xmin=80 xmax=100<br/>Tuple v2: xmin=100 xmax=0
T2->>H: SELECT title FROM tasks WHERE id=1
Note over T2: xip=[100,101] → v2 xmin=100 in xip → invisible<br/>v1 xmin=80 < snap_xmin=99 → committed, visible<br/>Ket qua: title cu
T1->>H: COMMIT (100 marked committed in CLOG)
T2->>H: SELECT title FROM tasks WHERE id=1 (RR — same snapshot)
Note over T2: Snapshot khong doi — xip van la [100,101]<br/>100 van trong xip → v2 van invisible<br/>Van thay title cu (Repeatable Read dam bao nay)
T2->>H: COMMITĐiểm mấu chốt: ở REPEATABLE READ, snapshot được chụp một lần khi BEGIN và giữ nguyên xuyên suốt transaction. Kể cả sau khi T1 commit, T2 vẫn thấy data cũ vì snapshot của T2 vẫn coi txid 100 là "đang active". Đây chính là Repeatable Read guarantee — đọc cùng row hai lần trong một transaction, thấy cùng kết quả.
Ở READ COMMITTED (default PostgreSQL), snapshot được chụp lại cho mỗi statement. Lần SELECT thứ hai của T2 (sau T1 commit) sẽ chụp snapshot mới không còn txid 100 trong xip_list → v2 visible → thấy title mới. Đây là non-repeatable read — hợp lệ ở READ COMMITTED nhưng không hợp lệ ở REPEATABLE READ.
7. PostgreSQL vs MySQL InnoDB — hai triết lý MVCC
Cùng mục tiêu (reader không block writer), nhưng implement theo hai hướng khác nhau hoàn toàn:
| Aspect | PostgreSQL | MySQL InnoDB |
|---|---|---|
| Lưu phiên bản cũ ở đâu? | Trực tiếp trên heap page (cùng table file) | Undo log segment riêng (ibdata, undo tablespace) |
| UPDATE làm gì? | INSERT tuple mới + set xmax tuple cũ | Sửa row tại chỗ + ghi bản ghi undo log |
| MVCC read | Check xmin/xmax visibility trực tiếp | Traverse undo chain ngược từ row hiện tại về phiên bản đúng |
| Dọn dead version | VACUUM (manual hoặc autovacuum) | Purge thread chạy background, tự động |
| Long tx impact | Bloat heap (snapshot pinned, VACUUM không reclaim được) | Bloat undo log (undo chain dài, read phải traverse thêm) |
| Index update khi UPDATE? | HOT update tránh được nếu đủ điều kiện | Cần update secondary index nếu value thay đổi |
PG: ưu điểm — write throughput cao hơn (INSERT mới nhanh, không cần traverse undo), không có contention trên shared undo tablespace. Nhược điểm — cần VACUUM chủ động để reclaim dead tuple; nếu autovacuum không đuổi kịp write load, heap bloat tích lũy.
InnoDB: ưu điểm — purge tự động hơn, không cần DBA quan tâm VACUUM. Nhược điểm — undo log có thể bloat rất lớn khi có long-running transaction (undo chain không thể purge khi có snapshot còn active).
8. Hint bits — tối ưu hoá visibility check
Mỗi lần kiểm tra visibility một tuple, PostgreSQL cần biết: xmin đã committed chưa? xmax đã committed chưa? Câu trả lời nằm trong CLOG (pg_xact) — phải đọc file trên disk nếu không có trong bộ nhớ. Nếu mỗi tuple read đều phải tra CLOG, chi phí tích lũy rất lớn.
Hint bits là giải pháp: mỗi tuple có 4 flag bit trong header:
| Flag | Ý nghĩa |
|---|---|
HEAP_XMIN_COMMITTED | xmin đã committed — không cần tra CLOG nữa |
HEAP_XMIN_INVALID | xmin đã aborted hoặc không valid |
HEAP_XMAX_COMMITTED | xmax đã committed — tuple đã bị xoá/update |
HEAP_XMAX_INVALID | xmax = 0 hoặc xmax aborted — tuple vẫn sống |
Workflow:
- Lần đầu visibility check một tuple mới:
HEAP_XMIN_COMMITTEDchưa set → phải tra CLOG → kiểm tra commit status củaxmin. - Nếu
xminđã committed → setHEAP_XMIN_COMMITTEDngay trên tuple header (best-effort, không WAL-logged). - Lần sau tra cùng tuple:
HEAP_XMIN_COMMITTEDđã set → skip CLOG lookup hoàn toàn.
Hệ quả thực tế bạn có thể quan sát:
-- Insert 1 trieu row, do thoi gian SELECT dau tien vs thu hai
-- (hint bits chua set sau INSERT)
INSERT INTO tasks (project_id, title, status)
SELECT 1, 'Task ' || g, 'todo'
FROM generate_series(1, 1000000) g;
-- SELECT 1: phai set hint bits cho ~1M tuple -> cham hon
\timing on
SELECT count(*) FROM tasks;
-- Time: 320 ms (vi du)
-- SELECT 2: hint bits da set, skip CLOG lookup
SELECT count(*) FROM tasks;
-- Time: 95 ms (nhanh hon dang ke)
Hint bits cũng giải thích tại sao lần SELECT * đầu tiên sau VACUUM hoặc sau bulk INSERT thường chậm hơn lần tiếp theo — đó là chi phí set hint bits cho các tuple mới chưa được kiểm tra.
9. CLOG (pg_xact) — commit status của mỗi transaction
CLOG (Commit Log) lưu trạng thái của mọi transaction ID đã từng tồn tại. Vị trí: $PGDATA/pg_xact/ (đổi tên từ pg_clog ở PG 10).
Cấu trúc: 2 bit per txid, encode 4 trạng thái:
| Giá trị | Trạng thái |
|---|---|
00 | In-progress (đang chạy) |
01 | Committed |
10 | Aborted (rollback) |
11 | Sub-committed (subtransaction đã commit, parent chưa) |
Kích thước file: mỗi segment 256 KB chứa trạng thái của 256 KB × 8 bit/byte / 2 bit/txid = 1,048,576 txid. Ở workload cao (10,000 tx/giây), một segment đủ cho khoảng 100 giây — PG tự động tạo segment mới khi cần.
# Xem cac segment CLOG hien co
ls -lh $PGDATA/pg_xact/
# -rw------- 1 postgres postgres 256K ... 0000
# -rw------- 1 postgres postgres 256K ... 0001
# ...
# Moi segment 256KB = 1M txid
CLOG được giữ trong bộ nhớ (shared buffer dành riêng cho CLOG) và flush định kỳ. Hint bits sinh ra để giảm CLOG lookup — một khi hint bit đã set, tuple không bao giờ tra CLOG nữa.
10. Pitfall — txid wraparound và long transaction
Pitfall 1 — txid wraparound: PostgreSQL dùng xid 32-bit → wrap sau ~2 tỷ transaction. Nếu database không được freeze kịp thời, PG shutdown ở readonly mode để tránh data corruption (row "từ tương lai" sẽ invisible với mọi snapshot hiện tại).
-- Kiem tra "tuoi" txid cua database (phai < 2,000,000,000)
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY xid_age DESC;
-- xid_age | datname
-- 1800000000 | taskflow <-- NGUY HIEM: autovacuum freeze can chay gap
-- autovacuum_freeze_max_age mac dinh: 200,000,000 (nen da trigger truoc do)
Autovacuum tự động freeze tuple khi age(xmin) vượt autovacuum_freeze_max_age (default 200M). Tuy nhiên nếu autovacuum bị tắt hoặc bị block bởi lock, xid_age tiếp tục tăng. Khi đến ngưỡng vacuum_freeze_max_age (default 400M tính từ datfrozenxid), PG từ chối nhận transaction mới.
Pitfall 2 — long transaction block VACUUM: Transaction running lâu giữ snapshot xmin thấp. VACUUM không thể reclaim dead tuple có xmax lớn hơn xmin của snapshot đó — dù những tuple này đã bị update/delete lâu rồi.
-- Phat hien transaction giu snapshot lau (co the block VACUUM)
SELECT
pid,
now() - pg_stat_activity.query_start AS duration,
age(backend_xmin) AS xmin_age,
state,
left(query, 80) AS query_preview
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY xmin_age DESC
LIMIT 10;
-- xmin_age cao (hàng tram ngan) = snapshot cu
-- VACUUM khong the reclaim dead tuple co xmax < xmin cua snapshot nay
-- -> heap bloat tich luy theo thoi gian
-- Neu can, terminate tx giu snapshot:
-- SELECT pg_terminate_backend(pid) WHERE pid = <pid_cu>;
Nguyên tắc: không bao giờ để transaction idle-in-transaction chạy dài. Đặt idle_in_transaction_session_timeout ở mức hợp lý (5–15 phút tùy workload).
11. Applied — TaskFlow dashboard consistent view
Scenario thực tế: dashboard TaskFlow hiển thị hai số liệu — tổng task và số task đã hoàn thành — để tính phần trăm tiến độ. Nếu hai query chạy độc lập ở READ COMMITTED, có thể xảy ra "data flash" không nhất quán:
-- ANTI-PATTERN: 2 query rieng o READ COMMITTED
-- Giua query 1 va query 2, user khac commit 1 task moi trang thai 'done'
SELECT count(*) FROM tasks WHERE project_id = 42; -- tra ve 100
-- (user X commit: INSERT task moi + UPDATE 1 task sang 'done')
SELECT count(*) FROM tasks WHERE project_id = 42
AND status = 'done'; -- tra ve 51
-- Ket qua: 51/100 = 51% -- nhung thuc te chi co 50 task 'done' truoc snapshot dau
-- Va 100 la so moi (chua co khi chup snapshot query 1)
-- -> ti le sai
Fix: bọc hai query trong một REPEATABLE READ transaction — cả hai dùng cùng một snapshot, nhìn thấy cùng thời điểm logic:
-- DUNG: RR transaction dam bao consistent snapshot xuyen suot
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) AS total_tasks
FROM tasks
WHERE project_id = 42;
-- Snapshot chup tai day (hoac tai BEGIN, tuy implementation)
SELECT count(*) AS done_tasks
FROM tasks
WHERE project_id = 42
AND status = 'done';
-- Cung snapshot -> 2 so lieu thuoc cung thoi diem logic
COMMIT;
-- Ket qua: ti le chinh xac, khong bi "data flash" giua 2 query
Đây là ứng dụng trực tiếp của MVCC: cùng một heap page được đọc hai lần với cùng snapshot → cả hai lần đều thấy đúng phiên bản tuple phù hợp với snapshot đó → kết quả nhất quán. Không có lock nào trên bảng tasks trong suốt quá trình — writer vẫn tự do INSERT/UPDATE song song.
12. Deep Dive
- PostgreSQL 14 Internals — Egor Rogov (free PDF) — Phần I "Isolation" và chương MVCC là tài liệu chi tiết nhất về cơ chế tuple visibility, snapshot, hint bits, và CLOG trong PG. Khoảng 108 trang cho phần isolation và MVCC; đây là nguồn gốc của phần lớn nội dung bài này.
- The Internals of PostgreSQL — Hironobu Suzuki, Ch.5 Concurrency Control — tài liệu miễn phí online, đồ thị rõ ràng về tuple structure, visibility rule step-by-step, và snapshot. Đọc Ch.5.1–5.4 để xem diagram heap page với nhiều phiên bản tuple.
- PostgreSQL Documentation Ch.13 — Concurrency Control — tài liệu chính thức về MVCC model, snapshot isolation, và hành vi của mỗi isolation level. Cần đọc kết hợp với bài 3 của module này (isolation levels và anomaly).
- PostgreSQL Documentation — Routine Vacuuming và xid Wraparound — giải thích cơ chế freeze,
autovacuum_freeze_max_age,vacuum_freeze_max_age, và cách tránh wraparound trong production. Đọc trước khi vào bài 5 của module này.
13. Tóm tắt
- MVCC = nhiều phiên bản tuple: PostgreSQL lưu nhiều version của cùng một row trên heap page, mỗi version có
xmin(ai tạo) vàxmax(ai xoá/update). Reader và writer không bao giờ block nhau. - UPDATE = INSERT mới + set xmax cũ: không sửa tuple tại chỗ. Tuple cũ có
xmax= txid của UPDATE và tồn tại trên heap cho đến khi VACUUM reclaim. - Snapshot =
(snap_xmin, snap_xmax, xip_list): chụp tại BEGIN (RR/Serializable) hoặc tại mỗi statement (RC). Xác định tuple nào visible với transaction đó. - Visibility rule:
xminphải committed và nằm ngoàixip_list;xmaxphải 0 hoặc chưa committed hoặc nằm trongxip_list. - RR giữ snapshot stable: đọc cùng row hai lần trong transaction → cùng kết quả. RC chụp snapshot mới mỗi statement → có thể thấy kết quả khác nhau.
- Hint bits tránh CLOG lookup lặp lại: sau lần check đầu,
HEAP_XMIN_COMMITTEDđược set → lần sau skip CLOG hoàn toàn → SELECT thứ 2 nhanh hơn đáng kể. - CLOG (pg_xact): 2 bit/txid, 4 trạng thái (in-progress/committed/aborted/sub-committed). Mỗi segment 256 KB chứa 1M txid.
- Long transaction block VACUUM: snapshot
xminthấp → VACUUM không reclaim dead tuple cóxmaxlớn hơn → heap bloat tích lũy. Monitor bằngage(backend_xmin)trongpg_stat_activity. - txid wraparound (32-bit): autovacuum freeze phải chạy trước khi
age(datfrozenxid)vượt 2 tỷ. Giám sát bằngage(datfrozenxid)trongpg_database.
14. Tự kiểm tra
Q1Trong PostgreSQL, khi bạn chạy `UPDATE tasks SET title = 'X' WHERE id = 1`, database thực sự làm gì với dữ liệu vật lý trên heap page? Vì sao cả hai phiên bản (cũ và mới) cùng tồn tại một thời gian?▸
PostgreSQL không sửa tuple cũ tại chỗ. Thay vào đó, UPDATE làm hai việc nguyên tử: (1) set xmax của tuple cũ = txid của transaction đang UPDATE; (2) INSERT một tuple mới với xmin = txid hiện tại, xmax = 0, chứa giá trị mới. Cả hai tuple cùng tồn tại trên heap.
Lý do phải giữ cả hai: các transaction đang chạy đồng thời có snapshot được chụp trước khi UPDATE commit. Theo snapshot đó, tuple cũ (xmax chưa committed theo snapshot) vẫn là phiên bản visible. Nếu xoá tuple cũ ngay, các transaction kia sẽ mất khả năng đọc phiên bản chúng cần. Tuple cũ chỉ được VACUUM reclaim sau khi không còn snapshot nào cần nó — bài 5 của module này giải thích điều kiện đó.
Q2Giải thích snapshot `(snap_xmin, snap_xmax, xip_list)`. Nếu snapshot có `snap_xmin = 99`, `xip_list = [100, 102]`, `snap_xmax = 105`, tuple nào visible trong số: (A) xmin=95 xmax=0, (B) xmin=100 xmax=0, (C) xmin=98 xmax=100, (D) xmin=103 xmax=0?▸
(A) xmin=95 xmax=0: xmin=95 < snap_xmin=99 → committed lâu trước snapshot, không trong xip_list → điều kiện 1 thỏa. xmax=0 → chưa bị xóa → điều kiện 2 thỏa. Visible.
(B) xmin=100 xmax=0: xmin=100 < snap_xmax=105, nhưng 100 có trong xip_list → đang active lúc snapshot taken, chưa committed theo snapshot → điều kiện 1 fail. Invisible.
(C) xmin=98 xmax=100: xmin=98 < snap_xmin=99 → committed, điều kiện 1 thỏa. xmax=100: 100 có trong xip_list → chưa committed theo snapshot → điều kiện 2 thỏa (xmax chưa committed → tuple chưa bị xóa theo snapshot). Visible.
(D) xmin=103 xmax=0: xmin=103 < snap_xmax=105, và 103 không có trong xip_list=[100,102]. Khi txid nằm trong khoảng snap_xmin..snap_xmax nhưng không trong xip_list, nghĩa là txid đó đã committed trước khi snapshot được taken (nó không còn active nên không có trong xip_list). Do đó điều kiện 1 thỏa — xmin=103 đã committed. xmax=0 → chưa bị xóa → điều kiện 2 thỏa. Visible.
Q3Vì sao `SELECT count(*) FROM tasks` lần đầu sau bulk INSERT 1 triệu row chậm hơn lần thứ hai đáng kể? Cơ chế nào giải thích sự khác biệt này?▸
Nguyên nhân: hint bits chưa được set sau INSERT. Mỗi tuple mới có header chưa có HEAP_XMIN_COMMITTED flag. Lần SELECT đầu tiên, với mỗi tuple, visibility check phải tra CLOG (pg_xact) để xác nhận xmin của tuple đó đã committed chưa. Sau khi tra CLOG và xác nhận, PG set HEAP_XMIN_COMMITTED trực tiếp lên tuple header (best-effort, không WAL-logged). Với 1 triệu tuple, phần tra CLOG tích lũy thành overhead đáng kể — cả I/O đọc CLOG pages lẫn CPU xử lý.
Lần SELECT thứ hai: mọi tuple đã có HEAP_XMIN_COMMITTED set → visibility check chỉ đọc flag bit trong header, skip CLOG lookup hoàn toàn → nhanh hơn nhiều. Đây là lý do production thường "warm up" table trước khi benchmark, và lý do cold start sau maintenance có thể thấy query chậm bất thường.
Q4Phân biệt hành vi của MVCC ở `READ COMMITTED` và `REPEATABLE READ` khi T1 update và commit trong khi T2 đang chạy. Đưa ra ví dụ cụ thể nơi sự khác biệt đó ảnh hưởng đến kết quả query.▸
READ COMMITTED: snapshot được chụp lại tại mỗi statement. Statement đầu T2 thấy snapshot S1. Sau T1 commit, statement thứ hai T2 chụp snapshot S2 mới — S2 không còn txid của T1 trong xip_list → thấy tuple mới T1 tạo ra. T2 có thể thấy kết quả khác nhau cho cùng query nếu chạy hai lần trong cùng transaction.
REPEATABLE READ: snapshot chụp một lần khi BEGIN và cố định xuyên suốt transaction. Dù T1 commit ở giữa, T2 vẫn dùng snapshot cũ → txid của T1 vẫn "active" trong xip_list của snapshot cũ → tuple mới T1 tạo invisible với T2. T2 đọc cùng row hai lần → cùng kết quả.
Ví dụ ảnh hưởng: Dashboard TaskFlow tính tiến độ sprint. T2 query 1: count(*) = 100. T1 commit insert task mới. T2 query 2 ở RC: count(*) = 101. Tỉ lệ tính ra sai vì mẫu số thay đổi giữa hai query. Ở RR: cả hai query đều trả về 100 — nhất quán, tỉ lệ đúng.
Q5CLOG lưu gì và vì sao hint bits làm giảm số lần phải tra CLOG? Trong trường hợp nào hint bit không được set và CLOG vẫn phải tra?▸
CLOG (pg_xact) lưu trạng thái commit của mỗi transaction: 2 bit per txid encoding 4 trạng thái (in-progress / committed / aborted / sub-committed). Khi visibility check cần biết xmin hay xmax của một tuple đã committed hay chưa, nó tra CLOG.
Hint bits (HEAP_XMIN_COMMITTED, HEAP_XMAX_COMMITTED, v.v.) được set trực tiếp trên tuple header sau lần tra CLOG đầu tiên. Lần sau, check chỉ đọc flag bit trong header — không cần I/O đến CLOG page. Đây là cache ở level tuple.
Khi hint bit không được set và vẫn phải tra CLOG: (1) Tuple mới chưa được SELECT lần nào — chỉ vừa INSERT xong; (2) Sau khi VACUUM chạy và reset hint bits (hiếm); (3) Tuple trên page vừa được đọc lần đầu từ disk vào buffer cache (page mới load, hint bits từ disk chưa trong memory). Lần tra CLOG đầu set hint bit → các lần sau fast path.
Q6Vì sao transaction chạy lâu (long-running transaction) có thể gây heap bloat trong PostgreSQL, dù số row thực sự không tăng? Cơ chế ngăn VACUUM trong trường hợp này là gì?▸
Transaction chạy lâu giữ một snapshot với snap_xmin thấp — bằng txid của nó tại thời điểm BEGIN. VACUUM khi quét heap page, quyết định reclaim dead tuple dựa trên: dead tuple an toàn để reclaim khi xmax của nó nhỏ hơn xmin của oldest active snapshot trong toàn hệ thống.
Nếu có một transaction với snap_xmin = 5000 đang chạy, VACUUM không thể reclaim bất kỳ dead tuple nào có xmax > 5000 — dù hàng triệu UPDATE đã xảy ra sau đó và tạo ra hàng triệu dead tuple với xmax=5001, 5002, ..., 999999. Tất cả đều "có thể được nhìn thấy" bởi snapshot 5000. Kết quả: heap phình to dù count(*) thực tế không tăng — dead tuple tích lũy không được reclaim.
Monitor: age(backend_xmin) trong pg_stat_activity — giá trị cao là tín hiệu cần terminate transaction đó.
Q7So sánh cơ chế lưu phiên bản cũ của PostgreSQL (tuple trên heap) và MySQL InnoDB (undo log). Trong kịch bản 1 transaction đọc row 100 lần bị update bởi các transaction khác, read path của hai hệ thống khác nhau thế nào?▸
PostgreSQL: mỗi UPDATE tạo tuple mới trên heap. Với 100 update, heap có 100 dead tuple + 1 live tuple. Khi T đọc, visibility check từng tuple: đọc xmin/xmax, check với snapshot, dừng khi tìm được tuple visible. Trong trường hợp xấu nhất (T cần version cũ nhất), phải check qua nhiều tuple dead trên heap. Nhưng trong thực tế, live tuple thường được tìm thấy sớm vì index trỏ thẳng vào ctid của version mới nhất; dead tuple chỉ xuất hiện nếu version mới không visible.
MySQL InnoDB: row hiện tại trên page là version mới nhất (in-place update). Mỗi version cũ lưu trong undo log với roll pointer từ row hiện tại sang version liền trước. Với 100 update: undo chain có 100 entry. Khi T cần version cũ, phải traverse undo chain ngược — đọc version N, N-1, N-2... cho đến khi tìm version phù hợp snapshot. Chain dài = read chậm hơn, đặc biệt với long-running transaction cần version rất cũ.
Kết luận: PG read path uniform (check heap tuple visibility), InnoDB read path có thể dài hơn tuyến tính theo độ dài undo chain. Nhưng InnoDB tránh được heap bloat vì purge thread dọn undo tự động.
Bài tiếp theo: VACUUM & dead tuples — vì sao TaskFlow phình to dù count(*) không tăng
Bài này có giúp bạn hiểu bản chất không?