Transaction ID wraparound — PostgreSQL tự chống thế nào, và khi nào nó bó tay

Câu hỏi hay gặp: "Wraparound thì PostgreSQL có tiến trình tự động fix không, hay phải chạy tay định kỳ?" — Trả lời ngắn: có, tự động, và không tắt được. Nhưng trả lời như vậy là rớt nửa sau của câu hỏi. Cơ chế tự động đó chạy đều mà vẫn có thể không tiến được một XID nào nếu có thứ đang chặn nó — và đó mới là nguyên nhân thật của mọi sự cố wraparound ngoài production. Bài này đi từ cơ chế, đo bằng lab thật, rồi tới runbook lúc 3 giờ sáng.

Thuật ngữ dùng trong bài
XID (transaction ID — số thứ tự 32-bit PostgreSQL cấp cho mỗi transaction có ghi) · wraparound (bộ đếm XID chạy hết vòng và quay về 0, làm dữ liệu cũ bị hiểu nhầm là "tương lai") · freeze (đánh dấu một dòng là "cũ vĩnh viễn", miễn nhiễm với wraparound) · relfrozenxid (XID chưa-freeze cũ nhất trong một bảng, nằm ở pg_class) · datfrozenxid (relfrozenxid nhỏ nhất của cả database, nằm ở pg_database) · age (số giao dịch đã trôi qua kể từ relfrozenxid — "bảng này trễ bao nhiêu rồi") · xmin horizon (mốc XID cũ nhất còn có thể bị nhìn thấy — VACUUM không được dọn/freeze vượt qua mốc này) · removable cutoff (chính là xmin horizon, tên PostgreSQL in ra trong log VACUUM) · anti-wraparound vacuum (lần vacuum bắt buộc do tuổi XID, khác với vacuum thường do rác) · failsafe (chế độ khẩn cấp: vacuum vứt hết phần việc không thiết yếu để chạy cho nhanh) · MultiXact / MXID (id riêng cho trường hợp nhiều transaction cùng khoá một dòng — có bộ đếm và wraparound riêng).
Kiểm chứng: tài liệu lấy qua Context7 (PostgreSQL 18: routine-vacuuming, runtime-config-vacuum, sql-createtable storage parameters, sql-prepare-transaction). Mọi con số tham số, output SQL và log server trong bài là output thật chạy trên PostgreSQL 18.4 (lab postgresql-learning, container my-test-pgdb). Các chuỗi WARNING/ERROR được lấy thẳng từ binary postgres 18.4 trong container. Phần chưa verify được đánh dấu rõ tại chỗ.

Trả lời 30 giây (khi interviewer hỏi thẳng)

Có tiến trình tự động. Autovacuum có một tầng riêng gọi là anti-wraparound vacuum: khi tuổi XID của một bảng vượt autovacuum_freeze_max_age (mặc định 200 triệu), launcher bắt buộc đẻ worker để freeze bảng đó — chạy kể cả khi đã set autovacuum = off, kể cả khi bảng set autovacuum_enabled = false. Nên không, tôi không cron VACUUM FREEZE định kỳ.
Nhưng tự động không có nghĩa là luôn thành công. VACUUM chỉ freeze được những dòng cũ hơn xmin horizon — mốc XID cũ nhất còn sống trong hệ thống. Một transaction mở quên đóng, một replication slot chết, một prepared transaction bị bỏ quên, hay một standby bật hot_standby_feedback đều ghim mốc đó lại. Khi bị ghim, anti-wraparound vacuum vẫn chạy đều mỗi phút nhưng freeze được 0 dòng và tuổi XID cứ tăng. Chạy tay cũng vô dụng y hệt. Nên việc thật sự phải làm không phải là chạy vacuum định kỳ, mà là alert trên age(datfrozenxid) và gỡ thằng đang ghim.

1. Nó là gì / chạy thế nào

1.1. XID là số 32-bit, và không gian XID là một vòng tròn

Mỗi transaction có ghi dữ liệu được cấp một XID. Mỗi tuple (bản vật lý của một dòng) mang xmin = XID đã tạo ra nó. Khi một transaction đọc, PostgreSQL so sánh XID của nó với xmin của tuple để quyết định "cái này tạo trước hay sau tôi".

Vấn đề: XID chỉ có 32 bit → khoảng 4,3 tỷ giá trị. Và PostgreSQL so sánh XID theo modulo 2³², nghĩa là không gian XID là một vòng tròn không có điểm đầu cuối. Với bất kỳ XID nào: 2 tỷ XID được coi là "quá khứ", 2 tỷ XID còn lại là "tương lai". Một dòng nằm yên quá 2 tỷ giao dịch sẽ đột nhiên rơi sang nửa "tương lai" — và mọi câu SELECT ngừng nhìn thấy nó.

