🤖
AI审核中

PostgreSQL WAL堆积排查实战

Java 15分钟 112浏览 0评论

你好呀,我是小邹。

假设你遇到这样一个现场:

业务表的大小没有明显变化,服务器磁盘使用率却持续上涨。进一步检查发现,增长的主要是 pg_wal 目录。配置中的 max_wal_size 明明只有几 GB,这个目录却已经膨胀到几十 GB。

第一反应可能是:检查点是不是没执行?是不是应该做一次 VACUUM?那些看起来很旧的 WAL 文件能不能直接删除?

真正需要先回答的问题是:

这些 WAL 为什么还必须保留,究竟是谁阻止了它们被回收?

PostgreSQL 的 WAL 回收受到恢复、复制和归档等条件共同影响。即使检查点正常执行,只要某些保留条件没有解除,旧 WAL 仍可能继续占用磁盘。

本文以 PostgreSQL 18 的主库为示例,诊断 SQL 以只读查询为主。文中的容量和速率均为教学假设,不代表某个真实生产环境的测量结果。

一、先纠正一个误区:max_wal_size 不是磁盘硬上限

max_wal_size 很容易被理解成“WAL 最多只能占这么多空间”。

但它实际上是与自动检查点相关的软限制。在高写入负载、归档失败、较大的 WAL 保留配置等情况下,实际占用可以超过这个值。把它从 8 GB 调成 4 GB,并不意味着系统会立即删除多出来的文件。

几个容易混淆的参数,可以这样区分:

参数 主要作用 不能据此得出的结论
max_wal_size 影响自动检查点的触发 整个 WAL 目录绝不会超过这个大小
wal_keep_size 为复制额外保留一定数量的历史 WAL 这是历史 WAL 的保留上限
max_slot_wal_keep_size 在检查点时限制复制槽可以要求保留的 WAL 范围 达到限制后,系统会自动暂停业务写入

其中,max_slot_wal_keep_size 默认是 -1,表示不通过这个参数限制复制槽保留 WAL 的数量。设置有限值可以约束风险,但也可能使落后过多的消费者失去继续复制所需的 WAL。

可以用下面这张图理解回收过程:

graph LR
    A["业务写入"] --> B["生成 WAL"]
    B --> C{"仍被恢复、复制或归档需要?"}
    C -->|是| D["继续保留"]
    C -->|否| E["按策略删除或复用"]
    F["复制槽进度停滞"] --> C
    G["归档失败或落后"] --> C
    H["检查点与保留配置"] --> C

因此,排查方向不应只是“为什么没有删文件”,而应该转向“哪些保留条件还没有解除”。这也是为什么盲目调整检查点参数,经常不能解决 WAL 堆积。

二、先确认现场:查的是哪台库,增长的是什么

1. 确认版本和主备角色

先执行:

SELECT
    current_setting('server_version') AS server_version,
    pg_is_in_recovery() AS is_standby;

本文后续使用的 pg_current_wal_lsn() 查询应在主库上执行。若 is_standbytrue,不要直接照搬主库的 LSN 诊断方式,应切换到相应主库,或针对备库使用恢复进度相关函数。

2. 查看 WAL 目录中的文件大小

SELECT
    count(*) AS file_count,
    pg_size_pretty(
        COALESCE(sum(size), 0)::bigint
    ) AS wal_directory_size
FROM pg_ls_waldir();

pg_ls_waldir() 返回 WAL 目录中普通文件的名称、大小和修改时间,默认可由超级用户以及具有 pg_monitor 权限的角色使用。这里汇总的是文件长度,不等于底层文件系统实际分配块的精确统计。

实际处置时,我建议同时记录两组数据:数据库看到的 WAL 文件大小,以及 WAL 所在存储卷的可用空间。后续所有“还剩多少时间”的判断,都应以真正承载 WAL 的存储卷为准。

3. 查看当前生效配置

不要只看仓库里的配置模板,直接查询实例当前使用的值:

SELECT
    name,
    setting,
    unit,
    source,
    pending_restart
FROM pg_settings
WHERE name IN (
    'data_directory',
    'wal_level',
    'max_wal_size',
    'min_wal_size',
    'wal_keep_size',
    'max_slot_wal_keep_size',
    'archive_mode',
    'archive_command',
    'archive_library'
)
ORDER BY name;

source 有助于确认配置来源,pending_restart 可以提示是否存在尚未通过重启生效的配置变更。排查时应围绕实例实际生效值展开,而不是围绕“我记得应该配置过什么”展开。

三、复制槽排查:连接还在,不代表进度正常

复制槽会保存消费者的进度,其生命周期并不依赖某一条连接。消费者断开之后,槽仍然可能保留,这正是它能支持后续继续消费的基础,也是无人维护的槽可能带来容量风险的原因。

下面的 SQL 可以同时查看槽状态、保留边界和逻辑消费确认进度:

