Đây là một trong số ít vấn đề của PostgreSQL có thể gây mất dữ liệu và ngừng hoạt động hoàn toàn — và nó đã hạ gục nhiều công ty lớn (Sentry, Mailchimp từng có sự cố nổi tiếng vì nó). "Transaction ID wraparound" nghe trừu tượng nhưng cơ chế rất cụ thể: transaction id của PostgreSQL là số 32-bit, và như mọi bộ đếm hữu hạn, nó sẽ tràn. Nếu để điều đó xảy ra, các dòng dữ liệu cũ bỗng trông như thuộc tương lai và biến mất. Bài này giải thích cơ chế, đo thật cách theo dõi rủi ro, và cho thấy vì sao VACUUM là bắt buộc — kể cả trên bảng chỉ đọc.

XID 32-bit sẽ tràn sau ~2 tỉ giao dịch

Mỗi giao dịch trong PostgreSQL nhận một transaction id (XID) tăng dần, dùng để quyết định phiên bản dòng nào hiển thị với giao dịch nào (nền tảng MVCC). XID là số nguyên 32-bit:

  • Phạm vi: 2^32 = 4.294.967.296 (~4,29 tỉ) giá trị.
  • Nhưng PostgreSQL so sánh XID theo modulo — một giao dịch coi ~2 tỉ XID là "quá khứ" và ~2 tỉ là "tương lai". Nên cửa sổ dùng được thực tế chỉ ~2 tỉ.

Vấn đề: nếu bộ đếm chạy hết ~2 tỉ và "vòng lại", một dòng được tạo bởi giao dịch cũ (XID thấp) bỗng trông như được tạo bởi giao dịch tương lai (vì modulo) — và trở nên vô hình với mọi giao dịch. Dữ liệu không bị xóa, nhưng biến mất khỏi mọi truy vấn. Đây là thảm họa.

Ảnh chụp đoạn mã SQL nền tối minh hoạ transaction ID wraparound hiểm họa 32-bit, XID là số 32-bit sẽ tràn sau khoảng 2 tỉ giao dịch mỗi giao dịch nhận một transaction id xid tăng dần kiểu 32-bit phạm vi 2 mũ 32 bằng 4,29 tỉ giá trị nhưng so sánh theo modulo cửa sổ dùng được chỉ khoảng 2 tỉ 2 tỉ quá khứ cộng 2 tỉ tương lai nếu bộ đếm tràn dòng cũ bỗng trông như tương lai biến mất, vì sao VACUUM là bắt buộc kể cả bảng chỉ đọc VACUUM FREEZE đóng băng dòng cũ gán trạng thái luôn hiển thị dòng frozen không cần so sánh xid nữa miễn nhiễm wraparound bảng append-only không sinh dead tuple nhưng vẫn cần freeze VACUUM FREEZE wt age relfrozenxid 1 xuống 0 reset tuổi, theo dõi rủi ro age datfrozenxid SELECT datname age datfrozenxid AS tuoi_xid round age datfrozenxid chia 2000000000 nhân 100 4 AS pct_toi_han FROM pg_database pct_toi_han tới 100 phần trăm 2 tỉ bằng khẩn cấp SELECT relname age relfrozenxid FROM pg_class ORDER BY age relfrozenxid DESC bảng nào già nhất, cơ chế phòng vệ tự động autovacuum_freeze_max_age bằng 200000000 tuổi 200M autovacuum ép một lượt freeze quyết liệt nếu vẫn không freeze tới 2 tỉ PG từ chối giao dịch mới phải vào single-user mode để cứu downtime nghiêm trọng

Hình 1: XID 32-bit có cửa sổ dùng được ~2 tỉ. VACUUM FREEZE đóng băng dòng cũ (miễn nhiễm wraparound) nên VACUUM bắt buộc kể cả bảng chỉ đọc. Theo dõi bằng age(datfrozenxid).

VACUUM freeze: cơ chế phòng vệ

PostgreSQL chống wraparound bằng cách freeze (đóng băng) các dòng cũ: gán cho chúng một trạng thái đặc biệt "luôn hiển thị", không còn cần so sánh XID nữa. Một dòng đã frozen miễn nhiễm với wraparound. Đây chính là công việc VACUUM làm (một trong ba việc ở bài trước), và là lý do VACUUM bắt buộc chạy kể cả trên bảng chỉ đọc hoặc append-only — những bảng này không sinh dead tuple, nhưng dòng của chúng vẫn cần freeze trước khi XID già đi.

Đo thật cơ chế freeze reset "tuổi" của bảng:

Ảnh chụp bảng kết quả đo thật nền tối transaction ID wraparound PostgreSQL 16 pg_settings pg_database pg_class pg_snapshot shared_buffers 128MB, XID 32-bit và các mốc freeze phạm vi XID 32-bit 2 mũ 32 bằng 4294967296 khoảng 4,29 tỉ cửa sổ dùng được tới hạn khoảng 2 tỉ do so sánh modulo autovacuum_freeze_max_age 200000000 ép freeze khi tuổi đạt mốc vacuum_freeze_min_age 50000000, theo dõi rủi ro age datfrozenxid database lab age datfrozenxid 101983 phần trăm tới hạn 2 tỉ 0,0051 phần trăm rất an toàn theo dõi cột này tiến gần 100 phần trăm 2 tỉ là khẩn cấp bảng già nhất pgbench_accounts age 101952, VACUUM FREEZE reset tuổi bảng wt sau INSERT age relfrozenxid 1 sau VACUUM FREEZE wt age relfrozenxid 0 freeze đóng băng dòng cũ đưa tuổi về 0 đây là việc VACUUM làm để chống wraparound, cốt lõi transaction id là 32-bit cửa sổ dùng được khoảng 2 tỉ nếu dòng cũ không được FREEZE trước khi bộ đếm tràn chúng bỗng trông như tương lai và biến mất thảm họa VACUUM freeze ngăn điều này nên VACUUM bắt buộc kể cả bảng chỉ đọc theo dõi age datfrozenxid tới khoảng 2 tỉ PG từ chối giao dịch

