Skip to content

事务 ID 与快照(XID and Snapshot) ​

上一节提到「每个事务会拿到一个快照」,但快照里到底装了什么?为什么它能让不同事务看到不同数据?这一切的基石是事务 ID(XID)——一个 32 位无符号整数。它既要标识事务,又要参与可见性判断,还要面对「用完之后会回卷」这个结构性难题。本节把 XID 的位域结构、分配规则、快照的四个字段、以及 CLOG 如何配合判断事务状态讲透。

前置知识

阅读本节前,建议先了解:MVCC 概述

基础概念 ​

事务 ID(XID, Transaction ID) 是 PostgreSQL 分配给每个写事务的唯一标识,它是一个 32 位无符号整数(范围 0 ~ 4,294,967,295)。它同时承担三种职责:

text
① 标识身份:写在元组的 xmin / xmax 里,说明"谁创建了/删除了这个版本"
② 判断可见性:配合快照判断一个版本对当前事务是否可见
③ 提供顺序:XID 按大小单调递增,大小关系就是"先后关系"

理解 XID 与快照需要先统一以下术语:

术语英文说明
事务 IDXID, Transaction ID32 位无符号整数,仅写事务才会分配
虚拟事务 IDVXID, Virtual XID格式为 backendID/localXID,只读事务用它标识自己,不消耗真实 XID
当前 XIDCurrent XID事务内第一次写操作时才分配,pg_current_xact_id() 可查
快照Snapshot记录"此刻哪些事务在运行"的数据结构
xmin(快照字段)Snapshot xmin所有活跃事务中最小的 XID,也称为水位线
xmax(快照字段)Snapshot xmax下一个将分配的 XID,大于等于它的都还没开始
xip_listIn-Progress List快照时刻正在运行的事务 ID 列表
提交日志CLOG / pg_xact记录每个 XID 的最终状态(提交/回滚)
事务年龄Age当前XID - XID,用无符号 32 位差值计算,衡量"多老"
回卷WraparoundXID 用尽后从 0 重新开始,会让"很老"的事务看起来"很新"
冻结Freeze把元组的 xmin 替换为特殊值,使其永不回卷

真实 XID 与虚拟 XID ​

一个常见误解是「每个事务都会分配 XID」。事实是:

sql
-- 只读事务:不分配真实 XID
BEGIN;
SELECT pg_current_xact_id_if_assigned() AS real_xid,
       pg_backend_pid() AS pid,
       txid_current_if_assigned() AS txid_alias;
text
 real_xid |  pid  | txid_alias
----------+-------+------------
          | 21043 |
sql
-- 执行一次写操作后,才真正分配 XID
UPDATE mvcc_demo SET note = 'v3' WHERE id = 1;
SELECT pg_current_xact_id_if_assigned() AS real_xid;
text
 real_xid
----------
      756
类型何时分配是否消耗 XID 空间如何查看
只读事务永不分配真实 XID✗ 不消耗pg_stat_activity.backend_xid
读写事务第一次写操作(INSERT/UPDATE/DELETE/SELECT FOR UPDATE)✔ 消耗一个pg_current_xact_id()
子事务SAVEPOINT 时分配✔ 消耗(比想象中快得多)pg_current_xact_id()

只读事务不消耗 XID 是重要的性能设计

一个纯报表系统无论跑多少查询,都不会推动 XID 消耗,也就不会加速回卷进程。但一旦查询里出现 FOR UPDATE、SELECT ... FOR SHARE,或者调用了会写表的函数(如 nextval()),就会转成写事务并消耗 XID。

XID 的比较规则 ​

XID 是 32 位整数,不能用普通的大小比较来判断先后,因为会有回卷。PostgreSQL 用「模 2³¹ 半区」规则:

text
假设当前 XID = 100,那么:
  · XID = 50    → 更早(在 2^31 范围内向后看,50 是"过去")
  · XID = 150   → 更晚(在 2^31 范围内向前看,150 是"未来")
  · XID = 3000000000 → 更早(因为 3000000000 - 100 > 2^31)

判定公式:若 (XID1 - XID2) mod 2^32 < 2^31,则 XID1 在 XID2 之后
PostgreSQL 内部用 TransactionIdPrecedes() / TransactionIdFollows() 实现