WITH wal_head AS MATERIALIZED (
    SELECT pg_current_wal_lsn() AS current_lsn
)
SELECT
    s.slot_name,
    s.slot_type,
    s.active,
    s.wal_status,
    s.restart_lsn,
    s.confirmed_flush_lsn,
    pg_wal_lsn_diff(
        h.current_lsn,
        s.restart_lsn
    ) AS restart_gap_bytes,
    pg_size_pretty(
        pg_wal_lsn_diff(h.current_lsn, s.restart_lsn)
    ) AS restart_gap,
    pg_size_pretty(
        pg_wal_lsn_diff(h.current_lsn, s.confirmed_flush_lsn)
    ) AS confirmed_gap,
    pg_size_pretty(s.safe_wal_size) AS slot_headroom
FROM pg_replication_slots AS s
CROSS JOIN wal_head AS h
ORDER BY restart_gap_bytes DESC NULLS LAST;

这里最重要的是区分三个含义:

字段 应该如何理解
active 当前是否正在通过这个槽进行流式消费
restart_lsn 消费者仍可能需要的最早 WAL 位置
confirmed_flush_lsn 逻辑槽消费者已确认接收数据的位置;物理槽为 NULL

消费者已经确认到某个位置,不等于更早的所有 WAL 都已经满足回收条件。(PostgreSQL)

假设得到下面这样一组教学示例:

复制槽 active restart_gap confirmed_gap
orders_cdc true 40 GiB 32 MiB
report_cdc false 12 GiB 12 GiB

第二个槽明显需要检查消费者是否断开;但第一个槽同样值得关注:它仍在连接和确认数据,保留边界却远远落后。

另外,不能把 40 GiB 和 12 GiB 直接相加,认定两个槽独占了 52 GiB 磁盘。这是两个 LSN 区间的长度,它们可能重叠;restart_gap 应当被理解为保留压力的估计,而不是独占空间账单。这个判断来自 WAL 共享保留机制和 LSN 字节距离的含义。

对于 wal_status,尤其要关注 unreservedlost:前者表示槽已不再保证保留所需 WAL,后者表示槽已经不可用。safe_wal_sizeNULL 也不能一律解释为“安全”,它既可能对应无限制配置,也可能对应已经失效的槽。

为什么 confirmed_flush_lsn 在前进,restart_lsn 却不动?

在逻辑解码场景中,长事务可能使重新解码所需的起点停留在较早位置。因此,看到确认进度较新、保留边界却长期不动时,应把长事务列为排查方向,而不是只反复重启消费者。

可以先筛选事务持续时间较长的会话:

SELECT
    pid,
    usename,
    application_name,
    state,
    clock_timestamp() - xact_start AS transaction_age,
    wait_event_type,
    wait_event,
    left(query, 160) AS query_sample
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND pid <> pg_backend_pid()
ORDER BY xact_start
LIMIT 10;

这一步是收集线索,不是自动终止依据。建议把事务开始时间、复制槽停滞时间和应用日志放在一起核对,再判断它是否相关、是否允许结束。

四、归档排查:failed_count 没增长,也不能直接判定正常

开启归档后,尚未成功归档的 WAL 不能正常进入后续回收流程。归档目的地写不进去,问题最终可能表现为主库本地磁盘不断增长。(PostgreSQL)

先查看归档统计:

SELECT
    archived_count,
    failed_count,
    last_archived_wal,
    last_archived_time,
    last_failed_wal,
    last_failed_time,
    stats_reset
FROM pg_stat_archiver;

这些字段是累计统计和最近一次事件记录。建议间隔一段时间连续采样,重点看计数是否继续变化,而不是只看某个历史失败时间。最近一次成功归档的文件,也不能证明所有更早文件都已经归档成功。

排查时,我会把以下现象放在一起看:

业务仍在持续写入,WAL 占用持续上升,但归档成功计数长期不动。

接着再检查归档目的地容量、运行用户权限、挂载状态、网络访问和数据库日志。单独一个“最近很久没有归档”的时间戳,不足以区分数据库空闲与归档故障。

还有一个容易漏掉的细节:某些导致归档进程退出并重启的错误,不会计入 pg_stat_archiver 的失败统计,例如部分信号终止、特定 shell 错误,以及归档函数抛出的某些错误。因此,failed_count = 0 不能替代日志检查。

更不能为了让告警消失,把归档命令改成一个永远返回成功、实际却不保存文件的空操作。PostgreSQL 会把成功返回值视为归档完成;错误地报告成功,可能破坏后续恢复所依赖的 WAL 归档链。

五、容量判断:同时计算两个“剩余时间”

“磁盘还有 30%”并不是一个足够有用的处置依据。

我更关心两个问题:

当前磁盘还能承受多久的净增长?当前复制槽的保留预算还能支撑多久?

这两个时间可能相差很大。

1. 先测 WAL 生成速率

可以间隔一段时间,在独立的自动提交查询中分别采集:

SELECT
    clock_timestamp() AS sampled_at,
    wal_bytes,
    stats_reset
FROM pg_stat_wal;

wal_bytes 是累计生成的 WAL 字节数。两次采样应检查 stats_reset 是否一致,并避免在同一个长事务里反复读取被缓存的累计统计。(PostgreSQL)

计算方式是:

