事务 ID 与快照(XID and Snapshot)
上一节提到「每个事务会拿到一个快照」,但快照里到底装了什么?为什么它能让不同事务看到不同数据?这一切的基石是事务 ID(XID)——一个 32 位无符号整数。它既要标识事务,又要参与可见性判断,还要面对「用完之后会回卷」这个结构性难题。本节把 XID 的位域结构、分配规则、快照的四个字段、以及 CLOG 如何配合判断事务状态讲透。
前置知识
阅读本节前,建议先了解:MVCC 概述
基础概念
事务 ID(XID, Transaction ID) 是 PostgreSQL 分配给每个写事务的唯一标识,它是一个 32 位无符号整数(范围 0 ~ 4,294,967,295)。它同时承担三种职责:
① 标识身份:写在元组的 xmin / xmax 里,说明"谁创建了/删除了这个版本"
② 判断可见性:配合快照判断一个版本对当前事务是否可见
③ 提供顺序:XID 按大小单调递增,大小关系就是"先后关系"理解 XID 与快照需要先统一以下术语:
| 术语 | 英文 | 说明 |
|---|---|---|
| 事务 ID | XID, Transaction ID | 32 位无符号整数,仅写事务才会分配 |
| 虚拟事务 ID | VXID, Virtual XID | 格式为 backendID/localXID,只读事务用它标识自己,不消耗真实 XID |
| 当前 XID | Current XID | 事务内第一次写操作时才分配,pg_current_xact_id() 可查 |
| 快照 | Snapshot | 记录"此刻哪些事务在运行"的数据结构 |
xmin(快照字段) | Snapshot xmin | 所有活跃事务中最小的 XID,也称为水位线 |
xmax(快照字段) | Snapshot xmax | 下一个将分配的 XID,大于等于它的都还没开始 |
xip_list | In-Progress List | 快照时刻正在运行的事务 ID 列表 |
| 提交日志 | CLOG / pg_xact | 记录每个 XID 的最终状态(提交/回滚) |
| 事务年龄 | Age | 当前XID - XID,用无符号 32 位差值计算,衡量"多老" |
| 回卷 | Wraparound | XID 用尽后从 0 重新开始,会让"很老"的事务看起来"很新" |
| 冻结 | Freeze | 把元组的 xmin 替换为特殊值,使其永不回卷 |
真实 XID 与虚拟 XID
一个常见误解是「每个事务都会分配 XID」。事实是:
-- 只读事务:不分配真实 XID
BEGIN;
SELECT pg_current_xact_id_if_assigned() AS real_xid,
pg_backend_pid() AS pid,
txid_current_if_assigned() AS txid_alias; real_xid | pid | txid_alias
----------+-------+------------
| 21043 |-- 执行一次写操作后,才真正分配 XID
UPDATE mvcc_demo SET note = 'v3' WHERE id = 1;
SELECT pg_current_xact_id_if_assigned() AS real_xid; 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³¹ 半区」规则:
假设当前 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 的异常值:
| 特殊值 | 常量名 | 含义 |
|---|---|---|
0 | InvalidTransactionId | 无效事务,xmax = 0 表示"此版本未被删除" |
1 | BootstrapTransactionId | 系统初始化时使用,正常数据中不会出现 |
2 | FrozenTransactionId | 冻结事务,表示"此版本永远可见,无需检查 CLOG" |
-- 冻结后的元组,xmin 会显示为 2
SELECT xmin, xmax, ctid, * FROM frozen_demo; xmin | xmax | ctid | id | note
------+------+-------+----+-------
2 | 0 | (0,1) | 1 | old核心机制
XID 的分配与生命周期
事务开始(BEGIN)
│
▼
拿到 VXID(backendID/localXID)
仅用于标识会话,不消耗真实 XID
│
┌───────┴────────┐
│ 只读? │
└───────┬────────┘
是 │ │ 否(有写操作)
▼ ▼
永不分配 XID 调用 GetNewTransactionId()
事务结束即释放 从 shared nextXid 取一个,并 +1
│
▼
写元组头 xmin/xmax
│
▼
提交/回滚时写 CLOG
(pg_xact 里 2 bit 状态)
│
▼
状态持久化,供其他事务查询关键点:XID 的分配是全局串行的,由一个共享变量 nextXid 递增。这是 PostgreSQL 在高并发写入下仍需要一个原子操作的原因之一。
-- 从控制文件信息中读下一次将被分配的 XID
SELECT pg_control_checkpoint();-[ RECORD 1 ]--------+-------------------------
checkpoint_lsn | 0/1A2B4C8
redo_lsn | 0/1800208
...
next_xid | 0:757next_xid 就是「下一个将被分配的 XID」,格式 epoch:low32。
快照的四个字段
快照是可见性判断的全部依据。它的内容可以从 pg_current_snapshot() 直接看到:
-- 查看当前快照(格式:xmin:xmax:xip_list)
SELECT pg_current_snapshot() AS snapshot; snapshot
------------------
745:760:746,748含义拆解:
| 字段 | 值 | 含义 |
|---|---|---|
xmin | 745 | 最小的活跃 XID。小于 745 的事务都已结束(提交或回滚),是最关键的"水位线" |
xmax | 760 | 下一个将分配的 XID。大于等于 760 的事务都还没开始 |
xip_list | 746,748 | 在 [745, 760) 区间内、此刻仍在运行的事务 ID |
由此推出可见性判断的核心逻辑:
对一个元组的 xmin(创建者):
· xmin < snapshot.xmin → 创建者已结束 → 查 CLOG 判断提交/回滚
· xmin >= snapshot.xmax → 创建者在快照之后才开始 → 不可见
· xmin 在 xip_list 中 → 创建者当时还在运行 → 不可见
· 其他 → 创建者当时已提交 → 可见
对一个元组的 xmax(删除者):
· xmax = 0 → 未被删除 → 可见
· xmax < snapshot.xmin → 删除者已结束 → 查 CLOG
· xmax >= snapshot.xmax → 删除者在快照之后才开始 → 还没删 → 可见
· xmax 在 xip_list 中 → 删除者当时还在运行 → 还没删 → 可见
· 其他(删除者已提交) → 已删 → 不可见用更直观的图示:
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 检测冲突 | 结果等价于某种串行执行顺序 |
-- 查看当前事务的隔离级别与快照
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; isolation | snapshot | snap_xmin | snap_xmax | xip_list
---------------+-----------------+-----------+-----------+----------
read committed| 745:760:746,748 | 745 | 760 | {746,748}-- 延迟获取快照,用于"看到更晚提交的数据"(PG13+ 支持)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 先做别的事
SELECT pg_sleep(1);
-- 直到这里才真正取快照
SELECT pg_snapshot_xmax(pg_current_snapshot()) AS snap_xmax;快照与长事务的耦合
快照一旦被取,就会把水位线钉住。这是长事务导致膨胀的根本机制:
会话 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 终于可以工作-- 从两个视角观察水位线:全局最老快照 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; 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 数据类型与一组操作函数,可以先取快照、隔一段时间再用同一个快照判断可见性:
-- 取两次快照并对比,直观看到"活跃事务变化"
SELECT pg_current_snapshot() AS snapshot_now;
SELECT pg_sleep(2);
SELECT pg_current_snapshot() AS snapshot_later; snapshot_now
-----------------
745:760:746,748
snapshot_later
-----------------
749:761:750-- 快照之间的差异比较(PG13+ 提供函数)
SELECT pg_snapshot_xmin(pg_current_snapshot()) AS xmin_now,
pg_snapshot_xip(pg_current_snapshot()) AS xip_now;相关参数
| 参数 | 默认值 | 生效方式 | 说明 |
|---|---|---|---|
autovacuum_freeze_max_age | 200000000 | 重启 | 单表 relfrozenxid 年龄超过此值即强制 autovacuum 冻结 |
vacuum_freeze_min_age | 50000000 | user | 元组 xmin 年龄超过此值才愿意冻结它 |
vacuum_freeze_table_age | 150000000 | user | 表 relfrozenxid 年龄超过此值就做全表扫描式 VACUUM |
vacuum_multixact_freeze_min_age | 5000000 | user | 多事务 ID 的冻结下限 |
vacuum_multixact_freeze_table_age | 150000000 | user | 多事务 ID 触发全表扫描的阈值 |
track_commit_timestamp | off | 重启 | 记录 XID 提交时间,可用 pg_xact_commit_timestamp() 查询 |
old_snapshot_threshold | -1 | 重启 | 快照可"过期"的时间,过期后旧版本可被回收(有读到旧数据失败的风险) |
idle_in_transaction_session_timeout | 0 | user | 空闲事务超时断开,直接保护水位线 |
transaction_timeout | 0 | user | 事务总时长上限(PG17+ 引入) |
# 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-- 表级覆盖冻结相关参数(只影响这一张表)
ALTER TABLE users SET (
autovacuum_freeze_max_age = 150000000,
autovacuum_freeze_min_age = 10000000
);详细说明
观察 XID 分配的速率
XID 消耗速率决定了「回卷」的风险大小。可以先采样,再推算:
-- 采样 1:记录当前 XID 与时间
SELECT pg_current_xact_id() AS xid_t1,
clock_timestamp() AS ts_t1; xid_t1 | ts_t1
--------+-------------------------------
760 | 2026-09-20 14:00:00.001+08-- 等一段时间后采样 2(示例中隔了 60 秒)
SELECT pg_current_xact_id() AS xid_t2,
clock_timestamp() AS ts_t2,
pg_current_xact_id() - 760 AS xid_consumed; xid_t2 | ts_t2 | xid_consumed
--------+--------------------------------+--------------
982 | 2026-09-20 14:01:00.012+08 | 222换算:60 秒消耗 222 个 XID ≈ 3.7 XID/秒。按这个速率,42 亿个 XID 可用约:
4294967296 ÷ 3.7 ≈ 1,160,800,000 秒 ≈ 36.8 年但要注意:XID 消耗速率不是线性的。批量导入、批量更新、大量 SAVEPOINT 都会让它飙升千百倍。
-- 用 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'; datname | xact_commit | xact_rollback | total_xacts | stats_reset
---------+-------------+---------------+-------------+-------------------------------
demo | 12830411 | 41022 | 12871433 | 2026-09-01 00:00:00.124511+08数据库级别的回卷距离监控
这是必须纳入监控的最关键指标。age(datfrozenxid) 表示「这个库最老的未冻结 XID 距今多久」:
-- 每个数据库的回卷距离(按年龄降序,最危险的在最前)
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; 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 |
-- 找出库内年龄最大的表(决定该库 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; 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 MBevents 表年龄 1.84 亿,占库的绝大部分——只要处理它,库的 datfrozenxid 就能大幅前进。
多事务 ID(MultiXact)与 mxid
除了 xmin/xmax,PG 还有一套并行的 ID 体系叫 MultiXact。它用于「多个事务同时锁定同一行」的场景(如多个会话都持有 FOR SHARE 锁):
单事务删除/更新 → xmax 直接存一个 XID
多事务共享锁 → xmax 存一个 MultiXact ID,指向 pg_multixact 里的成员列表MultiXact 也有自己的回卷问题,且空间更容易耗尽(pg_multixact/offsets 只有 32 位空间,约 20 亿但实际更紧张):
-- 查看 MultiXact 相关年龄
SELECT datname, mxid_age(datfrozenxid) AS mxid_age
FROM pg_database
ORDER BY mxid_age(datfrozenxid) DESC
LIMIT 3; datname | mxid_age
-----------+----------
demo | 12043012
postgres | 10231-- 找出 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; relname | mxid_age | relminmxid
------------+----------+------------
orders | 12043012 | 412349
users | 2034112 | 1002321冻结如何打破回卷限制
冻结的本质是把 xmin 改写成特殊值 2(FrozenTransactionId),并设置 HEAP_XMIN_FROZEN 标志位:
冻结前:
[ xmin=500 xmax=0 data... ] → 判断可见性要查 CLOG(500)
随着 XID 前进到 20 亿,500 这个值会因回卷变得"看起来像未来"
冻结后:
[ xmin=2 xmax=0 data... ] → 特殊值,永远可见,不需要查 CLOG
不受回卷影响 ✔-- 观察冻结前后的 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; xmin | xmax | xmin_age | id | note
------+------+----------+----+------------------
762 | 0 | 0 | 1 | frozen candidate-- 强制执行冻结(PG9.4+ 使用 VACUUM FREEZE 替代旧的 vacuum_freeze)
VACUUM (FREEZE, VERBOSE) freeze_demo;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.-- 冻结后:xmin 变成 2,且年龄变成 2147483647 - 2 之类的特殊值
SELECT xmin, xmax, age(xmin) AS xmin_age, * FROM freeze_demo; xmin | xmax | xmin_age | id | note
------+------+-------------+----+------------------
2 | 0 | 2147483647 | 1 | frozen candidate-- 更精确的判断:查看元组的冻结标志位
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; lp | t_xmin | t_infomask | has_xmin_frozen | valid
----+--------+------------+-----------------+-------
1 | 2 | 1282 | t | thas_xmin_frozen = t 就是证明:这个元组已经不参与回卷计算了。
实战示例
示例 1:完整观测一次「快照如何决定可见性」
这个示例用两个会话手工构造出快照的四个字段,然后验证可见性判断规则。
准备:
-- 会话 1:建表并插入初始数据
DROP TABLE IF EXISTS snap_demo;
CREATE TABLE snap_demo (id int PRIMARY KEY, note text);
INSERT INTO snap_demo VALUES (1, 'initial');INSERT 0 1会话 2 取快照(在更新之前):
-- 会话 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; snap | snap_xmin | snap_xmax
----------------+-----------+-----------
763:765:764 | 763 | 765会话 1 执行更新并提交:
-- 会话 1:更新数据(分配新 XID = 764,正好在会话 2 的 xip_list 里)
UPDATE snap_demo SET note = 'updated' WHERE id = 1;
SELECT pg_current_xact_id() AS writer_xid; writer_xid
------------
764-- 会话 1:提交
COMMIT;会话 2 验证可见性:
-- 会话 2:仍然看到旧值(因为 764 在快照的 xip_list 中)
SELECT xmin, xmax, ctid, note FROM snap_demo WHERE id = 1; 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,新版本变为可见。
-- 会话 2:提交后重新查询
COMMIT;
SELECT xmin, xmax, ctid, note FROM snap_demo WHERE id = 1; xmin | xmax | ctid | note
------+------+-------+---------
764 | 0 | (0,2) | updated示例 2:定位并解决回卷风险
这是一个真实运维场景的复现:某库的 age(datfrozenxid) 持续上涨,需要找到原因并处理。
第一步:确认风险等级
-- 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; datname | xid_age | pct_used | db_size
-----------+-----------+--------------------+---------
demo | 158233401 | 7.9116700500000000 | 46 GB
postgres | 312004 | 0.0156002000000000 | 8129 kB第二步:找出责任表
-- 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; 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第三步:排查为什么冻结没推进
-- 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; pid | state | xact_age | backend_xmin | query
-------+---------------------+---------------+--------------+--------------------
41002 | idle in transaction | 05:12:44.102 | 412349810 | SELECT count(*) FROM events;-- 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'); 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-- 5) 查该表的 autovacuum 历史(是否跑过、跑了多久)
SELECT relname, n_dead_tup, last_autovacuum, last_vacuum,
autovacuum_count, vacuum_count
FROM pg_stat_user_tables
WHERE relname = 'events'; relname | n_dead_tup | last_autovacuum | last_vacuum | autovacuum_count | vacuum_count
---------+------------+-----------------+-------------+------------------+--------------
events | 891234 | 2026-09-19 03:12 | | 14 | 0第四步:处理
-- 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;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-- 9) 验证年龄是否降下来
SELECT relname, age(relfrozenxid) AS xid_age, relfrozenxid
FROM pg_class
WHERE relname = 'events'; relname | xid_age | relfrozenxid
---------+---------+--------------
events | 10234 | 570112045 -- 库级别同步下降
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 类型可以保存并传递,用于实现跨查询的一致视图:
-- 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; snap_xmin | snap_xmax | xip
-----------+-----------+---------
570112 | 570120 | {570115}-- 2) 用 pg_visible_in_snapshot 判断某个 XID 在快照中是否可见
SELECT pg_visible_in_snapshot(570112::text::xid8, s) AS xid_in_snapshot
FROM snap_holder; xid_in_snapshot
-----------------
t-- 3) 把快照与当前快照做对比,找出"新提交的事务"
SELECT pg_snapshot_xmax(s) AS old_xmax,
pg_snapshot_xmax(pg_current_snapshot()) AS new_xmax
FROM snap_holder; old_xmax | new_xmax
----------+----------
570120 | 570234结果解读:
- 两次
pg_snapshot_xmax的差值是114,表示这段时间内新开了约 114 个写事务。 pg_visible_in_snapshot()可以回答「某个 XID 对这个快照是否可见」,这在做逻辑复制、增量同步、审计回溯时非常有用。- 快照是只读的、可序列化的数据结构,可以安全地在应用层缓存(但要清楚它会把水位线钉住)。
注意事项
- 不要用
pg_current_xact_id()做「无意义的心跳」,每次调用都会消耗一个 XID,加速回卷。
-- 错误!给只读查询加上这种"探活"会白白消耗 XID
-- SELECT pg_current_xact_id();-- 不推荐:用 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 消耗超预期的常见原因。
-- 观察子事务的 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