Skip to content

PostgreSQL 中的 MVCC — 6. VACUUM

原文:https://habr.com/en/companies/postgrespro/articles/484106/ (作者 Egor Rogov,PostgresPro)

前面讲了轻量级的页内清理只能局部地回收单个页面里的空间,这一篇讲真正意义上的 VACUUM 命令:它怎么处理整张表、怎么协调索引清理,以及为什么普通 VACUUM 没法真正缩小表文件的物理大小(那是 VACUUM FULL 要做的事)。

VACUUM 要解决什么问题

页内清理速度快,但只能利用单个页面内部现成的空闲空间,做不到跨页面整理。基础的 VACUUM 命令则会扫描整张表,把所有已经不可见(超出事件视界)的死元组连同索引里对应的引用一起清除掉。它的一个重要特性是:执行期间不阻塞正常的读写操作(虽然会阻塞部分 DDL 命令),可以和业务查询并发进行。VACUUM 依赖可见性映射来快速跳过那些已知不含死元组的页面,同时也会更新空闲空间映射,供后续插入使用。

一个简单的演示:

sql
CREATE TABLE vac(
  id serial,
  s char(100)
) WITH (autovacuum_enabled = off);
CREATE INDEX vac_s ON vac(s);
INSERT INTO vac(s) VALUES ('A');
UPDATE vac SET s = 'B';
UPDATE vac SET s = 'C';

执行 VACUUM 之后,之前那些死掉的旧版本会被彻底标记为"unused"(这与前面页内清理只是把它们标为"dead"不同),同时索引里指向这些死版本的条目也会被一并移除。

事件视界仍然是硬约束

VACUUM 同样受制于系列前文讲过的事件视界:只要还有事务持有一份能看到某个死元组的老快照,这个死元组就不能被清理,无论 VACUUM 运行多少次都无济于事——这正是"长事务导致表膨胀"这一常见运维问题的根本原因。

原文举例说明:如果有一个事务始终开着(哪怕它自己什么也不做),VACUUM 输出信息里会明确提示类似"1 dead row versions cannot be removed yet, oldest xmin: 4006"这样的内容,说明有死元组因为这个老快照的存在而无法被清理。

这引出一个现实的架构建议:OLTP(高频短事务写入)和 OLAP(长时间运行的报表查询)如果混跑在同一个数据库实例上,会互相拖累——报表类的长事务会持续拖住事件视界,导致高频更新的表持续膨胀。常见的解决方案是把报表查询挪到单独的只读副本上执行,避免它们和主库的写入事务共享同一个事件视界。

一旦这个长事务结束,事件视界前移,之前被卡住的死元组重新变得可清理,后续 VACUUM 就能正常回收它们(日志里会显示类似"removed 1 row versions in 1 pages")。

VACUUM 内部的三个阶段

一次完整的 VACUUM 大致分为三步:

第一步:扫描堆表(heap)。VACUUM 遍历表的页面(借助可见性映射跳过已知无需处理的页面),识别出所有死元组,把它们的标识符(TID)先记录到一个专门分配的内存数组里。这块内存来自 maintenance_work_mem 参数(默认 64MB),并且是一次性分配好的,不是按需增长。

第二步:清理索引。逐个扫描每一个相关索引,把指向这些已记录死元组的索引条目统统移除。这一步的关键在于:清理顺序是先索引后堆表——必须先确保索引里不再有任何指向这些死元组的引用,之后再去堆表里真正物理回收这些位置,否则会出现索引悬空引用的问题。

第三步:再次扫描堆表。这一次是真正把第一步标记好的死元组以及对应的行指针从页面里物理清除。因为第二步已经先把索引里的引用清干净了,这一步的清理是安全的。

这个流程带来一个直接的结果:表本身会被完整扫描两遍。如果一次 VACUUM 里死元组的数量超过了 maintenance_work_mem 分配的内存容量,装不下这么多 TID,那么索引清理这一步就要分批多轮进行——这意味着索引可能要被反复扫描好几次,对于大表来说会带来相当可观的额外 I/O 开销。

一个有用的优化:从 PostgreSQL 11 开始,如果 VACUUM 判断确实没有必要清理索引(比如整个表里根本没找到死元组),可以直接跳过索引扫描这一步,避免无谓的开销。

监控 VACUUM 的执行进度

方式一:VERBOSE 选项,会输出每个阶段的详细统计信息:

sql
VACUUM VERBOSE vac;

方式二:pg_stat_progress_vacuum 视图(PostgreSQL 9.6+ 提供),可以在 VACUUM 运行期间实时查看进度:

sql
SELECT * FROM pg_stat_progress_vacuum \gx

关键字段包括:phase(当前所处阶段,比如正在清理索引)、heap_blks_total(表的总页数)、heap_blks_scanned(已扫描的页数)、heap_blks_vacuumed(已完成清理的页数)、index_vacuum_count(索引已经被完整扫描过的次数)。

从这些指标可以推断出一些有用的信息:如果 index_vacuum_count 大于 1,通常说明 maintenance_work_mem 分配的内存不够用,导致索引被扫描了不止一遍;粗略的整体进度可以用 heap_blks_vacuumedheap_blks_total 的比值来估算。

关于内存容量的一个具体计算:一个行标识符(TID)占用 6 字节,那么 1MB 的 maintenance_work_mem 理论上能存下 1024×1024÷6 ≈ 174,762 个 TID(实际使用时会略微少留一点余量,以保证下一页的数据也能装得下)。

