Sooua
登录
返回文章列表
Linux / 网络 / 数据库基础设施··11 分钟阅读

一个空闲 logical slot 把 pg_wal 涨到 339MB:PostgreSQL 复制槽 WAL 保留的工程边界

复制槽是 PostgreSQL 让下游可靠消费 WAL 的机制,但它会把磁盘风险静默转嫁回主库。本文用 PostgreSQL 17 实测验证 max_slot_wal_keep_size 的两种行为分支(lost / extended),结合 17 新增的 invalidation_reason / inactive_since 列,给出复制槽监控、heartbeat 与失效处置的工程边界。

一个空闲 logical slot 把 pg_wal 涨到 339MB:PostgreSQL 复制槽 WAL 保留的工程边界
## 一句话观点

逻辑复制槽不是“开了就好”的开关,而是把下游消费者的故障半径接进了主库的磁盘。`max_slot_wal_keep_size` 决定的是:当下游消费失败时,先丢复制还是先丢主库。这一选择必须在上线前就明确,而不是在 `pg_wal` 涨满那天才补。

## 问题边界与实测对象

复制槽(replication slot)是 PostgreSQL 让 standby、逻辑复制订阅者或 Debezium 这类 CDC 消费者“可靠消费 WAL”的机制:只要槽存在,主库就不会回收槽尚未确认(`confirmed_flush_lsn`)之前的任何 WAL 段。文档原文写得很直白:

> If `max_slot_wal_keep_size` is -1 (the default), replication slots may retain an unlimited amount of WAL files.([PostgreSQL Replication 文档][docs])

这句话翻译过来就是:**默认配置下,一个停掉的下游可以让主库 `pg_wal` 涨到撑爆磁盘**。生产中常见路径是:CDC 消费者宕机/被回收 → slot 仍在 → WAL 不能被 checkpoint 回收 → 磁盘逼近 100% → 主库被迫拒绝写入或被 OOM killed。pgHealth、Mydbops、AirHelp、Zalando 等团队公开复盘里反复出现这个失败模式,没有一种是“偶发”的。

为了让结论可验证而不是“众所周知”,本文在本地 Docker 起了一个 PostgreSQL 17 实例做最小实测:

- 镜像:`postgres:17`(实际版本 `17.10 (Debian 17.10-1.pgdg13+1)`)
- 关键配置:`wal_level = logical`、`max_wal_size = 128MB`、`min_wal_size = 32MB`、`checkpoint_timeout = 30s`
- 工作负载:单表 `t(id, payload text)`,`payload` 为 ~3.2KB 的随机字符串,每批 200,000 行
- 槽:`pg_create_logical_replication_slot('demo_slot', 'pgoutput')`,**没有**任何消费者读取
- 实验只用于演示行为边界,**不是性能基准**,磁盘大小、表结构和负载与真实生产差异巨大,不能外推

> ⚠️ 边界声明:下面所有 LSN/字节数都是这台一次性 Docker 容器内的实测值;同样的写入模式在不同存储、不同 `wal_segment_size`、不同 checkpoint 调度下数字会显著不同。结论意义在于**行为分支**,而不是绝对体积。

## 复制槽与 WAL 回收的位置关系

```mermaid
flowchart LR
  Client[业务写入] --> WAL[(pg_wal/<br/>WAL 段)]
  WAL --> Checkpoint[Checkpoint<br/>回收 WAL]
  Checkpoint -->|受限于| MinLSN{min(<br/>restart_lsn of slots,<br/>standby flush LSN,<br/>wal_keep_size)}
  Slots[(pg_replication_slots<br/>logical / physical)] --> MinLSN
  Slots -. 消费 .-> Consumer[CDC / standby /<br/>logical subscriber]
  Consumer -->|advance<br/>confirmed_flush_lsn| Slots
  MinLSN --> Recycle[可回收<br/>WAL 段]
```

复制槽的本质,是**给 checkpoint 的 WAL 回收策略加了一个下界**。下界由所有槽中最旧的 `restart_lsn` 决定。任何让 `restart_lsn` 不前进的事件——消费者宕机、网络断、SUBSCRIPTION 被禁用、Debezium 任务挂起、低活跃数据库 LSN 不推进——都会把这个下界钉死,WAL 自然涨。

## 实测一:开启 `max_slot_wal_keep_size = 64MB`,槽被主动作废

`max_slot_wal_keep_size` 是 PostgreSQL 13+ 提供的“保险丝”:当槽保留的 WAL 超过这个上限时,下次 checkpoint 会**主动作废**这个槽,腾出 WAL,让主库优先存活。

