由 John Doe 八月 4, 2026
自动清理(Autovacuum)的高效运行,是 PostgreSQL 中最重要的自动维护任务。
目录
作者:Pep Pla
前言
清理(Vacuum),更准确地说是自动清理(Autovacuum),是 PostgreSQL 中最重要的自动维护任务。它不仅关乎数据库性能,更是数据库长期稳定运行的关键。运行过于频繁,会拖累性能;运行频次不足,同样会导致性能下降。工作进程太少,清理耗时过长;进程太多,又会过度消耗系统资源。维护工作内存不足时,多次索引扫描会让系统负载成倍增加。如果彻底禁用自动清理,它会以不受控的方式强制启动,带来更严重的影响。
相信你已经明白,理解自动清理的工作内容与运行机制至关重要。
自动清理会在行数阈值被触发时启动。在本文最后部分,我们将介绍一项基准测试,验证 “基于修改行数触发自动清理” 是否合理,或是应当考虑基于数据页的阈值等其他触发方式。我们还会测试索引对清理操作的性能影响。
本文将讲解自动清理的工作原理,阅读前需要读者对 PostgreSQL 内部原理有基础了解。
以下是你需要了解的术语,若已熟悉可直接跳过:
- MVCC(多版本并发控制):PostgreSQL 不会直接覆盖原有行数据,而是保留行的多个版本。该机制用于为同时运行的不同事务提供一致性视图,这也是长事务存在且表未被清理时,过期行版本会持续堆积的原因。事务通过 MVCC 判断行的可见性。
- 元组(Tuple):行数据在磁盘上的一个版本。一行数据被更新三次,就会产生三个历史元组。
- 死亡元组(Dead tuple):对所有事务都不再可见的元组。回收这些元组是清理操作的核心任务。
- 堆表(Heap):表的主体存储结构,所有元组都存放在这里,索引是独立的结构。
- 数据页(页 / 块,Page/Block):PostgreSQL 读写数据的基本单位,大小为 8KB。堆表由一系列数据页组成。如果一个页被标记为 “脏页”,说明它包含尚未写入磁盘的修改。
- 元组标识符(TID):元组的物理地址,标识元组所在的数据页与槽位。每个数据页内部有一个指针数组,指向页内行数据的实际位置,槽位就是该数组中的下标。通过这种设计,页内的行空间可以重新整理,而无需修改 TID。索引条目就是指向堆表的 TID。
- 清理(Vacuum):移除死亡元组的操作,同时还会完成一些其他工作。
- 自动清理(Autovacuum):PostgreSQL 在后台自动调度、执行的清理操作。
- 可见性映射(Visibility Map, VM):每个表对应的小型位图,标记哪些数据页上的所有元组都处于可见或已冻结状态(见 “冻结” 词条)。
- 冻结(Freezing):将旧元组标记为永久可见,使其事务 ID 不再影响可见性判断(见 “事务 ID 回卷” 词条)。这里的 “永久” 是相对概念:如果该行被修改,已冻结的元组会变为死亡元组,最终被清理操作移除。
- 事务 ID(XID)回卷:每个事务都会被分配一个 ID,用于标识哪些事务修改了哪些行,是可见性判断的核心依据。问题在于事务计数器是有限的,最终会循环复用。为避免异常,必须将旧行标记为永久可见(冻结),使其事务 ID 不再影响可见性。
- 膨胀(Bloat):死亡元组占用的存储空间。由于死亡元组不可见,这部分空间属于浪费。清理操作会尝试减少膨胀,但并非总能完全回收空间。
- 共享缓冲区(Shared buffers):PostgreSQL 的内存页缓存,几乎所有读写操作都会经过这里。
- 预写日志(Write-ahead log, WAL):所有数据修改在写入数据页之前,都会先记录到预写日志中,保障数据库崩溃后可以恢复。
- 检查点(Checkpoint):将共享缓冲区中修改后的脏页写入数据文件的时间点。
启动器与工作进程架构
自动清理启动器(autovacuum launcher)是一个后台进程,负责启动自动清理工作进程。它的目标是每隔 autovacuum_naptime 秒(默认 1 分钟)为每个数据库启动一个工作进程。如果有 N 个数据库,启动器会大致每隔 autovacuum_naptime / N 秒启动一个新的工作进程,以轮询方式遍历所有数据库。
但工作进程并非由启动器直接创建,而是由启动器向主进程(postmaster)发起请求,为选定的数据库 fork 出一个自动清理工作进程。
工作进程被创建后会独立运行,直到完成所属数据库内所有符合条件的表的清理后才会退出。系统最多可同时运行 autovacuum_max_workers 个工作进程(默认 3 个),且不限制同一数据库内的并发工作进程数。如果一个数据库有大量表需要清理,可以同时运行多个工作进程,进程间会通过协调避免重复清理同一张表。
每个数据库大致每隔一个 “休眠时长(naptime)” 就会被分配一个工作进程,即使没有表需要清理也会分配。
表的选择与优先级
工作进程内部的表筛选流程如下:
- 扫描
pg_class系统表,枚举数据库内所有表,然后从累积统计系统(pgstat)中获取每个表的关系级统计信息(如死亡元组数量等)。 - 将每张表的死亡元组数量与清理阈值对比(公式见下文)。
- 同时检查表是否需要执行 ANALYZE(有独立的阈值)。
- 检查防回卷需求:如果
pg_class.relfrozenxid的 “年龄” 超过autovacuum_freeze_max_age(默认 2 亿事务),或pg_class.relminmxid的年龄超过autovacuum_multixact_freeze_max_age(默认 4 亿),则无论其他阈值如何、即使表级设置了autovacuum_enabled = off,也会强制对该表执行清理。
优先级规则
数据库内部没有表级别的优先级排序。工作进程按照从 pg_class 扫描得到的顺序依次清理表。src/backend/postmaster/autovacuum.c 中的 do_autovacuum() 函数直接遍历 table_oids 列表。
工作进程会依次 “认领” 每张表:在共享内存中将表标记为 “正在被我处理”(该操作会短暂锁定内存结构,避免两个工作进程同时选中同一张表),然后调用 table_recheck_autovacuum() 重新读取系统表与统计信息,确认表仍需清理(因为可能已被其他工作进程处理完毕)。
防回卷的优先级在更高层级处理,也就是数据库选择阶段:启动器的 do_start_worker() 函数会优先将工作进程分配给最接近回卷阈值的数据库。
因此,表的清理顺序并不按死亡元组数量排序。在单个数据库内,处理顺序本质上就是系统表中的目录顺序。
清理操作如何确定待处理页面
选中表之后,工作进程不会盲目扫描每一个数据页,而是通过可见性映射(VM)跳过无需清理的页面。
可见性映射
每张表都有对应的可见性映射,这是一个位图结构,每个堆表页对应两个比特位:
- 全可见位(All-visible bit):该页上的所有元组对当前及未来的所有事务都可见。普通(非激进)清理通常可以直接跳过这类页面,因为没有死亡元组需要回收。但在某些场景下,全可见页也会被访问,例如主动冻结、预读优化(即使部分页面不需要,也会顺序读取)。
- 全冻结位(All-frozen bit):该页上的所有元组都已被冻结,标记了
HEAP_XMIN_FROZEN信息掩码位(PostgreSQL 9.4 之后,xmin 的值会被保留用于问题排查,而非物理覆盖为FrozenTransactionId,但很多人仍误以为 xmin 会被修改)。即使是激进 / 防回卷清理,也可以跳过这类页面。激进清理必须访问所有未被标记为全冻结的页面,尽可能多地冻结元组。
可见性映射大幅提升了清理效率,因为它减少了查找死亡元组时需要访问的页面数量。假设一张 10GB 的表,只有 50 个数据页包含死亡元组,清理操作只需读取约 320KB 的可见性映射(10GB 堆表、每页 8KB、每页 2 比特计算得出),然后只处理这 50 个页面,无需扫描全部 10GB 数据。
前文提到普通清理也可能为了主动冻结或预读访问少量额外页面,但可见性映射仍然消除了绝大多数随机 I/O。
可见性映射的比特位由执行 DML 语句的后端进程清除,通常由自动清理工作进程或手动执行的清理操作置位。
可见性映射还可用于索引仅扫描(index-only scan):判断是否需要访问堆表页来验证元组可见性。如果页在 VM 中被标记为 “全可见”,就无需执行可见性检查,也无需额外访问堆表,能显著提升性能。
扫描流程
工作进程对堆表执行顺序扫描,但扫描过程受可见性映射引导:
- 读取可见性映射,识别所有非全可见、可能包含死亡元组的页面。
- 逐个读取这些页面到共享缓冲区(如果尚未在缓存中)。
- 检查每个元组的头部信息(
t_xmin、t_xmax、t_infomask),判断元组是否死亡 —— 即元组被删除或更新,且没有任何运行中的事务还能看到它。 - 死亡元组会被收集到内存中的死亡 TID 存储区。从 PostgreSQL 17 开始,该存储结构为
TidStore—— 一种以块号为键的紧凑型自适应基数树,替代了旧版的排序ItemPointer数组及其 1GB 的硬上限。其大小受autovacuum_work_mem限制(默认值为 - 1,即沿用maintenance_work_mem的默认值 64MB);手动执行VACUUM时则直接使用maintenance_work_mem。
如果在扫描完整个表之前,工作内存就已被填满,工作进程会暂停堆表扫描,先处理已收集的死亡元组(索引清理 + 堆表清理),之后再从暂停处继续扫描。这意味着对一张更新频繁的大表执行一次清理,可能需要多次遍历索引。
利用缓冲环限制对缓存的影响
上文第 2 步提到 “将堆表页读取到共享缓冲区”,但如果清理操作把扫描到的所有页面都载入共享缓冲区,清理大表时会把其他查询依赖的页面挤出缓存,导致维护任务拖垮缓存性能。
PostgreSQL 通过一种缓冲区访问策略(通常称为缓冲环)来避免这个问题:
清理操作不会在整个共享缓冲池中随意分配页面,而是使用一小块循环复用的共享缓冲页面:当需要为新页面申请缓冲区且缓冲环已满时,它会回收环中最旧的缓冲区,而非从共享缓冲池中申请新页面。如果某个页面已经在共享缓冲池中,则不会被加入缓冲环。
缓冲环大小由 vacuum_buffer_usage_limit 控制,PostgreSQL 18 中默认值为 2MB(256 个 8KB 缓冲区),取值范围为 128KB 到 16GB,且上限为 shared_buffers 的 1/8(可以设置更高值,但实际生效值不会超过该上限)。设置为 0 时会完全禁用缓冲环,让清理操作无限制地使用共享缓冲区。
该限制同时适用于 ANALYZE 和自动清理(二者底层执行相同的清理代码)。VACUUM SQL 命令支持语句级的 BUFFER_USAGE_LIMIT 选项。
缓冲环的设计会带来一些影响:当被回收的缓冲区仍是脏页时,清理操作必须先将其写出,才能复用该槽位。而页面写出前必须先刷入对应的预写日志(WAL)。因此,一旦清理操作产生的脏页超过了缓冲环的容量,它就会自行执行写回操作,而非全部留给检查点进程或后台写进程。
这可以看作一种权衡:缓冲环限制了清理操作对缓存的影响,但要求清理过程中自行完成部分写回(以及 WAL 刷盘)。不过,被读入缓冲环的页面通常活跃度不高,短期内不会被再次访问。
调高 vacuum_buffer_usage_limit(或设为 0)会放宽限制,让清理运行得更快,但也会挤出更多活跃页面,后续给检查点进程带来更多工作。
元组存活状态判定
对于每个非全可见页面上的元组,清理操作会检查:
t_xmin(插入该元组的事务 ID):判断插入事务是否已提交。如果插入事务中止,该元组直接判定为死亡。t_xmax(删除 / 更新该元组的事务 ID):判断删除 / 更新事务是否已提交,且是否早于当前最旧的运行中事务(OldestXmin)。如果满足条件,则没有任何活跃事务还能看到这个版本的元组,该元组即为死亡元组。
和其他后端进程一样,清理操作通过读取 pg_xact(提交日志 / CLOG)判断事务的提交状态,并在元组头部设置提示位(HEAP_XMIN_COMMITTED、HEAP_XMAX_COMMITTED 等),这样后续访问就无需再次查询提交日志。
修改提示位会将页面标记为脏页,但默认不会写入 WAL,除非开启了数据校验和或 wal_log_hints 参数。提示位的作用是让后续事务无需查询提交日志,就能知道插入 / 修改事务的结果。
OldestXmin 是所有运行中事务可能仍需可见的最旧事务 ID,也被称为 “xmin 视界”。被晚于 OldestXmin 的事务删除或替换的元组不能被清理,因为部分活跃事务可能仍需访问它们。这也是长事务会限制清理空间回收能力的原因。
堆页的处理过程
在页面上识别出死亡元组后,会执行以下操作:
- 在堆表扫描阶段,死亡元组的行指针会被标记为
LP_DEAD。之后,等索引清理移除了所有悬空的索引引用后,清理操作会执行第二次堆表遍历,将这些指针转为LP_UNUSED状态,让槽位可以被复用。(对于没有索引的表,清理操作可以直接标记为LP_UNUSED,因为无需考虑索引指针。) - 页面会被紧凑化整理:存活元组向页面高地址端移动,空闲空间被集中到页面中部(行指针数组与元组数据区之间)。这个过程称为页修剪 / 碎片整理,会更新页面的
pd_lower(行指针末尾)和pd_upper(元组数据起始)字段,反映新的空闲空间状态。 - 该页在共享缓冲区中被标记为脏页,后续会由后台写进程、下一次检查点写出,或在缓冲环满、清理操作需要空间时写出。这对应
vacuum_cost_page_dirty代价事件(会为清理操作增加 20 点代价)。 - 更新可见性映射:移除死亡元组后,如果页面上剩余的所有元组对所有事务都可见,则置位全可见位。在激进 / 防回卷清理中,如果所有元组同时都已冻结,则置位全冻结位。
- 定期更新空闲空间映射(FSM)树(每处理
VACUUM_FSM_EVERY_PAGES个页面更新一次,或在堆表 / 索引清理阶段结束后更新,而非每个页面处理完都更新),对外公布新增的可用空间,供后续 DML 操作复用。
堆表截断
处理完所有页面后,清理操作会检查堆文件末尾的页面是否完全为空(所有死亡元组都被移除,且没有存活元组)。如果为空,就会截断文件,物理缩小表体积并将磁盘空间归还给操作系统。这是清理操作唯一能缩小表磁盘占用的场景。文件中间回收的空间只会被复用,不会归还给文件系统。
截断操作需要持有 AccessExclusiveLock(访问排他锁),可能会短暂阻塞并发访问。可以通过表级或全局参数 vacuum_truncate = off 禁用表截断功能。
索引清理
索引清理是清理操作中必不可少、且通常开销很高的环节。索引中保存着指向堆表元组的指针(TID),如果对应的堆表元组已死亡,索引条目就会变成悬空指针,必须被移除。
处理流程
堆表扫描完成后(或 maintenance_work_mem 填满后),清理操作的死亡 TID 存储区已填充完毕(按块号排序)。
对于表上的每个索引,清理操作会调用索引访问方法的 ambulkdelete 函数。对于 B 树索引,会先执行 btbulkdelete(),再调用 btvacuumscan()—— 该函数按物理顺序扫描整个索引(除元页面外的所有页面,包括全部叶子页),将每个索引条目的 TID 与死亡 TID 存储区比对,匹配的条目会被移除。
只有所有索引都清理完成后,清理操作才会回头清理堆表页面(标记 LP_UNUSED、紧凑化整理)。该流程由 lazy_vacuum_all_indexes() 以及后续的堆表清理阶段实现,代码位于 src/backend/access/heap/vacuumlazy.c。
执行顺序非常关键:必须先移除索引条目,再回收堆表元组槽位。否则,索引扫描可能会指向一个已被复用、存放了其他无关元组的槽位,返回错误结果。
索引清理为何开销巨大
PostgreSQL 文档中对 ambulkdelete 接口的说明提到:
这是一种 “批量删除” 操作,设计上通过扫描整个索引,逐个检查条目是否需要删除。
该机制没有局部索引扫描优化,设计要求完整扫描整个索引。死亡 TID 按堆表物理位置排序,但索引条目按键值排序,因此无法只扫描受影响的索引页,必须遍历所有叶子页。
带来的影响:
- 每个清理周期都会完整扫描所有索引。如果一张表有 5 个索引、索引总大小 100GB,每次完整遍历就要读取 500GB 的索引页。
- 如果死亡 TID 存储空间不足,堆表扫描中途暂停,
ambulkdelete就会被调用多次,每一批死亡 TID 对应一次调用。文档中说明:“由于maintenance_work_mem有限,当待删除元组数量较多时,ambulkdelete可能需要被调用多次。” 每次调用都会执行一次完整的索引扫描。对于 100GB 的表、64MB 工作内存、5 个索引的场景,可能产生数十次完整索引扫描。(PostgreSQL 17 的TidStore能在相同内存中存放多得多的死亡 TID,因此多轮扫描的情况比旧版本少见很多。)
这也是为什么在清理密集型负载中,通常需要调大 autovacuum_work_mem(或 maintenance_work_mem)。如果开启了 log_autovacuum_min_duration 记录清理日志,可以关注日志中的 index scans 字段。
索引清理优化
- 旁路优化(近零死亡元组场景):当表中包含
LP_DEAD条目的页面占比≤2%,且累计的死亡 TID 存储量低于 32MB 时,清理会进入旁路模式:跳过索引清理和第二轮堆表清理,避免执行完整索引扫描(因为收益很低)。该机制避免了 “零死亡元组瞬间完成,一个死亡元组就要多次全索引扫描” 的跳变问题。 INDEX_CLEANUP参数:默认值AUTO允许触发旁路优化;OFF强制清理始终跳过索引清理(接受索引膨胀);ON强制每次都执行完整索引清理。OFF适用于需要快速推进relfrozenxid的紧急场景。- B 树 “页面删除”:当 B 树叶子页在清理后变为空页时,该页会被标记为已删除,后续可被回收复用。索引文件大小不会缩小,但页面可以被后续操作复用。
- 简单 B 树元组删除:当查询通过索引扫描访问到死亡元组时,可以直接在索引中将该指针标记为死亡。后续如果该索引页需要更多空间(比如要插入新条目),可以直接移除指向死亡元组的索引条目来腾出空间,避免页面分裂。
- 自底向上删除(PostgreSQL 14+):B 树索引可以在页面分裂时主动移除已知的死亡条目,减少留给清理操作的工作量。
并发:清理操作与活跃后端进程
清理操作与正常数据库操作可以并发运行。它不会对表加排他锁,只会持有 ShareUpdateExclusiveLock(共享更新排他锁),该锁仅与其他清理操作、ALTER TABLE 以及部分 CREATE INDEX 操作冲突。
页级锁机制
清理操作读取或修改堆表页时,使用通用的共享缓冲区访问锁:
- 读取操作(即识别死亡元组):对共享缓冲区加共享缓冲区内容锁。
- 修剪与冻结操作(即移除死亡元组、设置 VM 标志):需要缓冲区清理锁,这是一种排他锁(其他后端进程不能持有该缓冲区的任何锁)。
- 普通清理中,如果无法立即获取清理锁(比如其他事务持有共享锁),清理会跳过该页的修剪 / 冻结,继续处理下一页。
- 激进(防回卷)清理则会等待锁释放,而非跳过。
这些锁的持有时间仅为内存页操作的时长,通常非常短暂,不会阻塞其他页面上的并发查询或 DML 操作。
后端进程读取正在被清理的页面时会发生什么
- 如果清理操作正在修改该页(持有清理锁):后端进程会等待清理释放锁,然后读取清理后的页面,只能看到存活元组 —— 死亡元组已被移除。这是安全的,因为这些死亡元组本来就对后端进程的快照不可见。
- 如果清理跳过了该页(未能获取清理锁):死亡元组仍留在页面上,但通过 MVCC 可见性检查,它们对后端进程不可见,会在后续的清理周期中被移除。
- 如果清理尚未处理到该页:后端进程正常读取,死亡元组仍存在但对 MVCC 快照不可见,在可见性检查时会被跳过。
清理运行时后端进程执行写操作
- 向被清理过的页面插入数据:清理释放了空间,FSM 记录了可用空间,插入操作可以直接复用这些空间,无冲突。
- 在同一张表上执行更新 / 删除:并发 DML 与清理的
ShareUpdateExclusiveLock不冲突。如果后端进程在清理尚未扫描到的页面上删除 / 更新元组,只要事务在清理处理该页前提交,清理就会识别并清理这些死亡元组;如果元组所在页面已被清理扫描过,则会在下一个清理周期被处理。 - 在清理正在处理的页面上执行更新 / 删除:缓冲区锁会串行化访问。如果清理先移除了死亡元组,之后后端进程更新同一页上的存活元组,二者操作不同的元组槽位,不会产生冲突。
索引清理期间的索引扫描
清理扫描索引移除死亡条目时,后端进程的并发索引扫描可以正常进行。B 树索引使用基于 “引脚(pin)” 的协议,避免清理删除任何被后端进程引脚住的页面。具体来说,清理会先将页面标记为 “半死亡”,只有当没有任何后端进程持有该页的引脚时,才会真正回收。该机制保证索引扫描永远不会访问到已被回收的页面。
阈值公式
当表的死亡元组数量超过阈值时,就会触发自动清理。从 PostgreSQL 18 开始,计算结果会受 autovacuum_vacuum_max_threshold 限制:
vacuum_threshold = Min(
autovacuum_vacuum_max_threshold,
autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples
)
默认值:
autovacuum_vacuum_threshold = 50autovacuum_vacuum_scale_factor = 0.2autovacuum_vacuum_max_threshold = 100,000,000(PostgreSQL 18 新增)
autovacuum_vacuum_max_threshold 的作用是避免超大表需要积累极多死亡元组才会触发清理。
插入触发的清理(PostgreSQL 13+)
仅插入操作也能触发清理。从 PostgreSQL 18 开始,比例因子项会乘以表的未冻结比例:
vacuum_insert_threshold =
autovacuum_vacuum_insert_threshold
+ autovacuum_vacuum_insert_scale_factor * reltuples * (1 - relallfrozen / relpages)
默认值:
autovacuum_vacuum_insert_threshold = 1000autovacuum_vacuum_insert_scale_factor = 0.2
公式中的 (1 - relallfrozen / relpages) 用于避免以插入为主的表,清理间隔持续变长。旧版本中,随着表体积增长,触发清理所需的插入行数也会同步增长;通过该优化,触发清理所需的插入量会趋于稳定。
ANALYZE 触发
判断是否需要执行 ANALYZE 的公式如下:
changed_tuples > analyze_threshold + analyze_scale_factor * reltuples
默认值:autovacuum_analyze_threshold = 50,autovacuum_analyze_scale_factor = 0.1。
可以通过 ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = ...) 进行表级覆盖,优先级高于全局配置。
基于代价的清理限流
基于代价的限流机制与上文提到的页级操作直接关联。清理操作每访问一个页面,都会根据操作类型产生对应的代价。
清理的 I/O 通过 “代价 + 延迟” 机制进行限流,该机制同时适用于手动 VACUUM 和自动清理:
vacuum_cost_page_hit = 1:页面已在共享缓冲区中(开销低:无 I/O,仅 CPU 检查元组)vacuum_cost_page_miss:PostgreSQL 17 及更早版本为 10,PostgreSQL 18 改为 2;页面从操作系统读入共享缓冲区(可能仍在操作系统页缓存中,不一定是物理磁盘读取)vacuum_cost_page_dirty = 20:清理修改了页面(移除死亡元组、紧凑化整理)。这是开销最高的操作,因为产生的脏页最终需要由后台写进程 / 检查点进程写入磁盘。
每个页面的代价是累加的。以 PostgreSQL 17(miss=10)为例:一个从磁盘读取并被修改的页面,总代价为 30;PostgreSQL 18(miss=2)下同一场景代价为 22。一个已在共享缓冲区中且被修改的页面(hit + dirty),两个版本代价均为 21。
代价限额由所有运行中的自动清理工作进程共享。如果同时有 3 个工作进程运行,每个进程每个周期的可用代价预算约为 200 / 3,即约 66。这意味着增加工作进程数不会线性提升 I/O 能力,因为总代价限额不变,每个进程分到的预算会相应减少。
全局限额由 autovacuum_vacuum_cost_limit 控制,默认值为 - 1,即继承 vacuum_cost_limit 的默认值 200。
工作进程在运行过程中持续累积代价点数。当某个工作进程的累计值达到分配的限额时,该进程会休眠 autovacuum_vacuum_cost_delay 时长(默认 2 毫秒)。
示例
使用默认配置(限额 = 200,延迟 = 2ms),单个工作进程,PostgreSQL 17(miss=10):
- 如果所有页面都是 “未命中 + 脏页写”(每个代价 30):每周期处理约 6 页(200/30),然后休眠 2ms,对应约 3000 页 / 秒。
- 如果所有页面都在共享缓冲区中且被修改(命中 + 脏页,代价 21):每周期处理约 9 页,对应约 4500 页 / 秒。
- 如果所有页面都是缓存命中且无修改(每个代价 1):每周期处理 200 页,对应约 100000 页 / 秒。
PostgreSQL 18(miss=2)下,未命中 + 脏页的单页代价降至 22,冷数据页的处理吞吐量提升至每周期约 9 页。
如果有 3 个工作进程共享限额,每个进程每周期只有 66 点代价,单进程吞吐量会按比例下降。
对于大型系统,默认配置通常过于保守。常见的调优方式是将 autovacuum_vacuum_cost_limit 调高至 1000-2000,和 / 或将关键表的 cost_delay 设为 0。
手动 VACUUM 的 vacuum_cost_delay 默认值为 0(无限流)。自动清理工作进程使用 autovacuum_vacuum_cost_delay,从 PostgreSQL 12 开始默认值为 2ms(更早版本为 20ms)。
可以通过表级存储参数 autovacuum_vacuum_cost_delay / autovacuum_vacuum_cost_limit 覆盖全局配置,针对高变更频率的表单独调整其对共享代价池的影响。
基准测试:清理代价的驱动因素
如前文所述,自动清理由死亡元组或插入元组的数量触发。但真正的代价驱动因素,是死亡元组的数量,还是这些死亡元组分布的数据页数量?
我们设计了一项基准测试,探究自动清理操作真正的代价驱动因素。
测试问题
我们希望验证两个假设:
- 自动清理的代价是由死亡元组数量驱动,还是由死亡元组分布的堆表页数量驱动?
- 索引数量会如何放大清理代价?
测试表与核心变量
每组测试都使用同一张表,每次测试前都会删除并重建:
CREATE TABLE bench_table (
id INTEGER NOT NULL, -- 顺序值 1 到 10,000,000
val INTEGER NOT NULL DEFAULT 0,
padding TEXT NOT NULL -- repeat('x', 96)
);
padding 列将行宽固定为 128 字节,每个 8KB 页大约存放 58 行。1000 万行对应约 172414 个堆表页,总大小约 1.3GB。
填充数据后执行 VACUUM FREEZE,让所有页面初始状态为全可见、全冻结,建立干净的基准。然后创建 0 到 5 个冗余 B 树索引,全部建在 id 列上 —— 每个索引都是独立的物理结构,清理操作都需要完整扫描一遍。
测试的自变量是死亡元组的分布方式。我们用两种策略删除相同数量的行,区别仅在于涉及的数据页不同:
| 策略 | 删除条件(删除 10% 数据) | 涉及脏页数 |
|---|---|---|
| 集中模式(compact) | WHERE id <= 1,000,000 |
前 10% 的页面(低 id 对应物理前序页面) |
| 分散模式(spread) | WHERE id % 10 = 0 |
100% 的页面(每页 58 行中约有 5-6 行被删除) |
本次基准测试使用 DELETE 而非 UPDATE,因此不会产生新的元组版本,表体积不会增长,也不会新增索引条目(无叶子页分裂)。
测试矩阵
6 种索引数量(0 - 5) × 3 种死亡元组比例(10% / 25% / 50%) × 2 种分布方式,共 36 种组合。每种组合重复执行 10 次,总计 360 轮测试。
单轮测试测量流程
- 重建表、填充数据、执行
VACUUM FREEZE,创建对应测试的索引(全程禁用该表的自动清理)。 - 执行
DELETE生成对应组合的死亡元组。 - 测试前执行
CHECKPOINT:刷出准备阶段产生的脏页,确保测试后的检查点只统计清理操作产生的脏页。 - 重置共享 I/O 计数器:
pg_stat_reset_shared('io' | 'bgwriter' | 'checkpointer')。注意不调用pg_stat_reset(),否则会清零n_dead_tup,导致自动清理无法触发。 - 记录开始时间,然后启用该表的自动清理,并设置触发参数(
autovacuum_vacuum_threshold = 1,autovacuum_vacuum_scale_factor = 0)。由于autovacuum_naptime设为 1 秒,启动器大约 1 秒内就会选中该表。 - 每 0.5 秒轮询一次,直到清理完成:
last_autovacuum晚于开始时间,且n_dead_tup = 0。 - 停止计时,强制执行测试后检查点,采集各项指标。
采集指标与来源
| 指标 | 来源 | 说明 |
|---|---|---|
实际耗时 duration_s |
clock_gettime(单调时钟) |
近似耗时(包含清理 + 休眠等待) |
| 自动清理工作进程的读 / 命中 / 写次数 | pg_stat_io,过滤 backend_type = 'autovacuum worker' |
隔离工作进程与其他进程的 I/O(PostgreSQL 18 特性) |
| 堆表与索引块数 | pg_statio_user_tables(前后差值) |
单表的精确统计 |
| 写操作拆分 | 各后端进程的 pg_stat_io + pg_stat_checkpointer |
统计脏页由哪个进程写出 |
| 脏页数 / 完成度 | pg_visibility_map_summary(来自 pg_visibility 扩展) |
I/O 通过两种方式记录:操作次数(reads/writes,可能是多块读取)和字节换算的页数(read_bytes/write_bytes ÷ 8192,不受多块合并影响,结果精确)。我们采用字节换算的页数进行分析。
每种组合的 10 次测试结果取中位数,并标注四分位区间(Q1 - Q3)。
测试环境
- PostgreSQL 版本:18.4(PGDG 官方版本,启用
pg_visibility扩展) - 操作系统 / 主机:Ubuntu 24.04 LTS,x86_64 架构,4 核专属 vCPU / 15GB 内存,SSD 存储
- 执行方式:基准测试在数据库主机本地通过 Unix 套接字运行(计时路径无网络开销)
- 关键配置参数:
shared_buffers = 4GBmaintenance_work_mem = 1GBwork_mem = 64MBautovacuum_naptime = 1sautovacuum_vacuum_cost_delay = 2msautovacuum_vacuum_cost_limit = 200vacuum_cost_page_miss = 2checkpoint_timeout = 15minmax_wal_size = 4GBfull_page_writes = ontrack_io_timing = on
- 表级触发参数:
autovacuum_vacuum_threshold = 1autovacuum_vacuum_scale_factor = 0
- 负载:1000 万行的表(172414 个堆表页,约 1.3GB)
- 5 个索引时总工作集 2.4GB(每个索引约 225MB),可完全容纳在共享缓冲区中
测试的 2.4GB 总工作集(堆表 + 5 个 225MB 的索引)全部常驻共享缓冲区,maintenance_work_mem = 1GB 保证索引清理只需单轮扫描。自动清理限流使用默认配置(cost_delay = 2ms)。以下耗时均为 10 次测试的中位数。
测试结果
假设 1:代价驱动因素是页面,而非元组
无索引时的耗时对比:
| 死亡元组比例 | 集中模式 | 分散模式 | 分散模式 / 集中模式比值 |
|---|---|---|---|
| 10%(100 万) | 5.0 秒 | 43.6 秒 | 8.7 倍 |
| 25%(250 万) | 11.5 秒 | 43.6 秒 | 3.8 倍 |
| 50%(500 万) | 21.8 秒 | 43.4 秒 | 2.0 倍 |
首先可以看到,分散模式场景下,死亡元组从 100 万增加到 500 万,耗时几乎保持不变(43.6、43.6、43.4 秒),因为所有场景下都是 100% 的页面被弄脏。这说明被访问的页面数才是清理代价的核心驱动因素。
而集中模式场景下,耗时随死亡元组数量线性增长,因为死亡元组越多,涉及的脏页数就越多。当死亡元组占比 50% 时,集中模式的耗时约为分散模式的一半。