这就是为什么元组头上会有 HEAP_XMIN_FROZEN 之类的标志位——必须把「非常老」的 XID 转换成不受回卷影响的特殊标记。

特殊 XID 值 ​

PostgreSQL 预留了三个特殊 XID,理解它们才能读懂 xmin 的异常值:

特殊值常量名含义
0InvalidTransactionId无效事务,xmax = 0 表示"此版本未被删除"
1BootstrapTransactionId系统初始化时使用,正常数据中不会出现
2FrozenTransactionId冻结事务,表示"此版本永远可见,无需检查 CLOG"
sql
-- 冻结后的元组,xmin 会显示为 2
SELECT xmin, xmax, ctid, * FROM frozen_demo;
text
 xmin | xmax | ctid  | id | note
------+------+-------+----+-------
    2 |    0 | (0,1) |  1 | old

核心机制 ​

XID 的分配与生命周期 ​

text
                      事务开始(BEGIN)
                            │
                            ▼
                    拿到 VXID(backendID/localXID)
                    仅用于标识会话,不消耗真实 XID
                            │
                    ┌───────┴────────┐
                    │ 只读?          │
                    └───────┬────────┘
                     是 │        │ 否(有写操作)
                        ▼        ▼
                  永不分配 XID   调用 GetNewTransactionId()
                  事务结束即释放   从 shared nextXid 取一个,并 +1
                                        │
                                        ▼
                                  写元组头 xmin/xmax
                                        │
                                        ▼
                                  提交/回滚时写 CLOG
                                  (pg_xact 里 2 bit 状态)
                                        │
                                        ▼
                                  状态持久化,供其他事务查询

关键点:XID 的分配是全局串行的,由一个共享变量 nextXid 递增。这是 PostgreSQL 在高并发写入下仍需要一个原子操作的原因之一。

sql
-- 从控制文件信息中读下一次将被分配的 XID
SELECT pg_control_checkpoint();
text
-[ RECORD 1 ]--------+-------------------------
checkpoint_lsn       | 0/1A2B4C8
redo_lsn             | 0/1800208
...
next_xid             | 0:757

next_xid 就是「下一个将被分配的 XID」,格式 epoch:low32。

快照的四个字段 ​

快照是可见性判断的全部依据。它的内容可以从 pg_current_snapshot() 直接看到:

sql
-- 查看当前快照(格式:xmin:xmax:xip_list)
SELECT pg_current_snapshot() AS snapshot;
text
     snapshot
------------------
 745:760:746,748

含义拆解:

字段值含义
xmin745最小的活跃 XID。小于 745 的事务都已结束(提交或回滚),是最关键的"水位线"
xmax760下一个将分配的 XID。大于等于 760 的事务都还没开始
xip_list746,748在 [745, 760) 区间内、此刻仍在运行的事务 ID

由此推出可见性判断的核心逻辑:

text
对一个元组的 xmin(创建者):
  · xmin <  snapshot.xmin  → 创建者已结束 → 查 CLOG 判断提交/回滚
  · xmin >= snapshot.xmax  → 创建者在快照之后才开始 → 不可见
  · xmin 在 xip_list 中     → 创建者当时还在运行 → 不可见
  · 其他                    → 创建者当时已提交 → 可见

对一个元组的 xmax(删除者):
  · xmax = 0               → 未被删除 → 可见
  · xmax <  snapshot.xmin  → 删除者已结束 → 查 CLOG
  · xmax >= snapshot.xmax  → 删除者在快照之后才开始 → 还没删 → 可见
  · xmax 在 xip_list 中     → 删除者当时还在运行 → 还没删 → 可见
  · 其他(删除者已提交)    → 已删 → 不可见

用更直观的图示:

text
XID 轴(快照时刻)→
  ...  742  743  744 │ 745      746   747   748  749 │ 750 ... 759 │ 760 ...
      └── 已结束 ────┘└─ 活跃 ─┘              └ 活跃 ┘└─ 活跃 ───┘└ 未开始 ┘
                          ↑                     ↑
                      在 xip_list 中         在 xip_list 中
        (xmin=745)                                            (xmax=760)

  744 之前的:一定已结束,查 CLOG
  745 ~ 759 之间且不在 xip_list:一定已提交
  745 ~ 759 之间且在 xip_list:仍在运行,其写入不可见
  760 及之后:还没开始,其写入不可见