WAL 生成速率 ≈ 两次 wal_bytes 的差值 ÷ 实际采样间隔

随后建立两种不同的估算:

估算目标 计算思路 使用条件
磁盘还能撑多久 扣除安全预留后的可用空间 ÷ 存储卷净增长速率 净增长速率持续为正
槽保留预算还能撑多久 safe_wal_size ÷ WAL 生成速率 保留上限有限,且槽的保留边界近似不动

第二个估算只针对 WAL 保留预算耗尽的风险,不包含其他失效原因,也不是精确倒计时;槽进度、写入变化和检查点时机会影响实际结果。(PostgreSQL)

假设:

  • 扣除安全预留后,磁盘还剩 30 GiB,净增长为 6 MiB/s。
  • 某个槽的 safe_wal_size 还剩 20 GiB,WAL 生成速率为 8 MiB/s。

按这些假设计算,磁盘约能支撑 85 分钟,而槽的保留预算约只能支撑 43 分钟

也就是说,即使磁盘尚未写满,下游复制也可能先遇到问题。

2. 用恢复窗口反推保留预算

假设业务要求消费者断开 90 分钟后,仍有机会接着消费;故障期间的 WAL 生成速率按 8 MiB/s 估算。

仅这一段中断产生的 WAL 就约为:

8 × 90 × 60 ÷ 1024 ≈ 42.2 GiB

这还没有加入既有积压、写入突增、恢复启动时间和其他保留需求。

因此,我建议把复制槽容量设计成一个明确的业务约定:允许中断多久、峰值写入是多少、恢复后能以多快速度追赶,以及超过预算后如何补数。

保留上限不是越大越安全,也不是越小越节省。它是在主库容量安全与下游恢复能力之间作出的取舍。

尤其不要在已有大量积压时,突然把上限调到低于当前需要的值,再主动执行检查点。限制是在检查点相关处理中落实的,这样做可能加速所需 WAL 的移除,而不是完成一次无损清理。

六、真正的处置顺序:先保护容量,再解除保留条件

当 WAL 所在文件系统耗尽时,PostgreSQL 可能触发 PANIC 停止服务。因此,临近写满时,应优先争取安全空间,而不是继续进行没有时间边界的诊断。

我的建议是按三个阶段处理。

第一阶段:争取处置时间

根据剩余空间和增长速率,评估扩容、暂停可延期批量写入或降低非核心写入压力。

不要直接删除 pg_wal 中的文件,也不要在尚未确认用途时清理数据库目录中的其他内容。先把“还能安全运行多久”变成一个明确数字,再安排修复顺序。

第二阶段:修复真正停滞的环节

复制槽停滞,就检查消费者、事务和解码流程;归档停滞,就恢复真实的归档能力。

对于疑似废弃的槽,我建议先核对所属服务、负责人、最后使用情况和下游恢复方案,再决定是否删除。active = false 只能说明当前没有消费连接,不能代替业务上的废弃确认。槽本来就可以独立于连接持续存在。

第三阶段:验证恢复,而不只是验证“连接成功”

建议至少跟踪这些结果:保留边界是否持续前进、归档是否重新完成、下游数据是否追赶,以及存储卷是否停止危险增长。

不要把“WAL 文件数量没有立即下降”直接判定为修复失败。PostgreSQL 可能把不再需要的旧文件复用为后续 WAL 文件,而不是全部删除,目录大小因此不一定立即回到很小的值。

验证目标应该是恢复可持续的运行状态,而不是让某一个数字立刻变得好看。

七、两个常见“清理动作”,为什么不该作为默认答案

1. 频繁执行 CHECKPOINT

检查点可以推进恢复所需的 WAL 边界,但不能替代消费者确认,也不能让未成功归档的文件凭空满足归档要求。

此外,检查点涉及脏页写出,过于频繁会增加 I/O 压力;在启用全页写保护时,更短的检查点间隔还可能增加后续 WAL 生成量。用反复检查点去追赶一个仍在失控增长的 WAL 目录,可能适得其反。

2. 直接执行 VACUUM FULL

VACUUM 主要处理表和索引中的旧行版本空间,不是 WAL 目录清理命令。

VACUUM FULL 还会重写表,需要额外空间,并对目标表获取排他性很强的锁。对于一个已经接近磁盘耗尽、根因却是 WAL 保留的实例,它通常不是合适的第一步。

这两种操作都不是绝对不能使用,关键在于先证明它们能解决当前问题,而不是因为名字里带着“检查”或“清理”,就把它们当成通用修复按钮。

八、总结:不是找到大目录就结束了

排查 WAL 堆积,我建议始终围绕三个问题展开:

为什么还要保留?保留边界有没有前进?剩余容量能否覆盖修复时间?

找到 pg_wal 很大,只完成了现象定位。进一步区分复制槽停滞、归档受阻和正常的保留复用行为,才能决定下一步该恢复消费者、修复归档、调整容量,还是继续观察。

真正可靠的处置,不是尽快删掉最多的文件,而是在保住数据和恢复能力的前提下,让 WAL 的生成、保留与回收重新回到可持续状态。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论