Bỏ qua để đến nội dung
Knowledge Hub

Transaction ID wraparound: vì sao PostgreSQL phải freeze tuple cũ? Nâng cao

Thời gian đọc ước tính: 8 phút

Bảng danh mục gần như không bao giờ được cập nhật, n_dead_tup = 0, nên nhóm vận hành cho rằng không cần VACUUM. Vậy mà PostgreSQL vẫn chạy autovacuum “to prevent wraparound” trên bảng đó.

Lý do: mỗi version row mang XID của transaction đã tạo nó, và XID thường chỉ có 32-bit.

Sau đủ nhiều transaction bộ đếm quay vòng; một tuple quá cũ vẫn được so sánh như XID thường sẽ bị hiểu nhầm là đến từ “tương lai” và trở nên vô hình.

PostgreSQL chống lại bằng cách freeze tuple đủ cũ. Đây là bảo trì an toàn dữ liệu, không phải tối ưu hiệu năng.

Bài chỉ có câu lệnh đọc trạng thái. Không mô phỏng hàng tỷ transaction, không hạ ngưỡng bảo vệ, không tắt autovacuum.

age() đếm số XID đã trôi qua, không phải thời gian. Các mốc dưới đây là mặc định của PostgreSQL 17 (đều từ tài liệu chính thức), đọc từ trên xuống khi tuổi tăng:

Tuổi XID (age) Mốc Điều xảy ra
0 Vừa freeze xong Không có gì phải làm
50 triệu vacuum_freeze_min_age Khi VACUUM chạy, freeze các tuple cũ hơn mốc này
150 triệu vacuum_freeze_table_age VACUUM tiếp theo chuyển sang quét aggressive để đẩy relfrozenxid lên
200 triệu autovacuum_freeze_max_age Autovacuum chống wraparound bắt đầu cho bảng đó, kể cả bảng không có dead tuple
1,6 tỷ vacuum_failsafe_age Failsafe: VACUUM bỏ cost-based delay và bỏ index vacuuming để chạy nhanh nhất có thể
Còn 40 triệu XID (cách mốc quay vòng, tức tuổi khoảng 2,107 tỷ) Server bắt đầu ghi WARNING ... must be vacuumed within N transactions
Còn 3 triệu XID (tuổi khoảng 2,144 tỷ) Server từ chối cấp XID mới: mọi lệnh ghi dừng

Giới hạn tuyệt đối là 2^31 = 2.147.483.648, tức khoảng hai tỷ XID đang “ở quá khứ”; nửa vòng còn lại coi là tương lai. Lưu ý các mốc cấu hình bị chặn bởi nhau: ví dụ vacuum_freeze_min_age hiệu lực tối đa bằng một nửa autovacuum_freeze_max_age.

Multixact (dùng khi nhiều transaction cùng khoá một row, xem row-level locks) có bộ đếm riêng và mốc riêng: autovacuum_multixact_freeze_max_age mặc định 400 triệu, vacuum_multixact_freeze_table_age 150 triệu, failsafe 1,6 tỷ. Cảnh báo ở 40 triệu và từ chối ở 3 triệu còn lại áp dụng tương tự. Hệ thống nhiều foreign key và FOR SHARE cần theo dõi cả mxid_age().

  • Freeze đánh dấu một tuple đủ cũ là luôn nhìn thấy, thay vì dựa vào so sánh XID. Từ PostgreSQL 9.4 đó là một bit trạng thái; xmin hiển thị có thể vẫn giữ nguyên số cũ.
  • pg_class.relfrozenxid là mốc XID cũ nhất có thể chưa freeze trong một relation; pg_database.datfrozenxid là mốc thấp nhất của cả database. Đó là mốc bảo thủ do VACUUM duy trì, không thể thay bằng MIN(xmin); đừng quét xmin toàn bảng để điều tra.
  • Bảng chỉ insert vẫn cần aggressive VACUUM vì tuple cũ còn mang XID cũ. PostgreSQL 18 cho VACUUM thường freeze sớm một phần page all-visible (eager freezing, điều chỉnh bằng vacuum_max_eager_freeze_failure_rate), giúp giảm bớt đợt freeze lớn, nhưng không bỏ nhu cầu freeze.
  • XID đang dùng là 32-bit trên PostgreSQL 17.
Tuổi XID tăng; VACUUM freeze trước khi bộ đếm vòng lại
Tuổi XID tăng; VACUUM freeze trước khi bộ đếm vòng lạiBước 1/4: Mỗi transaction cần ID nhận một XID trong không gian 32-bit. ID không tăng vô hạn trong trường 32-bit.XID 32-bitTuple cũFreeze
XID 32-bit
Bước 1/4

Mỗi transaction cần ID nhận một XID trong không gian 32-bit. ID không tăng vô hạn trong trường 32-bit.

  1. Transaction cần XID nhận một số trong không gian 32-bit.
  2. Tuple tạo ra mang XID đó; khi transaction mới xuất hiện, age(xmin) của nó tăng.
  3. VACUUM freeze tuple đủ điều kiện, visibility không còn phụ thuộc phép so sánh XID thường.
  4. Nếu bảng không được freeze kịp, autovacuum chống wraparound khởi động dù không có dead tuple; nếu vẫn thất bại, server cảnh báo rồi chặn lệnh cần XID mới.