快照的三种获取时机 ​

「什么时候取快照」直接决定隔离级别:

隔离级别快照获取时机效果
已提交读(Read Committed)每条语句开始时同一事务内不同语句可能看到不同数据
可重复读(Repeatable Read)事务开始时,仅取一次整个事务看到一致的数据视图
可串行化(Serializable)事务开始时取,并用 SSI 检测冲突结果等价于某种串行执行顺序
sql
-- 查看当前事务的隔离级别与快照
SELECT current_setting('transaction_isolation') AS isolation,
       pg_current_snapshot() AS snapshot,
       pg_snapshot_xmin(pg_current_snapshot()) AS snap_xmin,
       pg_snapshot_xmax(pg_current_snapshot()) AS snap_xmax,
       pg_snapshot_xip(pg_current_snapshot())  AS xip_list;
text
 isolation     |    snapshot     | snap_xmin | snap_xmax | xip_list
---------------+-----------------+-----------+-----------+----------
 read committed| 745:760:746,748 |       745 |       760 | {746,748}
sql
-- 延迟获取快照,用于"看到更晚提交的数据"(PG13+ 支持)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 先做别的事
SELECT pg_sleep(1);
-- 直到这里才真正取快照
SELECT pg_snapshot_xmax(pg_current_snapshot()) AS snap_xmax;

快照与长事务的耦合 ​

快照一旦被取,就会把水位线钉住。这是长事务导致膨胀的根本机制:

text
会话 A:BEGIN; SELECT ...;    ← t0 取快照,xmin = 500(水位线钉在 500)
会话 B:持续 INSERT/UPDATE   ← t1..t9 产生大量死元组(XID 501..900)

在 PostgreSQL 全局视角:
  · 仍有一个活跃快照的 xmin = 500
  · 所有 XID > 500 的死元组都"可能"被会话 A 看到
  · 因此 VACUUM 不能回收 XID 501..900 的任何死元组
  · oldest xmin 卡住不前进 → 表持续膨胀,直到会话 A 结束

会话 A:COMMIT;   ← 水位线立刻跳到 901,VACUUM 终于可以工作
sql
-- 从两个视角观察水位线:全局最老快照 vs 当前会话快照
SELECT backend_xmin, state, left(query, 40) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY backend_xmin
LIMIT 5;
text
 backend_xmin |        state        |                 query
--------------+---------------------+----------------------------------------
          500 | idle in transaction | SELECT * FROM reports;
          897 | active              | UPDATE users SET note = 'x' WHERE id = 1;

backend_xmin = 500 这一行就是拖住回收的元凶。

用 pg_snapshot 类型做比较 ​

PostgreSQL 提供了 pg_snapshot 数据类型与一组操作函数,可以先取快照、隔一段时间再用同一个快照判断可见性:

sql
-- 取两次快照并对比,直观看到"活跃事务变化"
SELECT pg_current_snapshot() AS snapshot_now;

SELECT pg_sleep(2);

SELECT pg_current_snapshot() AS snapshot_later;
text
 snapshot_now
-----------------
 745:760:746,748

 snapshot_later
-----------------
 749:761:750
sql
-- 快照之间的差异比较(PG13+ 提供函数)
SELECT pg_snapshot_xmin(pg_current_snapshot()) AS xmin_now,
       pg_snapshot_xip(pg_current_snapshot())  AS xip_now;

相关参数 ​

参数默认值生效方式说明
autovacuum_freeze_max_age200000000重启单表 relfrozenxid 年龄超过此值即强制 autovacuum 冻结
vacuum_freeze_min_age50000000user元组 xmin 年龄超过此值才愿意冻结它
vacuum_freeze_table_age150000000user表 relfrozenxid 年龄超过此值就做全表扫描式 VACUUM
vacuum_multixact_freeze_min_age5000000user多事务 ID 的冻结下限
vacuum_multixact_freeze_table_age150000000user多事务 ID 触发全表扫描的阈值
track_commit_timestampoff重启记录 XID 提交时间,可用 pg_xact_commit_timestamp() 查询
old_snapshot_threshold-1重启快照可"过期"的时间,过期后旧版本可被回收(有读到旧数据失败的风险)
idle_in_transaction_session_timeout0user空闲事务超时断开,直接保护水位线
transaction_timeout0user事务总时长上限(PG17+ 引入)
ini
# postgresql.conf
# 冻结节奏:默认值对多数场景够用,写入量极大的库可适度下调
vacuum_freeze_min_age = 50000000        # 单位是「事务个数」,不是容量
vacuum_freeze_table_age = 150000000
autovacuum_freeze_max_age = 200000000

