跳至内容

28.1 死元组与可见性

理解 VACUUM 的第一步,是放弃“表里只有当前行”的直觉。

PostgreSQL heap 存放的是行版本。一个逻辑主键在不同时间可能对应多条物理 tuple; 每个查询再用自己的 snapshot 判断哪一条可见。空间回收不能问:

这条旧版本对我还可见吗?

而要问:

集群清理边界之前,是否还存在任何合法快照可能看见它?

这两个问题之间的时间差,就是 MVCC 的空间债。

28.1.1 UPDATE/DELETE 如何产生旧版本

UPDATE 不是原地覆盖

概念上,一次更新经历:

old tuple
  xmax <- updating transaction
  t_ctid -> new tuple location

new tuple
  xmin <- updating transaction
  values <- new values

事务提交后:

  • 新快照通常看新版本;
  • 更新前已经建立的旧快照仍可能看旧版本;
  • rollback 则让更新产生的新版本不可见;
  • vacuum 不能在旧快照离开前移除它仍可能访问的版本。

DELETE 不需要创建“空的新行”,而是在旧版本上记录删除事务;它同样要等到删除前的 快照离开,才可物理回收。

这就是 PostgreSQL 18 官方维护文档强调的边界:UPDATE/DELETE 不立即移除旧行, 因为它可能仍对并发事务可见。参见 Routine Vacuuming

四个不同状态

不要把以下词混成一个 dead

状态含义能否立即物理移除
对当前 snapshot 不可见本查询不应返回未必
对所有可能 snapshot 都不可见已跨过清理边界通常可成为回收候选
已由 vacuum/prune 处理tuple/line pointer 已清理或重定向页内空间可复用
文件系统已收回关系文件缩小或重写完成是另一项操作结果

例如:

T1 BEGIN ISOLATION LEVEL REPEATABLE READ
T1 SELECT row                 -- snapshot S1

T2 UPDATE row
T2 COMMIT

T3 SELECT row                 -- sees new version
T1 SELECT row                 -- still sees old version

在 T1 结束前,T3 看不见旧版本不等于旧版本可删。

xminxmaxctid 是诊断入口,不是业务 API

在教学夹具可以观察:

SELECT
  ctid,
  xmin::text,
  xmax::text,
  id,
  revision
FROM maint.churn
WHERE id = 42;

但要保留三项边界:

  1. 普通 SQL 只返回当前 snapshot 可见的版本,不会自动展示完整版本链;
  2. ctid 会随 UPDATE、表重写和行移动变化,不能当持久业务键;
  3. xmin/xmax 是内部事务标识,存在冻结、回卷和 multixact 语义,不能当无限增长的 业务版本号。

若要检查页面内部,需要 pageinspect 等更侵入的诊断工具;它们适合受控故障分析, 不适合高频全库扫描。

一个容易忽略的命令级快照

数据修改 CTE 的兄弟子语句共享同一个命令快照:

WITH updated AS (
  UPDATE t SET payload = 'new'
  WHERE id <= 100
  RETURNING id
), deleted AS (
  DELETE FROM t
  WHERE id <= 100
  RETURNING id
)
SELECT ...;

不要依赖 deleted 再处理已经被 updated 修改的同一行,也不要用该命令末尾对原表的 count(*) 证明提交后状态。第 28 章实验最初正是在这里被验收器拒绝:

UPDATE count       40,000
DELETE count       10,000
same-command count 60,000
next-command count 50,000

删除确实发生了;同命令读仍使用旧 command snapshot。正确证据是把修改计数和提交后 状态拆成两个 SQL 命令。这一例子也说明:没有明确 snapshot,所谓“当前行数”并不完整。

n_dead_tup 是估计,不是验尸报告

常用视图:

SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  n_tup_ins,
  n_tup_upd,
  n_tup_del,
  n_tup_hot_upd,
  n_tup_newpage_upd,
  last_vacuum,
  last_autovacuum,
  vacuum_count,
  autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

