卷 IV · 事务CH 18深度 18/24

死元组、膨胀与 VACUUM

MVCC 让读写不互相阻塞,代价是一堆死元组——被作废、却还赖在表里的旧版本。它们会让表膨胀、查询变慢。这一章直面这份账单:死元组从哪来、VACUUM 怎么打扫、为什么 PostgreSQL 的 COUNT(*) 慢,以及一个必须警惕的名字——事务 ID 回卷。

死元组膨胀VACUUM回卷

死元组:MVCC 的必然产物

上一卷你已经看到:UPDATE 不覆盖,而是作废旧版本、追加新版本;DELETE 只给旧版本打个删除标记。所以每一次 UPDATEDELETE,都在表里留下一个死元组——一个被作废、但还占着物理空间的旧版本。

只要还有任何一个事务的快照可能需要看到某个旧版本,它就不能删。一旦所有可能需要它的事务都结束了,它就彻底成了垃圾——但它不会自己消失

▶ 动手 · 死元组累积与回收

下面这台真引擎里,每点一次 UPDATEDELETE,就会留下一个死元组(划掉、灰掉的行),看「死元组」计数往上涨。然后点 VACUUM,看它们被清走、空间被腾出。

你会直观地看到:活元组的数量没怎么变,死元组却在不断堆积——表在膨胀。这就是「表大小只增不减、明明没加多少数据却越来越大」的原因。VACUUM 一按,死元组被清掉,空间回收,表重新变紧凑。

VACUUM 做什么、不做什么

  • VACUUM(普通):把死元组占的空间标记为可复用,让后续的 INSERT / UPDATE 能填进去。它不把文件变小还给操作系统——表文件大小不变,只是内部腾出了空位。它不锁表,能和正常读写并行,是日常清理的主力。
  • VACUUM FULL:把整张表重写成一个紧凑的新文件,真正把空间还给操作系统。但它要加排他锁(期间这张表读写全停),还要额外磁盘空间,是「重手术」,只在表严重膨胀时偶尔用。
  • ANALYZE:顺便说这个——它不清死元组,而是收集表的统计信息(每列有多少不同值、数据怎么分布),供查询规划器估算成本。它常和 VACUUM 一起做(VACUUM ANALYZE)。这是通往下一卷的桥。
◆ autovacuum:它一直在后台替你打扫

你几乎不用手动 VACUUM——PostgreSQL 有个后台进程 autovacuum,会盯着每张表的死元组比例,超过阈值就自动来清理、并顺手 ANALYZE。默认配置对中小库够用。但对写入极其频繁的大表,默认阈值可能太保守,导致 autovacuum 追不上死元组产生的速度,表持续膨胀——这时要给这张表单独调紧 autovacuum 参数。「表莫名其妙越来越大、越来越慢」,十有八九是 autovacuum 没跟上,这是 PG 运维最常见的功课。

为什么 PostgreSQL 的 COUNT(*) 慢

一个让 MySQL 用户意外的事:SELECT count(*) FROM big_table 在 PostgreSQL 里可能很慢,因为它得老老实实扫一遍(或扫一遍索引),逐行做可见性判断——毕竟每一行都可能有多个版本,「对当前快照来说到底有多少行活着」只能数出来。数据库没有一个随时更新的「总行数」计数器。

这又是 MVCC 的直接后果:「有多少行」这个问题,对不同的事务快照可能有不同答案(你和别人看到的可见行数不一样),所以维护一个全局精确计数既昂贵又没意义。要快速估算行数,读统计信息(SELECT reltuples FROM pg_class WHERE relname='...')就够——它是 ANALYZE 收集的近似值,做「大概多少」足矣。

⚠ 事务 ID 回卷:那个必须警惕的名字

可见性判断靠比较事务号(xid)。但 xid 是 32 位的,约 42 亿个,用完会回卷(wraparound)——号码转一圈回到起点。如果不处理,回卷后「旧事务」的号会显得比「新事务」还大,可见性判断就会错乱,大量数据可能突然「消失」,这是灾难级故障。

PostgreSQL 的对策是 VACUUM 的另一项职责:冻结(freeze)——把足够老的元组的 xmin 标记成一个特殊的「永远可见」值,让它们不再参与号码比较。autovacuum 会在 xid 快用完前强制做一次冻结 VACUUM。你平时不用管,但要知道:如果长期禁用 autovacuum、或有一个几个月不结束的超长事务挡着,回卷风险就在逼近——PG 会先疯狂告警,再不理它就只读保护自己。这是 PostgreSQL 运维必须懂的一课。

⇄ 对照 MySQL
  • InnoDB 也要清理旧版本:它有 purge 线程清理 undo log 里不再需要的旧版本,角色类似 VACUUM。所以「需要后台清理」两家都有,只是清的东西不同(PG 清主表死元组,InnoDB 清 undo)。
  • COUNT(*):老式 MyISAM 把行数存在元信息里,COUNT(*) 秒回;但 InnoDB(现代默认)和 PostgreSQL 一样要扫,因为都是 MVCC。所以「PG 的 COUNT 慢」更准确的说法是「MVCC 引擎的精确 COUNT 都要扫」。
  • 没有回卷概念:InnoDB 的事务 ID 是 64 位、且实现方式不同,不存在 PostgreSQL 这种 32 位 xid 回卷问题。这是 PG 用户要额外操心、MySQL 用户没有的一项运维负担——也是 PG 社区一直在改进的方向(近年在推进 64 位 xid)。

这一章的一句话

死元组是 MVCC 的账单:UPDATE/DELETE 留下旧版本让表膨胀,VACUUM(多由 autovacuum 自动做)回收它们的空间并冻结老元组防回卷;COUNT(*) 慢、也是「多版本下行数因快照而异」的必然。

卷 IV 到此完整:事务给你原子性,MVCC 给你无阻塞的并发,VACUUM 替你还账。接下来的卷 V,是 PostgreSQL 那个「会思考的大脑」——查询规划器。你只写「要什么」,它认真替你算「怎么拿最省」。下一章,招牌引擎登场。