# 保护水位线:任何事务不得超过 30 分钟
idle_in_transaction_session_timeout = 30min
statement_timeout = 60s
sql
-- 表级覆盖冻结相关参数(只影响这一张表)
ALTER TABLE users SET (
  autovacuum_freeze_max_age = 150000000,
  autovacuum_freeze_min_age = 10000000
);

详细说明 ​

观察 XID 分配的速率 ​

XID 消耗速率决定了「回卷」的风险大小。可以先采样,再推算:

sql
-- 采样 1:记录当前 XID 与时间
SELECT pg_current_xact_id() AS xid_t1,
       clock_timestamp() AS ts_t1;
text
 xid_t1 |             ts_t1
--------+-------------------------------
    760 | 2026-09-20 14:00:00.001+08
sql
-- 等一段时间后采样 2(示例中隔了 60 秒)
SELECT pg_current_xact_id() AS xid_t2,
       clock_timestamp() AS ts_t2,
       pg_current_xact_id() - 760 AS xid_consumed;
text
 xid_t2 |             ts_t2              | xid_consumed
--------+--------------------------------+--------------
    982 | 2026-09-20 14:01:00.012+08     |          222

换算:60 秒消耗 222 个 XID ≈ 3.7 XID/秒。按这个速率,42 亿个 XID 可用约:

text
4294967296 ÷ 3.7 ≈ 1,160,800,000 秒 ≈ 36.8 年

但要注意:XID 消耗速率不是线性的。批量导入、批量更新、大量 SAVEPOINT 都会让它飙升千百倍。

sql
-- 用 pg_stat_database 的 xact_commit + xact_rollback 估算事务吞吐
SELECT datname,
       xact_commit,
       xact_rollback,
       xact_commit + xact_rollback AS total_xacts,
       stats_reset
FROM pg_stat_database
WHERE datname = 'demo';
text
 datname | xact_commit | xact_rollback | total_xacts |         stats_reset
---------+-------------+---------------+-------------+-------------------------------
 demo    |    12830411 |         41022 |    12871433 | 2026-09-01 00:00:00.124511+08

数据库级别的回卷距离监控 ​

这是必须纳入监控的最关键指标。age(datfrozenxid) 表示「这个库最老的未冻结 XID 距今多久」:

sql
-- 每个数据库的回卷距离(按年龄降序,最危险的在最前)
SELECT datname,
       age(datfrozenxid) AS xid_age,
       mxid_age(datfrozenxid) AS mxid_age,
       round(100.0 * age(datfrozenxid) / 2000000000, 2) AS pct_to_wraparound,
       pg_size_pretty(pg_database_size(datname)) AS db_size
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
text
  datname  |  xid_age  | mxid_age | pct_to_wraparound | db_size
-----------+-----------+----------+-------------------+---------
 demo      | 184231002 | 12043012 |              9.21 | 46 GB
 postgres  |    982104 |    10231 |              0.05 | 8129 kB

判断标准:

age(datfrozenxid)状态应采取的行动
< 1 亿健康正常监控
1 亿 ~ 1.5 亿关注检查 autovacuum 是否跟得上
1.5 亿 ~ 2 亿警戒vacuum_freeze_table_age 会触发全表冻结扫描
> 2 亿紧急autovacuum 会强制介入,此时无法再调参绕过
> 15 亿危险表级强制冻结已自动启动,需立刻排查长事务与 autovacuum
接近 20 亿致命实例拒绝新写入,只能停业务做 VACUUM FREEZE
sql
-- 找出库内年龄最大的表(决定该库 datfrozenxid 的就是它)
SELECT n.nspname AS schema_name,
       c.relname AS table_name,
       age(c.relfrozenxid) AS xid_age,
       c.relfrozenxid,
       pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 5;
text
 schema_name | table_name |  xid_age  | relfrozenxid | total_size