如果按脏页比例而非死亡元组数量绘制图表,所有数据点会呈现出更清晰的线性关系。

假设 2:索引会放大清理代价
索引数量会显著增加清理开销。每个冗余索引会带来近乎恒定的耗时增量:以集中模式 50% 死亡元组为例,0 到 5 个索引对应的耗时分别为 21.8、28.1、32.6、37.6、42.3、47.4 秒,平均每个索引增加约 5 秒耗时。
脏页数量也会影响索引开销:如果脏页数少,索引带来的额外开销也更低(比如集中模式 10% 死亡元组场景)。

I/O 数据体现出两种分布的不同特征:
- 分散模式 50% 死亡元组场景:无论索引数量 1 到 5 个,堆表访问块数稳定在 547323(堆表仅被 VM 引导扫描一次),索引块数每个索引增加 27422 块。每次
ambulkdelete都是一次完整的叶子页扫描。 - 集中模式 50% 死亡元组场景:堆表 I/O 更低(288699 块,因为只有部分页面有死亡元组),但索引块数增长速度是前者的 4 倍(每个索引约增加 109533 块)。这是因为连续删除 id 范围会清空整个 B 树叶子页,除了扫描之外还增加了页面删除与回收的开销(B 树会回收全空页面,而非合并半满页面)。
另外值得注意:堆表访问量从 0 个索引到 1 个索引时会有一次跃升(分散模式从 374903 增至 547323),因为索引清理会强制触发第二轮堆表遍历;而索引数量从 1 增加到 5 时,堆表访问量保持平稳。

