PostgreSQL运维避坑:表膨胀与WAL涨盘的6个典型故障
我经历过最狼狈的一次故障,是在一个周四下午。当时我正在开周会,告警平台把手机震得嗡嗡响:/data 磁盘使用率 92%,还没等我把笔记本打开,第二条告警直接弹了红灯——PostgreSQL 实例连接数被打满,应用侧订单全部卡死。登录服务器一看,磁盘 100%,数据库进入只读保护模式。排查到最后,罪魁祸首就两个:一个是 pg_wal 目录因为归档失败积压了几十个 GB,另一个是核心订单表膨胀到了正常体积的 5 倍,autovacuum 根本清不过来。
从 MySQL 转过来做 PostgreSQL 运维的人,最容易在这两个地方吃大亏。MySQL 的 InnoDB 有 purge 线程,MVCC 清理做得比较隐蔽;PostgreSQL 的 MVCC 清理则完全依赖 VACUUM 机制。而 WAL 的保留和回收策略又跟归档、复制、checkpoint 三层机制直接挂钩。平时它们都很安静,一旦出问题,表膨胀和 WAL 涨盘就是两个最典型的“平时不起眼、出事要人命”的坑。
这篇文章把我这几年踩过的 6 个坑整理了一下,有大坑也有小坑,按“现象 - 排查 - 解决”的顺序讲。如果你正在维护 PostgreSQL,或者正准备从其他数据库迁过来,希望多少能帮你少熬几次夜。
1. 坑一:表膨胀直到业务卡死才发现,慢查询只是表象
1.1 事发当天:一条 3 秒变 30 秒的 UPDATE
那次故障的表是一张订单流水表,逻辑数据量大概只涨了 10%,但表文件从 20GB 涨到了 90GB。最早暴露问题的其实不是磁盘监控,而是业务方反馈“订单状态更新越来越慢”。我抓了一下慢查询,发现一条平时只要 3ms 的主键 UPDATE 变成了 3 秒,偶尔飙到 30 秒。
一开始我怀疑是锁等待,查了 pg_locks 和 pg_stat_activity,没发现明显的阻塞。又怀疑是索引失效,跑了一遍 EXPLAIN,发现执行计划用的是主键索引,但扫描的页面数量比正常情况多了好几倍,一个不应该超过几十个 page 的主键更新,居然要扫上千个 page。这时候才把目光放到表的物理大小上。
用 \dt+ 看了一下表大小,才发现问题比想象中严重:表和它的 TOAST 表体积都大得离谱。再翻 pg_stat_user_tables,n_dead_tup 已经破千万级,而 n_live_tup 只有几百万。这张表实际上已经膨胀了很长一段时间,只是一直没被监控发现。
1.2 MVCC 与死元组:PostgreSQL 膨胀的本质
PostgreSQL 的膨胀根子出在 MVCC 的实现方式上。简单说,UPDATE 不是原地改数据,而是“插入一个新版本的行 + 把旧版本标记为死元组”;DELETE 也不是物理删除,只是标记一下。只要还有会话可能读到旧版本,这些死元组就不能被物理回收。
这个跟 MySQL InnoDB 的 purge 机制不太一样。PostgreSQL 里负责回收死元组的是 VACUUM:autovacuum 在后台自动跑,手动 VACUUM 在维护窗口跑。如果死元组的产生速度超过 VACUUM 的清理速度,表就会越撑越大,直到影响查询性能。
膨胀为什么影响性能?其实道理很直观:
- 表物理文件变大,顺序扫描和索引扫描要读的页面变多;
- 缓存命中率下降,大量热数据被挤出去,IO 压力上升;
- 膨胀到一定程度后,可见性映射和空闲空间映射的更新也跟不上,新的写入在页面里找不到合适的空闲空间,只能继续扩展新页面,形成恶性循环。
1.3 怎么快速定位一张表到底膨胀到什么程度
现在我做膨胀巡检,一般分三档。
第一档是快速看统计视图:
如果 n_dead_tup 长期在几十万、上百万,而且 last_autovacuum 和 last_vacuum 都显示一直有执行记录,说明 VACUUM 在清但清不动,下一步要去查是不是死元组产生太快,或者有长事务卡住了清理,这个放到坑二里细说。
第二档是用 pgstattuple 看精确的膨胀率:
重点看 dead_tuple_percent 和 free_percent。我自己的经验阈值是:死元组占比持续超过 20% 就要排期处理,超过 40% 基本已经影响业务了。
第三档是看索引膨胀。索引膨胀比表膨胀更隐蔽,用 pgstattuple 的索引版本:
索引页碎片多的时候,重建索引(REINDEX INDEX 或 REINDEX TABLE)能让索引体积明显瘦下来,扫描效率也会提升不少。
1.4 处理表膨胀的正确顺序
第一次处理这种问题,我踩过一个很典型的坑:第一时间想到 VACUUM FULL。VACUUM FULL 会申请 ACCESS EXCLUSIVE 锁,把表锁死,业务读写全部中断,而且它需要把整个表 rewrite 到新文件,磁盘没有足够余量的情况下根本跑不动。
更稳妥的顺序应该是:
- 先解决导致死元组堆积的根因(长事务、VACUUM 跟不上,见坑二和坑三);
- 让普通 VACUUM 或 autovacuum 先尽量回收能清掉的死元组;
- 在维护窗口对必要的大表做 VACUUM FULL,或者用 pg_repack 在线重排。pg_repack 不阻塞读写,但同样需要额外的磁盘空间和一些准备工作;
- 做完之后把大索引 REINDEX 一遍,把索引体积一起收掉。
2. 坑二:长事务让 autovacuum 白打工,死元组怎么都清不掉
2.1 一次“vacuum 明明在跑,但 n_dead_tup 纹丝不动”的排查
坑一里那张订单表其实不是第一次膨胀。第一次处理完,我本以为监控里 n_dead_tup 会慢慢降下来,结果等了半小时,数字不但没降,还在往上涨。手动执行 VACUUM (VERBOSE) 也能看到它扫了很多页面,但清理结果就是 0 行死元组被回收。
我第一反应是 VACUUM 太频繁了?不对,再频繁也不会一点效果都没有。然后我去查 pg_stat_activity,发现有一个来自某个内部工具的连接,state = 'idle in transaction',xact_start 已经停留在 10 个小时前。也就是说,这个会话打开了一个事务,但既没有提交也没有回滚,就这么一直挂着。
看到这个连接我一下就明白了:所有死元组对这个长事务来说仍然是“可能可见”的,VACUUM 不能碰它们。这不是 VACUUM 不努力,而是有人在背后按住了清理的水位线。
2.2 原理:xmin 水线决定能被清理的死元组范围
PostgreSQL 判断一个死元组能不能被物理回收,不是看它是不是死,而是看它是否对“所有当前活动事务”都不可见。系统需要维护一个全局最老的活跃事务 ID,通常叫 xmin。只要某个事务还活着,所有比它更早开始的事务产生的老版本都不能被清理。
长事务会让这个 xmin 水线卡在一个很老的位置。autovacuum 扫描再多页面,也会因为大量死元组还没越过“可见性水线”而无法回收。除了 idle in transaction,还有一种常见情况是 SQL 本身跑了很久,比如大查询或者长时间未提交的批处理任务,同样会把水线撑住。
这类问题不只是影响表膨胀。如果长事务一直持续下去,事务 ID 的消耗也不停,极端情况下会触发事务 ID 回卷(xid wraparound),PostgreSQL 会强制进入单用户模式做 VACUUM,那才叫真正的事故。
2.3 排查链路和止损操作
当时我执行的排查步骤是这样的:
重点看前三名事务,如果发现哪个连接已经开了几个小时甚至几天,就要警惕了。如果确认是无用连接,直接杀掉:
杀掉之后,VACUUM 会立刻开始回收死元组。我当时这个长事务一断,n_dead_tup 半小时内就从千万级掉到了几十万,表空间肉眼可见地缩了几个 GB。
2.4 预防参数,直接抄作业
杀会话只是止损,真正的问题在于为什么这种挂了几小时的事务能一直不被发现。后来我在配置里加了这几个兜底参数:
- idle_in_transaction_session_timeout:对大多数业务系统来说,事务空闲超过 60 秒基本就是不正常,让 PostgreSQL 直接把它断掉;
- statement_timeout:避免一条 SQL 无限期地跑下去,但要注意按业务实际情况调整,别误伤大查询;
- lock_timeout:避免因为等锁把整个事务拖到天荒地老。
同时在监控里把 pg_stat_activity 中的最长事务时长、idle in transaction 会话数做成看板指标,阈值到了就告警。这个坑的教训就是:表膨胀很多时候不是 VACUUM 不努力,而是有东西在背后锁住死元组,让 VACUUM 白打工。
3. 坑三:autovacuum 参数还按默认值跑,大表业务一涨就崩
3.1 默认触发机制对大表很不友好
处理完长事务之后,我本以为万事大吉,结果过了不到两周,同一张表又出现了膨胀的苗头。这次没有长事务,日志里也没有 VACUUM 报错,但 n_dead_tup 还是快速积累。
后来我开了 log_autovacuum_min_duration 的日志,发现一个问题:一张 200GB 的大表,autovacuum 经常要花 20 到 30 分钟才完成一轮扫描,扫完没过多久,死元组量又涨回去了。问题出在默认触发参数上。
PostgreSQL 的 autovacuum 触发阈值公式是:
默认 scale_factor = 0.2,threshold = 50。对一张 200GB、大概 1 亿行的表来说,要积累约 2000 万死元组才会触发一次 VACUUM。这个粒度对单张大表来说太粗了,等于业务已经把空间撑大了,VACUUM 才姗姗来迟。
3.2 autovacuum 成本控制:为什么它“明明在跑却清得慢”
另一个容易被忽略的参数是 autovacuum_vacuum_cost_limit。默认值是 -1,实际等效为 200 个 cost units。VACUUM 每执行一定量的 IO 操作就会累计 cost,cost 超过 limit 后要按 autovacuum_vacuum_cost_delay 休眠一下,让数据库给业务让路。代价就是一个写入量很高的实例上,autovacuum 会被频繁“踩刹车”,一轮 VACUUM 被拉得很长。
这两个因素叠加起来就是坑三的全貌:触发本来就晚,触发后还清得慢,最终只能看着表一点点膨胀。尤其是从 MySQL 生态转过来的同学,装好 PostgreSQL 后往往直接用默认参数跑,第一次遇到这种问题基本都要懵一下。
3.3 我的调参思路:先单表,再全局,最后观察
第一,先给核心大表单独开小灶。单表 storage parameter 的优先级高于全局参数,比直接全局调参更安全:
这里把 scale_factor 从 0.2 降到 0.05,意味着同样 1 亿行的表,只要积攒 500 万死元组就会触发;threshold 从 50 调到 1000,是避免小型表因为噪声频繁触发;cost_limit 提到 1000,让单轮清理更积极一些。
第二,全局层面不要一刀切。把全局 scale_factor 降到 0.1 左右,再配一个 log_autovacuum_min_duration 来观察:
第三,观察几轮 autovacuum 之后,确认 n_dead_tup 和表大小都稳定了,再决定是否进一步调整。调参之后千万不要心急,至少跑一个业务低峰加上一个高峰周期再评估效果。
3.4 千万别把 autovacuum 关掉
我见过有人在业务高峰把 autovacuum 直接关掉来“减少数据库负担”,结果过一夜表膨胀到不可收拾。autovacuum 是 PostgreSQL 空间回收的生命线,正常情况下保持开启就行。
平时把 autovacuum 开着,再配合坑一的巡检和坑二的超时参数,基本能挡住 90% 的膨胀问题。剩下 10% 就是坑四和坑五要讲的,WAL 涨盘。这个东西比表膨胀更急,因为它是直接写满磁盘的杀手。
4. 坑四:WAL 涨盘——archive_command 一路失败,pg_wal 静默堆积到爆盘
4.1 那一次差点把数据库磁盘写满的发现
回到开头说的那个周四下午。当时我先处理的是表膨胀,但磁盘告警并没有解除,因为处理完表膨胀后,/data 使用率只掉了不到 10%,而且还在持续上涨。查了大目录之后,才发现 pg_wal 目录已经占了 80 多 GB,这是典型的 WAL 文件堆积。
正常 WAL 目录一般就几个 GB,checkpoint 之后文件会被循环复用。如果 16MB 的文件数量持续增长,说明一定有东西在阻止 WAL 被回收。我第一反应就是查归档状态:
结果里 failed_count 是一个非常大的数字,last_failed_time 在不断刷新,而 archived_count 停在那不动。再看 PostgreSQL 日志,刷屏的错误都是“archive command failed with exit code 1”。
4.2 为什么归档失败会导致 WAL 不能回收
这里解释一个关键点:PostgreSQL 的 WAL 不是所有文件都能随便回收的。数据库在做崩溃恢复时需要从最近的 checkpoint 位置重放 WAL;如果开了归档,归档是异步的,checkpoint 之后、尚未确认成功归档的 WAL 段,数据库不会回收。因为一旦回收,数据恢复链就断了,这是“保证不丢数据的代价”。
归档失败的常见原因,我那次遇到的是归档目标 NFS 挂载点断掉,archive_command 执行到一半就返回非零。其他常见原因还有:
- 归档目录磁盘满了;
- 归档脚本本身有 bug,没处理管道错误;
- archive_command 里写死的工具路径不对;
- 归档目录权限不对,连写入都做不到;
- 用了某些云存储工具,网络不稳定,但脚本没有重试机制。
4.3 排查链路:从发现到修复
完整排查链路大概是这样:
第一步,确认 WAL 目录真的大了:
第二步,确认归档是否卡住:
第三步,看 PostgreSQL 日志,找到具体的 archive 失败原因,同时手动跑一次归档命令验证脚本是否正常。
一个比较典型的归档脚本长这样:
我后来给脚本加了 set -euo pipefail,并且在命令后面加了一个带失败重试和日志输出的小函数,避免这种“失败但没人知道”的静默问题。
第四步,修复归档存储。NFS 挂掉了就先恢复挂载,权限不对就 chown 归档目录,存储满了就清理归档目标里的旧文件。
第五步,等归档把积压的 WAL 追平。如果积压很大,比如已经攒了几千个 WAL 文件,不要干等,写一个归档 worker 脚本先把历史文件集中归档,或者临时调整 archive_timeout 来提高归档触发频率。
4.4 一个应急动作:能不能直接删 pg_wal?
不能。这个放到坑六专门讲。这里先给结论:WAL 是 PostgreSQL 数据一致性的命根子,没有归档确认就删 WAL,数据库可能在下次崩溃恢复时直接起不来。宁可把写入压住,也绝对不要贸然删 WAL 文件。
5. 坑五:复制槽没人消费,checkpoint 也回收不了 WAL
5.1 归档恢复了,WAL 还在涨
坑四修复完之后,pg_stat_archiver 的 archived_count 开始增长,failed_count 不再变化,但磁盘使用率还是在缓慢上升。怪了:归档恢复了,checkpoint 也在跑,为什么 WAL 目录还是越来越大?
后来我查了 pg_replication_slots,发现有一个 physical replication slot,active 是 false,restart_lsn 停在 10 天前的位置。某个测试环境做主从同步时建了一个 slot,后来从库被销毁了,slot 一直没人清理。就是这一个“死”slot 卡住了 WAL 回收。
5.2 机制:复制槽怎么影响 WAL 回收
复制槽的作用是为下游(物理从库、逻辑订阅)保留解码和回放所需的 WAL。PostgreSQL 在确定哪些 WAL 可以回收时,除了看 checkpoint 点,还要看所有复制槽里最老的 restart_lsn。只要有一个复制槽的 restart_lsn 落在很早的位置,它之后的所有 WAL 就必须全部保留。
这里有一个非常容易迷惑的点:复制槽不 active 的时候才更危险。active 的复制槽说明下游还在消费,就算落后,至少它是工作中的;而 inactive 的复制槽说明下游已经不在消费,restart_lsn 永远不动,WAL 却必须为它保留。很多人一看到 slot 存在但没有告警,就以为它没影响,其实这才是最能藏事故的状态。
5.3 怎么查询和计算落后量
这是我现在监控复制槽的常用 SQL:
如果某个 slot 的 retained_wal_size 已经到几十 GB,基本可以判定是问题槽了。
5.4 处理办法
第一步,确认这个 slot 到底还有没有人用。去问应用团队有没有对应的消费程序,比如逻辑订阅、Debezium、自研同步服务等。如果是临时搭建的测试同步环境,环境已经拆了,可以直接删:
第二步,如果确实有消费方,只是暂时不 active,比如逻辑订阅端停机维护,但 restart_lsn 落后太多,可以确认不再需要追回这部分 WAL 后,手动推进到当前 LSN:
注意这个操作必须非常谨慎,一旦推进,下游再要这些 WAL 就没了,重放会断链。
第三步,设置兜底上限。PostgreSQL 13 之后有 max_slot_wal_keep_size 参数,默认是 -1,也就是不限制。可以设成类似:
超出之后 PostgreSQL 会根据情况禁用该复制槽并允许回收更久的 WAL,避免一个槽把整个磁盘打满。这个参数不是银弹,但至少能让一个失控的 slot 不至于把实例拖死。早期版本没有这个参数,就只能靠监控和巡检来兜底。
另外提一句,用 pg_basebackup 搭主从时,如果没有显式指定 --no-slot,PostgreSQL 会默认创建一个临时复制槽来保证 base backup 期间的 WAL 不缺失,备份完成后这个槽会被清理。手动搭建流复制时自己创建的 slot,一定要记得在环境销毁后同步清理,这是我在很多团队里都看到过的隐患。
5.5 和坑四的区别
坑四是归档目标的问题,坑五是复制槽的问题,两者表现很像,都是 pg_wal 目录涨,但排查方向完全不同。我现在收到 WAL 涨盘相关的告警时,会习惯性地把 pg_stat_archiver、pg_replication_slots、pg_ls_waldir、checkpoint 状态这四张“牌”同时翻出来看,减少来回猜的时间。
6. 坑六:磁盘爆了之后乱操作,rm WAL 和盲目 VACUUM FULL 让我二次翻车
6.1 一次手滑,从“数据库慢”变成“数据库起不来”
在坑四、坑五都没有及时被解决的那段混乱期,我做过一次错误的应急。磁盘 100% 之后,我试图用 VACUUM FULL 把膨胀表空间压下来,结果 VACUUM FULL 需要把整表 rewrite 到新文件,磁盘没有余量,直接失败。旁边的同事比较慌,直接进 pg_wal 目录 rm 了几个看起来“很老”的 WAL 文件,想给磁盘腾点地方。
结果就是:过了一会儿,PostgreSQL 在尝试崩溃恢复时找不到连续的 WAL 段,实例直接起不来。当时没有完备的历史归档副本,最终只能从几天前的备份做时间点恢复,丢了数据也耽误了业务。这个代价比我前面所有坑加一起都大。
6.2 为什么直接删 WAL 是自杀行为
PostgreSQL 的 WAL 是一个连续的写入序列,段文件表面上是 16MB 一个的独立文件,但内部是紧密衔接的。数据库在崩溃恢复时会从 checkpoint 位置重放 WAL,一直到最后一个已知的 LSN。如果你删掉的 WAL 正好是恢复路径上的某一段,数据库会发现找不到下一段,直接报错退出。
即使当时没有崩溃,下次正常重启也可能触发问题。因为 PostgreSQL 启动时必定会做一次快速恢复检查,缺失 WAL 段会直接导致 startup 失败。手动删 WAL 还会破坏归档的连续性,如果归档里缺了一段,后面所有基于归档的备份和 PITR 都会在恢复路径上断掉。
6.3 磁盘 100% 时的正确处置顺序
现在遇到这种场景,我给自己定的顺序是:
- 先 df -h 确认磁盘状态,找出占空间的目录,不要一上来就对数据库文件动手;
- 如果罪魁是 pg_wal,先看 pg_stat_archiver 有没有归档失败,再看 pg_replication_slots 有没有落后 slot,先把根因修掉,再让 PostgreSQL 自己回收 WAL;
- 如果磁盘已经 100% 导致无法写入,宁可把实例停掉或直接切断业务写入,也不要碰 WAL 文件。停实例后把归档和复制槽问题处理好再启动,通常只是几分钟到几十分钟的事;
- 如果还有其他明显的空间占用来源,比如日志文件、临时文件、备份文件,先清理这些安全文件给磁盘腾出余量;
- 在磁盘有余量之后,才考虑做 VACUUM FULL 或 pg_repack 处理表膨胀,而且优先用 pg_repack,它不锁读写,对业务影响小;
- 如果实在没办法只能靠清理文件来恢复,必须确认要清理的 WAL 段已经被归档确认过,并且用 pg_archivecleanup 这种受控工具来做,而不是 rm 一把梭。
pg_archivecleanup 的典型用法类似:
工具会删除归档目录里所有早于指定 WAL 段的文件,它比手动 rm 要安全得多,因为你至少提供了一个明确的截止点。
6.4 容量规划与监控建议
这个坑踩完之后,我把监控面补全了。现在最低要求是这样:
- 磁盘空间使用率:70% 预警,85% 告警,95% 就要准备停机预案;
- pg_wal 目录大小:按实例情况设定阈值,一般超过 10GB 就要查;
- pg_stat_archiver:failed_count 增长要立即告警;
- pg_replication_slots:retained_wal_size 超过 5GB 要提醒;
- pg_stat_activity:最长事务时长、idle in transaction 数量做看板;
- log_autovacuum_min_duration:持续观察,提前发现 autovacuum 异常。
招一个运维的核心不是遇到问题时反应快,而是尽量把问题拦在“出事”之前。表膨胀和 WAL 涨盘这种问题,完全可以通过监控水位线和定期的视图巡检提前发现,不需要等业务都卡死了才去救火。
最后说点个人感受。表膨胀和 WAL 涨盘之所以让人头疼,本质上是同一类问题:PostgreSQL 自己的空间回收机制被人为卡住了。表膨胀是被 MVCC 死元组和长事务卡住,WAL 涨盘是被归档失败和复制槽卡住。遇到这类问题,我的建议是别急着加磁盘,更别急着删文件,先搞清楚到底是什么在阻碍数据库“自己回收空间”,这个思路比任何参数调优都重要。如果你最近正在被类似问题折腾,希望这 6 个坑能帮你少走一段弯路。
相关推荐
基于Python+Open CV的手势识别算法设计
伴随着人工智能时代的到来,人机交互的领域也逐渐成为研究的一大方向;其中,手势识别是人机交互领域的一项关键技术,自此手势识别也迎来了一波高潮,近几年无论是在消费领域、电子领域、数码领域、家电领域甚至汽车领域上,都能或多或少的见到手势识别的身影。 手势识别在设计智能高效的人机界面方面具有至关重要的作用, 目前手势识别已应用到手语识别、智能监控、到虚拟现实等各个领域,手势识别的原理都是利用各种传感器(例如红外、摄像头等)对手部的形态进行捕捉并进行建模,形成一个模型信息的序列帧,而后将这些信息序列转换为机器能够识别的相对应指令(例如打开、切换菜单、移动等)来完成控制。 采用Python的集成开发环境Pycharm进行本次课程设计,在Pycharm中进行需要库(模块)的下载,调取电脑摄像头,按帧读取摄像头采集到的头像,形态学处理,图像旋转(由于摄像头采集到的图像是镜像,需要用cv2.flip将摄像头采集到的图像进行处理),选取图片中固定位置作为手势输入,用红线画出手势识别框,基于hsv的肤色检测,进行高斯滤波,找出轮廓,求出图像中手势的凹凸点,手指间角度求取。
ROS-CV-ImageConverter:将ROS图像转换为OpenCV(cv :: Mat)类型的图像
ROS-CV图像转换器 依存关系 cv_bridge(ROS软件包)
计算机视觉图像处理-CV2与图像插值算法
用最近邻插值与双线性插值原理完成图像缩放 最近邻插值,是指将目标图像中的点,对应到源图像中后,找到最相邻的整数点,作为插值后的输。缺点:使用最近邻插值算法处理图像放大时,在图象中可能出现明显的块状效应 双线性插值,在两个方向上做三次线性插值。使用双线性插值算法处理图像放大时,在图象中可能出现马赛克 代码 import cv2 img = cv2.imread(’./xiabang.jpg’, cv2.IMREAD_UNCHANGED) #缩放比例 scale_percent = 0.3 width = int(img.shape[1] * scale_percent) height = int
基于水平集CV模型的图像分割(Matlab代码)
基于水平集CV模型的图像分割(Matlab代码),全套代码
导向滤波(guide_filter)的python实现
导向滤波(guide_filter)的python实现,可以结合暗通道;理论实现比较好的图像去噪效果。

