Skip to content

VACUUM 垃圾回收与 20 亿事务回卷治理

死元组与表膨胀根因、普通 VACUUM vs pg_repack 在线重建、VM 加速、32位事务ID 20亿回卷危机与冻结机制。

Updated View as Markdown
For humans

VACUUM 垃圾回收与 20 亿事务回卷治理

校招考察频度:★ 高频(运维必考·高频生死线) 一句话速记:堆内多版本无 Undo,死元组堆积成膨胀;VACUUM 页内修复用,在线整理 repack 扛;长事务阻塞清边界,防 20 亿回卷强制锁全库。

PostgreSQL 独特的堆内多版本设计,赋予了它极速回滚与轻量 WAL 的巨大优势;但作为代价,旧版本元组残留在数据页中所引发的表膨胀(Bloat)与垃圾回收(VACUUM),成为了每一位使用 PostgreSQL 的研发工程师与 DBA 必须跨越的核心生死线。在校招面试中,聊透 VACUUM 是展现生产级实战素养的最佳途径。


一、死元组(Dead Tuple)与表膨胀(Table Bloat)

1. 死元组是如何产生的?

正如 MVCC 章节所述:

  • DELETE 一行数据时,并不立刻抹除磁盘数据,只是打上标记;
  • UPDATE 一行数据时,实际上是旧版本标记失效 + 插入新版本。
  • 这些失效但仍然残留在 8KB 数据页中的旧数据行,被称为 死元组(Dead Tuples)。

2. 表膨胀的连锁反应与生产危害

只有当当前系统中没有任何活跃的事务快照还需要看到该版本时,死元组才具备被清理的资格。若死元组得不到及时清理,将引发严重的表膨胀:

  1. 磁盘 I/O 效率雪崩:原本 10 万行活跃数据只需占 10MB,膨胀后占了 1GB。执行全表扫描(Seq Scan)时需要从磁盘额外读入 100 倍的数据页;
  2. Buffer Pool 缓存命中率暴跌:大量被死元组污染的冷页挤占有限的 shared_buffers 内存;
  3. 索引同步膨胀:B-tree 索引叶子节点包含大量指向死元组的无效 CTID 指针。

3. 阻塞死元组清理的“四大元凶”(排障重点)

面试官:“如果排查发现 autovacuum 一直在跑,但死元组数量居高不下、表持续膨胀,可能是什么原因?”

  1. 长事务(Long-running Transaction):一个跑了数小时的报表或批量处理事务,其快照的水位线(xmin)卡在几小时前,导致该时刻之后产生的所有死元组都不能被清理!
  2. 空闲事务(idle in transaction · 生产头号杀手):开发代码中开启了事务(BEGIN),执行了一次查询后,连接既不提交也不回滚就挂起在那。该会话持有着历史 backend_xmin,拖死整个数据库的垃圾回收!
  3. 废弃的逻辑复制槽(Abandoned Replication Slot):下游消费端断开但复制槽未删除,PG 为了保证下游还能追上数据,会一直保留 WAL 和阻止死元组清理;
  4. 遗留的 2PC 预备事务(Prepared Transaction):分布式事务两阶段提交后应用宕机,残留未决事务。

二、普通 VACUUM vs VACUUM FULL vs pg_repack

三种垃圾回收方式对比

1. 普通 VACUUM(在线页内清理)

  • 操作方式:VACUUM 表名;(或后台 autovacuum 自动运行);
  • 核心工作:
    1. 扫描数据页,找出对所有快照均已不可见的死元组;
    2. 在页内将死元组空间标记为“可用”,更新 FSM(空闲空间映射),供后续新的 INSERT 优先复用该槽位;
    3. 更新 VM(可见性映射),加速未来的 Index-Only Scan;
    4. 推进元组冻结(Freeze)。
  • 关键认知:普通 VACUUM 绝不会将磁盘空间释放还给操作系统文件系统!(只有当表的最末尾全部是连续的空页时,才会发生小幅度的尾部截断收缩)。

2. VACUUM FULL(生产禁止直接使用!)

  • 操作方式:VACUUM FULL 表名;;
  • 底层行为:完全新建一个全新的数据文件,把存活的活元组紧凑搬过去,旧文件彻底删除归还操作系统;
  • 致命缺陷:必须持有 AccessExclusiveLock(最高级别排他锁)!在处理数千万行大表的数小时内,任何 SELECT、INSERT、UPDATE、DELETE 统统被锁死,业务直接瘫痪!

3. 工业界最佳实践:pg_repack 在线零停机膨胀治理