-------------+------------+-----------+--------------+------------
 public      | events     | 184230991 |    412349812 | 128 GB
 public      | audit_log  |  98231102 |    498342701 | 24 GB
 public      | users      |   4102334 |    592468769 | 2736 MB

events 表年龄 1.84 亿,占库的绝大部分——只要处理它,库的 datfrozenxid 就能大幅前进。

多事务 ID(MultiXact)与 mxid ​

除了 xmin/xmax,PG 还有一套并行的 ID 体系叫 MultiXact。它用于「多个事务同时锁定同一行」的场景(如多个会话都持有 FOR SHARE 锁):

text
单事务删除/更新 → xmax 直接存一个 XID
多事务共享锁     → xmax 存一个 MultiXact ID,指向 pg_multixact 里的成员列表

MultiXact 也有自己的回卷问题,且空间更容易耗尽(pg_multixact/offsets 只有 32 位空间,约 20 亿但实际更紧张):

sql
-- 查看 MultiXact 相关年龄
SELECT datname, mxid_age(datfrozenxid) AS mxid_age
FROM pg_database
ORDER BY mxid_age(datfrozenxid) DESC
LIMIT 3;
text
  datname  | mxid_age
-----------+----------
 demo      | 12043012
 postgres  |    10231
sql
-- 找出 MultiXact 年龄最大的表
SELECT c.relname,
       age(c.relminmxid) AS mxid_age,
       c.relminmxid
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY age(c.relminmxid) DESC
LIMIT 3;
text
  relname   | mxid_age | relminmxid
------------+----------+------------
 orders     | 12043012 |     412349
 users      |  2034112 |    1002321

冻结如何打破回卷限制 ​

冻结的本质是把 xmin 改写成特殊值 2(FrozenTransactionId),并设置 HEAP_XMIN_FROZEN 标志位:

text
冻结前:
  [ xmin=500  xmax=0  data... ]   → 判断可见性要查 CLOG(500)
  随着 XID 前进到 20 亿,500 这个值会因回卷变得"看起来像未来"

冻结后:
  [ xmin=2    xmax=0  data... ]   → 特殊值,永远可见,不需要查 CLOG
  不受回卷影响 ✔
sql
-- 观察冻结前后的 xmin 变化
CREATE TABLE freeze_demo (id int, note text);
INSERT INTO freeze_demo VALUES (1, 'frozen candidate');

-- 冻结前
SELECT xmin, xmax, age(xmin) AS xmin_age, * FROM freeze_demo;
text
 xmin | xmax | xmin_age | id | note
------+------+----------+----+------------------
  762 |    0 |        0 |  1 | frozen candidate
sql
-- 强制执行冻结(PG9.4+ 使用 VACUUM FREEZE 替代旧的 vacuum_freeze)
VACUUM (FREEZE, VERBOSE) freeze_demo;
text
INFO:  vacuuming "public.freeze_demo"
INFO:  "freeze_demo": found 0 removable, 1 nonremovable row versions in 1 out of 1 pages
DETAIL:  0 dead row versions cannot be removed yet.
sql
-- 冻结后:xmin 变成 2,且年龄变成 2147483647 - 2 之类的特殊值
SELECT xmin, xmax, age(xmin) AS xmin_age, * FROM freeze_demo;
text
 xmin | xmax |  xmin_age   | id | note
------+------+-------------+----+------------------
    2 |    0 |  2147483647 |  1 | frozen candidate
sql
-- 更精确的判断:查看元组的冻结标志位
SELECT lp, t_xmin, t_infomask,
       (t_infomask & 512)  <> 0 AS has_xmin_frozen,   -- HEAP_XMIN_FROZEN 位
       t_data IS NOT NULL AS valid
FROM heap_page_items(get_raw_page('freeze_demo', 0))
ORDER BY lp;
text
 lp | t_xmin | t_infomask | has_xmin_frozen | valid
----+--------+------------+-----------------+-------
  1 |      2 |       1282 | t               | t

has_xmin_frozen = t 就是证明:这个元组已经不参与回卷计算了。

实战示例 ​

示例 1:完整观测一次「快照如何决定可见性」 ​

这个示例用两个会话手工构造出快照的四个字段,然后验证可见性判断规则。

准备:

