Chapter 04 · 核心观念 ②
MVCC 与 VACUUM:每次写都在留下旧版本
前三章默认"表里的行就在那儿、统计信息能反映现实"。这一章揭开 PostgreSQL 写入的真相——每次写都在制造旧版本,VACUUM 是清道夫——表膨胀、长事务的危害、freeze 都从这一条事实派生。
本章你将建立的心智模型
- 核心观念:
UPDATE= 写新版本 + 留死元组,DELETE= 打删除标记;靠每个版本的xmin/xmax+ 事务快照判断可见性 - 死元组的定义与表膨胀:对所有活跃事务都不再可见的旧版本,堆积起来就是膨胀
- VACUUM 回收死元组占用的空间,但留给本表复用、不还盘给操作系统
- HOT 更新:未改被索引列时,新版本不建索引项,显著减少索引维护
- autovacuum 触发公式与调优;freeze 与事务 ID wraparound
- 可见性映射(visibility map)如何把这一章连回第 03 章的 index-only scan
4.1核心观念:每次写都留下旧版本
UPDATE 不原地修改,而是写入一个新行版本,旧版本变成死元组(dead tuple);DELETE 只是给行打上删除标记。
从 MySQL(InnoDB)过来的工程师,脑子里的图像是"UPDATE 把行就地改了,旧值进 undo log,提交后 undo 很快被清掉"。PostgreSQL 的存储模型完全不同:它不就地改,而是把新版本作为一条新元组写进堆(heap),旧元组原地留着、只是被标记为已被某事务取代。这套机制叫 MVCC(多版本并发控制)。理解它是关键,因为后面所有现象——表为什么会膨胀、长事务为什么危险、为什么删了一半数据文件却没变小——全都是这一条事实的直接推论。
每个行版本(tuple)在堆里都带两个隐藏的系统字段:
xmin:创建这个版本的事务 ID(XID)。xmax:删除或取代这个版本的事务 ID;若该版本还"活着",xmax为 0。
于是同一逻辑行的多个版本会在堆里共存:一次 UPDATE 把旧版本的 xmax 设为当前事务 ID,同时插入一个 xmin 为当前事务 ID 的新版本。DELETE 更简单——只设旧版本的 xmax,不插入新版本。真正的物理删除不在 DELETE 这一刻发生,而是后来由 VACUUM 完成。
UPDATE 两次,堆里留下三个版本。注意:UPDATE 在物理层面是"插入新版本 + 标记旧版本",不是"覆盖"。前两个版本立刻变成占着空间的死元组,直到 VACUUM 来回收——这就是表膨胀的微观起点。"留旧版本"不是设计缺陷,是 MVCC 换来并发读写不互相阻塞的代价(§4.2 解释为什么)。代价的另一面是:写多的表会持续制造死元组,必须有人定期清。这个"人"就是 VACUUM。这一章的全部内容,本质上是这一笔交易的账单与还款方式。
4.2可见性:快照决定你看到哪个版本
每个事务按自己的快照,只看到 xmin 已提交且早于快照、且(xmax 未提交或晚于快照)的那个版本。
同一行有多个版本,一个事务该看到哪个?答案藏在它启动时(或语句开始时)拿到的一个快照里。快照记录了"此刻哪些事务已提交、哪些还在进行"。判定一个版本对当前事务是否可见,规则可以简化成两句:
- 它的
xmin对应的事务已提交,且在当前事务的快照之前——这个版本对它"已经存在"。 - 它的
xmax为空,或对应事务尚未提交 / 晚于当前快照——这个版本对它"还没被删"。
两条同时成立,这个版本才对当前事务可见。这正是 MVCC 的卖点:读不阻塞写、写不阻塞读——读事务沿着快照找到属于自己的旧版本,完全不必等正在改这一行的写事务提交。
死元组的精确定义
有了可见性规则,就能精确定义死元组(dead tuple):一个旧版本,如果对所有当前活跃事务都不再可见——也就是没有任何快照还需要它——它就是死元组,可以被安全回收。注意"对所有活跃事务"这个限定:只要还有一个老事务的快照仍能看到它,它就不是死元组,VACUUM 不能动它。
一个长时间不提交的事务,会一直持有它启动时的老快照。VACUUM 判定死元组时必须保守:任何"这个老事务仍能看到"的旧版本,都不能回收。于是只要这个长事务挂着,期间所有表产生的死元组都被"钉住",无法清理 → 死元组持续堆积 → 表膨胀。这把第 01 章 wait_event 里的 Lock / 长事务,和本章的膨胀,接成了同一条链:一个忘记提交的事务,既能堵住锁,又能在背后悄悄把表撑大。
一个 BI 工具开了一个事务跑大报表,跑了 40 分钟还没结束。这 40 分钟里,一张高频更新的 orders 表产生的死元组,能被 autovacuum 回收吗?为什么?
展开答案(先想一步再点)
不能(至少回收不掉那些报表事务仍能看到的版本)。报表事务持有一个 40 分钟前的老快照,VACUUM 必须假设它还会读到那时可见的旧版本,因此把这些死元组全部跳过。结果:orders 在这 40 分钟里只进不清,膨胀。
排查时,SELECT * FROM pg_stat_activity WHERE state <> 'idle' ORDER BY xact_start; 找最老的 xact_start;治理长事务(超时、拆事务、只读副本跑报表)往往比拼命调 autovacuum 更治本。
4.3VACUUM:回收死元组(并喂养优化器)
VACUUM 扫描表,把对所有事务都不可见的死元组占用的空间标记为可重用。
文档会说"VACUUM 回收死元组占用的空间"。容易被误解的是"回收"二字——它不等于把磁盘还给操作系统。下面这条澄清是本节的核心,也是面试高频追问。
关键澄清:普通 VACUUM 不缩小文件
普通 VACUUM 把死元组占的空间标记为该表后续可重用(记进表的空闲空间映射 free space map)。同一张表之后的 INSERT / UPDATE 会优先填进这些空洞。但表的物理文件不会变小、空间不还给操作系统——磁盘占用维持在历史峰值。所以"删了一半数据,df 看磁盘却没降"不是 bug,是预期行为。
| 命令 | 做什么 | 锁 | 磁盘空间 | 何时用 |
|---|---|---|---|---|
VACUUM(普通) | 标记死元组空间为本表可重用 | 轻量,不阻塞读写 | 不缩小文件,留给本表复用 | 日常;通常交给 autovacuum |
VACUUM FULL | 重写整张表到新文件,挤掉所有空洞 | ACCESS EXCLUSIVE,锁住整表 | 缩小文件、归还操作系统 | 已严重膨胀且能停机;生产慎用 |
VACUUM FULL 确实能把膨胀的表重写紧凑、把空间还给操作系统,但它持有 ACCESS EXCLUSIVE 锁——执行期间这张表读写全部阻塞,大表会锁住几十分钟。生产环境别直接对热表跑。在线替代是扩展 pg_repack:它在后台重建表、只在最后一瞬间做一次极短的切换,几乎不阻塞业务。
VACUUM 顺带做的三件事
除了回收空间,一次 VACUUM(或 autovacuum)还会:
- 更新统计信息(autovacuum 会捎带触发 ANALYZE)——直接喂养 第 02 章讲的优化器代价估算。VACUUM 跟不上,统计也容易跟不上,优化器就开始估错。
- 更新可见性映射(visibility map):把"整页都是对所有事务可见的活元组"的数据页标为 all-visible。第 03 章的 index-only scan 正是靠这个标记决定能不能免回表——页没被标 all-visible,index-only scan 就退化成要回堆。
- freeze 老行:把足够老的行版本标记为"对所有事务永远可见",防止事务 ID 回绕(§4.6)。
VACUUM 不只是"清垃圾"。它同时维护着第 02 章的统计信息和第 03 章 index-only scan 依赖的可见性映射。所以"autovacuum 没跟上"的后果是复合的:表膨胀(本章)+ 统计过期导致选错计划(02)+ 本可免回表的扫描退化(03)。一个被忽视的 autovacuum,能同时点燃三章里的问题。
PG 17 把 VACUUM 记录待清理死元组 TID 的数据结构换成了 TidStore,移除了沿用多年的1GB 死元组内存上限。此前 maintenance_work_mem 调再大,单轮 VACUUM 最多也只用 1GB 存 TID,大表往往要多轮扫描索引;现在 maintenance_work_mem 能被充分利用,大表 VACUUM 的索引清理轮数显著减少、更快完成。升级到 17+ 的大库,这是一项几乎白拿的收益。
4.4HOT 更新:UPDATE 的省钱路径
如果一次 UPDATE 没有修改任何被索引的列、且当前堆页有空闲空间,新版本就放在同一页、且不新建任何索引项。
这条路径叫 HOT(Heap-Only Tuple)。回想 §4.1:普通 UPDATE 要插入新版本,并为每一个索引都加一条指向新版本的索引项——表上有 5 个索引,一次 UPDATE 就要动 5 个索引。HOT 把这笔开销省掉:新版本通过页内的一条指针链接到旧版本,索引项仍指向旧位置、顺着链就能找到新版本,因此无需触碰任何索引。
HOT 触发有两个硬条件,缺一不可:
- 这次
UPDATE没有改任何被索引的列(改了status,而status上没索引——可以;改了建有索引的email——不行)。 - 旧版本所在的堆页还有空闲空间容纳新版本。
第二个条件给了一个可调旋钮:fillfactor。它让 PostgreSQL 在每个数据页里故意留白(默认表是 100,即填满;设成 90 表示每页只填 90%、留 10% 给后续更新)。频繁更新的表把 fillfactor 调到 90 左右,新版本更容易挤进同一页 → HOT 命中率上升。
-- 为高频更新的表留出页内空间,促成 HOT 更新
ALTER TABLE counters SET (fillfactor = 90);
-- 重写后生效(对已有数据)
VACUUM FULL counters; -- 注意会锁表;或用 pg_repack
-- 观察 HOT 命中:n_tup_hot_upd 占 n_tup_upd 越高越好
SELECT relname, n_tup_upd, n_tup_hot_upd
FROM pg_stat_user_tables
WHERE relname = 'counters';
第 03 章讲过"每个索引都会拖慢写入"。HOT 给出了机制层面的解释:只要 UPDATE 不碰被索引的列,就能走 HOT、零索引维护;但一旦改了任何一个被索引的列,HOT 立刻失效,所有索引都要加新项。所以在频繁更新的表上,给一个"经常被改的列"建索引,代价远不止"多一个索引"——它会让该表大量 UPDATE 从 HOT 跌回全索引维护。建索引前先问:这列会不会被高频 UPDATE?
4.5表膨胀:诊断与处理
死元组堆积,使表和索引占用的空间远超实际有效数据,这就是膨胀(bloat)。
膨胀是前几节的必然产物:写产生死元组(§4.1),长事务或滞后的 autovacuum 让死元组清不掉(§4.2),而普通 VACUUM 又不缩小文件(§4.3)。结果是一张逻辑上只有 1GB 有效数据的表,物理上占了 5GB。
诊断:先看 pg_stat_user_tables
第一手信号来自系统视图 pg_stat_user_tables:n_dead_tup(死元组数)、n_live_tup(活元组数)、last_autovacuum(上次自动清理时间)。死元组占比高、且 last_autovacuum 很久以前(或为空),就是膨胀正在发生的直接证据。
SELECT relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 5;
relname | n_live_tup | n_dead_tup | dead_pct | last_autovacuum
-------------+------------+------------+----------+-------------------------------
counters | 120000 | 4830000 | 97.6 | 2026-05-30 02:11:09 -- 三天前
orders | 9800000 | 410000 | 4.0 | 2026-06-02 06:30:55
audit_log | 30000000 | 120 | 0.0 | 2026-06-02 07:01:12
counters:活元组 12 万,死元组却有 483 万——dead_pct=97.6%。这张表 97% 以上的元组都是垃圾,且last_autovacuum停在三天前,autovacuum 明显没跟上。典型的高频更新计数器表膨胀(正是本章末尾挑战题的原型)。orders:死元组占比 4%、当天清过——健康。
需要精确的膨胀字节数(而非比例估计)时,装扩展 pgstattuple:SELECT * FROM pgstattuple('counters'); 会给出活/死元组的确切字节、空闲空间占比。
膨胀的后果:连回第 01 章的页读
膨胀不是"占点磁盘"那么轻。一张膨胀的表,同样的数据散落在更多的数据页里:
- 顺序扫描要读更多页:有效数据没变,但
Seq Scan必须读完所有膨胀的页。这直接体现在 第 01 章BUFFERS的read数变大。 - 缓存命中率下降:页变多,
shared_buffers装不下,更多页要从磁盘读(连第 05 章)。 - 索引也膨胀:索引同样积累指向死元组的项,变大、变深,查找变慢。
处理:三个层次
- 治本:让 autovacuum 跟上(§4.6)。绝大多数膨胀的根因是 autovacuum 对这张表太迟钝,调参后稳态膨胀会回落到可接受区间。
- 已严重膨胀、需立刻回收磁盘:
VACUUM FULL(锁表,停机窗口内用)或pg_repack(在线、几乎不阻塞)。 - 设计层面:对天然高频更新的表,用 HOT 友好设计(§4.4)从源头减少死元组与索引维护。
4.6autovacuum 调优 + freeze/wraparound
autovacuum 是后台进程,按一条公式自动判断"这张表的死元组够多了,该清了"——公式默认值对大表偏迟钝,这是膨胀的头号配置原因。
触发公式
对每张表,autovacuum 在死元组数超过下面这个阈值时触发清理:
vacuum 触发阈值
= autovacuum_vacuum_threshold (默认 50)
+ autovacuum_vacuum_scale_factor (默认 0.2) × 表的行数(reltuples)
例:一张 1000 万行的表
= 50 + 0.2 × 10,000,000
= 2,000,050 → 要积累约 200 万死元组才触发一次 autovacuum
那个 scale_factor = 0.2 意味着:表要积累相当于自身 20% 的死元组才会被清。小表无所谓,但一张一亿行的表,就要堆到 2000 万死元组才触发——这期间表已经明显膨胀、查询已经变慢。修法:对大表单独调小 scale_factor(如 0.01,即 1%),或改用按行数的绝对阈值(PG 13+ 支持每表覆盖)。下面是按表覆盖的写法:
-- 对一张高频更新的大表,让 autovacuum 更积极
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01, -- 1% 死元组就清,而非默认 20%
autovacuum_vacuum_threshold = 1000
);
PG 18 新增参数 autovacuum_vacuum_max_threshold(默认 1 亿行),给上面的线性公式加了一个绝对上限:死元组一旦达到这个数就触发,不再等"20% 比例"。这正面解决了"超大表用比例公式触发太晚"的老问题——以前要靠手动调 scale_factor,现在默认就有兜底。升级到 18 的大库可以少操一份心。
freeze 与事务 ID wraparound
事务 ID(XID)是 32 位的,约 21 亿个值用完后会回绕(wraparound)归零。可见性判断依赖"当前 XID 与版本 xmin 的大小关系",一旦 XID 回绕,"过去"和"未来"会错乱,本该可见的老数据会突然变得不可见——这是会丢数据的灾难级故障。
防回绕的机制就是 freeze:VACUUM 把足够老的行版本标记为"已冻结",即对所有事务永远可见,从此不再参与 XID 大小比较,把它们"移出"回绕的威胁。参数 autovacuum_freeze_max_age(默认 2 亿)控制:一张表最老的未冻结 XID 距今超过这个年龄,就强制触发一次 freeze 用的 autovacuum——即使你把 autovacuum 关了也照样跑。
如果 freeze 长期跟不上(常因 autovacuum 被关、或长事务持续阻止冻结),XID 年龄逼近回绕极限时,PostgreSQL 会强制数据库进入只读、拒绝新写入,直到你跑 VACUUM 把年龄降下来。这就是俗称的 "vacuum-or-die"。线上见到日志里 database is not accepting commands to avoid wraparound 就是它——此时唯一出路是赶紧 VACUUM(freeze)。预防:别关 autovacuum,盯住长事务和 SELECT datname, age(datfrozenxid) FROM pg_database;。
回绕风险的两个长期痛点正在被 PG 19 处理:其一,64 位 MultiXact 成员——把另一处 32 位计数器(用于多事务行锁)扩到 64 位,大幅推后另一类回绕;其二,并行 autovacuum 索引清理,让大表的 freeze/清理能用多核加速、更难落后。这些是 PG 19+/即将到来的特性,现网 13–17 仍要靠 §4.6 的调参和长事务治理来防回绕。
关键参数速查
| 参数 | 默认 | 作用 | 何时调 |
|---|---|---|---|
autovacuum_vacuum_scale_factor | 0.2 | 触发阈值里的"比例项" | 大表调小(0.01~0.05),最常调的旋钮 |
autovacuum_vacuum_threshold | 50 | 触发阈值里的"常数项" | 小表/想要绝对下限时配合调 |
autovacuum_vacuum_max_threshold | 1 亿(PG 18+) | 给比例公式封顶的绝对上限 | PG 18+ 超大表,通常用默认即可 |
autovacuum_freeze_max_age | 2 亿 | 强制 freeze 的 XID 年龄上限 | 写入极猛、逼近回绕时调小以更早冻结 |
autovacuum_naptime | 1 min | 两轮检查之间的休眠 | 更新极频繁的库可调短 |
autovacuum_max_workers | 3 | 同时清理的 worker 数 | 表多、单轮清不完时调大 |
maintenance_work_mem | 64 MB | 单次 VACUUM 可用内存(连 05 章) | 大表 VACUUM 慢时调大(PG 17+ 受益最明显) |
把六节收成一句话:写制造死元组(§4.1)→ 可见性 + 长事务决定哪些能清(§4.2)→ VACUUM 清但不还盘(§4.3)→ HOT 从源头少造垃圾(§4.4)→ 清不及时就膨胀(§4.5)→ autovacuum 调参 + freeze 兜底(§4.6)。排查一张"越用越慢、越来越大"的表,顺着这条链走一遍,根因几乎总在其中某一环。
§本章 self-check
先合上教程,把答案写下来再对照。这一章的题大多没有"背一下就行"的答案,需要你把因果链走一遍。
- PostgreSQL 里一条
UPDATE在物理层面做了什么?为什么说它会"留下旧版本"?xmin/xmax各记什么? - 一张表
DELETE掉了 90% 的行,跑完普通VACUUM后,操作系统看到的文件大小会变小吗?为什么?要真正回收磁盘该怎么办、代价是什么? - 一个 BI 报表事务开了 30 分钟没提交。请把它和"另一张高频更新表的膨胀"用一条因果链连起来——为什么前者会导致后者?
- 一张表上某列被高频
UPDATE。给这列建索引,除了"多一个索引要维护",还会带来什么更大的写入代价?用 HOT 解释。
答案(先做完再展开)
- 它插入一个新行版本,并把旧版本的
xmax设为当前事务 ID(不就地覆盖)。旧版本仍物理留在堆里,成为死元组。xmin= 创建该版本的事务 ID;xmax= 删除/取代该版本的事务 ID(活着时为 0)。 - 不会变小。普通
VACUUM只把死元组空间标记为本表可重用(进 free space map),不缩小文件、不还操作系统。要真正回收磁盘:VACUUM FULL(重写整表、归还空间,但持ACCESS EXCLUSIVE锁、锁住整表)或在线的pg_repack。 - 长事务持有一个 30 分钟前的老快照;VACUUM 判定死元组时必须保守,任何"这个老事务仍能看到"的旧版本都不能回收。于是这 30 分钟里高频更新表产生的死元组全被"钉住"、清不掉 → 只进不清 → 膨胀。即:长事务通过"钉住老快照"间接撑大了另一张毫不相关的表。
- 它会让该表大量
UPDATE失去走 HOT 的资格。HOT 要求"未修改任何被索引列",一旦这个高频更新列被索引,每次改它都不能 HOT → 必须为所有索引各加一条新项 + 制造死元组。写入代价从"零索引维护"跳到"全索引维护",远不止多一个索引那么简单。
一张每秒上千次 UPDATE 的计数器表,膨胀严重、查询变慢
线上一张 counters 表(几行到几万行,每行一个计数,业务每秒 UPDATE ... SET count = count + 1 上千次)。最近这张很小的表,简单的按主键查询却越来越慢,磁盘占用涨到几个 GB。请列出你的诊断步骤,并给出至少两种修复方向。
提示(卡住再展开)
诊断:① pg_stat_user_tables 看 counters 的 n_dead_tup / n_live_tup 和 last_autovacuum——通常会看到死元组远超活元组、autovacuum 落后;② n_tup_hot_upd vs n_tup_upd 看 HOT 命中率是不是很低;③ 用 pgstattuple 量化膨胀字节;④ pg_stat_activity 按 xact_start 找有没有长事务。
修复方向(任选两个以上):(a) 把这张表的 autovacuum_vacuum_scale_factor 调到极小(如 0.01)+ 调小 autovacuum_naptime,让清理跟上每秒上千的死元组;(b) HOT 友好设计 + 降 fillfactor 到 90,让新版本留在同页、走 HOT;(c) 已经膨胀的存量用 pg_repack 在线重写(别用 VACUUM FULL 锁热表)。
两个隐藏因素(题眼):其一,如果有长事务持着老快照,调多猛的 autovacuum 都清不掉被钉住的死元组——先排查 xact_start;其二,若计数列恰好被索引(或表上有其它索引、且更新碰到了被索引列),每次 UPDATE 都无法 HOT、还要维护索引,膨胀和慢会雪上加霜——检查这列是不是不必要地建了索引。