SELECT datname,
age(datfrozenxid) AS xid_age,
2147483648 - age(datfrozenxid) AS xid_remaining
FROM pg_database
ORDER BY xid_age DESC;

Đặt alert theo xid_age của từng database, và thêm age(relfrozenxid) lớn nhất trong từng database:

Mức Gợi ý ngưỡng (với mặc định 200M) Hành động
Quan sát Trên 200 triệu và đang giảm sau mỗi đợt autovacuum Bình thường: autovacuum đang làm việc
Cảnh báo Trên 500 triệu, hoặc không giảm sau một đợt autovacuum Tìm thứ đang giữ horizon (bên dưới)
Page người trực Trên 1 tỷ Cách failsafe (1,6 tỷ) chưa xa; xử lý ngay trong ngày

Các ngưỡng 500 triệu và 1 tỷ là gợi ý vận hành của bài này, không phải số của PostgreSQL. Đo thêm tốc độ tiêu thụ XID (lấy mẫu age(datfrozenxid) mỗi vài phút) để quy ra “còn bao nhiêu ngày”: tuổi 10 triệu có thể là nhiều tháng ở hệ ít ghi hoặc vài giờ ở hệ ghi dày.

Script trong repo in các ngưỡng cấu hình, tuổi từng database và 10 relation già nhất (có nối TOAST):

Terminal window
docker compose -f examples/docker-compose.yml up -d postgres
docker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-wraparound/check-xid-age.sql

Khi tuổi không giảm: tìm thứ giữ horizon

Phần tiêu đề “Khi tuổi không giảm: tìm thứ giữ horizon”

Transaction mở lâu, prepared transaction, replication slot cũ (và replica bật hot_standby_feedback) giữ chân trời XID, nên VACUUM chạy xong tuổi vẫn không giảm. Tăng ngưỡng cấu hình chỉ dời cảnh báo. Bắt đầu bằng truy vấn quan sát:

SELECT pid, state, xact_start, age(backend_xid) AS xid_age,
age(backend_xmin) AS xmin_age, query
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL OR backend_xmin IS NOT NULL
ORDER BY xact_start NULLS LAST;
SELECT gid, prepared, age(transaction) AS xid_age
FROM pg_prepared_xacts ORDER BY prepared;
SELECT slot_name, active, age(xmin) AS xmin_age,
age(catalog_xmin) AS catalog_xmin_age
FROM pg_replication_slots;

Các cột XID có thể NULL, age(NULL) cũng NULL: không phải lỗi. Xoá slot có thể buộc replica dựng lại, hãy xác nhận với người vận hành replica.

Nếu server đã phát cảnh báo wraparound, xử lý nguồn giữ horizon rồi chạy VACUUM trên đúng database với quyền đủ xử lý cả system catalogs, theo hướng dẫn của đúng phiên bản. VACUUM FULL không phải cứu hộ: nó rewrite bảng, lấy lock mạnh và bản thân có thể cần XID.

  1. Alert age(datfrozenxid) theo database: cảnh báo 500M, page 1B (và mxid_age() tương ứng).
  2. Giám sát age(relfrozenxid) lớn nhất theo bảng, nhớ cả TOAST.
  3. Ghi tốc độ tiêu thụ XID mỗi ngày để biết “còn bao lâu” thay vì chỉ con số tuyệt đối.
  4. Định kỳ rà prepared transaction, replication slot không hoạt động, session idle in transaction.
  5. Bảng khổng lồ insert-only: lên lịch VACUUM (FREEZE) ngoài giờ cao điểm thay vì chờ autovacuum aggressive.
  6. Không tắt autovacuum, không dùng VACUUM FULL để cứu hộ.
  7. Có runbook cho trường hợp đã có WARNING: ai được phép dừng session, xoá slot, chạy VACUUM.

Freeze đúng nhịp

  • Row sống lâu hơn vòng đời một XID 32-bit mà vẫn đúng visibility.
  • Autovacuum chống wraparound tạo khoảng đệm lớn trước giới hạn thật.
  • age(relfrozenxid) và age(datfrozenxid) giúp phát hiện xu hướng bằng truy vấn chỉ đọc.

Chi phí và rủi ro vận hành

  • Aggressive VACUUM quét nhiều page, tốn I/O hơn VACUUM thường.
  • Transaction, slot hoặc prepared transaction giữ horizon làm freeze không tiến.
  • Tăng ngưỡng hoặc tắt autovacuum chỉ trì hoãn và giảm thời gian phản ứng.

Tự kiểm tra: (1) Vì sao bảng chỉ insert vẫn cần aggressive VACUUM? (2) age(datfrozenxid) = 10 triệu có cho biết còn bao nhiêu ngày an toàn không? (3) Failsafe ở 1,6 tỷ thay đổi hành vi VACUUM thế nào?