sql
-- 会话 1:建表并插入初始数据
DROP TABLE IF EXISTS snap_demo;
CREATE TABLE snap_demo (id int PRIMARY KEY, note text);
INSERT INTO snap_demo VALUES (1, 'initial');
text
INSERT 0 1

会话 2 取快照(在更新之前):

sql
-- 会话 2:开始可重复读事务,钉住快照
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

SELECT pg_current_snapshot() AS snap,
       pg_snapshot_xmin(pg_current_snapshot()) AS snap_xmin,
       pg_snapshot_xmax(pg_current_snapshot()) AS snap_xmax;
text
     snap       | snap_xmin | snap_xmax
----------------+-----------+-----------
 763:765:764    |       763 |       765

会话 1 执行更新并提交:

sql
-- 会话 1:更新数据(分配新 XID = 764,正好在会话 2 的 xip_list 里)
UPDATE snap_demo SET note = 'updated' WHERE id = 1;
SELECT pg_current_xact_id() AS writer_xid;
text
 writer_xid
------------
        764
sql
-- 会话 1:提交
COMMIT;

会话 2 验证可见性:

sql
-- 会话 2:仍然看到旧值(因为 764 在快照的 xip_list 中)
SELECT xmin, xmax, ctid, note FROM snap_demo WHERE id = 1;
text
 xmin | xmax | ctid  |  note
------+------+-------+---------
  763 |  764 | (0,1) | initial

结果解读:

  • 快照是 763:765:764,其中 xip_list = {764}——764 号事务在取快照时仍在运行。
  • 更新者 XID 恰好是 764,所以对新版本(xmin=764)判断为「创建者当时在运行」→ 不可见。
  • 对旧版本(xmin=763, xmax=764)判断为「删除者当时在运行」→ 未删,仍可见。
  • 因此会话 2 看到 initial,且 xmax 已变成 764——它知道"有人要删我,但那个人还没提交完"。
  • 会话 2 COMMIT 后再查询,xip_list 中不再有 764,新版本变为可见。
sql
-- 会话 2:提交后重新查询
COMMIT;
SELECT xmin, xmax, ctid, note FROM snap_demo WHERE id = 1;
text
 xmin | xmax | ctid  |  note
------+------+-------+---------
  764 |    0 | (0,2) | updated

示例 2:定位并解决回卷风险 ​

这是一个真实运维场景的复现:某库的 age(datfrozenxid) 持续上涨,需要找到原因并处理。

第一步:确认风险等级

sql
-- 1) 查看所有库的回卷距离(按危险度排序)
SELECT datname,
       age(datfrozenxid) AS xid_age,
       age(datfrozenxid)::numeric / 2000000000 * 100 AS pct_used,
       pg_size_pretty(pg_database_size(datname)) AS db_size
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
text
  datname  |  xid_age  |      pct_used      | db_size
-----------+-----------+--------------------+---------
 demo      | 158233401 | 7.9116700500000000 | 46 GB
 postgres  |    312004 |  0.0156002000000000 | 8129 kB

第二步:找出责任表

sql
-- 2) 定位库内年龄最大的表
SELECT n.nspname AS schema_name,
       c.relname  AS table_name,
       age(c.relfrozenxid) AS xid_age,
       pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
       c.relfrozenxid
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 5;
text
 schema_name | table_name  |  xid_age  | total_size | relfrozenxid
-------------+-------------+-----------+------------+--------------
 public      | events      | 158233311 | 128 GB     |    412349812
 public      | audit_log   |  52110233 | 24 GB      |    498342701
 public      | users       |   9012334 | 2736 MB    |    592468769

第三步:排查为什么冻结没推进

sql
-- 3) 查长事务(冻结无法推进的第一原因)
SELECT pid, state,
       now() - xact_start AS xact_age,
       backend_xmin,
       left(query, 50) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
  AND now() - xact_start > interval '1 hour'
ORDER BY backend_xmin;
text
  pid  |        state        |   xact_age    | backend_xmin |       query
-------+---------------------+---------------+--------------+--------------------
 41002 | idle in transaction | 05:12:44.102  |    412349810 | SELECT count(*) FROM events;
sql
-- 4) 查 autovacuum 是否被禁用或调得太保守
SELECT name, setting, unit, source
FROM pg_settings
WHERE name IN ('autovacuum', 'autovacuum_freeze_max_age',
               'vacuum_freeze_table_age', 'vacuum_freeze_min_age',
               'autovacuum_max_workers', 'autovacuum_naptime');
