标签: PostgreSQL

  • 深入 PostgreSQL 陷阱排查:长事务冻结 xmin 引发的 MVCC 膨胀风暴与 Autovacuum I/O 瘫痪实战

    某次核心交易链路突发严重告警,PostgreSQL 主库 Load Average 瞬间飙升至 80+,NVMe SSD 的 IOPS 被彻底打满(100% util),核心订单更新接口的 p99 延迟从 5ms 直接恶化到 3000ms 以上,业务几乎处于熔断边缘。

    结论先行:这是一起典型的由开发侧“防御性代码”缺失引发的内核级雪崩。一个低频的 BI 抽数 Python 脚本在开启事务后,因遇到未捕获的异常而挂起,导致连接池中残留了一个长达 72 小时的 idle in transaction(空闲事务)。这个幽灵连接死死卡住了 PostgreSQL 的全局 xmin(最老活跃事务ID)水位线。在这 3 天里,核心订单表经历数千万次 UPDATE,由于 xmin 冻结,Autovacuum 无法清理任何死元组(Dead Tuples),导致一张原本 5GB 的表膨胀到了 80GB。当监控系统触发阈值,运维手动 kill 掉该幽灵连接的瞬间,积压的 Autovacuum 进程如饿狼般扑向这 75GB 的垃圾数据,引发了灾难性的 I/O 读写风暴。

    最终救火操作:立即调整 autovacuum_vacuum_cost_limit 降低清理 I/O 速率,并在线使用 pg_repack 重组表空间碎片。

    案发现场与底层逻辑

    排查过程异常简单但令人窒息。登录数据库,敲下第一条排查 SQL:

    SELECT pid, state, duration, query 
    FROM (
        SELECT pid, state, now() - xact_start AS duration, query 
        FROM pg_stat_activity 
        WHERE state = 'idle in transaction'
    ) a 
    ORDER BY duration DESC LIMIT 1;
    

    结果赫然显示一个持续了 3 days 02:14:15 的空闲事务。

    为什么一个没有任何读写动作的空闲连接,能把数据库逼到 OOM 甚至 I/O 瘫痪的绝境?这必须从 PostgreSQL 的 MVCC(多版本并发控制)实现机制说起。

    不同于 MySQL (InnoDB) 将 Undo Log 存放在独立的表空间,PostgreSQL 采用了“Append-Only”式的 MVCC 设计。每次对一行数据进行 UPDATE,PG 内核并不会覆盖原数据,而是将其标记为不可见(设置 xmax),并在堆表(Heap Table)中插入一行新版本的数据。

    为了实现快照隔离(Snapshot Isolation),PG 必须保留那些“可能被当前活跃事务读取”的旧版本数据。Autovacuum 进程在决定是否物理清理一行死元组时,判断依据是:该行数据的 xmax(删除它的事务ID)是否小于当前系统所有活跃事务中最小的那个 xmin。

    由于那个挂起的 BI 抽数脚本死死咬住了 3 天前的 xmin,这 3 天内产生的所有历史数据版本,对 Autovacuum 来说都是“不可触碰”的。于是,系统只能眼睁睁看着堆表无限制膨胀,不仅消耗大量磁盘,更致命的是污染了 shared_buffers:业务正常的全表扫描或范围查询,不得不把海量的无用死元组读入内存,导致 Buffer Cache 命中率雪崩。

    致命的“按下葫芦浮起瓢”:Autovacuum I/O 风暴

    当确认是长事务作祟后,通常的操作是 SELECT pg_terminate_backend(pid); 杀掉连接。但真正的灾难往往发生在此时。

    长事务被清理,全局 xmin 瞬间推进,原本被冻结的 75GB 死元组瞬间变成了“可回收垃圾”。此时,一直处于休眠或空转状态的 Autovacuum Worker 立即被唤醒,开始对严重膨胀的表执行 Vacuum 扫描。

    由于前期有同学为了“加快常规清理速度”,将 autovacuum_vacuum_cost_limit 从默认的 200 激进调大到了 10000,且 autovacuum_vacuum_cost_delay 设为了 0。这就导致 Autovacuum 进程在扫描那 75GB 数据时,几乎没有任何限流(Throttling),直接跑满了底层存储的吞吐带宽。

    普通 Vacuum 会产生大量的随机读(扫描表页面和索引页面)以及写入(更新 FSM 空间映射表和 VM 可见性映射表,并产生相应的 WAL 日志)。正常的业务读写被这股突如其来的 I/O 洪流无情碾压,最终引发了 Load 飙升和 QPS 熔断。

    防御性配置与最佳实践

    把数据库的生死存亡交由业务代码的严谨性来保证,是运维架构的失职。针对此类 MVCC 膨胀与 Vacuum 陷阱,必须在数据库层面实施强制的防御性配置。

    1. 强制斩断长事务(核心防线) 在 postgresql.conf 中必须配置以下参数,绝不允许无限期的空闲事务存在。

    # 终止超过 60 秒一直处于 idle in transaction 状态的会话
    idle_in_transaction_session_timeout = 60000 
    # 限制单个语句的最大执行时间(防慢查询打爆 CPU)
    statement_timeout = 30000 
    

    2. 精细化控制 Autovacuum 的 I/O 成本 不要试图关闭 Autovacuum,也不要无脑调大参数。Vacuum 应该像温水煮青蛙一样持续在后台运行。

    # 默认 200 太小,10000 太大,通常建议根据 IOPS 能力设为 1000-2000
    autovacuum_vacuum_cost_limit = 2000
    # 给予每次清理后短暂的暂停,避免连续霸占 I/O
    autovacuum_vacuum_cost_delay = 2ms 
    

    3. 监控 oldest_xmin 与死元组率 常规的 CPU 和内存监控无法提前预警 MVCC 膨胀。必须将 pg_stat_user_tables.n_dead_tup(死元组数)和 age(datfrozenxid) 接入 Prometheus。

    此外,需要明白一个残酷的事实:普通的 VACUUM 操作只能把空间标记为可用,更新 FSM 表(Free Space Map),供后续 INSERT/UPDATE 复用,它绝对不会将物理磁盘空间还给操作系统。如果表已经膨胀到了 80GB,常规 VACUUM 跑完,表文件依然是 80GB。要缩减物理文件大小,只能使用锁定全表的 VACUUM FULL,或者在生产环境使用非阻塞工具 pg_repack 或 pg_squeeze。

    排查清单:PostgreSQL 膨胀与 I/O 突增速查

    1. 查杀空闲长事务: 定位并清理:SELECT pid, pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction' AND state_change < now() - INTERVAL '5 minutes';

    2. 确认表级膨胀率(死元组占比): 使用内置统计视图:SELECT relname, n_live_tup, n_dead_tup, (n_dead_tup::float / (n_live_tup + n_dead_tup + 1)) * 100 AS dead_tup_ratio FROM pg_stat_user_tables ORDER BY dead_tup_ratio DESC LIMIT 5;

    3. 排查复制槽(Replication Slot)残留: 除了长事务,废弃的复制槽也会冻结 xmin 和 WAL。检查并清理:SELECT slot_name, plugin, slot_type, active, age(xmin) FROM pg_replication_slots;

    4. 抓取当前 Vacuum 进度与 I/O 开销: 查看 pg_stat_progress_vacuum 视图,确认当前正在执行清理的表,以及处于哪个扫描阶段(如 scanning heap 或 vacuuming indexes)。

  • 深入 PostgreSQL 陷阱排查:频繁更新索引列打破 HOT 机制引发的 WAL 写放大与 IOPS 耗尽实战

    排查过程中接到告警,某核心订单库主节点 IOPS 持续处于 100% 饱和状态,iowait 飙升导致 CPU Load Average 冲破 80,P99 延迟从平时的 10ms 劣化到 4000ms+。排查发现 QPS 并没有出现突增,但磁盘的写入吞吐量(Write Bandwidth)却达到了平时的十倍以上,且主要消耗在 WAL 日志写入和 Autovacuum 进程上。直接抛出结论:开发人员在 updated_at 字段上创建了毫无意义的单列 B-Tree 索引,且业务代码每次 UPDATE 必定刷新该时间戳。这一行为彻底破坏了 PostgreSQL 的 HOT(Heap-Only Tuples) 机制,导致每一次普通的数据行更新都退化为全局索引更新,叠加 Checkpoint 后的 Full Page Writes (FPW),引发了灾难级的 WAL 写放大与 MVCC 索引膨胀。删掉废柴索引并微调 fillfactor 后,IOPS 直接断崖式下降 85%。

    案发现场与指标佐证

    登陆机器第一步,先看系统 I/O 状态,iostat -x 1 的数据简直刺眼:

    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme1n1           0.00     0.00  120.50 8540.20  1024.00 98560.00    23.00    14.50    1.68    3.50    1.65   0.11 100.00
    

    高 w/s,高 wkB/s,磁盘完全被写请求打满。

    切进数据库,看看到底是哪些表在作妖。通过查询 pg_stat_user_tables,我抓取了系统里 Update 最频繁的前 5 张表,重点观察它们的 HOT 更新率(HOT Ratio):

    SELECT relname,
           n_tup_upd,
           n_tup_hot_upd,
           ROUND((n_tup_hot_upd::numeric / NULLIF(n_tup_upd, 0)) * 100, 2) AS hot_ratio
    FROM pg_stat_user_tables
    WHERE n_tup_upd > 100000
    ORDER BY n_tup_upd DESC LIMIT 5;
    

    执行结果:

      relname   | n_tup_upd | n_tup_hot_upd | hot_ratio 
    ------------+-----------+---------------+-----------
     orders     |  85940321 |        120340 |      0.14
     users      |   4039102 |       3980120 |     98.53
     inventory  |  15039201 |      14800300 |     98.41
    

    orders 表的 HOT 更新率竟然只有可怜的 0.14%。这就意味着,这表中 99.86% 的 Update 操作,PG 内核都在老老实实地写入全套索引记录。

    我查了一下 orders 表的索引结构:除了主键和常规的状态、用户 ID 索引外,赫然躺着一个 idx_orders_updated_at。 问业务研发为什么建这个索引?答曰:“ORM 自动生成的,想着以后可能会按修改时间排序拉取数据,就先建上了。” 毫无数据访问逻辑支撑的“防御性建索引”,在 MySQL 里可能只是浪费点空间和微小的写入性能,但在 PostgreSQL 里,这简直是自杀。

    为什么打破 HOT 机制是致命的?

    PostgreSQL 的 MVCC 实现与 MySQL (InnoDB) 截然不同。InnoDB 是原地更新(In-Place Update),旧版本数据存放在 Undo Log 中;而 PG 没有独立的 Undo 表空间,它的 Update 本质上是 Delete + Insert。即标记旧元组(Tuple)为不可见,并在页(Page)中插入一个全新的元组。

    如果每次 Update 都要在所有索引树上插入新指针,这种写放大是不可接受的。因此 PG 引入了 HOT (Heap-Only Tuples) 机制。 满足 HOT 的条件极其严苛,但收益极高:

    1. 更新的字段上不能有任何索引。

    2. 当前数据页(Block)内部必须有足够的剩余空间存放新元组。

    只要满足上述条件,PG 就只会在 Heap 层面做行链更新(旧行指向新行),所有的索引结构都无需修改。

    在本次事故中:

    1. 业务线每修改一次订单状态,都会执行类似 UPDATE orders SET status = 2, updated_at = NOW() WHERE id = 100; 的语句。

    2. 因为 updated_at 存在索引,直接破坏了 HOT 条件 #1。

    3. 表的 fillfactor 是默认的 100,页面平时塞得满满当当,破坏了 HOT 条件 #2。

    蝴蝶效应开始了:

    • 写放大 X 倍: orders 表共有 6 个索引。一次单行更新,不仅要写 1 个新的 Heap 数据,还要在 6 个 B-Tree 索引中各插入 1 条新记录。

    • WAL 风暴: PG 为了保证崩溃恢复(Crash Recovery),在每次 Checkpoint 后的第一次页面修改时,会将整个 8KB 页面完整写入 WAL(即 Full Page Writes, FPW)。因为索引频繁变动,大量的数据页和 6 个索引树的页面都在被高频触碰,导致大量触发 FPW,WAL 日志生成速度高达数百 MB/s,直接榨干 SSD 的 IOPS。

    • Autovacuum 瘫痪: 索引膨胀导致 Autovacuum 清理死元组时需要扫描和清理庞大的索引树,进一步加剧了磁盘 I/O 竞争,引发恶性循环。

    破局与调优落地

    弄懂了底层逻辑,解决起来极其简单,只需要做减法。

    1. 物理超度无用索引 既然业务根本没有使用 updated_at 过滤数据的慢查询,果断 DROP INDEX CONCURRENTLY idx_orders_updated_at;。 (注意:必须加上 CONCURRENTLY 以避免锁表阻塞线上交易)。

    2. 预留页内空间,召唤 HOT 机制 对于这种极度高频 Update 的核心表,必须将页填充率(fillfactor)下调,给页内更新留出空间:

    ALTER TABLE orders SET (fillfactor = 85);
    

    注:这条命令仅对新分配的页生效。对于存量数据,我不建议在线上直接跑 VACUUM FULL,而是用 pg_repack 选个低峰期重整表结构,或者就让它随着增量数据自然过渡。

    3. WAL 压缩兜底 为了防御未来可能的类似写放大,在 postgresql.conf 中开启 WAL 压缩(PG 15+ 推荐 zstd,低版本用 on 启用 pglz):

    wal_compression = zstd
    

    开启后会消耗微量的 CPU 算力(1%-2%),但能将 FPW 造成的 WAL 日志体积压缩 60% 以上,对 I/O 瓶颈极具性价比。

    调整执行后的 10 分钟内,orders 表的 HOT Ratio 迅速攀升至 80% 以上,磁盘写入量暴跌至几兆每秒,iowait 归零,P99 延迟回归 10ms,世界清静了。

    排查清单:PostgreSQL HOT 机制与 WAL 异常速查

    如果你遇到类似的 PG 磁盘 I/O 瓶颈,请按以下 4 步排查:

    1. 查 HOT 命中率 执行本文上方的 pg_stat_user_tables 检查 SQL,如果高频更新表(n_tup_upd 大)的 hot_ratio 低于 50%,必有隐患。

    2. 查索引命中与浪费 通过 pg_stat_user_indexes 查询哪些索引从未被 Scan 过: sql SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0 AND schemaname = 'public'; 没有任何查询命中的索引,如果还在被高频 Update 字段牵连,立刻 Drop。

    3. 查 WAL 生成速率 在 pg_stat_bgwriter 或系统级通过监控面板查看 WAL Write 速率。如果 WAL 写入量远超实际业务数据变动量,重点排查大面积 Update 打破 HOT 引发的 FPW 风暴。

    4. 查 fillfactor 配置 执行 SELECT relname, reloptions FROM pg_class WHERE relname = '你的表名';。如果高频 Update 且无索引覆盖的表 fillfactor 还是默认的空值(即 100),建议改至 85-90 之间。

  • 深入 PostgreSQL 陷阱排查:Idle in Transaction 引发的 MVCC 巨量膨胀与 IOPS 耗尽实战

    排查过程中,某核心业务数据库突然遭遇性能断崖:QPS 从日常的 8000 跌至不足 500,99线延迟从 5ms 飙升至 3.5s,主机 Load Average 直接突破 120,IOPS 被瞬间打满。 最终结论:这是一个典型由“防御性配置缺失”加上“烂SQL”引发的血案。某个后台数据同步 Python 脚本由于异常终止,遗留了一个长达 72 小时的 idle in transaction 僵尸连接。这个僵尸连接死死拖住了全局的 xmin 水位,导致 Autovacuum 进程虽然疯狂运转,却无法清理这三天内产生的几千万条 Dead Tuples。核心表体积膨胀了 40 倍,原本走内存缓存的 Index Scan 退化成了疯狂刷盘的物理 I/O,最终引发整个集群雪崩。

    解法很直接:Kill 掉僵尸 PID,利用 pg_repack 在线重建表空间,并在全局配置文件中强制开启 idle_in_transaction_session_timeout。

    案发现场与指标确认

    当时接警后,第一反应是看系统监控。iostat -x 1 显示磁盘 %util 持续 100%,读写带宽却并不高,典型的随机小 I/O 耗尽了存储性能。 切到数据库层,执行基础探查:

    SELECT datname, usename, state, query, extract(epoch FROM now() - xact_start) AS duration
    FROM pg_stat_activity
    WHERE state != 'idle' ORDER BY duration DESC LIMIT 5;
    

    输出结果令人窒息,排名第一的连接 state 为 idle in transaction,duration 竟然高达 259200 秒(整整三天)。

    紧接着看一眼核心交易表的膨胀情况:

    SELECT relname, n_live_tup, n_dead_tup, 
           round(n_dead_tup * 100.0 / (n_live_tup + n_dead_tup + 1), 2) AS bloat_ratio
    FROM pg_stat_user_tables 
    WHERE relname = 'orders_hot';
    

    结果显示 n_live_tup 为 120 万,而 n_dead_tup 高达 4800 万,膨胀率超 97%。表物理大小从正常的 500MB 飙到了将近 20GB。

    底层原理解析:为什么一个 Idle 连接能搞死整个集群?

    要理解这个致命陷阱,必须搞懂 PostgreSQL 的 MVCC(多版本并发控制)底层实现。 与 MySQL InnoDB 将旧版本数据塞进独立 Undo Log 的做法不同,PostgreSQL 采用了“原地追加”的设计。当你执行 UPDATE 时,PG 其实是做了一个 DELETE + INSERT,旧行(Dead Tuple)和新行(Live Tuple)都存在同一个数据页(Page)里。

    为了清理这些旧行,PG 依赖后台的 Autovacuum 进程。但 Autovacuum 回收旧行的核心前提是:系统中没有任何活跃事务可能还会读取到这行数据。 判断标准就是事务 ID(XID)。如果旧行的 xmax(即删除该行的事务 ID)小于当前系统中所有活跃事务的最小事务 ID(全局 xmin),这行数据才能被安全回收。

    灾难的逻辑链条就此闭环:

    1. 那个挂掉的 Python 脚本开启了一个事务(BEGIN),执行了一条简单的 SELECT,然后由于网络问题断开或进程 OOM 被杀,但它使用的连接池没有发送 COMMIT 或 ROLLBACK。

    2. 数据库层面,这个连接就卡在了 idle in transaction 状态,它的事务 ID 被永久保留。

    3. 核心表 orders_hot 每天有极高频的并发 UPDATE,产生海量 Dead Tuples。

    4. Autovacuum 定期唤醒扫描这块表,但一比对 XID,发现那个长事务的 xmin 比这些 Dead Tuples 的 xmax 还要老。

    5. Autovacuum 只能叹口气,原封不动地放过这些旧数据。

    6. 数据页被迅速塞满,新数据只能申请新的 Page,导致表物理文件无限膨胀。缓存命中率(Hit Ratio)断崖式下跌,每次查询都需要扫过几百个全是废数据的 Page,I/O 直接被打爆。

    在开发眼里,似乎只是“一个空闲连接没关”,而在 PG 内核引擎里,这等同于“冻结了整个数据库的时间线”。

    解决与止血方案

    不要试图在业务高峰期直接执行 VACUUM FULL,这会申请 AccessExclusiveLock 排他锁,直接导致全表读写阻塞,引发更惨烈的级联故障。

    第一步:物理超度僵尸连接

    SELECT pg_terminate_backend(pid) 
    FROM pg_stat_activity 
    WHERE state = 'idle in transaction' AND extract(epoch FROM now() - xact_start) > 300;
    

    注:干掉之后,Autovacuum 终于能够正常回收之后的数据页,但这无法缩减已经膨胀的物理文件大小。

    第二步:使用 pg_repack 无锁重建 借助 pg_repack 插件,在不影响业务读写的情况下重新组织表物理文件。

    pg_repack -h localhost -U postgres -d mydb -t orders_hot -j 4
    

    原理:它会建一张结构相同的新表,触发器同步增量数据,老表数据导完后交换表名,全程只需要极短的锁时间。

    第三步:配置层面的防御性编程 永远不要相信客户端侧的规范,所有的保障必须在数据库内核层面兜底。在 postgresql.conf 中强制注入以下参数:

    # 斩断 idle in transaction 狗皮膏药,超时 60 秒直接 Kill(PG 9.6+ 支持)
    idle_in_transaction_session_timeout = 60000 
    
    # 设置普通语句执行超时,防止烂 SQL 拖死 CPU
    statement_timeout = 30000 
    
    # 如果是 PG 14+,可以考虑开启 idle_session_timeout
    idle_session_timeout = 300000 
    

    避坑总结

    技术团队过度依赖 ORM 框架和连接池默认配置,往往容易忽略数据库原生会话生命周期的管理。PG 的 MVCC 架构在带来极高并发性能的同时,也把垃圾回收的压力交给了运维层面。 在架构设计时,如果你的业务属于“高频 Update/Delete”场景,监控体系里如果没有对 pg_stat_user_tables.n_dead_tup 和长事务时间做强力告警,那就是在抱着定时炸弹裸奔。

    排查清单:PostgreSQL MVCC 膨胀与长事务速查

    1. 一键定位长事务与慢查询 sql SELECT pid, datname, usename, state, backend_xid, backend_xmin, extract(epoch FROM now() - xact_start) AS duration, query FROM pg_stat_activity WHERE state != 'idle' AND xact_start IS NOT NULL ORDER BY duration DESC LIMIT 10;
    2. 侦测表空间与索引膨胀率 重点关注 n_dead_tup 与 n_live_tup 比例。比例超过 20% 且表极大的,需要介入处理。 sql SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;
    3. 检查 Autovacuum 运行状态 如果系统 I/O 正常但膨胀严重,检查 Autovacuum 进程是否在干活,或者是否被长事务卡住。 sql SELECT * FROM pg_stat_activity WHERE query LIKE 'autovacuum%';
    4. 验证防御性配置是否生效 执行 SHOW idle_in_transaction_session_timeout;,确保该值不为 0。
  • 深入 PostgreSQL 陷阱排查:僵尸复制槽引发的 xmin 冻结与 Autovacuum I/O 风暴实战

    排查过程中,我们接手了一个险些造成核心交易系统雪崩的 PostgreSQL 性能故障。表现非常直接:主库 Load Average 突然从 2 飙升到 80+,核心订单表查询的 99 线延迟从 5ms 暴涨至 3s 以上,底层 NVMe SSD 的 I/O 写入利用率打满 100%。

    直接抛出结论:下游大数据团队停用了一个 Flink CDC 抽取任务,但未在 PG 主库清理对应的逻辑复制槽(Logical Replication Slot)。这个处于离线(active = false)状态的僵尸复制槽死死拖住了全局的 catalog_xmin 推进,导致主库 MVCC 机制失效,几个核心大表的死元组(Dead Tuples)疯狂膨胀。当某位值班兄弟发现 WAL 目录快满,为了释放空间“急中生智”手动 Drop 掉该复制槽时,被压抑已久的 xmin 水位线瞬间释放,触发了史诗级的 Autovacuum 工作流。几个 Worker 进程以近乎全速的 I/O 扫描并清理上百 GB 的死元组,直接击穿了存储 I/O,引发全站查询阻塞。

    盲目 Drop 复制槽而不评估 MVCC 历史债务,是典型的只管表象不顾底层的操作。下面拆解底层的运作机理和防御性配置。

    案发现场与指标特征

    故障爆发时,首先看 iostat:

    iostat -dx 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme0n1           0.00     0.00 45200.00 1205.50 361600.00 15600.00    16.25    32.50    0.70    0.65    2.50   0.02 100.00
    

    磁盘 %util 顶在 100%,大量的随机读(r/s 极高),这通常不是正常的业务流量。

    切到数据库内部看 pg_stat_activity 的等待事件:

    SELECT pid, wait_event_type, wait_event, state, query 
    FROM pg_stat_activity 
    WHERE state = 'active' AND wait_event_type = 'IO';
    

    发现几十个业务连接卡在 DataFileRead,更致命的是,出现了几个底层的进程,query 字段显示: autovacuum: VACUUM public.orders

    回头翻看慢查询日志和监控,发现在 Autovacuum 启动前,有人执行了: SELECT pg_drop_replication_slot('flink_cdc_orders');

    这就是血案的导火索。

    底层原理解析:为什么 Drop 复制槽会击穿 I/O?

    PostgreSQL 的 MVCC 实现与 MySQL/Oracle 不同,它没有独立的 Undo Log 表空间,而是将旧版本的数据(Tuple)直接留在原表的 Heap 文件中。

    为了判断哪些旧版本 Tuple 可以被安全清理,PG 维护了一个全局的可见性底线——xmin horizon。如果系统中没有任何活跃事务、预备事务或复制槽需要看到某个旧版本,Autovacuum 就可以将其作为死元组(Dead Tuple)回收。

    逻辑复制槽(用于 CDC 日志解析)为了保证在解码 WAL 时能够读取到完整的系统表元数据,会持有一个 catalog_xmin。 只要下游 CDC 断开连接且不推进 LSN,这个复制槽就会变成“僵尸”,其 catalog_xmin 永远停留在断开的那一刻。

    -- 查看复制槽状态的致命视角
    SELECT slot_name, plugin, active, xmin, catalog_xmin, restart_lsn 
    FROM pg_replication_slots;
    

    连锁反应如下:

    1. 垃圾回收停滞:catalog_xmin 停滞,导致全局 xmin horizon 无法推进。

    2. 表极度膨胀:业务高频执行 UPDATE/DELETE,产生海量 Dead Tuples,但 Autovacuum 扫描后发现这些 Tuple 还处于 xmin horizon 之上,无法物理回收。表体积从 50GB 膨胀到 200GB。

    3. WAL 堆积:物理/逻辑复制槽不仅阻塞 xmin,还会通过 restart_lsn 阻塞 WAL 日志的清理。PG 的 pg_wal 目录开始告警。

    4. 引爆 I/O 风暴:运维强行 pg_drop_replication_slot(),释放了 catalog_xmin 和 WAL 限制。xmin horizon 瞬间跳跃到当前最新。Autovacuum 再次醒来时,发现有 150GB 的死元组“终于”可以清理了,开始执行极其沉重的索引和堆表扫描。如果没有做好 I/O 成本节流配置,Autovacuum 会消耗掉所有磁盘带宽。

    止血与防御性配置落地

    1. 现场紧急止血

    不能任由 Autovacuum 把主库 I/O 打死,必须先将其阻断,再限流恢复。

    第一步,干掉正在作恶的 autovacuum 进程(PG 内部机制会自动重启它,但我们要争取时间修改配置):

    -- 找出 autovacuum worker 并 cancel
    SELECT pg_cancel_backend(pid) 
    FROM pg_stat_activity 
    WHERE query LIKE 'autovacuum: %';
    

    第二步,动态调低 Autovacuum 的 I/O 侵略性:

    -- 增加每次清理达到成本限制后的睡眠时间(默认可能是2ms,调大到20ms)
    ALTER SYSTEM SET autovacuum_vacuum_cost_delay = '20ms';
    -- 减小单次清理的成本限制(让它更快休眠)
    ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 200;
    -- 重新加载配置
    SELECT pg_reload_conf();
    

    2. 长期防御性调优(避坑指南)

    别指望所有研发都会记得下线 CDC 时通知 DBA,系统必须具备自我保护能力。

    防御手段 A:限制复制槽的 WAL 保留量 (PostgreSQL 13+ 特性) 在 PG 13 之前,一个失联的复制槽可以直接把磁盘撑爆。PG 13 引入了救命参数 max_slot_wal_keep_size。当复制槽保留的 WAL 超过此值时,PG 会强制使该槽失效(Invalidate),宁可让下游 CDC 报错重做,也不能让主库挂掉。

    # postgresql.conf
    # 允许单个复制槽最多保留 50GB 的 WAL
    max_slot_wal_keep_size = 50GB 
    

    防御手段 B:杀死长连接与长事务 除了复制槽,长时间 Idle in transaction 的连接同样会死锁 xmin horizon。

    # postgresql.conf
    # 强制断开闲置超过 10 分钟的事务连接,防止 xmin 冻结
    idle_in_transaction_session_timeout = 600000 
    

    防御手段 C:精细化 Autovacuum I/O 控制 默认的 Autovacuum 参数在 SSD 时代显得过于保守,但如果是极度膨胀后的突发清理,又会过于激进。建议基于磁盘 I/O 能力计算 cost_limit,不要使用一刀切的默认值。基于 NVMe 盘的基线建议:

    autovacuum_max_workers = 5
    autovacuum_vacuum_cost_limit = 2000
    autovacuum_vacuum_cost_delay = 2ms
    

    保证日常能快读清理完毕,不至于把债务堆积到爆发。

    排查清单:PostgreSQL 存储膨胀与 I/O 突增速查

    为了便于后续快速定位同类问题,请重点检查以下几项:

    1. 检查全局 xmin 阻塞源: 执行 SELECT * FROM pg_replication_slots WHERE active = false; 查找僵尸复制槽。

    2. 排查长事务/预备事务: 查询 pg_stat_activity 中 state = 'idle in transaction' 且 backend_xid / backend_xmin 极老的会话;排查 pg_prepared_xacts 中未提交的 2PC 事务。

    3. 确认表膨胀率 (Dead Tuples): 查询 pg_stat_user_tables,对比 n_live_tup 与 n_dead_tup 的比例,若 n_dead_tup 极高且持续不降,说明 MVCC 清理链已断裂。

    4. 监控 Autovacuum 行为: 观察系统视图 pg_stat_progress_vacuum,检查当前是否有表正在经历阶段耗时极长的 vacuum_index 或 vacuum_heap。

    5. WAL 堆积安全阈值验证: 检查当前 PG 版本是否配置了 max_slot_wal_keep_size 作为底线防御,否则将随时面临全盘占满的宕机风险。

  • 深入 Zabbix 监控雪崩排查:LLD 发现风暴引发的 Proxy 缓存积压与 History Syncer 夯死实战

    近期处理了一起 Zabbix 6.0 LTS 集群雪崩事故。根因是某业务线引入劣质自定义 LLD 模板,单机生成逾万监控项,引发 Proxy 缓存打满与 History Syncer 进程 100% 繁忙,最终压垮后端 DB IO 导致全局断连。核心解法:阻断异常 LLD 发现、调优 Zabbix 核心缓存参数,并将底层存储彻底迁移至 PostgreSQL + TimescaleDB 解决写入墙问题。

    故障现场:Queue 积压与 Poller 满载

    排查过程中,监控大屏首先报警的是 Zabbix Queue 严重积压,延迟超过 10 分钟的 item 数量直线飙升破 5 万。登录 Zabbix Server 核心节点,top 命令显示 Load Average 飙升至 80+,系统 iowait 长期盘踞在 40% 以上。

    查看 Zabbix Server 日志 /var/log/zabbix/zabbix_server.log,满屏都是极其致命的告警:

    Zabbix server history syncer processes more than 75% busy
    Zabbix server history syncer processes more than 100% busy
    server is out of memory: Out of memory (data: 256M, index: 64M)
    cannot accept connection from proxy "cn-sh-proxy-01": max number of Trapper processes reached
    

    切到前端分布式 Proxy 节点 /var/log/zabbix/zabbix_proxy.log,同样处于崩溃边缘:

    cannot send proxy data to server at "10.0.0.10": Zabbix server connection failed
    history cache is full, sleeping for 1 second
    

    表象很清晰:数据写不进数据库,导致 Zabbix Server 的 History Syncer(负责将内存数据刷入 DB 的核心进程)全部夯死。Server 端 Trapper 进程耗尽,导致 Proxy 无法上报数据,Proxy 本地的 HistoryCache 被打爆,最终整个监控链路瘫痪。

    为什么一个简单的自定义模板能搞垮整个监控集群?

    很多开发在写 Zabbix 监控脚本时,缺乏“防御性编程”思维。抓取故障现场的 Proxy sqlite3 库(或本地临时文件),发现罪魁祸首是一个名为 Custom_K8s_Pod_Discovery 的 LLD (Low-Level Discovery) 脚本。

    该脚本通过 Python 遍历全量 Pod 状态,但没有做任何 Limit 限制和状态机过滤。单台 Kubernetes Node 上的脚本直接返回了近 5MB 的 JSON Array:

    {
      "data": [
        {"{#PODNAME}": "web-api-7b89f...", "{#NAMESPACE}": "prod", "{#CONTAINER}": "nginx"},
        // ... 往下还有 15000+ 个对象
      ]
    }
    

    Zabbix LLD 引擎在处理这个宏大 JSON 时,会为每一个 {#PODNAME} 动态生成 5 个 Item(CPU、内存、网络 IO 等)。 算一笔账:1 台机器抛出 15000 个实体 $\times$ 5 个 Item = 75000 个监控项。 如果是 100 台节点的集群,瞬间生成 750 万个新监控项。

    这些海量监控项每 30 秒采集一次数据,疯狂涌入 Zabbix Proxy。 Proxy 的默认 HistoryCacheSize 仅有区区 16M,瞬间被打满。随后 Proxy 将庞大的 Payload 塞给 Zabbix Server,Server 端的 History Syncer 试图将这几百万条并发写入后端的 MySQL history_uint 表。MySQL InnoDB 面对这种毫无规律的极高频并发 Insert,B+ 树页分裂严重,NVMe 磁盘的 IOPS 直接打满,写延迟达到 500ms 以上,彻底堵死。

    架构级改造:从 MySQL 到 PG+TimescaleDB

    在千万级 Item 的企业监控场景下,MySQL 表分区脚本(如常用的 partitioning.sql 存储过程)不仅维护极其痛苦,且对历史数据的清理依然会产生锁争用。

    解决写入瓶颈的最终态方案,是利用原生时序数据库。Zabbix 从 5.0 开始深度支持 PostgreSQL + TimescaleDB 扩展,将 history 相关的表转化为 hypertable,实现按时间维度的透明 Chunk 分片。

    迁移与落地步骤:

    1. 部署 PostgreSQL 14 与 TimescaleDB 插件。

    2. 导入 Zabbix 基础 Schema 后,务必执行 TimescaleDB 转换脚本:

    # Zabbix 6.0 环境下开启 TimescaleDB 支持
    zcat /usr/share/doc/zabbix-sql-scripts/postgresql/timescaledb.sql | sudo -u zabbix psql zabbix
    
    1. 在 Zabbix Server 开启内部历史数据压缩(极大降低磁盘 IO 并节省 70% 空间):
    -- 连接到 zabbix 库
    UPDATE config SET db_extension='timescaledb', history_compression_status=1, history_compress_older='7d';
    

    切换到 TimescaleDB 后,Zabbix History Syncer 的写操作变成了针对内存中最新 Chunk 的顺序追加写(Append-only),避开了全表扫描和巨型 B-Tree 维护,单机轻松抗住 10万+ QPS 的监控项写入。

    调优与防御性配置落地

    底层存储问题解决后,必须对 Zabbix 核心配置进行防御性加固,防止类似 LLD 风暴再次冲垮服务。

    1. Zabbix Server 核心参数重调

    编辑 /etc/zabbix/zabbix_server.conf:

    # 根据物理内存,大幅提高历史缓存,作为 DB 抖动时的缓冲池
    HistoryCacheSize=2G
    HistoryIndexCacheSize=256M
    ValueCacheSize=1G
    
    # 增加数据刷盘进程数(需结合 DB 最大连接数考量)
    StartHistorySyncers=30
    
    # 增加处理 Proxy 和 Agent 主动上报的 Trapper 进程
    StartTrappers=100
    
    # 禁用 Server 端轮询,强制全部走 Proxy 分布式采集
    StartPollers=0
    

    2. Zabbix Proxy 缓冲防御

    编辑 /etc/zabbix/zabbix_proxy.conf:

    # 提高 Proxy 侧的缓存,容忍更长时间的 Server 端断连
    HistoryCacheSize=1G
    HistoryIndexCacheSize=128M
    
    # 严格控制外部脚本超时时间,防止进程卡死(默认3秒,最大不超过10秒)
    Timeout=10
    

    3. 数据预处理(Pre-processing)截流

    针对自定义监控项,强制要求在 Zabbix Web UI 的 Item Preprocessing 中配置以下规则:

    • Discard unchanged with heartbeat (心跳抑制): 如果监控值没有变化,直接在 Proxy/Server 端丢弃,只在达到 heartbeat(如 1 小时)时强制写入一次。这能削减 60% 以上的无用状态写入。

    • 正则表达式过滤: 对 LLD 发现的文本进行白名单截断,丢弃非核心进程的数据。

    常见问题

    Q1: Proxy 报错 “Zabbix server connection failed”,但网络 Ping 和 Telnet 都通,如何排查? 通常不是网络问题,而是 Zabbix Server 端的 Trapper 进程全忙。检查 Zabbix Server 监控大屏上的 Zabbix server trapper processes busy 指标是否达到 100%。若是,需调大 StartTrappers,或检查是否有超大 Payload 正在阻塞网络层解析。

    Q2: 监控项经常出现断点,日志提示 “first network error, wait for 15 seconds”,如何优化? 这是 Poller 进程在执行某些慢请求(如大文本抓取、远端 API 调用)时超时了。Zabbix 默认超时 Timeout=3 秒。建议将耗时任务改成 Agent 端的异步 Crontab 写入本地文件,Zabbix 只做简单的 vfs.file.contents 读取;或者将 Timeout 谨慎上调至 10。

    Q3: 迁移到 TimescaleDB 后,Zabbix 的 Housekeeper 还需要开启吗? 绝对不需要对历史表开启。开启 TimescaleDB 后,应在 Zabbix UI 的 “Administration -> General -> Housekeeping” 中,勾选 Override item history period 并启用内部机制。旧数据的清理会由 DB 原生的 drop_chunks() 函数瞬间完成,而不是 Housekeeper 一行行执行极度耗 IO 的 DELETE 语句。

    Q4: 怎样防止自定义 LLD 脚本再次引发灾难? 运维必须剥夺业务组直接创建 LLD Template 的权限。通过 CI/CD 管道扫描业务侧提交的脚本,限制 LLD 返回的 JSON 最大数组长度(如不超过 200)。此外,在 Zabbix 中利用 “LLD overrides” 功能,强制要求匹配特定正则的对象才能触发 Item 发现。

  • 深入 PostgreSQL 生产表膨胀雪崩:长事务挂起引发的 autovacuum 失效与 XID Wraparound 宕机危机

    近期处理了一起极其经典的 PostgreSQL 数据库性能雪崩事故。核心表现为核心集群 CPU Load 飙升至 100+,读写 P99 延迟从 5ms 暴增到 3000ms,同时监控面板上的磁盘利用率以肉眼可见的速度疯狂攀升(每小时吃掉数十 GB)。

    结论先行:业务服务因某个非预期的异常退出,留下了一个长达数天的 idle in transaction(事务空闲)会话。这个幽灵会话死死按住了全局的 xmin 水位线,导致底层的 autovacuum 进程虽然疯狂拉起扫表,却无法清理任何死元组(Dead Tuples),最终引发海量表膨胀,并险些触发 PG 核心的 XID Wraparound(事务 ID 环绕)强制只读宕机保护。

    解决方法极其简单粗暴:pg_terminate_backend(pid) 杀掉僵尸进程,并在全局强制开启 idle_in_transaction_session_timeout 防御性配置。随后通过 pg_repack 无锁重建膨胀表。

    现场还原:当磁盘 I/O 被无效扫描打满

    排查过程中,第一视角的监控极其惨烈:

    1. iostat 显示底层 NVMe 盘的 %util 长时间顶在 100%,大量的随机读写。

    2. 慢查询日志被打爆,平平无奇的单行 UPDATE 和 SELECT 居然要跑几秒钟。

    直觉告诉我,数据扫描路径出问题了。连上数据库,直接看活跃会话:

    SELECT pid, usename, state, backend_xid, backend_xmin, duration 
    FROM (
        SELECT pid, usename, state, backend_xid, backend_xmin, 
               now() - xact_start AS duration 
        FROM pg_stat_activity 
        WHERE state != 'idle'
    ) sq 
    ORDER BY duration DESC LIMIT 5;
    

    结果极其刺眼:排名第一的会话状态是 idle in transaction,duration 已经高达 96:12:45(整整四天!)。

    再看系统视图里的表膨胀情况:

    SELECT relname, n_live_tup, n_dead_tup, 
           round(n_dead_tup::numeric / (n_live_tup + n_dead_tup + 0.01) * 100, 2) AS dead_ratio
    FROM pg_stat_user_tables 
    ORDER BY n_dead_tup DESC LIMIT 5;
    

    核心订单表的 n_dead_tup 高达数亿,dead_ratio 超过 70%。这意味着业务每次查询,PG 都要在磁盘上额外扫描 70% 的废弃数据,I/O 不炸才是见鬼了。

    底层原理:为什么一个 idle 会话能拖垮整个集群?

    很多人从 MySQL 迁移到 PostgreSQL 时,最不适应的就是它的 MVCC(多版本并发控制)实现。

    MySQL 把旧版本数据存放在独立的 Undo Log 里,而 PG 的设计更为激进——直接把新老版本(Tuples)写在同一个数据文件中。 当执行 UPDATE 或 DELETE 时,PG 只是在老元组的头部打上过期标记(xmax),然后插入一个新元组。这些被打上标记的老旧死元组,全靠后台的 autovacuum 进程来回收空间。

    但 autovacuum 清理死元组有一个铁律:必须保证当前系统中没有任何活跃事务可能再访问到这些元组。

    这里就涉及全局最小活跃事务 ID(xmin)。 如果系统中存在一个事务 A(比如我们抓到的那个僵尸会话),它在 4 天前开启(执行了 BEGIN 并且做过查询),那么 PG 必须为事务 A 保留它开启那个时间点的所有数据快照。 在事务 A 提交或回滚之前,全局的 xmin 水位永远无法向前推进。

    这就是最致命的地方:即便这 4 天里产生了上亿个死元组,autovacuum 正常按计划被唤醒,它扫描了整个表,发现这些死元组的 xid 都比那个僵尸事务 A 的 xid 要大,于是它一个字节都不能删,只能无奈地退出。循环往复,白白消耗大量 I/O 去扫表,却做着无用功。

    致命一击:XID Wraparound 保护

    更可怕的还在日志里。查看 postgresql.log,发现大量类似这样的告警:

    WARNING:  database "prod_db" must be vacuumed within 10000000 transactions
    HINT:  To avoid a database shutdown, execute a database-wide VACUUM in that database.
    

    PG 的事务 ID(XID)是一个 32 位的无符号整数,最大约 42 亿。为了处理环绕(即 XID 耗尽后从头开始),PG 把 XID 空间一分为二,过去 21 亿是“过去”,未来 21 亿是“未来”。 为了防止极其古老的事务 ID 变成“未来”导致数据不可见,PG 强制要求在 XID 跨度达到 20 亿之前,必须通过 VACUUM 冻结(Freeze)旧事务。

    因为那个 4 天前的僵尸事务拦住了 autovacuum 的清理与冻结逻辑,XID 正在逼近环绕红线。一旦触发 autovacuum (to prevent wraparound),这是最高优先级的强制清理操作,它会无视常规调度并疯狂吃光 I/O。如果最后还没清理完,PG 会为了保护数据不损坏,强行将整个数据库锁死进入只读模式(shutdown)。

    防御性落地:如何给系统系上安全带

    一个开发连直连线上 DB 手敲 BEGIN 忘了 COMMIT 去喝咖啡,或者微服务里一个没有设置 Timeout 的 HTTP 请求持有了 DB 链接挂死,就能让整个集群陪葬。这种架构容错率极低,必须从配置层面进行防御性斩断。

    1. 止血操作: 立刻执行斩首,将该 PID 强杀:

    SELECT pg_terminate_backend(pid);
    

    杀掉之后,autovacuum 终于能工作了,观察磁盘 I/O 依然很高,但那是正在真正清理死元组。

    2. 核心防御配置(必须写进 postgresql.conf):

    # 强制终止空闲在事务中的会话(救命配置,单位毫秒)
    idle_in_transaction_session_timeout = 600000  # 10分钟
    
    # 强制终止超长查询(防止烂SQL打满CPU)
    statement_timeout = 30000  # 30秒
    
    # 开启 autovacuum 慢执行日志,增强可观测性
    log_autovacuum_min_duration = 1000 # 超过1秒的清理记录到日志
    

    3. 空间回收: autovacuum 只能把死元组标记为可复用,它不会把磁盘空间还给操作系统(除非死元组刚好在文件的最后)。 对于已经严重膨胀的表,直接执行 VACUUM FULL 会获取最高级别的排他锁(AccessExclusiveLock),直接导致业务阻塞报错。 生产环境的唯一正解是使用 pg_repack 或 pg_squeeze 插件:

    # 在线无锁重建膨胀表,将真实数据拷贝到临时表并交换文件指针
    pg_repack -h localhost -d prod_db -t public.orders -j 4
    

    排查清单与同类问题速查

    1. 检查挂起长事务:周期性监控 pg_stat_activity 中 state = 'idle in transaction' 且 duration > 5m 的会话,直接触发告警。

    2. 监控表膨胀率:通过 pg_stat_user_tables 结合 pg_class 估算 dead_tuple 比例,超过 20% 的大表需人工介入检查。

    3. 关注 XID Age:监控 datfrozenxid 的年龄(age(datfrozenxid)),如果超过 autovacuum_freeze_max_age(默认 2 亿)且持续攀升,说明系统的冻结机制已失效,距离全盘宕机倒计时开始。

    4. 警惕复制槽(Replication Slot)滞留:除了长事务,未被消费的废弃逻辑复制槽也会拖住 xmin,导致主库无法清理死元组,需通过 pg_replication_slots 视图排查清理。

  • 深入 PostgreSQL MVCC 机制:从 500GB 表膨胀看 Autovacuum 与 xmin 陷阱实战

    PG 核心表突发 500GB 膨胀,查询 P99 从 50ms 飙升至 3s。根因是长事务或废弃复制槽拖住了全局 xmin 视界,导致 Autovacuum 彻底失效,引发 Dead Tuples 堆积的“死亡螺旋”。本文拆解 PG 14 的 MVCC 与 VACUUM 底层机制,并给出防御性调优基线。

    现场与线索

    某次排查过程中,监控大盘开始疯狂告警:某 PG 14.5 生产集群磁盘使用率突破 85%,核心业务读写耗时严重劣化。登录主机查看系统负载,Load Average 飙升至 60+(32核机型),I/O Wait 居高不下。

    通过 pg_stat_user_tables 排查表状态,发现一张核心订单表 n_dead_tup(死元组数量)高达数千万,表物理体积从原本的 50GB 暴涨到了 550GB。

    显然,这是一次典型的表膨胀(Table Bloat)。立刻手动触发分析:

    VACUUM VERBOSE orders;
    

    终端很快返回了令人窒息的日志:

    INFO:  vacuuming "public.orders"
    INFO:  "orders": found 0 removable, 45120485 nonremovable row versions in 702581 pages
    DETAIL:  45120485 dead row versions cannot be removed yet, oldest xmin: 245189012
    

    关键信息在 0 removable 和 cannot be removed yet。Autovacuum 其实在正常调度,只是它无权清理这些死元组。

    为什么正常执行的 Autovacuum 无法回收死元组(Dead Tuples)?

    要解释这个问题,必须扒开 PostgreSQL 基于多版本并发控制(MVCC)的底层实现。

    在 PG 中,执行 UPDATE 或 DELETE 并不会原地修改或删除数据。UPDATE 实际上是 DELETE + INSERT。旧的数据行被称为 Dead Tuple。PG 在每行数据(Tuple)头部维护了两个核心隐藏字段:

    • t_xmin:插入/更新该元组的事务 ID。

    • t_xmax:删除/更新该元组的事务 ID(如果是活元组,此值为 0)。

    当 Autovacuum 扫描表时,它需要判断一个 Dead Tuple 是否可以被物理回收。判断的唯一法则基于 全局最小活跃事务 ID(Global Xmin Horizon)。 如果一个 Dead Tuple 的 t_xmax 大于或等于 当前系统中正在运行的最老活跃事务的 ID(也就是 oldest xmin),那么这个 Dead Tuple 绝对不能被清理。因为那个老事务如果执行查询,根据 MVCC 可见性规则,它依然需要读取这行“历史数据”。

    因此,一旦系统中出现“刺客”拖住了全局 xmin,哪怕你把 Autovacuum 调得再激进,也是徒劳。常见的“刺客”有三种:

    1. 长事务(Long Transactions):代码里忘了 COMMIT 的 idle in transaction 状态。

    2. 废弃的逻辑复制槽(Abandoned Replication Slots):下游消费端宕机,导致主库一直保留 WAL 和 xmin 视图。

    3. 两阶段提交的孤儿事务(Prepared Transactions)。

    揪出元凶

    执行以下 SQL 抓取系统中最老的事务或复制槽:

    -- 查长事务
    SELECT pid, usename, state, backend_xmin, backend_xid, 
           age(backend_xmin) AS xmin_age, query, state_change
    FROM pg_stat_activity 
    WHERE backend_xmin IS NOT NULL 
    ORDER BY age(backend_xmin) DESC LIMIT 5;
    
    -- 查复制槽
    SELECT slot_name, plugin, slot_type, active, xmin, 
           catalog_xmin, age(xmin) AS xmin_age 
    FROM pg_replication_slots 
    ORDER BY age(xmin) DESC;
    

    排查发现,业务端有个定时任务触发了死锁异常被捕获,但在异常处理逻辑中漏掉了 Rollback,导致一个 idle in transaction 的会话挂了 3 天,彻底锁死了全局 xmin 推进。

    解决手段简单粗暴:直接 pg_terminate_backend(pid) 杀掉僵尸会话。随后 Autovacuum 迅速介入,大量死元组被标记为可复用空间(FSM, Free Space Map)。

    生产级 Autovacuum 防御性调优基线

    原生 PG 的默认配置极度保守,是为了能在树莓派或低配虚机上跑起来而设计的。把默认配置直接上到几十核的高并发生产环境,等于给系统埋雷。

    为了防止类似“死亡螺旋”的发生,我们在 postgresql.conf 中必须落地以下防御性配置策略(基于 PG 14):

    1. 斩断长事务的黑手

    绝对不要相信业务代码能完美处理所有异常分支。在数据库侧兜底是运维的基本素养。

    # 防御性配置:强制终结空闲时间过长的事务(极度重要)
    idle_in_transaction_session_timeout = '10min'
    
    # (可选)针对高并发OLTP,设置单条语句最大执行时间
    # statement_timeout = '30s'
    

    2. 限制复制槽的 WAL 与 xmin 保留

    PG 13 引入了关键配置,防止死掉的逻辑复制槽把主库磁盘撑爆。

    # 限制复制槽最大保留的WAL大小,超出此值将强制失效复制槽
    max_slot_wal_keep_size = '50GB'
    

    3. 释放 Autovacuum 的 I/O 枷锁

    默认的 autovacuum_vacuum_cost_limit 是 200,它限制了 VACUUM 进程的 I/O 速率,导致在大表中 VACUUM 速度远落后于 UPDATE 产生垃圾的速度。

    # 降低触发阈值,避免累积过多才开始清理
    # 默认是0.2(20%),对于1亿行的表,要等2000万行变更才触发,太晚了
    autovacuum_vacuum_scale_factor = 0.05
    autovacuum_analyze_scale_factor = 0.02
    
    # 提升 VACUUM 的 I/O 配额限制(默认 200 太低,SSD 环境可以直接上 2000~5000)
    autovacuum_vacuum_cost_limit = 2000
    autovacuum_vacuum_cost_delay = 2ms
    

    避坑指南:很多人喜欢调大 autovacuum_max_workers(默认 3)。注意,所有 Worker 是平分 autovacuum_vacuum_cost_limit 这个 I/O 额度的。如果只加 Worker 不加 Limit,每个 Worker 的执行速度反而会变得像蜗牛一样慢,加剧锁竞争。

    常见问题 (FAQ)

    Q1:表已经膨胀到 500GB 了,杀死长事务后,Autovacuum 跑完了,为什么磁盘空间没有释放? A:Autovacuum 只能将 Dead Tuple 占用的空间标记为 Free Space Map (FSM) 供后续 INSERT/UPDATE 复用,它不会将空间退还给操作系统(除非恰好死元组都在表文件的物理末尾)。如果急需释放磁盘空间,不能使用常规 VACUUM,需要使用 VACUUM FULL(会获取 8 级排他锁,阻塞读写),或在生产环境使用第三方工具 pg_repack 或 pg_squeeze 进行在线无锁空间重组。

    Q2:日志里疯狂打印 “WARNING: oldest xmin is far in the past”,并伴随 Transaction ID Wraparound 警告,怎么救? A:XID 环绕是 PG 最严重的问题之一。PG 的事务 ID 是 32 位整数(约42亿),用尽后会绕回,导致未来的事务把旧数据看作不可见,造成“数据蒸发”错觉。当到达防环绕阈值时,PG 会强制进入只读模式。 一旦触发警告,需立即停止应用写入,找出拖住 xmin 的老事务/复制槽清理掉,并手工执行 VACUUM (FREEZE, VERBOSE) table_name 来冻结老数据的 XID。若已宕机,需进入单用户模式(Single-User Mode)手动执行 Freeze。

    Q3:频繁触发 VACUUM 会不会对 WAL 产生剧烈影响? A:会。当开启了 wal_log_hints = on 或使用了数据校验和(Checksums),且发生 Checkpoint 后的第一次页面修改时,PG 会触发全页写入(Full Page Writes, FPW)。VACUUM 过程中如果是第一次 touch 某个页,也会产生大量的 FPW,导致 WAL 体积激增。这是保证 Crash Safe 的必要代价。应对策略是合理拉长 Checkpoint 间隔(调大 max_wal_size 和 checkpoint_timeout),降低 FPW 的发生频率。