Vòng tròn không gian XID với kim "now" quay Không gian XID là vòng tròn. Nửa phía sau kim "now" là quá khứ, nửa phía trước là tương lai. Một dòng đứng yên tại XID 100: ban đầu nó nằm trong nửa quá khứ nên vẫn đọc được; khi kim quay quá 2 tỷ giao dịch, chính dòng đó rơi vào nửa tương lai và biến mất khỏi mọi SELECT. Dòng đã được VACUUM freeze thì không theo luật này, luôn nằm ở quá khứ. XID 0 (= 4,3 tỷ) 1,07 tỷ 2,15 tỷ 3,2 tỷ row A xmin = 100 (nằm yên, không ai đụng) kim = XID hiện tại quay mãi, không dừng PostgreSQL nhìn row A thế nào? now − xmin < 2 tỷ → QUÁ KHỨ Row A nằm trong nửa xanh phía sau kim. SELECT thấy bình thường. Mọi thứ ổn. now − xmin > 2 tỷ → TƯƠNG LAI Kim đã quay qua row A. Nó thành "chưa commit". SELECT không thấy nữa. Data vẫn trên đĩa. Lối thoát: VACUUM freeze row A Dòng frozen mang FrozenTransactionId — không theo luật so sánh modulo ở trên, nên luôn được coi là cũ hơn mọi XID. Vĩnh viễn an toàn. Nên luật sống còn là: mọi bảng phải được vacuum ít nhất 1 lần mỗi 2 tỷ giao dịch.
Kim quay = XID hiện tại tăng dần. Row A đứng yên. Đúng lúc kim đi được nửa vòng (2 tỷ giao dịch), row A tự nhiên đổi từ "quá khứ" sang "tương lai" và biến mất khỏi mọi truy vấn — dữ liệu không mất khỏi đĩa, chỉ là không còn cách nào đọc ra. VACUUM freeze là thứ duy nhất phá được vòng lặp này.

1.2. Freeze, và hai cột cần thuộc

VACUUM đánh dấu các dòng đủ cũ là frozen: đã commit đủ lâu để chắc chắn mọi transaction hiện tại và tương lai đều nhìn thấy. Dòng frozen thoát khỏi luật so sánh modulo, nên không bao giờ bị wraparound.

CộtỞ đâuNghĩa
relfrozenxidpg_class (mỗi bảng)XID chưa-freeze cũ nhất còn trong bảng này
datfrozenxidpg_database (mỗi DB)min() của relfrozenxid mọi bảng trong DB
age(relfrozenxid)hàmSố giao dịch đã trôi qua kể từ mốc đó = "trễ bao nhiêu"

Toàn bộ hệ thống cảnh báo của PostgreSQL chạy trên đúng một con số: age().

1.3. Năm tầng tự động — số mặc định thật trên PG 18.4

Chạy trên container lab, không sửa gì:

SELECT name, setting FROM pg_settings WHERE name IN (
  'vacuum_freeze_min_age','vacuum_freeze_table_age','autovacuum_freeze_max_age',
  'vacuum_failsafe_age','autovacuum_naptime','autovacuum_multixact_freeze_max_age');

                name                 |  setting
-------------------------------------+------------
 autovacuum_freeze_max_age           | 200000000
 autovacuum_multixact_freeze_max_age | 400000000
 autovacuum_naptime                  | 60
 vacuum_failsafe_age                 | 1600000000
 vacuum_freeze_min_age               | 50000000
 vacuum_freeze_table_age             | 150000000
Năm tầng chống wraparound theo tuổi XID Thang đo tuổi XID của một bảng, chạy từ trên xuống. Mốc 50 triệu: VACUUM tiện tay freeze. Mốc 150 triệu: VACUUM nâng cấp thành aggressive quét cả bảng. Mốc 200 triệu: autovacuum bắt buộc nổ kể cả khi autovacuum tắt. Mốc 1,6 tỷ: failsafe bỏ cost delay và bỏ vacuum index. Còn 40 triệu: WARNING mỗi commit. Còn 3 triệu: database từ chối mọi lệnh ghi. age(relfrozenxid) của một bảng — trục không theo tỉ lệ age 50 triệu — vacuum_freeze_min_age VACUUM chạy vì lý do khác (rác) thì tiện tay freeze dòng cũ hơn mốc này. Chưa chủ động gì. 150 triệu — vacuum_freeze_table_age VACUUM tự nâng cấp thành aggressive: quét cả bảng, không tin visibility map nữa. 200 triệu — autovacuum_freeze_max_age Autovacuum BẮT BUỘC nổ — kể cả autovacuum = off. Log: "to prevent wraparound". 1,6 tỷ — vacuum_failsafe_age Phao cứu sinh: vứt cost delay, bỏ luôn bước vacuum index, dùng cả shared_buffers. còn 40 triệu XID — cảnh báo WARNING: database "x" must be vacuumed within 39999999 transactions còn 3 triệu XID — PHANH TAY ERROR: database is not accepting commands that assign new transaction IDs Ghi bị chặn toàn bộ. Đọc vẫn chạy. 3 triệu dư ra là chỗ để admin còn DROP/TRUNCATE được.
Hai tầng đầu (xanh) là autovacuum "ăn ké" — nó chạy vì dead tuple, tiện tay freeze. Tầng ba (vàng) mới là cơ chế tự động thật sự dành riêng cho wraparound. Hai tầng đỏ cuối là phanh tay: PostgreSQL thà chặn ghi còn hơn để mất dữ liệu. Chuỗi WARNING/ERROR trong hình lấy thẳng từ binary postgres 18.4.

