ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PostgreSQL僵尸复制槽导致WAL膨胀磁盘写满事故复盘与预防

PostgreSQL僵尸复制槽导致WAL膨胀磁盘写满事故复盘与预防 1. 事故开篇一条磁盘告警短信引发的深夜先交代一下背景。我维护的一套 PostgreSQL 12 主库跑在 Linux 服务器上数据量不算大日常事务吞吐也一般部署两年多一直安安稳稳以至于我一度觉得这套库“根本不用操心”。直到那天晚上 23:47监控系统连续弹出三条告警/data分区使用率超过 85%、90%、95%间隔不到十分钟。等我打开电脑登录服务器的时候df -h已经显示/data分区使用率 100%数据库日志里开始刷PANIC: could not write to file pg_wal/...和could not fsync file主库已经处于半死不活的状态——写事务全部卡死应用侧报错一片。这套环境用的是 PostgreSQL 12部署时我从官方源码编译安装数据目录放在/data/pgdataWAL 目录默认在/data/pgdata/pg_wal。当时我第一反应是磁盘真的被数据撑爆了毕竟这库跑了好几年中间做过几次大表归档按理说不会这么快满。但等我看清楚占用情况后后背一阵发凉整个/data分区一共 500GB其中/data/pgdata/base里的业务数据只占了不到 120GB而/data/pgdata/pg_wal目录整整吃掉了 300 多 GB——一个正常时间线里只该保留几 GB 到几十 GB WAL 的目录膨胀到了逻辑上的十几倍。这就是我这次要复盘的核心事故一个“僵尸复制槽”Zombie Replication Slot导致 PostgreSQL 的 WAL 文件无法被清理最终把整个数据盘写满数据库直接进入只读/瘫痪状态。这篇文章我尽量把整个过程从现象、排查、原理到处理、预防完整地梳理出来。里面有不少细节和踩坑点都是常规文档里不会写的希望能帮你省下半夜爬起来救库的精力。2. 排查路径从df到真凶的十八弯2.1 第一轮排查磁盘空间到底被谁吃了登录服务器后我的第一步永远是df -h确认整体水位然后用du逐层深入找大目录。这个思路虽然笨但在这种场景下最可靠df -h /data du -sh /data/pgdata/* | sort -rh | head -20输出结果里/data/pgdata/pg_wal高居榜首占掉 300 多 GB。我第一反应是“WAL 归档出问题了”因为如果archive_modeon而archive_command长时间失败主库会一直保留 WAL 不清理。我赶紧去查pg_stat_archiver视图SELECT archived_count, failed_count, last_archived_wal, last_failed_wal, last_failed_time FROM pg_stat_archiver;结果正常failed_count是 0last_failed_time是 NULL归档并没有卡住。这就排除了最常规的 WAL 堆积原因。接着我检查了checkpoint情况SELECT checkpoint_write_time, checkpoint_sync_time, buffers_written FROM pg_stat_bgwriter;数据在持续更新说明检查点还在推进但 WAL 依然不断膨胀。那时我心里已经隐约有一个判断这种情况基本只有复制槽replication slot能让 PostgreSQL 违背“检查点后清理旧WAL”的默认逻辑。2.2 第二轮排查回头看哪些连接在“赖着不走”我先用pg_stat_replication看了一下当前的流复制情况SELECT pid, usename, application_name, client_addr, state, sync_state, write_lag, flush_lag, replay_lag FROM pg_stat_replication;结果这台主库上压根没有任何活动的流复制连接没有备库、没有订阅端一个pid都没有。你说怪不怪明明没有备库在拉数据WAL 却在疯狂堆积。这台库之前是搭过一套流复制备库的后来因为机房迁移备库下线了当时我只是在备库侧停了服务主库侧并没有清理对应的复制槽。这是典型的“主备架构下线时清理不干净”的现场。要确认到底有没有残留的复制槽我执行了这条命令SELECT slot_name, slot_type, active, active_pid, restart_lsn, confirmed_flush_lsn FROM pg_replication_slots;结果一目了然Slot NameSlot TypeActiveActive PIDRestart LSNConfirmed Flush LSNstandby_slot_oldphysicalfalse(null)0/EDDCB120(null)一个物理复制槽名字叫standby_slot_oldactive是falseactive_pid是空。它已经“死”了大半年但 PostgreSQL 依然认账只要它存在restart_lsn之前的 WAL 就一个都不能删。这个 LSN 对应的 WAL 文件就是数据库磁盘爆满的真凶。2.3 第三轮排查WAL为什么必须留着可能有读者会问一个“不活跃的复制槽”为什么能让 PostgreSQL 死守着一堆 WAL 不删这不是 bug是设计使然。PostgreSQL 的物理复制槽physical replication slot在创建时会记录一个restart_lsn含义是“从哪个 LSN 开始备库可能需要重新读取 WAL”。只要这个复制槽还存在哪怕没有任何备库连接主库也会认为“未来可能有一个备库会从这个位置开始续传”所以无论如何都不能删除这个 LSN 之前的 WAL。对 PostgreSQL 来说删除一段可能被未来备库需要的 WAL比磁盘写满更不可接受——前者会导致无法恢复的复制断裂后者至少数据库还活着磁盘腾一腾还能缓过来。逻辑复制槽logical replication slot更狠它除了像物理槽一样卡住 WAL还会卡住系统目录的清理catalog_xmin可能导致pg_catalog表膨胀vacuum清不掉死元组。这次的槽是物理槽相对“温和”一些只卡 WAL但后果也已经足够严重。3. 僵尸复制槽到底是怎么把数据库搞挂的3.1 复制槽的运作机制它本质上是个记账本复制槽这个概念很多 PostgreSQL 使用者接触过但真正理解其“粗暴”之处的人不多。我打个比方复制槽就像图书馆里一本“被预约的书”。只要有人预约了某本书备库/订阅端注册了一个槽图书馆就必须把这本书一直留在书架上对应的 WAL 不能删哪怕预约人已经出国半年没来取书也不能下架。物理复制槽是流复制streaming replication的核心机制。正常情况下主库把新的 WAL 通过流复制协议推给备库备库接收并应用后会回报自己的接收位点flush_lsn主库据此推进复制槽的restart_lsn旧 WAL 才能被放心清理。这套机制解决了“备库断连后重连时主库找不到旧 WAL”的问题——只要槽还在主库就为备库保留足够多的 WAL 历史。问题恰恰出在“只要槽还在”。系统只帮你保留不帮你判断“这个槽还有没有用”。当时我那个standby_slot_old槽active_pid已经是 NULL说明主库已经感知到没有任何进程在使用这个槽了但它依然不清理因为 PostgreSQL 设计哲学是“宁可保留不可误删”。3.2 WAL 堆积的完整链条事故的完整链条是这样的半年前我创建了物理复制槽standby_slot_old分配给了当时的一台备库。机房迁移时停掉了备库但主库侧的复制槽没有被 drop。备库停机后主库不断产生新的 WAL检查点不断推进但每次清理 WAL 时都会发现restart_lsn需要的旧 WAL 还在。系统只能不断保留从restart_lsn开始往后的所有 WAL于是pg_wal目录只增不减。平时业务量小WAL 增长慢问题不明显那天正好赶上业务方做了一波批量数据修正短时间产生了大量 WAL直接把磁盘最后一点空间耗尽。很多人会误以为“只有交易量大的库才会遇到这种事”其实不然。我当时那个库日增 WAL 大概 20GB 左右如果复制槽一年不清理一年就是 7TB 的 WAL——哪怕业务量再小早晚有一天会把磁盘拖垮。这跟业务量大小无关纯粹是时间问题。3.3 为什么叫“僵尸”复制槽“僵尸复制槽”这个词不是我发明的运维圈子里早就这么叫了。它的特征概括起来就是槽对象还在但已经没有对应的消费端它不干活却一直指着主库的 WAL 不放。常见的产生场景我整理了一下产生场景具体原因典型特征备库停机下线机房迁移、硬件报废备库直接销毁但主库没清理槽activefalseactive_pid 为 NULL逻辑订阅端故障订阅端数据库损坏或被重建订阅关系没了但发布端槽还在slot_typelogicalconfirmed_flush_lsn 长期不动高可用切换主备切换后新主库带上了旧复制槽记录备库却已不识别槽指向的备库 UUID 不匹配pg_basebackup 遗留用复制槽方式做基础备份备份结束后槽没删备份时间点创建的临时槽残留每一种场景我都见过不止一次而且它们的共同点是故障发生时数据库本身并没有立刻报错等到磁盘被填满才会爆发。这也是这类事故最阴险的地方——它慢刀子割肉等你发现时往往已经晚了。4. 处理与恢复一步步把数据库救回来4.1 应急止血先保住磁盘磁盘已经 100% 满的时候数据库基本处于不可写状态连pg_drop_replication_slot都可能因为需要写 WAL 而失败。我当时做的是这样一套应急序列先看看pg_wal里到底有哪些文件确认最老的文件时间和 LSNls -lh /data/pgdata/pg_wal | head -30如果磁盘真的彻底满了、无法执行任何需要写 WAL 的操作可以考虑临时把 WAL 目录里的旧文件手动移到别的分区给数据库“留一口气”。注意这只是一种应急手段移动 WAL 文件不是官方推荐的常规操作如果该 WAL 真的被复制槽引用移走可能导致后续恢复失败。我当时是先腾出了约 20GB 空间移走了一批明显很旧、且确认复制槽 restart_lsn 之后的文件数据库才恢复可写状态。提示如果你也遇到磁盘打满、库里连drop都执行不了的情况优先考虑从业务侧临时停掉写负载同时从 OS 层快速清理其他大文件日志、临时文件腾出空间而不是直接动 WAL。只有在实在没有办法的情况下才考虑手动移动 pg_wal 文件。数据库恢复响应后立刻执行SELECT pg_drop_replication_slot(standby_slot_old);要特别提醒的是这个操作必须确保没有进程还在使用该复制槽。如果槽是 active 的drop 会直接报错。判断标准就是前面看到的active和active_pid字段——active_pid为 NULL 时才可以安全 drop。4.2 清理完复制槽之后WAL 不会立刻消失复制槽 drop 掉之后很多人的第一反应是去pg_wal目录看发现文件还在然后就开始慌以为没删干净。这其实是正常的PostgreSQL 不会因为复制槽消失就立刻把旧 WAL 全删了它要等下一次 checkpoint 之后按正常的 WAL 保留策略来清理。我当时 drop 完槽马上做了一个手动 checkpointCHECKPOINT;然后观察 WAL 目录容量变化。半个小时后再查pg_ls_waldir()发现文件已经被回收了一大半磁盘使用率从 100% 降到了 75% 左右。这里顺便说一句如果你想快速看到 WAL 目录的精确大小用这条 SQL 最方便SELECT pg_size_pretty(sum(size)) AS total_wal_size FROM pg_ls_waldir();如果 PostgreSQL 版本比较老10 以下pg_ls_waldir()可能不存在那就直接du -sh $PGDATA/pg_wal就行。4.3 恢复后的健康检查清理完复制槽和 WAL数据库恢复了大半但我的工作还没结束。接下来我需要确认几个关键项检查pg_stat_replication是否还有异常残留SELECT * FROM pg_stat_replication;确认没有任何幽灵连接在干扰主库。检查归档是否正常SELECT * FROM pg_stat_archiver;检查数据库整体状态SELECT pg_is_in_recovery(); SELECT current_setting(max_wal_size), current_setting(checkpoint_timeout);最后对关键业务表做一次VACUUM ANALYZE因为磁盘满的那段时间里可能有不少事务回滚、中止表的统计信息和可见性子需要重新整理。整个恢复过程从发现磁盘满到数据库完全恢复正常花了我大概两个小时。大头时间不是花在操作上而是花在确认“这个复制槽到底能不能 drop”上面。这种时候一定要忍住冲动宁可多查几遍也不要手快闯祸。5. 亡羊补牢构建防再发的监控与巡检体系5.1 专门盯防复制槽状态的巡检脚本这次事故之后我做了一个决定把复制槽状态纳入核心监控不仅看有没有还要看它“活没活着”。我写了一个简单的巡检脚本逻辑是这样的SELECT slot_name, slot_type, active, active_pid, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal_size FROM pg_replication_slots;核心判断规则如果存在activefalse的复制槽且持续时间超过 1 小时就触发警告。如果某个复制槽的restart_lsn与当前 WAL 插入位点之间的差距持续扩大就触发警告。每周日凌晨自动跑一遍全量检查输出报告确认所有槽都有对应的消费端在正常工作。脚本本身不复杂关键是坚持执行。很多团队可能在事故发生的当下把规则配好了过了几个月就视而不见。我个人的做法是把它挂到值班群里每周发一条巡检结果别人烦不烦我不知道但至少我不会再忘。5.2 磁盘与 WAL 目录容量告警除了复制槽本身WAL 目录的容量监控也非常重要。我把监控粒度从“磁盘分区 80% 告警”细化到了“pg_wal 目录容量异常增长告警”。具体做法用pg_ls_waldir()定时采样记录 WAL 目录总大小和历史增长速率。如果 WAL 目录大小超过max_wal_size的 3 倍以上正常情况 WAL 目录大小应该在max_wal_size附近波动就触发告警。打个比方如果max_wal_size1GB那么pg_wal目录理论上应该在 2~3GB 左右浮动超过 3GB 就要引起警觉。我当时那台库max_wal_size2GBpg_wal却涨到了 300GB这种量级的偏差靠人力肉眼发现实在太晚了必须要靠监控自动盯。再补充一个容易被忽略的点WAL 目录的增长是“阶梯式”的不是“线性”的。检查点触发后旧 WAL 才被清理所以观察时要看趋势不要在单个时间点上做决策。以前我见过一个同事因为某个瞬间 WAL 目录大了点就紧张兮兮地把max_wal_size翻了一倍结果一个问题变成了另一个问题真是得不偿失。5.3 变更管理部署和下线都别忘清理槽说到底复制槽残留九成以上是“变更管理”问题不是技术问题。备库下线、订阅端重建、机房迁移这些操作在流程里加上“检查并清理复制槽”这一条就能避免绝大多数事故。我给团队定的规矩很简单任何流复制/逻辑复制节点下线下线计划中必须有“主库侧检查复制槽并确认是否删除”这一项。高可用切换演练后必须检查新主库上是否有旧复制槽残留。使用pg_basebackup -R --slotxxx做备份的话备份完成后要检查槽是否被删除或改为临时槽--temporary。每季度做一次复制槽专项审计把“槽清单 对应消费端 最后活跃时间”列成表格发给所有相关责任人确认。这些规矩看起来平凡但实际执行起来非常有效。至少在我后续管理的环境里再没有出现过第二起“僵尸槽”事故。6. 常见问题排查速查表与避坑心得6.1 典型场景和排查命令速查我把这次事故以及平时积累的类似案例整理成了一份速查表分享给你场景典型特征优先排查命令磁盘使用率缓慢上涨归档正常可能是复制槽卡 WALSELECT * FROM pg_replication_slots;主库可以查询但一写数据就卡死磁盘满了WAL 写不进去df -hdu -sh $PGDATA/pg_waldrop 复制槽报错“replication slot is active”还有备库/订阅端在连SELECT * FROM pg_stat_replication;逻辑复制槽导致 catalog 膨胀订阅端长期离线检查catalog_xmin是否远落后于当前事务高可用切换后老槽残留活跃备库 UUID 与槽不匹配对比pg_stat_replication的application_name和槽名pg_wal 目录不满但 WAL 归档堆积归档命令失败SELECT * FROM pg_stat_archiver;遇到复制槽相关的问题记住一个总原则清理任何槽之前确认它对应的消费端是否还在。宁可多等一会儿、多问一句也不要直接 drop。一旦误删了还在使用的复制槽备库或订阅端断线后可能无法续传需要重建整个复制关系那是比磁盘写满更麻烦的事。6.2 一些写在最后的心得这次事故给我最深的教训不是“要监控磁盘”而是**“任何看似安全的长期运行组件都可能在某一天以最意想不到的方式咬你一口”**。复制槽平时看不见摸不着如果不 watch 它它就会像埋在地雷区里的定时炸弹只等一个导火索。我现在管理 PostgreSQL 环境有几个习惯已经固化下来第一任何与副本、订阅、备份相关的任务结束之后第一件事就是查pg_replication_slots。不是等监控告警了再查而是任务完事就查十秒钟的事情成本极低。第二不要把 WAL 目录和数据文件放在同一个分区。如果业务允许把pg_wal放到独立磁盘或至少独立分区即使 WAL 异常膨胀也不会立刻拖垮整个数据目录。这个建议对生产环境非常实用尤其是那些磁盘空间本来就紧张的小团队。第三复制槽的名字一定要起得有意义比如包含备库 IP、用途、创建日期。我见过太多人用slot1、slot2这种名字等到排查问题时根本分不清哪个槽对应哪台机器只能一台一台去对效率极低。最后再分享一个实用小技巧如果你不确定某个物理复制槽到底还在不在用可以先在备库侧关闭流复制服务然后观察主库的pg_replication_slots视图里那个槽的active是否变为false。如果变成false了说明消费端确实已经不在这时候再确认业务影响、做清理就稳妥得多。这个方法是我在多次误删边缘反复试出来的比直接搜索文档来的直观得多。数据库这行越往后越发现最危险的永远不是那些复杂到看不懂的功能而是那些“平时用不上、用了就忘、忘了就出事”的小角落。复制槽就是最典型的代表。希望这篇复盘能帮你少踩一次这个坑至少别再让一条“僵尸”槽半夜把你叫醒。
返回列表