n_dead_tup 来自累计统计系统,更新是最终一致的,且本来就是估计。它适合:

  • 找趋势;
  • 排优先级;
  • 关联写入速率和维护时间;
  • 发现“长期只增不降”的异常。

它不适合单独证明:

  • 精确有多少物理旧版本;
  • 多少版本已经可由 vacuum 移除;
  • 表文件浪费了多少字节;
  • 是否应该 VACUUM FULL

受控诊断可补:

CREATE EXTENSION pgstattuple;

SELECT *
FROM pgstattuple('app.orders'::regclass);

pgstattuple 会扫描关系,能给更直接的 tuple/free-space 证据,但它也消耗 I/O;大型 生产表要先评估窗口,可考虑 pgstattuple_approx 或抽样型 bloat estimate。所谓 “更精确”不是“零成本”。

谁决定“仍可能可见”

清理边界受多类对象影响:

running transaction snapshot
backend_xmin
idle in transaction
logical replication slot xmin/catalog_xmin
standby feedback
prepared transaction

因此 VACUUM 没清掉时,先找保留者,而不是先提高 vacuum worker。第 28.3 节会把 每一类对象拆开。

28.1.2 vacuum、prune、HOT 与可见性图

VACUUM 不是唯一清理旧版本的地方,也不是所有清理都做同一件事。

page pruning:局部、机会式

访问 heap page 时,如果页面上有可安全裁剪的版本链,PostgreSQL 可以做 page pruning:

remove no-longer-needed intermediate tuple data
convert root line pointer to redirect
compact page free space
preserve chain reachability for indexes

它的作用域是当前页,不会:

  • 扫全表;
  • 清所有索引死条目;
  • 更新全关系统计;
  • 推进整个表的 relfrozenxid
  • 替代周期性 vacuum。

因此出现:

n_dead_tup decreased before autovacuum

并不神秘,可能是热点页被访问时发生了 pruning。

HOT:避免不必要的索引版本

PostgreSQL 18 的 HOT 条件是:

  1. 更新没有修改任何被普通索引引用的列;核心中的 summarizing index 例外是 BRIN;
  2. 原 tuple 所在页面有足够空间放新版本。

满足时:

  • 新版本不需要给普通索引添加新 index tuple;
  • 中间版本可由 page pruning 更便宜地移除;
  • 索引仍通过原始 line pointer 沿 HOT chain 找到可见版本。

参见 Heap-Only Tuples

HOT 不是 UPDATE 的固定属性。下面这些都会降低它:

update indexed column
update expression-index referenced column
page has no room
wide row grows
fillfactor leaves too little reserve
write pattern moves working set to packed pages

监控:

SELECT
  relname,
  n_tup_upd,
  n_tup_hot_upd,
  n_tup_newpage_upd,
  round(
    100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0),
    2
  ) AS hot_pct,
  round(
    100.0 * n_tup_newpage_upd / nullif(n_tup_upd, 0),
    2
  ) AS newpage_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC;

实验表使用 fillfactor=70,更新 40,000 行时观察到:

n_tup_hot_upd      15,000
n_tup_newpage_upd  25,000

这不是“70% fillfactor 应得到 37.5% HOT”的公式。它只是说明同样不改索引列的 update, 仍有 25,000 行因为页内空间条件转到新页。是否调整 fillfactor,要联合:

HOT gain
base table footprint
cache residency
scan cost
insert density
rewrite cost

不能只追求 100% HOT。

普通 VACUUM 的四项工作

官方文档把日常 vacuum 目的分成:

  1. 回收或复用 UPDATE/DELETE 占用的空间;
  2. 更新 planner statistics;
  3. 更新 visibility map,帮助 index-only scan;
  4. 防止 XID/MXID 回卷。

一次命令不一定对每项做相同强度。例如:

VACUUM table
VACUUM (ANALYZE) table
VACUUM (FREEZE) table
VACUUM (INDEX_CLEANUP OFF) table

语义不同。INDEX_CLEANUP OFF 在极端防回卷场景可减少工作,但若长期跳过,索引死条目 和 heap line pointer 会累积;PostgreSQL 18 还有 failsafe 机制可在危险年龄自动跳过 某些昂贵工作。不要把临时救险选项变成常规模板。

FSM:哪里还有可放新 tuple 的空间

每个 heap 和除 hash 外的 index relation 都有 Free Space Map。它按页记录可用空间的 近似信息,帮助 insert/update 找到可复用页。

CREATE EXTENSION pg_freespacemap;

SELECT
  count(*) AS pages,
  sum(avail) AS reusable_bytes,
  max(avail) AS largest_page_free_bytes
FROM pg_freespace('app.orders'::regclass);

FSM 回答的是:

关系内部哪些页有空间可供后续写入?

它不回答:

操作系统现在多了多少 free bytes?

官方结构说明见 Free Space Map

VM:哪些页可以被安全跳过

heap relation 的 Visibility Map 每页两位:

bit含义主要用途
all-visible页内 tuple 对所有事务可见,没有 tuple 需要 vacuumindex-only scan 可跳 heap visibility check
all-frozen页内 tuple 已冻结anti-wraparound vacuum 可跳过

VM 是保守结构:

bit = 1 -> 条件必须为真
bit = 0 -> 条件可能不真,也可能尚未被 vacuum 证明

修改页面会清位,只有 vacuum 置位。因此:

all_visible = 0

不能直接推出页面里一定有 dead tuple。

观察:

CREATE EXTENSION pg_visibility;

SELECT *
FROM pg_visibility_map_summary('app.orders'::regclass);

需要进一步一致性检查时:

SELECT * FROM pg_check_visible('app.orders'::regclass);
SELECT * FROM pg_check_frozen('app.orders'::regclass);

非空结果意味着 VM 与 heap 的约束可能损坏,应停止普通维护、保全证据并进入第 35 章 的数据抢救流程,而不是“清空 VM 看看”。pg_truncate_visibility_map 是修复性、 超级用户操作,会迫使后续 vacuum 重建 VM,必须有明确故障证据和变更记录。

官方说明见 Visibility Mappg_visibility

一张图看职责

UPDATE / DELETE
  |
  v
old row versions --------> snapshot horizon
  |                             |
  | page-local                  | when safe
  v                             v
prune / HOT chain          VACUUM heap scan
  |                             |
  +------ reusable page space --+--> FSM
                                |
                                +--> index cleanup
                                +--> VM all-visible/all-frozen
                                +--> relfrozenxid / relminmxid
                                +--> optional ANALYZE

28.1.3 回收可重用空间不等于归还文件系统

普通 VACUUM 的 steady-state 目标

高 churn 表最健康的状态通常不是“每晚回到最小文件”,而是:

minimum live footprint
  + space consumed between vacuum cycles
  = stable relation plateau

后续更新和插入复用 plateau 内的空闲页,文件不再无限增长。PostgreSQL 官方文档建议 用较频繁的普通 vacuum 维持稳态,避免把 VACUUM FULL 当周期任务。

普通 VACUUM 也可能截断尾部

两个绝对命题都错:

normal VACUUM always shrinks files     false
normal VACUUM never shrinks files      false

普通 vacuum 主要原地处理页面;若关系尾部形成连续空页且锁等条件允许,它可能截断尾部。 文件中间的空洞不能靠截尾交还操作系统,但仍可由关系复用。

因此正确表述是:

普通 VACUUM 不承诺按 dead tuple 数缩小文件;其主要产物是可重用空间,并可能在 条件满足时截断空闲尾部。

用三类 size,不用一个数字