Hai điểm tầng 3 hay bị hỏi tiếp, doc PG 18 nói thẳng:

PG 18 còn có eager freeze scan (vacuum_max_eager_freeze_failure_rate, mặc định 0.03): VACUUM thường thử freeze sẵn một ít trang all-visible mỗi lần, để không dồn cục vào một trận aggressive vacuum khổng lồ. Dòng eagerly scanned trong log vacuum là cái này. Tham số đã verify có thật trong pg_settings của 18.4; còn hiệu quả thực tế trên bảng lớn thì chưa verify — bảng lab quá nhỏ để đo.

1.4. Bằng chứng: autovacuum tự nổ, không ai gọi

Không thể đốt 200 triệu XID trong lab, nên hạ ngưỡng ngay trên bảng test — cùng một cơ chế, chỉ khác con số:

CREATE TABLE tmp_wrap (id int primary key, v text)
  WITH (autovacuum_freeze_max_age = 100000);
INSERT INTO tmp_wrap SELECT g, 'x' FROM generate_series(1,1000) g;

-- đốt 105.000 XID bằng procedure có COMMIT trong vòng lặp
CREATE PROCEDURE tmp_burn(n int) LANGUAGE plpgsql AS $$
BEGIN
  FOR i IN 1..n LOOP
    PERFORM pg_current_xact_id();
    COMMIT;
  END LOOP;
END $$;

SET synchronous_commit = off;
CALL tmp_burn(105000);

Rồi ngồi nhìn, không gọi VACUUM lần nào:

09:28:18  tmp_wrap age=105006 | autovacuum: chưa chạy
09:28:28  tmp_wrap age=105006 | autovacuum: chưa chạy
09:28:38  tmp_wrap age=105006 | autovacuum: chưa chạy
09:28:49  tmp_wrap age=105006 | autovacuum: chưa chạy
09:28:59  tmp_wrap age=105005 | autovacuum: 2026-09-23 07:28:58+00  n=1   <-- TỰ NỔ

Log server (bật log_autovacuum_min_duration = 0):

LOG:  automatic vacuum to prevent wraparound of table "isolation_demo.public.tmp_wrap"
      index scans: 0
      pages: 0 removed, 5 remain, 5 scanned (100.00% of total), 0 eagerly scanned
      removable cutoff: 605502, which was 0 XIDs old when operation ended
      new relfrozenxid: 500497, which is 1 XIDs ahead of previous value
      frozen: 0 pages from table (0.00% of total) had 0 tuples frozen

Cơ chế tự động hoạt động đúng như quảng cáo: đúng 1 chu kỳ autovacuum_naptime (60 giây) sau khi vượt ngưỡng, worker tự sinh ra, log ghi rõ to prevent wraparound.

Nhưng đọc kỹ dòng cuối: frozen: 0 tuples, relfrozenxid chỉ nhích 1 XID. Nó chạy mà gần như không làm được gì. Đó là toàn bộ nội dung mục 3.

1.5. MultiXact — người họ hàng hay bị quên

Khi nhiều transaction cùng khoá một dòng (SELECT ... FOR UPDATE / FOR SHARE, hoặc foreign key check), PostgreSQL sinh một MultiXactId để ghi "những ai đang khoá". MXID cũng là bộ đếm 32-bit, cũng wraparound được, và có bộ tham số song song: autovacuum_multixact_freeze_max_age = 400000000.

Ba khác biệt quan trọng (doc PG 18, mục 24.1.5.1):

Hệ quả cho việc monitor: alert phải nhìn cả hai, không chỉ age(datfrozenxid).

2. Lợi ích

3. Tác hại / hạn chế

3.1. Tự động ≠ thành công — cái bẫy chính