text
                name                | setting   | unit |   source
------------------------------------+-----------+------+-------------
 autovacuum                         | on        |      | default
 autovacuum_freeze_max_age          | 200000000 |      | default
 autovacuum_max_workers             | 3         |      | default
 autovacuum_naptime                 | 60        | s    | default
 vacuum_freeze_min_age              | 50000000  |      | default
 vacuum_freeze_table_age            | 150000000 |      | default
sql
-- 5) 查该表的 autovacuum 历史(是否跑过、跑了多久)
SELECT relname, n_dead_tup, last_autovacuum, last_vacuum,
       autovacuum_count, vacuum_count
FROM pg_stat_user_tables
WHERE relname = 'events';
text
 relname | n_dead_tup | last_autovacuum | last_vacuum | autovacuum_count | vacuum_count
---------+------------+-----------------+-------------+------------------+--------------
 events  |     891234 | 2026-09-19 03:12 |             |               14 |            0

第四步:处理

sql
-- 6) 终止卡住水位线的长事务(确认无业务影响后再执行)
SELECT pg_terminate_backend(41002);

-- 7) 对大表单独放宽冻结节奏,让全表扫描式 VACUUM 更快完成
ALTER TABLE events SET (
  autovacuum_freeze_max_age = 300000000,   -- 降低触发频率(但要评估风险)
  autovacuum_vacuum_cost_limit = 2000,     -- 提高每次工作配额,跑得更快
  autovacuum_vacuum_cost_delay = 0         -- 不主动让路,尽快完成
);

-- 8) 手工执行一次冻结 VACUUM(128GB 表,耗时较长,建议在低峰期)
VACUUM (FREEZE, VERBOSE, ANALYZE) events;
text
INFO:  vacuuming "public.events"
INFO:  aggressively vacuuming "public.events"
INFO:  launched 3 parallel workers
INFO:  "events": found 891234 removable, 48230112 nonremovable row versions in 16777216 out of 16777216 pages
DETAIL:  0 dead row versions cannot be removed yet, oldest xmin: 570112045
INFO:  "events": truncated 0 pages, created 0 pages
INFO:  analyzing "public.events"
INFO:  "events": scanned 30000 of 16777216 pages, containing 48230112 live rows and 0 dead rows
sql
-- 9) 验证年龄是否降下来
SELECT relname, age(relfrozenxid) AS xid_age, relfrozenxid
FROM pg_class
WHERE relname = 'events';
text
 relname | xid_age | relfrozenxid
---------+---------+--------------
 events  |   10234 |    570112045
text
 -- 库级别同步下降
  datname  |  xid_age  |      pct_used
-----------+-----------+--------------------
 demo      |    230114 | 0.0115057000000000

结果解读:

  • age(relfrozenxid) 从 158233311 降到 10234,库级从 1.58 亿 降到 23 万——回卷风险解除。
  • aggressively vacuuming 是「强制冻结模式」的标志,它会扫描所有页而非只扫有死元组的页,因此慢得多。
  • 耗时的主要瓶颈是 I/O 与长事务;autovacuum_vacuum_cost_delay = 0 让 autovacuum 不再主动让路,能显著缩短时间,但会增加 I/O 压力。
  • 最关键的是第 6 步:不杀掉那个挂了 5 小时的空闲事务,任何 VACUUM 都无法把年龄降下来——因为 oldest xmin 卡在 412349810。
  • 后续应把「最老事务年龄」纳入监控告警,而不是等年龄涨到 1.5 亿才发现。

示例 3:用快照函数做「同一时刻的一致读」 ​

pg_snapshot 类型可以保存并传递,用于实现跨查询的一致视图:

sql
-- 1) 先捕获一个快照,保存到变量里
CREATE TEMP TABLE snap_holder AS
SELECT pg_current_snapshot() AS s;

SELECT pg_snapshot_xmin(s) AS snap_xmin,
       pg_snapshot_xmax(s) AS snap_xmax,
       pg_snapshot_xip(s)  AS xip
FROM snap_holder;
text
 snap_xmin | snap_xmax |   xip
-----------+-----------+---------
    570112 |    570120 | {570115}