SELECT
  pg_relation_size('app.orders')       AS heap_bytes,
  pg_indexes_size('app.orders')        AS index_bytes,
  pg_total_relation_size('app.orders') AS total_bytes;

再联合:

SELECT *
FROM pgstattuple('app.orders');

SELECT sum(avail)
FROM pg_freespace('app.orders');

这些值回答不同问题:

指标回答不回答
heap bytes主 fork 当前文件规模其中多少马上可移除
index bytes全部索引文件规模每个索引是否逻辑健康
total bytesheap + indexes + TOAST 等总体OS 会不会马上得到空间
free_spaceheap 扫描看到的自由空间未来 workload 是否会复用
FSM sumallocator 已知的页内空间精确物理空洞
dead tuple旧版本数量/字节是否被长快照保留

正式 run 的反直觉结果

baseline heap                 61,440,000 bytes
after churn heap              87,040,000 bytes
after holder-blocked vacuum   87,040,000 bytes
after release + freeze        87,040,000 bytes

dead tuples:
  with old snapshot           50,000
  after release               0

FSM reusable:
  final                       51,920,000 bytes

表已经具备很大的内部复用空间,却没有缩小。这是普通 vacuum 的正常结果,不是失败。

更重要的是,第一次普通 vacuum 在旧 snapshot 存在时:

progress samples   128
phases             initializing, scanning heap
dead tuples        still 50,000

它确实工作了;只是清理边界不允许移除那些版本。第二次在释放保留者后:

progress samples   171
phases             scanning heap, vacuuming indexes, vacuuming heap
dead tuples        0
all-frozen pages   10,625

“命令成功”与“达成预期回收”必须分别验收。

什么时候才需要把空间交还 OS

先回答:

Will the table reuse the space within the retention horizon?

若会:

  • 保留稳定 plateau;
  • 让 autovacuum 跟上;
  • 调整 fillfactor/索引设计;
  • 监控增长斜率。

若不会,且空间有现实价值:

one-time purge
tenant offboarding
retention shortened
schema removed wide columns
index permanently overgrown
filesystem headroom endangered

才评估重写。

重写决策必须有预算

object: app.orders
live_bytes: ...
estimated_rewrite_bytes: ...
extra_disk_required: ...
wal_generated_estimate: ...
replica_replay_headroom: ...
archive_headroom: ...
lock_mode: ...
long_transaction_wait: ...
duration_estimate: ...
rollback: ...
backup_and_restore_proof: ...

不同方法:

方法主要效果主要代价
normal VACUUM页内复用、VM/freeze不整理中间空洞
VACUUM FULL重写并缩 heapACCESS EXCLUSIVE、额外空间、WAL、长窗口
CLUSTER按索引重写排序强锁、额外空间、后续不会自动保持
pg_repack较在线地重建extension、额外对象/空间、trigger/锁/失败治理
logical copy/swap最大控制力迁移与双写/切换复杂度
partition detach整片退役设计前提、DDL/依赖/归档流程

第 28.4 节展开前三类重建,第 28.5 节处理分区退役。

停止线

看到以下任一情况,不要继续“加大清理”:

oldest backend_xmin is unexplained
replication slot consumer ownership unknown
prepared transaction ownership unknown
free disk cannot hold rewrite
backup exists but restore untested
replica/archive headroom insufficient
lock queue begins to grow
suspected structural corruption

前六项先补治理证据;最后一项转入 第 35 章:数据抢救与工程取证

本节检查清单

你应能对一张表给出:

logical live rows
cumulative dead estimate
physical tuple/free-space sample
heap / index / total bytes
HOT / new-page update ratio
VM all-visible / all-frozen
oldest holder
last vacuum / autovacuum
write and growth rate
expected future reuse

只有这些信息组合起来,VACUUM 是否健康、是否被阻断、是否需要重写才是可回答的问题。

延伸阅读


返回本章目录 · 下一节:autovacuum 的触发与资源 · 查看全书目录 · 查看索引中心

最后更新于