# 冻结、XID 与保留者

LLMS 索引： [llms.txt](/llms.txt)

---

空间膨胀会让系统越来越慢；事务 ID 回卷可能让系统为了保护数据而拒绝写入。

两者都由 `VACUUM` 参与治理，却不是同一个风险：

```text
space debt:
  obsolete tuple -> reusable page space

age debt:
  old XID/MXID -> frozen/advanced horizon
```

一张几乎不更新的静态表可能没有 dead tuple，却必须周期性冻结；一张高 churn 表可能
每天 vacuum，仍被一个旧 snapshot 阻止回收。成熟运维要同时看“垃圾速度”和“年龄
安全线”。

## 28.3.1 XID 年龄、冻结与回卷保护 {#item-28-3-1}

### XID 是全局 32 位循环空间

普通内部 `xid` 为 32 位，约每 42.9 亿次分配回卷一次。PostgreSQL 用模 $2^{32}$
比较：

```text
relative to current XID:
  about 2 billion are in the past
  about 2 billion are in the future
```

若一个行版本保留超过约 20 亿个事务，它原本的插入 XID 会从“过去”落到“未来”的
比较区间。冻结的目的，是把已确定对所有当前与未来事务可见的老版本标成永久过去。

注意：

- XID 在事务第一次需要写入时才分配，不一定等于 `BEGIN` 时间顺序；
- `txid_current()` 等旧接口与 `xid8`/epoch 语义不同；
- 不能用 32 位 `xmin` 做长期业务序列或跨回卷排序。

