深夜的IO风暴:由一个长事务引发的PG MVCC与WAL写放大效应解析

凌晨三点,机房的制冷机组应该正发出单调的轰鸣,而我面对的只有屏幕上刺眼的告警红框。某个核心 PostgreSQL 集群的 IO Util 瞬间被打到了 100%,TPS 从平时的 5000 直接跳水到不到 200。 登到机器上,敲下 iostat -xdm 1,看到 awaitw/s 指标飙得极高。这不是突发的并发查询导致的读瓶颈,而是极端的写盘风暴。 顺手查了一下 pg_stat_activity,活跃连接数并没有激增。排查这种毫无征兆的写瓶颈,必须从 PG 的内核机制去倒推:在什么情况下,正常的业务写入会导致成倍的底层物理 IO?

幽灵长事务与 MVCC 的死穴

我首先怀疑的是 Autovacuum 失效导致的表极度膨胀。查了一下当前的死元组(dead tuples)情况:

SELECT relname, n_dead_tup, n_live_tup, 
       round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables 
ORDER BY n_dead_tup DESC LIMIT 5;

结果令人吃惊,几张核心大表的 dead_ratio 居然达到了惊人的 60% 以上。系统后台的 autovacuum worker 确实在跑,但清理效率极其低下。 直接看系统里有没有卡住的事务:

SELECT pid, usename, state, backend_xid, backend_xmin, 
       EXTRACT(EPOCH FROM (now() - xact_start)) AS duration_sec, query
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'active')
ORDER BY duration_sec DESC LIMIT 5;

列表第一行赫然出现了一个处于 idle in transaction 状态的会话,duration_sec 已经达到了惊人的 18000 秒(5个小时)。这是一个下游数据抽取的脚本,开启了事务,跑完 SELECT 后由于网络或者应用逻辑问题,一直没有发 COMMITROLLBACK。 在 PostgreSQL 的 MVCC 实现里,这个被遗忘的会话就是最致命的毒药。 PostgreSQL 的 MVCC 与 MySQL/InnoDB 有着本质的区别。InnoDB 通过 Undo Log 存放旧版本数据,而当前数据页永远是最新的。PG 则是 Append-only 模式。更新一行数据(UPDATE),本质上是把旧行的系统字段 xmax 标记为当前事务ID,然后插入一行全新的数据,其 xmin 为当前事务ID。新旧数据通常共存在同一个文件、甚至同一个数据页中。 当旧数据不再对任何活跃事务可见时,它就成了 dead tuple,需要 Autovacuum 进程来回收空间。但判断一条 dead tuple 能否被清理的边界,是全局最老的活跃事务ID(即系统的 OldestXmin)。 那个挂了 5 个小时的 idle in transaction 犹如一把铁锁,死死卡住了全局的 OldestXmin 向前推进。这 5 个小时内,整个实例所有表产生的所有 UPDATE 和 DELETE 操作遗留的死元组,全部无法被物理回收。

从表膨胀到 WAL 全页写(FPW)风暴

如果仅仅是表膨胀,通常表现是查询变慢(由于要扫描更多的数据页),但这解释不了 IO 被打满的写风暴。 进一步观察系统的写行为,发现极高比例的 IO 来自于 WAL(Write-Ahead Logging)目录。 这引出了 PG 内核的另一个关键机制:Full Page Writes (FPW)。 PostgreSQL 的默认页大小是 8KB,而绝大多数 Linux 文件系统的块大小是 4KB。在极端情况下(例如系统断电或内核崩溃),一个 8KB 的 PG 数据页可能只有一半(4KB)被成功写入磁盘,这就是所谓的“撕裂页(Torn Page)”。 为了保证数据的一致性,PG 引入了 FPW 机制。当开启 full_page_writes = on(默认且强烈建议开启)时,在每次 Checkpoint 之后的第一次修改某个数据页,PG 不仅仅把行级的数据变更(逻辑日志)写入 WAL,而是把整个 8KB 的数据页镜像完整地写入 WAL。 把长事务、MVCC 膨胀和 FPW 串联起来,整个故障链路就完全清晰了:

  1. 长时间未提交的事务卡住了 OldestXmin,导致大量死元组无法被回收。

  2. 表急剧膨胀,原本 1 个数据页能存放的记录,现在散落在了 5 个甚至 10 个数据页中。

  3. 业务在进行高频 UPDATE 时,由于数据离散,修改操作跨越了比平时多得多的数据页。

  4. 由于写入量大,脏页迅速累积,导致 Checkpoint 被频繁触发。

  5. 致命一击:每次 Checkpoint 后,这些被极度分散的、数量庞大的数据页只要遭遇第一次修改,就会触发 Full Page Writes。

  6. WAL 日志量呈指数级暴增,直接击穿了存储的 IOPS 瓶颈。