VACUUM không được phép freeze một dòng nếu còn ai đó có thể còn cần nhìn thấy bản cũ của dòng đó. Ranh giới ấy là xmin horizon, PostgreSQL in ra trong log với tên removable cutoff. Nó bị ghim bởi XID cũ nhất còn sống trong toàn hệ thống.

Removable cutoff bị một transaction mở lâu ghim lại Trục XID tăng dần sang phải. Kim "now" chạy sang phải liên tục. Một transaction mở lúc 2 giờ sáng ghim removable cutoff tại một điểm cố định; vùng freeze được (xanh) không nhích, còn khoảng cách từ cutoff tới now (đỏ) rộng dần ra, chính là age đang tăng. Khi transaction đó đóng lại, cutoff nhảy lên sát now và vùng xanh nuốt trọn phần đỏ, age về 0. Trục XID tăng dần → removable cutoff — bị ghim BEGIN lúc 02:00, chưa COMMIT backend_xmin = 605503 now freeze được tới đây Pha 1 — anti-wraparound vacuum chạy đều mỗi 60 giây, và vô dụng frozen: 0 tuples · new relfrozenxid: +1 XID · age vẫn tăng theo now Pha 2 — transaction đóng → cutoff nhảy lên sát now cùng một câu VACUUM FREEZE đó, không sửa gì → age = 0
Khoảng đỏ chính là age. Nó rộng ra không phải vì vacuum lười, mà vì cutoff bị ghim. Chạy vacuum bằng tay ở pha 1 cho kết quả y hệt autovacuum: freeze 0 dòng. Thứ duy nhất có tác dụng là đóng cái transaction kia lại.

Demo thật trên PG 18.4. Mở một transaction rồi để đó, đốt 50.000 XID, rồi gọi VACUUM FREEZE bằng tay:

-- session 1: giữ một XID sống
BEGIN;
SELECT pg_current_xact_id();   -- 605503
-- (để yên, không commit)

-- session 2
CALL tmp_burn(50000);
SELECT age(relfrozenxid) FROM pg_class WHERE relname='tmp_wrap2';   --  50002

VACUUM (FREEZE, VERBOSE) tmp_wrap2;
INFO:  aggressively vacuuming "isolation_demo.public.tmp_wrap2"
       removable cutoff: 605503, which was 50003 XIDs old when operation ended
       frozen: 0 pages from table (0.00% of total) had 0 tuples frozen

SELECT age(relfrozenxid) FROM pg_class WHERE relname='tmp_wrap2';   --  50003  (KHÔNG GIẢM)

removable cutoff: 605503 — đúng bằng XID của thằng đang mở transaction. Sau khi transaction đó đóng, chạy lại đúng câu lệnh đó, không sửa một chữ:

VACUUM FREEZE tmp_wrap2;
SELECT relname, age(relfrozenxid) FROM pg_class WHERE relname='tmp_wrap2';

  relname  | age
-----------+-----
 tmp_wrap2 |   0
Kết luận quan trọng nhất của bài: chạy VACUUM bằng tay không cứu được wraparound. Autovacuum đã chạy sẵn rồi, và nó bị chặn bởi đúng thứ sẽ chặn anh. Gỡ thằng đang ghim mới là cách cứu.

3.2. Bốn thứ ghim xmin horizon