Hình 2: XID 32-bit phạm vi ~4,29 tỉ, cửa sổ dùng được ~2 tỉ. Database lab có age(datfrozenxid) 101.983 (0,0051% tới hạn — rất an toàn). VACUUM (FREEZE) đưa age(relfrozenxid) của bảng từ 1 về 0.

Theo dõi rủi ro: age(datfrozenxid)

Chỉ số quan trọng nhất cần theo dõi là tuổi của XID đóng băng cũ nhất — càng già càng gần wraparound:

SELECT datname, age(datfrozenxid) AS tuoi_xid,
  round(age(datfrozenxid)::numeric / 2000000000 * 100, 4) AS pct_toi_han
FROM pg_database;
-- lab: 101.983 → 0,0051% tới hạn (rất an toàn)

age(datfrozenxid) cho biết đã bao nhiêu giao dịch trôi qua kể từ XID đóng băng cũ nhất trong database. Khi nó tiến gần 2 tỉ (100% tới hạn), đó là tình huống khẩn cấp. Ở cấp bảng, age(relfrozenxid) cho biết bảng nào "già" nhất:

SELECT relname, age(relfrozenxid) FROM pg_class
WHERE relkind='r' ORDER BY age(relfrozenxid) DESC;

Mọi công cụ giám sát PostgreSQL nghiêm túc đều cảnh báo khi tuổi này vượt ngưỡng (thường vài trăm triệu).

Cơ chế tự động và kịch bản ác mộng

PostgreSQL có phòng vệ tự động qua autovacuum_freeze_max_age (mặc định 200 triệu): khi tuổi một bảng đạt mốc này, autovacuum ép một lượt vacuum freeze quyết liệt, kể cả nếu bạn đã tắt autovacuum cho bảng đó. Đây là lưới an toàn.

Nhưng nếu freeze vẫn không xảy ra (autovacuum bị tắt hoàn toàn, hoặc không theo kịp trên hệ thống cực nhiều giao dịch), tuổi tiến gần 2 tỉ và PostgreSQL vào chế độ khẩn cấp: đầu tiên là cảnh báo trong log, rồi ở mức nguy hiểm hơn nó từ chối mọi giao dịch mới để bảo vệ dữ liệu — phải dừng database và vào single-user mode chạy VACUUM để cứu. Đây là downtime nghiêm trọng, và là nguyên nhân của nhiều sự cố production nổi tiếng.

Đánh đổi cần cân nhắc

Đừng bao giờ tắt autovacuum toàn cục. Cám dỗ tắt autovacuum để "tiết kiệm I/O" trên hệ thống ghi nhiều là con đường thẳng tới wraparound. Nếu autovacuum gây tải, hãy chỉnh nó (cost limit, số worker) chứ đừng tắt — vì nó là thứ duy nhất tự động freeze chống wraparound.

PostgreSQL mới giảm rủi ro nhưng không xóa bỏ. Các phiên bản gần đây cải thiện freeze (freeze theo trang, aggressive vacuum thông minh hơn), và có đề xuất XID 64-bit trong tương lai. Nhưng cho tới khi đó, trên PostgreSQL 16 XID vẫn 32-bit — theo dõi tuổi vẫn là việc vận hành bắt buộc.

Bảng lớn ít ghi vẫn cần freeze định kỳ. Một bảng dữ liệu lịch sử khổng lồ, gần như không đổi, dễ bị bỏ quên — autovacuum ít đụng tới vì không có dead tuple. Nhưng XID của nó vẫn già đi theo giao dịch toàn cục. autovacuum_freeze_max_age cuối cùng sẽ ép freeze, nhưng lượt đó trên bảng lớn tốn nhiều I/O đột ngột; theo dõi và freeze chủ động tránh cú sốc.

Ba ý mang về

  1. Transaction id là 32-bit với cửa sổ dùng được ~2 tỉ: nếu bộ đếm tràn mà dòng cũ chưa được freeze, chúng bỗng trông như tương lai và biến mất khỏi mọi truy vấn — mất dữ liệu và ngừng hoạt động, không phải lý thuyết mà đã xảy ra ở nhiều công ty lớn.
  2. VACUUM freeze là cơ chế phòng vệ, nên VACUUM bắt buộc kể cả bảng chỉ đọc: đo thật, VACUUM (FREEZE) đóng băng dòng cũ và đưa age(relfrozenxid) về 0 — bảng append-only không sinh dead tuple nhưng vẫn cần freeze.
  3. Theo dõi age(datfrozenxid) và không bao giờ tắt autovacuum: chỉ số này tiến gần 2 tỉ là khẩn cấp; autovacuum_freeze_max_age (200 triệu) là lưới an toàn tự động, nhưng nếu để tràn PostgreSQL sẽ từ chối giao dịch và cần single-user mode để cứu.

Phần sau ta quay lại công cụ đo lường thực dụng đã dùng nhiều lần: Phần sau mổ xẻ pgstattuple — cách đo bloat chính xác bằng cách quét bảng, đọc các con số nó trả về, và khi nào dùng bản ước lượng nhanh thay thế.