ANALYZE 顺带一提

严格来说 ANALYZE(用于更新统计信息,供查询规划器使用)和 VACUUM 是两个独立的功能,但经常被放在一起使用:

sql
VACUUM ANALYZE vac;

VACUUM ANALYZE 只是依次先跑 VACUUM 再跑 ANALYZE,两者顺序执行,并不会因为合在一起而有额外的性能收益。而在自动化的 autovacuum 机制里(下一篇的主题),这两个动作是被整合到同一次进程调用里完成的,效率会更高一些。

VACUUM FULL:真正缩小文件

普通 VACUUM 解决不了的问题

普通 VACUUM 只能在现有页面内部回收空间,不会减少表占用的页面数量——换句话说,即便一张表因为大量删除而变得非常"空洞",它的物理文件大小也不会因为普通 VACUUM 而缩小(唯一的例外是:如果文件末尾恰好有整块被完全清空的页面,这部分可以被直接截断归还给操作系统)。

这种"体积虚高但内容稀疏"的状态会带来几个实际问题:全表扫描变慢(因为要扫过大量本质上是空的页面);占用更多的缓冲区缓存空间;索引树可能因此多出不必要的层级;浪费磁盘空间,也会拖累备份的存储成本和耗时。

VACUUM FULL 的做法

VACUUM FULL 会把整张表连同它的索引从头彻底重建:先创建一套全新的文件,把有效数据紧凑地写进去(写入时会考虑 fillfactor 参数),完成后再删除旧文件。整个过程按顺序先重建表本身,再逐个重建每个索引。这个过程需要额外的磁盘空间(新旧文件会同时存在一段时间),重建完成后旧文件才会被释放。

用 pgstattuple 观察膨胀程度

可以用 pgstattuple 扩展直接量化一张表的"膨胀"程度:

sql
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('vac') \gx

其中 tuple_percent 字段表示页面里真正有效数据所占的百分比。对索引也有类似的检查手段:

sql
SELECT * FROM pgstatindex('vac_s') \gx

关键字段是 avg_leaf_density,表示索引叶子页面里有效信息所占的密度百分比。

原文给出了一个具体的对比实验数据:一张表初始大小是表本身 65MB、索引 69MB,tuple_percent 为 94.47%(数据密度很高,几乎没有浪费)。删除掉其中 90% 的行之后,表和索引的文件大小完全没有变化,依然是 65MB 和 69MB,但 tuple_percent(或 avg_leaf_density)骤降到只剩 9.41% ——绝大部分空间实际上是空洞。执行 VACUUM FULL 之后,表被压缩到 6648KB、索引压缩到 6480KB,密度恢复到 94.39% 和 91.08%,基本回到了紧凑状态。

值得一提的是,VACUUM FULL 之后表和索引对应的物理文件会换成新的文件编号(filenode 发生了变化)。

原文还解释了为什么重建索引往往比逐条插入更划算:用现成的、已排好序的数据整体构建一棵 B-树索引,要比一行一行插入数据、逐步增量维护索引结构高效得多——这也是为什么 VACUUM FULL 选择重建而不是原地整理索引。

对于特别大的表,逐行统计膨胀程度的 pgstattuple 本身开销可能过高,可以改用 pgstattuple_approx,它会跳过可见性映射里已标记为"全可见"的页面,给出一个近似但成本低得多的估算结果。

VACUUM FULL 的代价

VACUUM FULL 有一个显著的限制:它在整个执行过程中会独占锁定这张表,阻塞其他所有对该表的访问,直到完成为止。对于访问频繁、不能承受长时间中断的生产系统来说,这通常是不可接受的。

一个常见的替代方案是社区扩展 pg_repack——它可以在后台增量地重建表和索引,只在最后收尾阶段短暂地加锁,大幅缩短对业务的影响窗口。

几个相关命令的对比

CLUSTER:效果和 VACUUM FULL 类似,同样是重建表文件,但额外还会按照指定索引的顺序把数据行物理重新排列,这在某些查询模式下能带来更好的索引访问局部性。需要注意的是,这种排序效果不会随着后续的增删改自动维持,数据慢慢又会变得"无序"。

REINDEX:单独重建某一个索引,VACUUM FULL 和 CLUSTER 在重建索引这一步内部用的正是类似的机制。

TRUNCATE:逻辑上和 DELETE 类似(都是清空表数据),但实现方式完全不同——DELETE 只是把行标记为已删除,依然需要后续 VACUUM 才能真正回收空间;而 TRUNCATE 直接抛弃旧文件、创建一个全新的空文件,速度快得多。代价是 TRUNCATE 需要在事务结束前一直独占锁定这张表。

小结

VACUUM 的核心工作分三步:扫描堆表找出死元组、清理索引里对应的引用、再回头物理清除堆表里的死元组——这个"先索引后堆表"的顺序是保证清理安全性的关键。VACUUM 的效果始终受限于事件视界,长事务会直接导致表膨胀且无法通过更频繁地跑 VACUUM 来缓解。普通 VACUUM 能回收页面内部的空间,但无法缩小表文件本身的物理体积;要真正压缩文件大小,需要用开销更高、且会独占锁表的 VACUUM FULL(或者用 pg_repack 这类在线重建工具作为折中方案)。这些内容为下一篇讨论 autovacuum 如何自动化地调度这套清理机制打下了基础。