事务标识内部说明见
[Transactions and Identifiers](https://www.postgresql.org/docs/18/transaction-id.html)。

### 冻结是 flag，不是把 `xmin` 改成 2

PostgreSQL 9.4 以后，冻结通常设置 tuple header flag，并保留原 `xmin` 供取证；不能
因为查询还看到原 `xmin` 就断言“没冻结”。老版本升级数据库可能仍见
`FrozenTransactionId=2`。

正确证据是：

```text
relfrozenxid advance
VM all-frozen pages
VACUUM VERBOSE freeze output
pg_visibility checks
```

而不是：

```sql
SELECT count(*) WHERE xmin <> '2';
```

### 关系、数据库和 TOAST 三层年龄

每个普通表/物化视图：

```text
pg_class.relfrozenxid
pg_class.relminmxid
```

每个数据库：

```text
pg_database.datfrozenxid = minimum relation relfrozenxid
pg_database.datminmxid   = minimum relation relminmxid
```

database 值用于 cluster-level 预警，relation 值用于定位。表的 TOAST relation 也可能
最老，不能漏。

数据库总览：

```sql
SELECT
  datname,
  age(datfrozenxid) AS xid_age,
  mxid_age(datminmxid) AS mxid_age,
  datallowconn
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
```

当前数据库按关系下钻：

```sql
SELECT
  c.oid::regclass AS relation,
  c.relkind,
  age(c.relfrozenxid) AS heap_xid_age,
  age(t.relfrozenxid) AS toast_xid_age,
  greatest(
    age(c.relfrozenxid),
    coalesce(age(t.relfrozenxid), 0)
  ) AS effective_xid_age,
  mxid_age(c.relminmxid) AS heap_mxid_age,
  mxid_age(t.relminmxid) AS toast_mxid_age,
  pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
LEFT JOIN pg_class AS t ON t.oid = c.reltoastrelid
WHERE c.relkind IN ('r', 'm')
ORDER BY effective_xid_age DESC
LIMIT 50;
```

`relfrozenxid` 是最近一次成功推进该边界的 vacuum 结果，不是“最老一行的精确插入
时间”。`age()` 是相对于当前 XID 的事务数量，不是秒。

### 把年龄换成时间余量

同样 100 million age：

```text
100 TPS XID allocation       about 11.6 days
10,000 TPS XID allocation    about 2.8 hours
```

因此告警要看：

$$
\text{headroom seconds} \approx
\frac{\text{configured threshold} - \text{current age}}
     {\text{recent XID allocation rate}}
$$

并给 maintenance duration、业务尖峰和失败重试留余量。仅用固定 age percentage，
无法表达突然增长的 XID burn rate。

获取近似分配速率可对固定间隔的 `pg_current_xact_id()`/监控计数做差，但读取函数是否
分配 XID、采样事务本身的影响要按接口语义处理。Pigsty 的 PGSQL Persist 历史曲线更
适合看持续速率，原生 catalog 负责当前边界。

### regular、aggressive 和 failsafe

```text
regular VACUUM
  scans pages likely needing work
  may skip all-visible pages

aggressive VACUUM
  visits every page that might contain unfrozen XID/MXID
  advances relfrozenxid / relminmxid when full necessary coverage achieved

failsafe
  last-resort anti-wraparound mode
  removes cost delay
  may bypass non-essential index maintenance
  prioritizes age correctness
```

相关门槛：

```text
vacuum_freeze_min_age
vacuum_freeze_table_age
autovacuum_freeze_max_age
vacuum_failsafe_age

vacuum_multixact_freeze_min_age
vacuum_multixact_freeze_table_age
autovacuum_multixact_freeze_max_age
vacuum_multixact_failsafe_age
```

PostgreSQL 会限制部分有效值，例如 `vacuum_freeze_table_age` 不会有效超过
`autovacuum_freeze_max_age` 的 95%。不要只读配置文件；读 `pg_settings.setting`
确认实际值。

### eager freeze

PostgreSQL 18 的普通 vacuum 可能主动扫描一部分 all-visible 但未 all-frozen 的页面，
提前冻结，减少以后 aggressive vacuum 的工作；相关行为可用
`vacuum_max_eager_freeze_failure_rate` 调整。

这意味着：

```text
regular vacuum
```

不再等价于“绝不扫描可跳过页”，但它仍不保证每次全表 aggressive coverage。

### 实验中的年龄证据

第 28 章夹具不是回卷压力测试；它绝不消耗数十亿 XID。它只验证：

```text
baseline relfrozenxid age         14
post VACUUM (FREEZE) age          2
all-frozen pages                  10,625
```

这些值证明 freeze 路径在一次性表上生效，不证明生产 threshold、maintenance duration
或 headroom 合理。

## 28.3.2 长事务、`backend_xmin` 与 idle in transaction {#item-28-3-2}

### 事务久不等于一定持有旧 snapshot，反之亦然

`xact_start` 告诉你事务开始时间；`backend_xmin` 告诉你该 backend 对 vacuum horizon
的贡献。它们相关，但不等价：

```text
old xact_start + backend_xmin       likely reclamation holder
old xact_start + no backend_xmin    still examine locks/XID/state
recent xact_start + old xmin        possible imported/exported snapshot
idle outside transaction            usually no MVCC snapshot retention
idle in transaction                 dangerous candidate
```

查询：

```sql
SELECT
  pid,
  datname,
  usename,
  application_name,
  client_addr,
  state,
  xact_start,
  query_start,
  state_change,
  backend_xid,
  backend_xmin,
  age(backend_xid) AS xid_age,
  age(backend_xmin) AS xmin_age,
  wait_event_type,
  wait_event,
  left(query, 200) AS query
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
  AND (
    backend_xid IS NOT NULL
    OR backend_xmin IS NOT NULL
    OR state LIKE 'idle in transaction%'
  )
ORDER BY age(backend_xmin) DESC NULLS LAST, xact_start;
```

### idle in transaction 为什么危险

应用执行：

```text
BEGIN
SELECT ...
client pauses / forgets COMMIT
```

backend 不用 CPU，却可能：

- 保留 snapshot，阻止 dead tuple 回收；
- 持有 relation/row/advisory locks；
- 占用 backend 与连接池槽；
- 让 DDL、vacuum、reindex 等等待；
- 让 table/index/WAL 间接增长。

所以“CPU 为 0”不是无害。

### timeouts 要按角色与协议设计

PostgreSQL 18 提供：

```text
idle_in_transaction_session_timeout
transaction_timeout
statement_timeout
lock_timeout
idle_session_timeout
```

其中：

- `idle_in_transaction_session_timeout` 专门终止在开放事务中 idle 过久的 session；
- `transaction_timeout` 限制整个事务跨度；
- 普通 idle session 不持有开放事务，危害与 idle-in-xact 不同；
- pool/middleware 可能无法优雅处理被服务端突然关闭的连接。

更稳妥的做法：

```sql
ALTER ROLE app_rw IN DATABASE appdb
  SET idle_in_transaction_session_timeout = '2min';

ALTER ROLE analyst IN DATABASE appdb
  SET transaction_timeout = '30min';
```

示例值不是通用推荐。应用必须：

- 正确 rollback/retry；
- 不在事务中等待用户输入或远程 API；
- 流式读取时理解 cursor/snapshot 生命周期；
- 为 migration、batch、backup 使用独立角色和窗口。

### 精确处置，不批量杀 idle

处置协议：

```text
1. identify PID + role + database + application + client
2. verify backend_xmin/xid/locks and business transaction
3. contact owner or follow pre-approved runbook
4. prefer graceful commit/rollback
5. cancel statement if only statement must stop
6. terminate session only when necessary and retry-safe
7. verify holder disappeared
8. rerun/observe vacuum and business correctness
```

正式实验只终止：

```text
database          pg36_maintenance
role              dbuser_pg36maint
application       pg36-ch28-old-snapshot
PID               exact observed PID
backend_xmin      non-null
matched sessions  exactly 1
```

结果：

```text
terminated sessions       1
remaining holder sessions 0
unrelated terminated      0
```

没有使用“杀掉所有 idle in transaction”。

### 读副本也可能把 horizon 反馈到主库

`hot_standby_feedback` 可降低 standby query cancellation，但会把所需 xmin 反馈到
primary，导致 primary 保留 dead tuple。取舍是：

```text
cancel long replica query
vs
retain primary heap versions
```

有 replication slot 时，还要联合 `pg_replication_slots.xmin`。只在 standby 查
`pg_stat_activity` 可能找不到 primary 膨胀的完整原因。

## 28.3.3 复制槽 `xmin` 与孤儿 `pg_prepared_xacts` {#item-28-3-3}

### 复制槽有两类保留线

`pg_replication_slots` 中：

| 字段 | 保留什么 |
|---|---|
| `xmin` | 数据库必须保留的最老事务；更晚删除的 tuple 不能被 vacuum 移除 |
| `catalog_xmin` | 逻辑解码所需系统目录 tuple |
| `restart_lsn` | consumer 仍可能需要的最老 WAL |
| `confirmed_flush_lsn` | 逻辑 consumer 已确认接收的位置 |
| `wal_status/safe_wal_size` | WAL 保留状态与走向 lost 的余量 |

空间问题要分开：

```text
xmin/catalog_xmin -> heap/catalog bloat and vacuum horizon
restart_lsn       -> pg_wal retention
```

“复制槽只会撑大 WAL”是错的。

查询：

```sql
SELECT
  slot_name,
  slot_type,
  database,
  active,
  active_pid,
  age(xmin) AS xmin_age,
  age(catalog_xmin) AS catalog_xmin_age,
  restart_lsn,
  confirmed_flush_lsn,
  wal_status,
  safe_wal_size,
  inactive_since,
  conflicting,
  invalidation_reason,
  failover,
  synced
FROM pg_replication_slots
ORDER BY
  greatest(
    coalesce(age(xmin), 0),
    coalesce(age(catalog_xmin), 0)
  ) DESC,
  slot_name;
```

### inactive 不等于 orphan

一个 inactive slot 可能是：

- 正常短暂断线；
- 灾备系统等待窗口；
- CDC consumer 故障；
- 切换后遗留；
- 已废弃对象；
- standby 同步 slot。

drop slot 前必须确认：

```text
consumer owner
expected reconnect
replica/CDC resume semantics
required WAL/rows already lost?
will replica need rebuild?
failover/synced restrictions
```

官方文档明确提示：若删除仍会回来使用的 slot，对应 replica 可能需要重建。

因此：

```sql
SELECT pg_drop_replication_slot('unknown_slot');
```

不是发现 inactive 后的第一步。

### prepared transaction 没有客户端也能继续持有状态

两阶段提交：

```text
BEGIN
changes
PREPARE TRANSACTION 'gid'
-- original session may leave
COMMIT PREPARED / ROLLBACK PREPARED later
```

进入 prepared 后，它仍可持有：

- 已分配 XID；
- 行/表锁；
- 可见性和清理边界影响；
- 未决业务结果。

查询：

```sql
SELECT
  transaction,
  age(transaction) AS xid_age,
  gid,
  prepared,
  clock_timestamp() - prepared AS prepared_for,
  owner,
  database
FROM pg_prepared_xacts
ORDER BY age(transaction) DESC;
```

不要因为 `pg_stat_activity` 没有对应客户端，就判断“事务已消失”。

### orphan 处置需要业务协调器事实

```text
GID maps to which distributed transaction?
coordinator decision is commit or rollback?
did participants commit elsewhere?
is retry idempotent?
what locks and rows are affected?
```

没有这些事实时，随意 `ROLLBACK PREPARED` 可能破坏跨系统一致性，随意
`COMMIT PREPARED` 也可能提交应回滚的业务。

正确 runbook：

```text
inventory
  -> coordinator/ledger lookup
      -> peer participant status
          -> explicit decision
              -> COMMIT/ROLLBACK PREPARED
                  -> verify age/locks/business invariants
```

若业务并不需要 2PC，应让 `max_prepared_transactions=0` 保持禁用，而不是启用后希望
“没人会忘”。

### 统一保留者清单

一张事故表至少包括：

| source | identifier | age | owner | needed by | action | proof |
|---|---|---:|---|---|---|---|
| backend | PID/app | xmin age | team | transaction | commit/terminate | holder gone |
| slot | slot name | xmin/catalog age | CDC/DBA | consumer | resume/drop | consumer state |
| prepared | GID | xid age | coordinator | distributed tx | commit/rollback | business ledger |

只有 owner 和 action 明确，才进入执行。

## 28.3.4 紧急态先解除保留并让 VACUUM 完成 {#item-28-3-4}

这一目的标题刻意没有写“立刻 `VACUUM FREEZE`”。

对 PostgreSQL 18，必须区分：

```text
proactive maintenance
warning / shrinking headroom
write-refusal protection state
```

它们的正确动作不同。

### 第一阶段：预防与早期告警

正常系统应在远离危险线时：

```text
monitor xid/mxid age and burn rate
keep autovacuum healthy
let aggressive vacuum complete
remove long-lived holders
schedule targeted VACUUM where needed
validate relfrozenxid advancement
```

在这个阶段，针对静态大表使用 `VACUUM (FREEZE)` 可以是有计划的维护动作：

```sql
VACUUM (FREEZE, VERBOSE) app.archive_2020;
```

前提是：

- 已评估 I/O、WAL、锁和 replica；
- 不是为了掩盖未知 blocker；
- 目标表与 TOAST 均被验收；
- 生产窗口已批准。

Pigsty 的：

```bash
pig pg freeze mydb
```

封装的是 freeze vacuum；适合明确的计划动作，不应脱离 PostgreSQL 版本语义当作所有
XID 事故的万能按钮。

### 第二阶段：系统已接近或进入拒绝新 XID

PostgreSQL 18 官方文档说明：

- 临近回卷点会先产生必须 vacuum 的 warning；
- 剩余不足约 3 million XID 时，系统拒绝分配新 XID以保护数据；
- 已在运行的事务可继续，新的只读事务可启动；
- 普通 `VACUUM` 仍可执行。

此时的优先顺序是：

```text
1. resolve old prepared transactions
2. end long-running open transactions
3. remove only confirmed obsolete replication slots
4. run ordinary VACUUM in affected database / oldest relations
5. restore normal operation
6. repair autovacuum/root cause
```

官方文档对 PostgreSQL 18 还明确说：

```text
do not use VACUUM FULL
do not use VACUUM FREEZE
single-user mode is normally unnecessary and undesirable
```

原因是 hard-stop 状态要做**恢复正常所需的最小工作**：

- `VACUUM FULL` 自身需要/消耗 XID、强锁且重写；
- `VACUUM FREEZE` 做超过最小恢复所需的工作；
- single-user mode 会绕开保护并引入停机风险。

这与某些旧版本文章或旧 runbook 不同。执行时以安装版本官方文档为准。

### “让冻结完成”的正确含义

在日常或 early warning 阶段：

> 不要为了降低 I/O 不断 cancel 防回卷 vacuum；解除 blocker，让 aggressive
> freeze 推进并验收年龄。

在已经拒绝 XID 的 hard-stop 阶段：

> 不要把 `FREEZE` option 当口号；按 PostgreSQL 18 最小恢复流程先解除 prepared
> xact、长事务和旧 slot，再让普通 `VACUUM` 完成。

两者共同反对：

```text
raise autovacuum_freeze_max_age to silence alert
disable autovacuum
cancel every anti-wraparound worker
restart hoping age disappears
delete pg_xact files
reset xid counters by hand
```

这些都没有安全地冻结旧 tuple，可能把正确性风险推向灾难。

### 紧急 runbook 的 fail-closed 门

```yaml
incident:
  postgresql_version: 18.x
  primary_identity: verified
  xid_or_mxid: xid
  oldest_database: ...
  oldest_relations: [...]
  burn_rate_per_second: ...
  estimated_headroom: ...

holders:
  prepared: [...]
  backends: [...]
  slots: [...]
  ownership_confirmed: false

execution:
  ordinary_vacuum_only: true
  vacuum_full: forbidden
  vacuum_freeze_in_hard_stop: forbidden
  force_drop: forbidden
  single_user: not_planned

validation:
  write_assignment_restored: ...
  datfrozenxid_advanced: ...
  oldest_relation_advanced: ...
  warnings_stopped: ...
  autovacuum_root_cause: ...
```

`ownership_confirmed=false` 时，不能自动 drop slot 或 resolve prepared transaction；
需要事故指挥者和业务 owner 决策。

### XID 与 MXID 要分案

MXID 用于多事务共同锁行，拥有独立：

```text
pg_multixact storage
relminmxid / datminmxid
freeze thresholds
failsafe thresholds
exhaustion effects
```

XID 耗尽会阻断所有需要新 XID 的写；MXID 耗尽主要阻断需要创建新 multixact 的锁类
写入。事故名称、监控和 runbook 不能只写“wraparound”。

### 恢复写入不等于事故闭环

普通 vacuum 让系统重新接受写入，只是止血。根因可能仍是：

```text
application transaction leak
stale logical slot
2PC coordinator failure
worker starvation
I/O insufficient
autovacuum misconfiguration
huge database never covered
maintenance repeatedly canceled
```

事故关闭条件：

```text
headroom restored
burn rate understood
all holder ownership recorded
oldest tables and TOAST advancing
autovacuum completion observable
alerts and capacity model corrected
recurrence test passed
```

否则下一次只是时间问题。

## 本节检查清单

```text
installed PostgreSQL major/minor
database xid/mxid age
top relation + TOAST age
configured and effective thresholds
XID/MXID burn rate and headroom time
backend_xmin holders
idle-in-transaction sessions
slot xmin/catalog_xmin/restart_lsn
prepared transaction GID/owner/decision
anti-wraparound worker phase
ordinary vs aggressive vs failsafe state
version-correct emergency procedure
post-recovery root-cause action
```

## 延伸阅读

- [PostgreSQL 18：Preventing Transaction ID Wraparound Failures](https://www.postgresql.org/docs/18/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND)
- [PostgreSQL 18：Transactions and Identifiers](https://www.postgresql.org/docs/18/transaction-id.html)
- [PostgreSQL 18：Vacuuming / Freezing Configuration](https://www.postgresql.org/docs/18/runtime-config-vacuum.html)
- [PostgreSQL 18：`pg_database`](https://www.postgresql.org/docs/18/catalog-pg-database.html)
- [PostgreSQL 18：`pg_replication_slots`](https://www.postgresql.org/docs/18/view-pg-replication-slots.html)
- [PostgreSQL 18：`pg_prepared_xacts`](https://www.postgresql.org/docs/18/view-pg-prepared-xacts.html)
- [PostgreSQL 18：Client Timeouts](https://www.postgresql.org/docs/18/runtime-config-client.html)
- [Pigsty：`pig pg freeze`](https://pigsty.io/docs/pig/pg/#pg-freeze)

---

[上一节：autovacuum 的触发与资源](../02/) · [返回本章目录](../) · [下一节：膨胀与重建](../04/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