实测命令(节选,删除了无关输出):

```bash
$ docker exec -i pg-slot-demo psql -U postgres -d demo \
    -c "SHOW max_slot_wal_keep_size; SHOW max_wal_size;"
 max_slot_wal_keep_size
------------------------
 64MB
 max_wal_size
--------------
 128MB

$ docker exec -i pg-slot-demo psql -U postgres -d demo -c \
    "SELECT pg_create_logical_replication_slot('demo_slot', 'pgoutput');"
 pg_create_logical_replication_slot
------------------------------------
 (demo_slot,0/193D728)
```

第一次 200k 写入后,`pg_wal` 从 17MB 涨到 65MB,槽进入 `reserved` 状态,开始“占着 WAL 不让回收”:

```text
65M	/var/lib/postgresql/data/pg_wal
 slot_name | active | wal_status | retained | safe_wal_size
-----------+--------+------------+----------+---------------
 demo_slot | f      | reserved   | 42 MB    |      29829648
```

这里 `wal_status = reserved`、`safe_wal_size ≈ 29MB` 表示:**槽还安全,但只剩 29MB 余量**。继续追加约 800k 次写入并执行 `CHECKPOINT`,行为分支立即出现:

```text
=== after ~800k more inserts + checkpoint ===
97M	/var/lib/postgresql/data/pg_wal
 slot_name | active | wal_status | retained | safe_wal_size
-----------+--------+------------+----------+---------------
 demo_slot | f      | lost       |          |
```

服务器日志里同步出现:

```text
2026-06-12 21:04:06.465 GMT [62] LOG:  invalidating obsolete replication slot "demo_slot"
2026-06-12 21:04:06.465 GMT [62] HINT:  You might need to increase "max_slot_wal_keep_size".
```

这一行日志,就是 PostgreSQL 替你做了取舍:**与其让主库被磁盘拖死,不如让这个下游永远丢一段 WAL**。下游再来读这个槽时,明确报错:

```text
ERROR:  can no longer get changes from replication slot "demo_slot"
DETAIL:  This slot has been invalidated because it exceeded the maximum reserved size.
```

PostgreSQL 17 在 `pg_replication_slots` 上新增了两列,让事后审计更直接:

> Add column `pg_replication_slots.invalidation_reason` to report the reason for invalid slots; Add column `pg_replication_slots.inactive_since` to report slot inactivity duration.([PostgreSQL 17 Release Notes][rel17])

实测查询:

```sql
SELECT slot_name, wal_status, invalidation_reason, inactive_since
FROM   pg_replication_slots;
```

```text
 slot_name | wal_status | invalidation_reason |        inactive_since
-----------+------------+---------------------+-------------------------------
 demo_slot | lost       | wal_removed         | 2026-06-12 21:04:06.465661+00
```

`invalidation_reason = wal_removed` 把“为什么槽消失了”从模糊的“可能磁盘问题”变成可机读字段,这是 17 之前要靠日志 grep 才能拼出来的事实。

## 实测二:`max_slot_wal_keep_size = -1`,主库为下游让步

把限制改回默认值 `-1`,新建 `demo_slot3`,仍然没有任何消费者,对同一表追加约 1.6M 行:

```bash
$ docker exec -i pg-slot-demo psql -U postgres -d demo -c \
    "ALTER SYSTEM SET max_slot_wal_keep_size = -1;"
$ docker exec -i pg-slot-demo psql -U postgres -d demo -c \
    "SELECT pg_create_logical_replication_slot('demo_slot3','pgoutput');"
# … 8 轮 200k 行写入 + CHECKPOINT …

=== unbounded slot, after ~1.6M inserts (should keep growing) ===
369M	/var/lib/postgresql/data/pg_wal
 slot_name  | active | wal_status | retained | safe_wal_size
------------+--------+------------+----------+---------------
 demo_slot3 | f      | extended   | 339 MB   |
```

几个关键观察:

1. `pg_wal` 从 17MB 涨到 369MB,远远超过 `max_wal_size = 128MB`。`max_wal_size` 只是个软目标,复制槽可以合法地把它推穿。
2. `wal_status = extended` 在文档定义里就是“**当前已超过 `max_wal_size` 但仍被保留**”,意味着主库在拿磁盘换“将来某个消费者回来还能继续”。
3. 这个槽**永远不会自动作废**,磁盘只会一直被它占。