对于已经严重膨胀的大表,生产环境唯一推荐的在线缩表方案是开源扩展 pg_repack:

  1. 创建与原表结构一致的影子表(Shadow Table);
  2. 在原表上建立触发器,将正在发生的增量变更(DML)实时写入增量日志表;
  3. 将原表的存活数据全量导入影子表,并在影子表上重建所有索引;
  4. 追平增量日志;
  5. 仅在最后短暂毫秒级获取短暂排他锁,瞬间完成原表与影子表的系统表元数据原子对调(Swap),随后清理旧表。
  • 效果:实现真正意义上的 7×24 在线零停机空间释放。

三、autovacuum 核心触发公式与调优

PostgreSQL 默认开启 autovacuum 守护进程,其触发清理的阈值公式为:

死元组数阈值 = autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor × 全表活元组数)
  • 默认配置:autovacuum_vacuum_threshold = 50,autovacuum_vacuum_scale_factor = 0.2(即死元组达到 20% 时触发)。
  • 生产调优陷阱:对于 1 亿行记录的大表,20% 意味着必须积累 2000 万条死元组 才会触发一次清理!此时表已经极度膨胀。
  • 解决:针对高频更新的核心业务表,设置更激进的表级参数:
    ALTER TABLE orders SET (
        autovacuum_vacuum_scale_factor = 0.05,  -- 5% 死元组立刻清理
        autovacuum_vacuum_cost_limit = 2000     -- 放宽 I/O 限速
    );

四、20 亿事务 ID 回卷灾难(TXID Wraparound · 顶级事故)

这是 PostgreSQL 历史上最著名、后果最严重的底层架构灾难。在校招面试中,一旦你能把“20 亿回卷”的数学逻辑与防御机制讲得清晰透彻,面试评级必定直接拉满!

1. 为什么是 20 亿?

  • PostgreSQL 采用 32 位无符号整数 表示事务 ID(XID),总容量约为 42.9 亿(2^32);
  • 为了实现无限循环复用,PostgreSQL 采用了类似“时钟”的环形模运算逻辑:
    • 对任何当前事务 T 而言,前 21 亿个 ID 被定义为“过去(Past)”,后 21 亿个 ID 被定义为“未来(Future)”;
  • 回卷灾难逻辑:
    • 假设一条元组在事务 ID = 100 时被插入;
    • 数据库持续运行,事务 ID 飞速自增,当系统事务 ID 增加到 100 + 21.4 亿 时:
    • 按照环形计算规则,原本属于过去的 100 突然变成了未来!
    • 这导致该元组在所有快照中瞬间被判定为不可见,业务数据发生大规模静默丢失!

2. 破局之道:冻结(Freeze)机制

为了防止老数据“被回卷吃掉”,VACUUM 引入了 Freeze(冻结) 操作:

  • VACUUM 扫描老数据页时,如果发现某元组的创建事务已经非常古老(超过 vacuum_freeze_min_age,默认 5000 万个事务):
  • 引擎会将该元组的 xmin 替换为特殊的 FrozenTransactionId(值固定为 2),或在元组头直接打上 HEAP_XMIN_FROZEN 状态位;
  • 规则:FrozenTransactionId 永远被判定为“比世界上的任何普通事务都要早”,它从此永生可见,彻底脱离了 32 位环形比较的计算!
  • 完成后,推进当前表的最小存活事务标记(relfrozenxid)。

3. 终极防御:紧急只读保护模式

  • 当表的年龄(age(relfrozenxid))逼近 20 亿(通常在剩余几百万次事务时),autovacuum 会强制启动侵略性防回卷清理(Aggressive Vacuum);
  • 最严酷的事故场景:如果此时有长事务或死锁强行卡住了清理,系统事务 ID 即将耗尽到临界点:
  • PostgreSQL 内核会立刻执行自杀式停机保护:强行拒绝所有写操作(INSERT / UPDATE / DELETE),整库进入只读保护状态(Read-Only Panic)!
  • 此时只能单用户模式停机登录并强制执行清理,业务将遭受长时间中断。

4. 生产避坑准则

  1. 监控数据库与表的年龄(必须接入告警平台):
    SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;
    若 age 超过 10 亿立刻排查,绝不能让其逼近 20 亿。
  2. 配置空闲事务超时强杀:
    -- postgresql.conf 核心防灾配置
    idle_in_transaction_session_timeout = 60000  # 空闲超 60 秒强行断开
Navigation

Type to search…

↑↓ navigate↵ selectEsc close