postgresql 的 update 不是原地修改,而是插入新元组并标记旧元组 xmax 为当前事务 id,形成死元组,这是 mvcc 实现读写不阻塞、保障事务一致性的核心机制。

UPDATE 为什么不是“原地改”,而是“写新+标旧”
PostgreSQL 的 UPDATE 从不真正覆盖旧行——它在物理层面插入一条新元组(tuple),同时把旧元组的 xmax 字段设为当前事务 ID,标记为“对后续事务不可见”。这个被标旧的元组,就是死元组(dead tuple)。
这么做的根本原因是为了实现 MVCC:正在运行的长事务可能还需要看到旧版本数据,系统必须保留它,直到所有依赖它的事务结束。所以死元组不是 bug,是并发一致性的代价。
常见错误现象:
- 表大小持续增长,但
SELECT COUNT(*)行数几乎不变 -
pg_stat_all_tables.n_dead_tup值飙升(比如 >10 万),而n_live_tup变化不大 - 顺序扫描变慢,即使加了索引,I/O 读取量却远超预期
死元组不清理,空间就永远卡在表文件里
PostgreSQL 的页(page)是 8KB 固定大小,一旦分配给某张表,就不会归还给操作系统。死元组只释放页内空间,但页本身仍属于该表文件。这意味着:
-
VACUUM只重用这些页内空闲空间(供后续INSERT或UPDATE使用),不会缩小表文件尺寸 -
VACUUM FULL才会重建整个表、压缩页、释放空页并返还磁盘空间,但它需要排他锁,业务高峰期基本不可用 - 索引同样受牵连:每个死元组在索引中也存有对应条目,索引膨胀常比表更严重
典型陷阱:有人看到 n_dead_tup 高,立刻手动跑 VACUUM FULL,结果锁表 20 分钟,下游服务全挂——其实该先查 GetOldestXmin() 看是否被长事务阻塞,再决定是杀事务还是调参。
autovacuum 为什么经常“赶不上趟”
默认 autovacuum 触发条件是:表中“变更行数”达到 autovacuum_vacuum_scale_factor * n_live_tup + autovacuum_vacuum_threshold(默认为 20% + 50)。对一张 500 万行的表,得攒够 100 万死元组才启动——这期间磁盘已多占好几个 GB。
高频更新场景下容易失效的几个关键点:
- SSD 机器上
autovacuum_vacuum_cost_limit默认值(200)太保守,建议调到4000加速清理 - 存在未关闭的复制槽(replication slot),哪怕备库断连,也会让
oldest_xmin卡住不动,导致死元组完全无法回收 -
idle_in_transaction_session_timeout没开,应用端连接挂起但事务未提交,长期 hold 住快照边界
验证是否被阻塞最直接的方式是查:SELECT pid, backend_start, state_change, query FROM pg_stat_activity WHERE state = 'idle in transaction';
别只盯着表,索引膨胀才是隐藏性能杀手
很多人只监控 pg_stat_all_tables.n_dead_tup,却忽略索引——而索引里的“假存活”元组更难清理。因为索引项不存 xmax,只能靠表元组状态间接判断;一旦表中死元组没被 vacuum 清理,索引里对应项就一直算“有效”,持续占用空间、拖慢索引扫描。
一个常被忽视的事实:pg_stat_all_indexes 不暴露索引死元组数。要估算索引膨胀率,得用 pg_bloat_check 或结合表膨胀率反推——比如表膨胀 30%,那它的 B-tree 索引实际占用空间大概率也虚高 30% 以上。
真正棘手的是:索引膨胀后,即使你对表执行了 VACUUM,索引结构也不会自动重组。必须显式执行 VACUUM(或 REINDEX)才能清理索引页中的“幽灵条目”。