这两次实测合在一起,就是 `max_slot_wal_keep_size` 这个参数的真实含义:

```mermaid
stateDiagram-v2
    [*] --> reserved: 槽创建/正常滞后
    reserved --> extended: 滞后超过 max_wal_size 但仍可保留
    extended --> lost: 超过 max_slot_wal_keep_size<br/>checkpoint 主动作废
    reserved --> unreserved: 滞后被消费者追平
    extended --> unreserved: 消费者赶上
    unreserved --> [*]
    lost --> [*]: 下游必须重建订阅
```

## 不是所有槽都可以被作废

这一段是工程师最容易踩的坑:`max_slot_wal_keep_size` 并**不**保护所有场景。

- **逻辑复制槽**(如本文实验、Debezium、`pg_logical_emit_message`):超过阈值时 checkpoint 会作废它,下游读到 `wal_removed` 必须重新初始化(重新 snapshot/重新建订阅)。
- **物理复制 + `hot_standby_feedback = on`**:standby 会通过 feedback 把 `xmin` 回钉到主库,`pg_wal` 涨的同时还会**阻止 vacuum**,引起 catalog/heap bloat。这部分**并不**只靠 `max_slot_wal_keep_size` 解决,还需要 `old_snapshot_threshold` / 关闭 `hot_standby_feedback` 的取舍。
- **逻辑订阅 + 长事务**:即使 slot 看起来在前进,长事务(订阅端 / 发布端)会让 `catalog_xmin` 不前进,从而把 catalog WAL 钉住。这一类不会触发 `max_slot_wal_keep_size`,需要单独监控 `pg_stat_activity.backend_xmin` 和 `catalog_xmin`。

换句话说,“我开了 `max_slot_wal_keep_size`,磁盘不会爆”是**错的**结论。它只把“下游消费失败”这一类风险收敛到可控范围。

## 监控信号:四个必须采集的字段

实战中,pgHealth、Mydbops、AirHelp 公开的 RCA 几乎都收敛到同一组监控查询。结合 PostgreSQL 17 的新列,给出最小可用版本:

```sql
SELECT
    slot_name,
    slot_type,
    database,
    active,
    wal_status,                       -- reserved / extended / unreserved / lost
    invalidation_reason,              -- 17+: wal_removed / rows_removed / wal_level_insufficient ...
    pg_size_pretty(
      pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
    ) AS retained_wal,
    pg_size_pretty(safe_wal_size)     AS safe_wal_size,
    inactive_since,                   -- 17+
    now() - inactive_since AS inactive_for
FROM   pg_replication_slots
ORDER  BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC NULLS LAST;
```

四个我会上报到告警系统的字段:

| 字段 | 告警含义 | 阈值参考 |
| --- | --- | --- |
| `retained_wal` | 该槽当前钉住多少 WAL | 占 `pg_wal` 物理盘 ≥30% 立即告警 |
| `wal_status` | 状态机当前所在分支 | `extended` 立即告警,`lost` 是事故已经发生 |
| `safe_wal_size` | 距离触发 `max_slot_wal_keep_size` 的余量 | <10% `max_slot_wal_keep_size` 告警 |
| `inactive_since` | 槽最近一次活跃的时间(17+) | >30min 对在线 CDC 必告 |

AirHelp 公开的经验值是把 `max_slot_wal_keep_size` 设到“磁盘剩余空间的若干百分比”而不是固定 GB,但前提是 `pg_wal` 与数据目录在同一卷上;分开挂载时这个公式不能直接用。要点是:**阈值要从“磁盘可承受 WAL 上限”反推,而不是从“CDC 想要多大重放窗口”出发**。

## 把下游的失败半径收回到下游:heartbeat + lsn 推进

Debezium 文档里那段“replication slots are guaranteed to retain all WAL segments required for Debezium even during Debezium outages”不是营销话术,是**警告**:消费者必须主动让 slot 前进,否则主库一定会承担 WAL 累积。

两类常见失效:

1. **低活跃数据库**:被复制的表很少更新,slot 的 `confirmed_flush_lsn` 长时间不动,但其他高活跃数据库的写入不断推高 `current LSN`,WAL 就被这个静止 slot 钉住。Debezium 提供两种 heartbeat:heartbeat 表(定期 UPDATE)、`pg_logical_emit_message()` 直接写 WAL 逻辑消息。
2. **消费者 commit 慢于 flush**:Debezium 默认要等 sink 端写出后才推进 lsn,对低活跃流会反复触发上面这条。Zalando 在 2025 年公开的工程贴里讲到他们贡献的 `lsn.flush.mode=connector_and_driver` + `offset.mismatch.strategy=trust_greater_lsn` 用于解耦:连接器先把 lsn 在驱动层推进,避免主库被慢 sink 拖累。