现场阻断与内核调优

定位到根因后,处理现场的手段必须果断。 第一步,干掉那个毒瘤事务,释放 OldestXmin

SELECT pg_terminate_backend(pid_of_the_idle_transaction);

杀掉事务后,Autovacuum 终于能够正常工作了。但由于堆积的 dead tuples 太多,默认的 Autovacuum 参数显得杯水车薪,反而因为长时占用磁盘 IO 影响业务。此时需要动态干预,提升清理效率的同时限制瞬时 IO 消耗。 我调整了集群的几个关键配置:

-- 提高允许并发清理的工作进程数
ALTER SYSTEM SET autovacuum_max_workers = 6;
-- 降低每次清理触发休眠的阈值,让清理操作更平滑而不是突刺
ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '2ms';
-- 提高每个休眠周期的资源消耗上限,加快整体进度
ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 2000;
-- 针对核心膨胀表,临时降低 NAPTIME,让其被更高频地关注
ALTER SYSTEM SET autovacuum_naptime = '30s';
SELECT pg_reload_conf();

同时,为了缓解 WAL 频繁 Checkpoint 带来的 FPW 放大效应,需要拉大 Checkpoint 的跨度,让尽可能多的页更新合并在一个 Checkpoint 周期内,从而只产生一次 FPW。

-- 拉长检查点超时时间
ALTER SYSTEM SET checkpoint_timeout = '15min';
-- 增大 WAL 文件的最大配额,防止因 WAL 数量超限强制触发 Checkpoint
ALTER SYSTEM SET max_wal_size = '20GB';
-- 调整检查点完成的平滑度,防止 Checkpoint 刷脏页的突刺
ALTER SYSTEM SET checkpoint_completion_target = '0.9';
SELECT pg_reload_conf();

调整完成后,观察 iostat 上的 w/s 开始稳步回落。此时可以通过 pg_stat_bgwriter 视图确认 Checkpoint 的状态:

SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean 
FROM pg_stat_bgwriter;

重点关注 checkpoints_req(因 WAL 满而强制触发的检查点)与 checkpoints_timed(因时间到达触发的检查点)的比例。调整参数后,checkpoints_req 的增长明显停滞,说明系统已经回到了由时间驱动的平稳刷脏状态,FPW 风暴被有效阻断。

规避机制

把连接闲置等价于事务挂起,是很多开发者使用 ORM 框架时的常见误区。在 PG 这种强依赖 MVCC xmin 推进的数据库中,长事务是万恶之源。 在生产环境中,不能单纯依赖开发者的代码质量来保证数据库的稳定。必须在服务端设置兜底防线。 在 postgresql.conf 中,我会强制设置以下参数,这是防御此类故障的底线:

# 终止超过 30 分钟处于 idle in transaction 状态的会话
idle_in_transaction_session_timeout = '30min'
# 终止执行时间超过 1 小时的超长查询(根据业务容忍度设定)
statement_timeout = '3600s'

屏幕上的 IO 图表终于拉平了。其实底层系统的很多所谓“诡异”问题,拆解到最后,都是由最基础的机制(Append-only, WAL, Page Cache)在特定的极度边界下产生的连锁反应。搞懂了底层的流转逻辑,解决问题不过是顺水推舟。