## 一句话观点
逻辑复制槽不是“开了就好”的开关,而是把下游消费者的故障半径接进了主库的磁盘。`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
返回文章列表
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 与失效处置的工程边界。

评论
请 登录 后参与讨论。
加载中…