sql
-- 2) 用 pg_visible_in_snapshot 判断某个 XID 在快照中是否可见
SELECT pg_visible_in_snapshot(570112::text::xid8, s) AS xid_in_snapshot
FROM snap_holder;
text
 xid_in_snapshot
-----------------
 t
sql
-- 3) 把快照与当前快照做对比,找出"新提交的事务"
SELECT pg_snapshot_xmax(s) AS old_xmax,
       pg_snapshot_xmax(pg_current_snapshot()) AS new_xmax
FROM snap_holder;
text
 old_xmax | new_xmax
----------+----------
   570120 |   570234

结果解读:

  • 两次 pg_snapshot_xmax 的差值是 114,表示这段时间内新开了约 114 个写事务。
  • pg_visible_in_snapshot() 可以回答「某个 XID 对这个快照是否可见」,这在做逻辑复制、增量同步、审计回溯时非常有用。
  • 快照是只读的、可序列化的数据结构,可以安全地在应用层缓存(但要清楚它会把水位线钉住)。

注意事项 ​

  • 不要用 pg_current_xact_id() 做「无意义的心跳」,每次调用都会消耗一个 XID,加速回卷。
sql
-- 错误!给只读查询加上这种"探活"会白白消耗 XID
-- SELECT pg_current_xact_id();
sql
-- 不推荐:用 SELECT ... FOR UPDATE 做"只读"查询,会强制分配 XID
-- SELECT * FROM users WHERE id = 1 FOR UPDATE;   -- 只读意图却消耗了 XID
  • 不要长期设置 old_snapshot_threshold,它允许回收「活跃快照还需要的旧版本」,会导致查询报 snapshot too old。
  • autoanalyze 不涉及冻结,只有 VACUUM 会推进 relfrozenxid。频繁 ANALYZE 不能解决回卷问题。
  • 手工冻结是重操作:VACUUM FREEZE 会扫描整表所有页,大表上可能持续数小时并产生大量 I/O。
  • 表级 autovacuum_freeze_max_age 调高只会延后风险,不解决问题本身;调高后一旦触发,冻结窗口会更紧。
  • mxid_age 同样要监控,pg_multixact 的空间比 XID 更紧张,且报错更早出现。
  • 不要依赖 pg_stat_activity.backend_xmin 判断长事务,它只在事务已分配 XID 时才非空;用 xact_start 更可靠。
  • xid8 类型不受回卷影响,它是一个 64 位类型,适合在应用层保存「事务序号」;但 xid 与 xid8 不能直接比较。
  • 子事务(SAVEPOINT)会消耗 XID,ORM 框架滥用 SAVEPOINT 是 XID 消耗超预期的常见原因。
sql
-- 观察子事务的 XID 消耗
BEGIN;
SELECT pg_current_xact_id() AS xid_before;
SAVEPOINT sp1;
SELECT pg_current_xact_id() AS xid_after_savepoint;   -- 已 +1
RELEASE SAVEPOINT sp1;
COMMIT;

最佳实践 ​

  • 把 age(datfrozenxid) 与最老事务年龄放入监控,阈值可设 1亿 警告、1.5亿 严重。
  • 永远不要禁用 autovacuum;需要控制开销时对单表调 autovacuum_vacuum_cost_delay,而不是全局关掉。
  • idle_in_transaction_session_timeout 应在所有生产实例上设置,这是保护水位线最有效的一道防线。
  • 对高频写的大表使用分区,冻结可以逐分区进行,每次只扫描一个分区而非整表。
  • 定期巡检 relfrozenxid 最大的表,提前处理,不要等触发强制冻结。
  • 批量导入等高 XID 消耗操作后立即 VACUUM,避免 XID 消耗与死元组同时堆积。
  • 注意 ORM 的 SAVEPOINT 使用,必要时关掉后端的 savepoint 支持以减少 XID 消耗。
  • 把 track_commit_timestamp 设为 on(若能接受少量开销),有了提交时间才能在事后回答「这行是什么时候改的」。
  • 理解 VACUUM FREEZE 与普通 VACUUM 的差别:前者扫全部页并改写 xmin,后者只处理有死元组的页;不要在业务高峰对大表做冻结。
  • 监控 pg_multixact 目录大小与 mxid_age,它比 XID 更早暴露问题。

下一节

继续学习:元组版本与 ctid

参考链接 ​