由 John Doe 七月 27, 2026
摘要:在本文中,我们将结合一个真实案例,介绍 VACUUM 收缩表文件时引发的读副本锁阻塞问题。
目录
问题背景
业务场景:客户使用 PostgreSQL 读副本承载读查询,要求查询延迟在秒级 SLA 以内;主库业务模式为对 16 个哈希分区的分区表,频繁执行大批量 DELETE 后通过 COPY 命令刷新数据。
故障表现:读副本上的 SELECT 查询周期性超时,无法满足 SLA。
初步排查:副本的 startup 进程会随机对某个分区申请排他锁,该锁会阻塞所有针对该分区的 SELECT 查询,进而导致超时;且锁的出现与主库的 autovacuum 执行周期强相关。
根因定位

PostgreSQL 的 VACUUM 在清理死元组后,会尝试将表末尾连续的空闲页(即高水位线之后的空白空间)归还操作系统,这个截断表文件的操作在主库需要对表加排他锁。
主节点一旦成功截断了表文件,会将这个修改记录到 WAL(预写日志)中。备节点的 Startup 进程在回放这条日志时,为了保证主备物理文件严格一致,必须在备库上获取该表的 AccessExclusiveLock。
结果:
- 阻塞新请求: Startup 进程申请排他锁会进入锁队列,这将直接导致备库上所有后续新发起的
SELECT查询全部被堵塞。 - 干掉老请求: 如果备库上正好有较长的查询正在执行,Startup 进程会等待一段固定的时间(受
max_standby_streaming_delay参数控制)。一旦超时,为了不让备库的数据延迟过大,PostgreSQL 会强行取消正在执行的读业务,抛出大名鼎鼎的canceling statement due to conflict with recovery错误。
解决方案
对分区表的所有分区执行参数调整,关闭 VACUUM 的自动截断功能:
ALTER TABLE [table name / partition name] SET (vacuum_truncate = false);
用空间换稳定: 关闭自动截断后,表文件尾部的空闲空间不会还给操作系统,物理文件不会变小。但这些空间依旧留在文件中,可以被该表未来的 INSERT 或 UPDATE 重复利用。这彻底消除了主库释放锁、备库回放锁带来的读业务阻塞问题。
为了平衡“避免锁阻塞”和“磁盘空间回收”,我们可以基于 pg_freespacemap 扩展进行监控和管理:
安装 pg_freespacemap 扩展,编写自定义 PL/pgSQL 函数 show_empty_pages,可以逆向扫描表的页,定位高水位线位置,计算表末尾可截断的空闲页数与对应空间大小。
若监控发现表大小持续增长、末尾空闲空间过多,可在业务低峰期手动执行带截断的 VACUUM 回收空间:
VACUUM (verbose, truncate true) [schema].[table name];
执行该命令时,会在 VACUUM 输出中看到一条额外的消息,表示 VACUUM 截断了表文件:
INFO: table "large_table": truncated 302534 to 302233 pages
VACUUM 截断时的业务先行原则
为了不阻塞主节点上的正常业务,autovacuum 在尝试截断文件时,使用的是条件锁(Conditional Lock)。
autovacuum 会尝试获取 AccessExclusiveLock,但如果此时表上有任何其他业务操作(例如 SELECT、INSERT、UPDATE 正在持有共享锁),条件锁就会获取失败。另外,在从表末尾逆向扫描空闲空间时,检查到有新的业务访问请求时,VACUUM 也会直接放弃截断文件的动作,结束工作。它不会在队列里死等,也不会杀掉业务进程。因此,对于读写极其频繁的表,autovacuum 几乎抓不到那转瞬即逝的空闲窗口,表文件物理大小就降不下来。
总结与建议
该问题在 PostgreSQL 中没有通用的最优解,需要结合业务权衡:
- 对 SLA 要求高的读副本场景,可以关闭
vacuum_truncate避免锁阻塞,同时定期监控表文件的空闲空间,手动回收空间; - 也可以通过调整 autovacuum 的执行频率等参数,降低截断操作的影响;
- 核心原则是理解 VACUUM 每一步的副作用,结合自身业务 SLA 与空间诉求做出调整。
当然,也可以考虑使用 Redrock Postgres,从底层存储与事务机制层面,根本上解决数据膨胀的问题。
参考
Shane Borden:理解 PostgreSQL VACUUM 操作中的高水位锁定问题
PostgreSQL 源代码:access/heap/vacuumlazy.c:lazy_truncate_heap()