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 看不见旧版本不等于旧版本可删。
xmin、xmax、ctid 是诊断入口,不是业务 API
在教学夹具可以观察:
SELECT
ctid,
xmin::text,
xmax::text,
id,
revision
FROM maint.churn
WHERE id = 42;但要保留三项边界:
- 普通 SQL 只返回当前 snapshot 可见的版本,不会自动展示完整版本链;
ctid会随 UPDATE、表重写和行移动变化,不能当持久业务键;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 条件是:
- 更新没有修改任何被普通索引引用的列;核心中的 summarizing index 例外是 BRIN;
- 原 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 目的分成:
- 回收或复用 UPDATE/DELETE 占用的空间;
- 更新 planner statistics;
- 更新 visibility map,帮助 index-only scan;
- 防止 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 需要 vacuum | index-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 Map 与
pg_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 ANALYZE28.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 bytes | heap + indexes + TOAST 等总体 | OS 会不会马上得到空间 |
free_space | heap 扫描看到的自由空间 | 未来 workload 是否会复用 |
| FSM sum | allocator 已知的页内空间 | 精确物理空洞 |
| 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 | 重写并缩 heap | ACCESS 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 是否健康、是否被阻断、是否需要重写才是可回答的问题。
延伸阅读
- PostgreSQL 18:Routine Vacuuming
- PostgreSQL 18:VACUUM
- PostgreSQL 18:Heap-Only Tuples
- PostgreSQL 18:Free Space Map
- PostgreSQL 18:Visibility Map
- PostgreSQL 18:
pgstattuple - PostgreSQL 18:
pg_visibility