写操作拆分:谁来写脏页
脏页的写出进程并非固定不变:
- 索引数量为 0 或 1 时,自动清理工作进程几乎不执行写操作:它弄脏堆表缓冲区后,全部留给检查点进程刷盘。
- 随着索引数量增加,工作进程自身的写操作会增多:分散模式 50% 死亡元组场景下,0 到 5 个索引对应的工作进程写出页数分别为 0、2.7 万、5.4 万、8.2 万、10.9 万、13.6 万;集中模式 50% 场景下为 0、0、1.4 万、2.7 万、4.1 万、5.5 万。
这正是缓冲环的效果:当索引清理产生的脏页超过 2MB 缓冲环的容量时,工作进程必须自行写出并回收页面,而非全部延后给检查点。因此,随着索引数量增加,写开销从检查点进程转移到清理工作进程,这是缓存保护机制的直接结果。

最后我们有了一个涵盖所有 36 种组合的清理持续时间的热力图:

结论
测试结论与预期一致:脏页数量是自动清理的主要代价驱动因素,其次是索引数量。相同数量的死亡行,分布方式不同可能带来截然不同的自动清理开销。
基准测试的适用范围与局限性
测试结果很明确,但基于合成负载得出的结论需要审慎看待。我们的基准测试刻意简化了场景,而生产环境中的清理问题恰恰源于复杂性。
- 我们测试的本质是 “套着自动清理外壳的 VACUUM 操作”:单表、阈值设为 1、比例因子为 0、休眠时间 1 秒,测量的是单个工作进程处理单张表的孤立开销。它没有体现自动清理特有的调度逻辑:启动器的表选择、
autovacuum_max_workers的资源竞争、工作进程间共享的代价限额等。这些调度动态往往对生产环境影响重大,但本测试未覆盖。 - 测试使用的是合成负载:死亡元组来自空闲表上的单次批量 DELETE,无并发操作,没有长事务拖住
OldestXmin。生产环境中,清理操作经常因为拿不到清理锁而跳过页面,本测试中从未出现这种情况,因此每次测试都能将页面清理至 100% 全可见。本测试衡量的是理想状态下表的清理性能,而非生产环境的持续变更场景。 - 所有数据都能容纳在内存中,因此这不是 I/O 瓶颈场景。整张表都在共享缓冲区里,读取基本都是缓存命中,写操作大多延后给检查点(除了索引较多时工作进程自身的写操作)。测试衡量的 “代价” 主要是页面访问量与 CPU 开销,以及延后的检查点写操作。生产中那些因堆表、索引放不下缓存而导致 I/O 瓶颈的清理,影响会严重得多,本测试未覆盖该场景。可以认为结论适用于逻辑工作量层面,而非 I/O 瓶颈场景;I/O 瓶颈下的差异可能更大,但未在本次测试中验证。
- 冗余的相同索引不具备普适性。在整数列上建 5 个 B 树索引,只是为了让索引工作量线性增长的测试手段。真实场景中的索引在宽度、键类型、相关性、膨胀程度、填充因子、自底向上删除特性上都有差异。测试得出的斜率(每个索引增加 5 秒;分散模式每个索引 27422 个索引块,集中模式为 109533 块)是特定场景下的结果,不能直接套用到宽索引、复合索引或文本索引上。
- 测试排除了冻结的影响。以
VACUUM FREEZE为基准,未包含防回卷 / 激进清理 —— 这类清理必须访问所有未全冻结的页面,往往是生产环境中影响最大的事件。此外,旁路优化、内存不足导致的多轮ambulkdelete也被规避了,而这些因素会让生产环境中的清理运行时间呈现非线性,难以预测。
我们的结论是成立的:清理代价由脏页数量驱动,而非死亡元组数量,背后的机制也清晰可解释。但本次基准测试使用的是单表、全内存容纳、刚完成冻结、无并发活动的理想场景。在该条件下,运行时间与页面访问量都随脏页数和索引数线性增长。这些结论能否推广到繁忙、数据量大于内存的生产系统,仍有待验证,这也是我们未来的研究方向。