这两条在写第一行 `CREATE PUBLICATION` 之前就要决定,不能等到 SRE 凌晨被告警喊起来才补。

## 取舍清单:上线前必须答清楚的问题

| 问题 | 默认错误答案 | 正确做法 |
| --- | --- | --- |
| 下游消费失败时是丢复制还是丢主库? | “都不丢,加监控就行” | 写明 `max_slot_wal_keep_size`,写明哪条 slot 允许被作废 |
| `pg_wal` 的物理上限是多少? | “等磁盘告警再说” | `max_slot_wal_keep_size` ≤ `pg_wal` 卷剩余空间的 ~50%,并 reservation 给临时事务 |
| 复制槽是否是 HA 失败点? | “HA 框架应该会处理” | 显式启用 PostgreSQL 17 的 slot failover(`pg_create_logical_replication_slot(... failover := true)`)或评估 pgEdge / 物化方案 |
| Debezium / Subscriber 是否会主动 advance lsn? | “它自己会的” | 配 heartbeat 表或 `pg_logical_emit_message()`,监控 `confirmed_flush_lsn` 推进速率 |
| 槽被作废后下游怎么办? | “重启 connector” | 文档化重建流程:drop slot、初始 snapshot、双写/补偿、回放断点 |
| catalog_xmin 是不是也在卡? | 通常被忽略 | 监控 `pg_stat_activity` 长事务 + `catalog_xmin`,别只看 `restart_lsn` |

## 失效后的处置:被作废的槽不要硬救

实测里被作废的 `demo_slot` 不能再被消费:任何 `pg_logical_slot_get_changes` 都会返回 `can no longer get changes ... slot has been invalidated`。在生产里硬救它(修改文件、回滚 LSN)通常比重建付出的代价更大,正确路径是:

1. 停止下游消费者,避免它在重连失败循环里继续消耗主库连接。
2. 在主库 `SELECT pg_drop_replication_slot('<name>');`。
3. 在主库重建 slot:`pg_create_logical_replication_slot(..., failover := true)`(17+ 可以让 standby 接管)。
4. 在下游侧重新执行初始 snapshot 或按业务允许的方式做补偿同步;记录这次跳过的 LSN 区间作为审计证据。
5. 复盘后,把这次的“为什么消费者卡住”补到 runbook,不要让同一类事故复发。

这一步流程看起来朴素,但很多团队的真实事故复盘里,第一步往往是“先扩盘”,然后下游连接器循环消费失败把主库连接打满,整套系统进入更糟的退化态。先**切掉下游、再处理槽**,是被多份公开 RCA 反复验证过的顺序。

## 结尾的边界声明

本文实测仅证明:在 PostgreSQL 17、Docker 默认存储、上文给出的最小配置下,逻辑槽会按文档描述触发 `extended → lost` 状态迁移、生成 `invalidation_reason = wal_removed` 字段。它**不**证明:

- 任何具体生产环境的磁盘曲线和阈值都按上面比例发生;
- `max_slot_wal_keep_size` 是“治百病”的开关,它不解决长事务、`hot_standby_feedback`、catalog_xmin 卡住等正交问题;
- Debezium / 订阅者侧的 lsn 推进策略可以照搬,每个 sink 的语义和延迟约束都需要单独评估;
- 任何 cross-version 行为,比如 PG13/14/15 上 `wal_status = lost` 字段的可用性、HA 框架对 slot 的接管行为,都按本文逻辑成立。

下一步可复现实验设计:把 `max_slot_wal_keep_size` 改成不同档位(0、64MB、256MB、-1),用 `pgbench -c 4 -T 60` 跑混合负载,记录 `wal_status` 转变时机、`safe_wal_size` 衰减曲线、checkpoint 触发频率,再加入一个真实的 logical 消费者验证 Debezium heartbeat 与 `lsn.flush.mode` 不同模式下 WAL 的稳态体积差异。这一步需要专门的物理盘和监控栈,已超出今日单容器实测的范围。

[docs]: https://www.postgresql.org/docs/current/runtime-config-replication.html
[rel17]: https://www.postgresql.org/docs/17/release-17.html
分享

评论

登录 后参与讨论。

加载中…

相关文章