flowchart TD
  Q["age(datfrozenxid) tăng mãi
dù autovacuum vẫn chạy"] --> H["Ai đang giữ XID cũ nhất?"] H --> B1["Backend mở transaction lâu
idle in transaction, report chạy 6 tiếng
pg_stat_activity.backend_xmin"] H --> B2["Replication slot cũ hoặc chết
kể cả slot active = false
pg_replication_slots.xmin"] H --> B3["Prepared transaction bị bỏ quên
2PC không ai commit/rollback
pg_prepared_xacts"] H --> B4["Standby bật hot_standby_feedback
đang chạy query dài
pg_stat_replication.backend_xmin"] B1 --> P["xmin horizon bị ghim"] B2 --> P B3 --> P B4 --> P P --> R["VACUUM chạy → frozen: 0 tuples"] R --> S["age không giảm → tiến dần tới phanh tay"]
Thứ ghimNhìn ở đâuCách gỡGhi chú
Replication slot cũ / chết pg_replication_slots.xmin, catalog_xmin pg_drop_replication_slot('ten') Thủ phạm số 1. Slot inactive cũng ghim. Verify standby/CDC còn cần không đã.
Transaction mở lâu / idle in transaction pg_stat_activity.backend_xmin idle_in_transaction_session_timeout; nặng thì pg_terminate_backend() Hay do ORM quên commit, hoặc migration tool treo.
Prepared transaction (2PC) bị bỏ quên pg_prepared_xacts ROLLBACK PREPARED 'gid' Không dùng 2PC thì set max_prepared_transactions = 0 cho khỏi dính.
Standby + hot_standby_feedback = on pg_stat_replication.backend_xmin Kill query bên standby, hoặc tắt feedback Report chạy 6 tiếng ở standby ghim primary suốt 6 tiếng.

Một câu query gộp cả bốn nguồn — đây là câu duy nhất cần nhớ lúc 3 giờ sáng:

SELECT 'backend'       AS nguon, pid::text AS id, age(backend_xmin) AS tuoi_xid, state
  FROM pg_stat_activity WHERE backend_xmin IS NOT NULL
UNION ALL
SELECT 'repl_slot',     slot_name, age(xmin), active::text FROM pg_replication_slots
UNION ALL
SELECT 'prepared_xact', gid, age(transaction::text::xid), NULL FROM pg_prepared_xacts
UNION ALL
SELECT 'repl_standby',  application_name, age(backend_xmin), state FROM pg_stat_replication
ORDER BY 3 DESC NULLS LAST;

Output thật trên lab (primary có 1 standby đang stream):

    nguon     |     id      | tuoi_xid |   state
--------------+-------------+----------+-----------
 backend      | 780257      |        0 | active
 repl_slot    | standby1    |          | true
 repl_standby | walreceiver |          | streaming

3.3. Anti-wraparound vacuum không nhường lock

Autovacuum thường rất lịch sự: đang chạy mà có ai xin lock xung đột với SHARE UPDATE EXCLUSIVE của nó (ví dụ ALTER TABLE) thì nó tự hủy nhường đường. Anti-wraparound vacuum thì không. Doc PG 18 nói rõ:

"However, if the autovacuum is running to prevent transaction ID wraparound (i.e., the autovacuum query name in the pg_stat_activity view ends with (to prevent wraparound)), the autovacuum is not automatically interrupted."

Hệ quả thực tế: migration của anh treo sau một cái lock, và mọi query sau đó xếp hàng phía sau nó. Kill worker cũng vô ích — 60 giây sau (autovacuum_naptime) nó quay lại, và toàn bộ phần việc nó đã làm bị vứt đi.

3.4. Nó dồn cục chứ không nhỏ giọt

Mặc định nghĩa là một bảng phải trễ tới 200 triệu XID mới bị tầng anti-wraparound đụng tới. Trên bảng 500 GB append-only, đó là một trận quét toàn bảng kéo dài nhiều giờ, IO tăng vọt — và thường rơi đúng giờ cao điểm, vì giờ cao điểm mới là lúc XID bị đốt nhanh nhất.

Cộng với 3.3 (không nhường lock), đây là công thức của một sự cố: tự nhiên giữa trưa, một bảng lớn bị vacuum không dừng được, deploy treo, IO bão hoà.

3.5. Failsafe cứu được wraparound nhưng để lại rác

Khi failsafe bật (age > 1,6 tỷ), VACUUM bỏ qua bước vacuum index. Nó cứu được cluster khỏi chết, nhưng index không được dọn: entry chết còn nguyên, index phình. Sau sự cố phải dọn riêng — thường là REINDEX CONCURRENTLY. Đây là cái giá ít người nhắc tới của failsafe, và là lý do đừng coi mốc 1,6 tỷ là "vẫn ổn, PostgreSQL lo rồi".

4. Trade-off

Phương ánĐượcMấtKhi nào chọn
Để mặc định, chỉ monitor Không tốn công. Đúng cho khoảng 90% hệ thống. Freeze dồn cục ở mốc 200 triệu, dễ trùng giờ cao điểm. Mặc định. Chọn cái này trừ khi có lý do đo được để đổi.
Hạ autovacuum_freeze_max_age (100–150 triệu) Freeze sớm, mỗi lần ít việc hơn, cú sốc IO nhỏ hơn. Anti-wraparound vacuum chạy thường xuyên hơn → cái lock không-nhường-đường xuất hiện nhiều hơn. Hệ đốt XID nhanh (> 50 triệu XID/ngày) hoặc có vài bảng rất lớn.
Cron vacuumdb --freeze giờ thấp điểm Chủ động chọn thời điểm; biến một trận bão thành nhiều cơn mưa. Thêm một thứ phải nuôi và phải monitor. Không thay thế cơ chế tự động. Bảng rất lớn + có cửa sổ bảo trì rõ ràng.
Tăng autovacuum_freeze_max_age (ví dụ 800 triệu) Ít bị anti-wraparound vacuum làm phiền. Ăn mòn khoảng đệm an toàn. pg_xact/pg_multixact phình. Khi nổ thì nổ to. Gần như không bao giờ. Chỉ khi đã đo và biết chính xác mình đang làm gì.
Partition bảng lớn relfrozenxid là per-table: partition cũ freeze một lần rồi thôi, không bao giờ bị quét lại. Phải thiết kế khoá phân mảnh, quản lý partition, UNIQUE toàn cục khó hơn. Bảng log/outbox/audit tăng liên tục. Đây là cách giảm chi phí freeze tốt nhất về lâu dài.

Tiêu chí chọn, gọn lại:

flowchart TD
  A["Đốt bao nhiêu XID mỗi ngày?
(đo: age(datfrozenxid) hôm nay − hôm qua)"] -->|"dưới 50 triệu"| B["Để mặc định.
Chỉ cần alert + log_autovacuum."] A -->|"trên 50 triệu"| C["Có bảng nào trên 100 GB không?"] C -->|"Không"| D["Hạ autovacuum_freeze_max_age
xuống 100–150 triệu toàn cluster"] C -->|"Có"| E["Bảng đó append-only?"] E -->|"Có"| F["Partition theo thời gian.
Partition cũ freeze 1 lần rồi yên."] E -->|"Không"| G["Hạ freeze_max_age riêng cho bảng đó
+ cron vacuumdb --freeze giờ thấp điểm"]

Một câu nên nói trong phỏng vấn: "tăng autovacuum_freeze_max_age để đỡ phiền là vay nợ — trả bằng một đêm 3 giờ sáng".

5. Khó khăn khi vận hành thực tế

5.1. Đúng một câu query để alert

SELECT datname,
       age(datfrozenxid)              AS tuoi_xid,
       mxid_age(datminmxid)           AS tuoi_mxid,
       2000000000 - age(datfrozenxid) AS xid_con_lai
FROM pg_database ORDER BY 2 DESC;

Output thật trên lab:

    datname     | tuoi_xid | tuoi_mxid | xid_con_lai
----------------+----------+-----------+-------------
 postgres       |   499751 |     21976 |  1999500249
 isolation_demo |   499751 |     21976 |  1999500249
 template1      |   499751 |     21976 |  1999500249
 template0      |   499751 |     21976 |  1999500249

Chú ý template0 cũng có tuổi — nó không connect được nhưng vẫn được autovacuum freeze bình thường, và vẫn tính vào ngưỡng toàn cluster.

NgưỡngMứcVì sao
500 triệu (25%)warning, giờ hành chínhCòn cả tuần để điều tra. Bình thường không bao giờ chạm tới.
1 tỷ (50%)page, gọi người dậyCó thứ đang chặn. Không phải chuyện tự khỏi.
1,5 tỷ+đã muộnĐừng đặt alert đầu tiên ở đây — lúc đó chỉ còn vài giờ.

Alert thứ hai, quan trọng không kém: replication slot inactive > 1 giờ. Slot chết là nguyên nhân chung của hai sự cố khác nhau — wraparound và đầy đĩa WAL. Một alert, chặn được cả hai.

5.2. Bật log, nếu không thì mù

# postgresql.conf
log_autovacuum_min_duration = 0     # hoặc '1s' nếu log quá ồn

Không có dòng này, lúc sự cố anh sẽ không biết autovacuum có chạy không, chạy bao lâu, freeze được bao nhiêu dòng — tức là không phân biệt được "nó không chạy" với "nó chạy mà bị chặn", mà hai cái đó cần hai cách xử lý hoàn toàn khác nhau.

5.3. Nhìn tiến độ một vacuum đang chạy

SELECT pid, phase, heap_blks_scanned, heap_blks_total, delay_time
FROM pg_stat_progress_vacuum;

Cột delay_time (đã verify có trong pg_stat_progress_vacuum của 18.4) cho biết nó đang ngủ vì cost delay hay đang thật sự làm việc — thông tin quyết định khi phải chọn giữa "chờ thêm" và "tăng vacuum_cost_limit".

Và để biết một worker có phải loại không-nhường-lock hay không, nhìn tên query:

SELECT pid, now() - xact_start AS chay_bao_lau, query
FROM pg_stat_activity WHERE query LIKE 'autovacuum:%';

-- "autovacuum: VACUUM public.orders (to prevent wraparound)"  <-- loại không nhường lock

5.4. Chi phí con người

Cơ chế này không cần ai chạy tay hằng ngày — nhưng cần một người hiểu nó vào lúc alert nổ. Runbook phải viết sẵn: lúc DB đã từ chối ghi không phải là lúc đi đọc tài liệu. Mục 6 dưới đây viết đúng để làm việc đó.

6. Case thực tế và cách xử lý

Case A — Alert báo tuoi_xid = 1,3 tỷ, autovacuum chạy suốt mà không giảm

Đây là kịch bản đã demo ở 3.1, phóng to lên production.

  1. Xác nhận nó có chạy không — phân biệt "không chạy" với "chạy mà bị chặn":
    SELECT pid, now()-xact_start AS chay_bao_lau, query
    FROM pg_stat_activity WHERE query LIKE 'autovacuum:%';
    Thấy autovacuum: VACUUM public.orders (to prevent wraparound) → nó đang làm việc. Vấn đề không phải "nó lười".
  2. Chạy câu query 4 nguồn ở mục 3.2. Giả sử ra: repl_slot | cdc_debezium | tuoi_xid 1300000000.
  3. Xác minh trước khi xoá. Slot đó active chưa? restart_lsn có nhúc nhích trong 1 giờ qua không? Hỏi team CDC. Drop nhầm slot đang dùng = consumer mất vị trí đọc, phải resync từ đầu.
  4. SELECT pg_drop_replication_slot('cdc_debezium');
  5. Trong 1–2 phút, chu kỳ autovacuum kế tiếp sẽ hạ relfrozenxid. Theo dõi age() tụt dần. Không cần restart gì.
  6. Sau sự cố: thêm alert cho slot inactive > 1 giờ. Đây là sửa gốc — cùng một nguyên nhân còn gây đầy đĩa WAL, fix một lần được cả hai.
Đừng pg_terminate_backend cái autovacuum worker để "dọn đường". Nó quay lại sau 60 giây, và anh vừa vứt đi toàn bộ phần việc nó đã làm được.

Case B — 3 giờ sáng: DB từ chối ghi

ERROR:  database is not accepting commands that assign new transaction IDs
        to avoid wraparound data loss in database "shop"
HINT:   Execute a database-wide VACUUM in that database.
        You might also need to commit or roll back old prepared transactions,
        or drop stale replication slots.

App chết toàn bộ phần ghi. Phần đọc vẫn sống.

flowchart TD
  S["ERROR: not accepting commands"] --> N1["KHÔNG restart DB
Restart không giúp gì và xoá mất trạng thái chẩn đoán"] N1 --> N2["Kết nối vẫn vào được. Đọc vẫn chạy."] N2 --> N3["Chạy query 4 nguồn (mục 3.2)
→ gỡ hết thứ đang ghim xmin"] N3 --> N4["Tìm bảng tội đồ:
ORDER BY age(relfrozenxid) DESC LIMIT 20"] N4 --> N5["SET vacuum_cost_delay = 0;
VACUUM (FREEZE, VERBOSE) bang_to;"] N5 --> N6["Failsafe đã tự bật từ 1,6 tỷ
→ đọc log xác nhận"] N6 --> N7["datfrozenxid lùi xuống → ghi được lại NGAY
không cần restart"] N7 --> N8["Sau sự cố: REINDEX index của bảng đó
vì failsafe đã bỏ qua bước vacuum index"]

Chi tiết từng bước:

  1. Không restart. Restart không giải quyết gì, làm mất trạng thái chẩn đoán, và kéo dài downtime. DB vẫn nhận connection và vẫn cho đọc.
  2. Gỡ thứ đang ghim trước. Đây mới là bước cứu. Chạy vacuum trước khi gỡ = tốn hàng giờ IO cho một việc chắc chắn thất bại (xem 3.1).
  3. Tìm bảng tội đồ:
    SELECT relname, age(relfrozenxid)
    FROM pg_class WHERE relkind IN ('r','m','t')
    ORDER BY 2 DESC LIMIT 20;
    Nhớ để 't' — bảng TOAST có relfrozenxid riêng và cũng làm cả cluster kẹt được. Trong log demo ở 1.4 có đúng một dòng cho pg_toast.pg_toast_764981.
  4. Vacuum thẳng bảng đó, không cost delay:
    SET vacuum_cost_delay = 0;
    VACUUM (FREEZE, VERBOSE) schema.bang_to;
  5. Ghi được lại ngay khi datfrozenxid lùi xuống dưới ngưỡng — không cần restart, không cần đợi hết cả database.
  6. Single-user mode: chỉ khi cần DROP/TRUNCATE một bảng rác khổng lồ để khỏi phải vacuum nó. Doc PG 18 khuyến cáo tránh vì nó tắt luôn các chốt an toàn chống wraparound. 3 triệu XID dư ra tồn tại chính là để làm việc này.
  7. Dọn sau sự cố: failsafe đã bỏ qua vacuum index → index còn đầy entry chết. REINDEX INDEX CONCURRENTLY các index của bảng đó (xem CREATE INDEX CONCURRENTLY — REINDEX CONCURRENTLY có cùng bộ đánh đổi).

Case C — Deploy giữa trưa treo ở ALTER TABLE

  1. Migration treo, query xếp hàng sau nó, app timeout dây chuyền.
  2. SELECT * FROM pg_locks WHERE NOT granted; → thấy ALTER TABLE đang chờ.
  3. Tìm thằng giữ lock → là autovacuum: VACUUM orders (to prevent wraparound).
  4. Quyết định: kill nó thì migration chạy được ngay, nhưng 60 giây sau nó quay lại và toàn bộ công nó đã làm mất trắng. Nếu bảng lớn, đây là vòng lặp vô tận — deploy nào cũng đụng, và tuổi XID thì cứ tăng.
  5. Xử lý đúng: hoãn migration sang cửa sổ bảo trì, để vacuum chạy cho xong. Rồi hạ autovacuum_freeze_max_age riêng cho bảng đó để lần sau nó freeze sớm hơn và mỗi lần ít việc hơn.
  6. Chặn từ gốc: thêm một bước kiểm tra age(relfrozenxid) của các bảng lớn vào pipeline, ngay trước bước chạy migration — cùng chỗ đã có các kiểm tra khác trong migration trong CI/CD. Thấy bảng nào sắp chạm ngưỡng thì dừng deploy trước, thay vì để nó treo giữa chừng.

7. Câu hỏi phụ interviewer hay hỏi tiếp

"Tắt autovacuum = off thì anti-wraparound có còn chạy không?"
Còn. Doc PG 18 nói thẳng: "even when this parameter is disabled, the system will launch autovacuum processes if necessary to prevent transaction ID wraparound". Set ở mức bảng (autovacuum_enabled = false) cũng vậy — doc ghi rõ "except to prevent transaction ID wraparound".

"VACUUM và VACUUM FREEZE khác gì?"
VACUUM thường chỉ freeze dòng cũ hơn vacuum_freeze_min_age (50 triệu). VACUUM FREEZE tương đương đặt mốc đó bằng 0 → freeze mọi thứ có thể freeze, và luôn quét toàn bảng. Cả hai đều bị chặn y hệt nhau bởi xmin horizon — đã demo ở 3.1.

"Replica có bị wraparound riêng không?"
Không. Standby nhận relfrozenxid qua WAL từ primary, nó không tự vacuum. Nhưng standby gây ra wraparound cho primary qua hot_standby_feedback = on: một query báo cáo chạy 6 tiếng bên standby ghim xmin bên primary suốt 6 tiếng đó.

"Tại sao PostgreSQL chặn ghi khi còn 3 triệu XID, không chặn ở 0?"
Để admin còn chỗ thở: vào được DB và DROP/TRUNCATE bảng rác khổng lồ thay vì phải ngồi vacuum nó. Không có khoảng đệm đó thì mọi thao tác cứu đều cần ghi, mà ghi thì đã bị chặn — deadlock về mặt vận hành.

"Partition có giúp gì không?"
Có, và đây là cách tốt nhất về lâu dài. relfrozenxid là per-table, mà mỗi partition là một table riêng. Partition tháng cũ không còn ghi → freeze một lần rồi thôi, không bao giờ bị quét lại. Bảng 500 GB một cục thì lần nào tới hạn cũng phải quét cả 500 GB.

"age() vượt 2 tỷ là đã mất dữ liệu chưa?"
Chưa. PostgreSQL chặn ghi ở mốc còn 3 triệu XID, trước khi wraparound thật sự xảy ra. Mất dữ liệu chỉ xảy ra nếu chạy single-user mode — nơi các chốt an toàn bị tắt.

"Làm sao biết hệ thống đốt bao nhiêu XID mỗi ngày?"
Ghi lại age(datfrozenxid) mỗi ngày cùng giờ và lấy hiệu; hoặc SELECT pg_current_xact_id() hai thời điểm rồi trừ. Con số này quyết định toàn bộ mục 4 — không có nó thì mọi lựa chọn tham số đều là đoán.

"Có nên dùng VACUUM FULL cho wraparound không?"
Không. VACUUM FULL lấy ACCESS EXCLUSIVE lock (khoá hoàn toàn bảng), viết lại toàn bộ bảng, cần thêm chỗ trống bằng kích thước bảng. Nó có reset tuổi XID, nhưng đổi lấy downtime — trong khi VACUUM FREEZE thường làm được cùng việc mà chỉ lấy SHARE UPDATE EXCLUSIVE.


Tóm tắt: ba việc cần làm

1. Bỏ ý định cron VACUUM FREEZE — cơ chế tự động đã có, và nó bị chặn bởi đúng thứ sẽ chặn bản chạy tay của anh.
2. Alert trên age(datfrozenxid) ở 500 triệu / 1 tỷ, và cả mxid_age(datminmxid).
3. Alert trên replication slot inactive > 1 giờ, và bật log_autovacuum_min_duration = 0. Hai thứ rẻ nhất, chặn được phần lớn sự cố.