流复制:从 WAL Sender 到 Slot 溢出的多米诺效应
在 PG 里配流复制不算难——pg_basebackup
拉一个基础备份,写一条 primary_conninfo,启动
standby
就行。但流复制在生产环境的故障形态远不止”延迟大了追一追”。它涉及
WAL Sender/Receiver
进程的内部协议、同步复制的持久性语义和等待链、Failover 时的
Timeline 协商、Replication Slot 对 WAL
回收的制约——以及最致命的多米诺效应:一个被遗忘的 Slot
可以悄无声息地填满磁盘,打垮 Primary。
本文从源码路径拆解 PG
流复制的内核骨架,重点解释三个运维中最常见但理解最浅的高风险场景:Slot
溢出如何从”standby 宕机”一步步发展到”primary
PANIC”、pg_stat_replication
的三层延迟指标为什么在 Slot 卡住时全都显示 0、以及 Promote
之后的 split-brain 为什么 pg_rewind
不能完全救你。
一、流复制的整体架构
PG 的流复制基于 WAL 的物理日志传输,不是 SQL 级别的复制。Primary 把 WAL record 发给 standby,standby 像做 crash recovery 一样 replay 这些 record——redo 出来的页面和 primary 一模一样。这意味着流复制是物理复制,传输的是 page-level 的变更,不是逻辑行。
sequenceDiagram
participant P as Primary Backend
participant WB as WAL Buffer (Primary)
participant WS as WAL Sender
participant WR as WAL Receiver
participant S as Standby Recovery
P->>WB: XLogInsert() 写入 WAL record
P->>P: XLogFlush() 刷到磁盘
WS->>WB: 读取 WAL record
WS->>WR: 网络传输 (libpqwalreceiver)
WR->>S: 写入 WAL 文件
S->>S: StartupXLOG() → REDO 恢复
Note over WR,S: Standby 端按 LSN 顺序重放
```text
Primary 上的 WAL Sender(`walsender`)负责从 WAL 或 archive 读取 WAL record,通过网络发送。Standby 上的 WAL Receiver(`walreceiver`)接收这些 record,写入本地 WAL 文件,然后由 Startup 进程执行 REDO 恢复。`pg_stat_replication` 的三个延迟字段(`write_lag`、`flush_lag`、`replay_lag`)正是对应这个过程的三道闸门。
---
## 二、WAL Sender:WalSndLoop 的发送路径
### 启动流程
WAL Sender 是 Primary 上的一个 backend 进程。当一个带有 `replication=true` 参数的客户端连接到达时,Postmaster 看到这是一个复制连接,不走通常的 `PostgresMain()` 查询循环,而是进入 `WalSndMain()`:
```c
// src/backend/replication/walsender.c
void WalSndMain(void) {
// 1. 初始化进程上下文,绑定 PGPROC
InitProcess();
// 2. 处理客户端的 IDENTIFY_SYSTEM 命令(获取 systemid, timeline, xlogpos)
// 3. 处理 CREATE_REPLICATION_SLOT / START_REPLICATION 等复制协议命令
// 4. 进入主循环:只在这里做一件事——发送 WAL
WalSndLoop();
}
WAL Sender 的大部分生命周期都在 WalSndLoop()
中:
// src/backend/replication/walsender.c, WalSndLoop()
static void WalSndLoop(void) {
for (;;) {
// 1. 等待 WAL 数据到达(或等待输出缓冲区可写)
WaitLatchOrSocket(MyLatch, WL_SOCKET_READABLE | WL_LATCH_SET |
WL_POSTMASTER_DEATH | WL_EXIT_ON_PM_DEATH, ...);
// 2. 从 WAL 读取新的 record 放入输出缓冲区
if (pq_is_send_pending()) {
pq_flush_if_writable(); // 先刷新输出缓冲区
}
else {
XLogSendPhysical(); // 读取下一个 WAL record 并发送
}
// 3. 处理来自客户端的 feedback 消息(standby 报告自己的 flush LSN)
ProcessRepliesIfAny();
}
}
```text
这个循环的核心是一个生产者-消费者结构:`XLogSendPhysical()` 充当生产者,从 WAL 读取 record 写入发送缓冲区;`pq_flush_if_writable()` 充当消费者,把缓冲区的内容通过网络 socket 发出去。如果 standby 消费得慢,发送缓冲区会填满,WAL Sender 会阻塞在 `pq_flush_if_writable()` 上。
### XLogSendPhysical:逐 record 的发送
```c
// src/backend/replication/walsender.c, XLogSendPhysical()
static void XLogSendPhysical(void) {
XLogRecPtr startptr;
XLogRecPtr endptr;
Size nbytes;
// 1. 确定发送范围:从 sentPtr(已发送位置)到当前 WAL 写入位置
startptr = sentPtr;
endptr = GetFlushRecPtr(); // 当前已刷盘的 WAL 位置
// 2. 边界保护:不能发送比 standby 自己 WAL 位置的更旧数据
if (startptr < sentPtr)
startptr = sentPtr;
// 3. 从 WAL 读取数据
XLogRead(send_data, startptr, endptr - startptr);
// 4. 打包成复制协议消息('w' 类型,代表 WAL data)
// 消息格式:'w' + startLSN + endLSN + timestamp + data
pq_putmessage('w', send_data, nbytes);
// 5. 更新 sentPtr
sentPtr = endptr;
}关键点:startptr 不能超过 standby 报告的
flush_lsn。如果 standby 是一个 cascade
复制的中继(它也把自己的 WAL 发给下一个 standby),它的
sentPtr 受限于自己已经 flush 的 WAL
位置——它不能发送自己还没收到的东西。
WAL Sender 的三种 WAL 来源
WAL Sender 不一定总是从当前 WAL 段读取数据。当 standby 落后太多,需要的 WAL 段已经被回收时,有三种后备路径:
- 当前 WAL 段:最直接的路径,从
pg_wal目录中当前正在写入的 WAL 文件读取。 - 历史 WAL 段:如果 standby 需要仍存在于
pg_wal中的早期 WAL 段,直接从对应文件读取。 - WAL Archive:如果 WAL
段已被回收但归档到了
archive_library指定的路径,WAL Sender 会调用XLogArchiveIsReadyOrErr()检查归档是否可用,然后通过XLogFileReadAnyTLI()读取。
如果这三种来源都找不到所需的 WAL 段——standby 需要的 LSN
对应的 WAL 段既不在 pg_wal 中也没归档——WAL
Sender 会报错断开连接。这正是 Slot 溢出的直接后果:WAL 段被
Slot 保护不被回收,但如果 Slot 被删除或失效,standby
再连回来就永久缺了一段 WAL。
三、WAL Receiver:WalRcvLoop 的接收与恢复
接收端架构
Standby 上的 WAL
Receiver(walreceiver)是一个由 Startup 进程
fork 出来的辅助进程。它的职责比较简单但同样关键:
// src/backend/replication/walreceiver.c, WalReceiverMain()
void WalReceiverMain(void) {
// 1. 通过 libpqwalreceiver 库连接到 Primary
wrconn = walrcv_connect(conninfo, &err);
// 2. 发送 IDENTIFY_SYSTEM,获取 systemid, current_timeline, xlogpos
walrcv_identify_system(wrconn, &sysid, ¤t_timeline, &startpos);
// 3. 发送 START_REPLICATION(指定 startpos 和 slot_name)
walrcv_startstreaming(wrconn, startpos, slotname);
// 4. 进入接收主循环
WalRcvLoop();
}
```text
`WalRcvLoop()` 是 WAL Receiver 的主循环:
```c
// src/backend/replication/walreceiver.c, WalRcvLoop()
static void WalRcvLoop(void) {
for (;;) {
// 1. 等待 Primary 发来的 WAL 消息
WaitLatchOrSocket(MyLatch, WL_SOCKET_READABLE | ...);
// 2. 读取消息类型 'w'(WAL data)或 'k'(keepalive)
if (type == 'w') {
// 3. 把接收到的 WAL data 写入本地 WAL 文件
XLogWalRcvWrite(buf, len, startLSN);
}
// 4. 定期发送 feedback 消息(报告自己的 flush LSN)
XLogWalRcvSendReply();
}
}XLogWalRcvWrite() 把接收到的 WAL data
追加写入 standby 本地的 WAL 文件。对应
pg_stat_replication 的
write_lag——表示 WAL data 从 Primary 发送到
standby 写入本地 WAL 文件的延迟。
三阶段延迟的精确语义
从 WAL Receiver 的代码路径可以精确定义三层延迟的语义:
| 指标 | 对应操作 | 含义 |
|---|---|---|
write_lag |
WAL Receiver 把数据写到本地 WAL 文件页缓存(还没 fsync) | WAL 已经到达 standby 但还在 OS buffer 中 |
flush_lag |
WAL Receiver 把数据 fsync 到磁盘 | WAL 已持久化到 standby 磁盘 |
replay_lag |
Startup 进程 REDO 完这条 WAL record | WAL 对应的数据变更已在 standby 上可见 |
这三个阶段是严格有序的:write 先于 flush,flush 先于
replay。如果 write_lag 很大且接近
flush_lag,瓶颈在网络或 standby
的磁盘顺序写。如果 flush_lag 很小但
replay_lag 很大,瓶颈在 standby 的 redo
速度(CPU 或磁盘随机 IO)。
零值陷阱
这三个值等于 0 有两种完全不同的含义:
- 真的没延迟:Standby 与 Primary 紧凑同步,所有 WAL 都及时处理完。
- Slot 卡住了:Standby 已经失联,没有新的
WAL
被消费,
write_lag/flush_lag/replay_lag都停在最后一次成功处理时的值。如果卡住的时间足够长,这些值会是 0 而非连续增长——因为计算方式是最后一批 WAL 的处理时间差,失联后没有新输入,自然没有延迟。
区分的方法是看 pg_stat_replication 的
state 列:streaming
表示连接正常,延迟指标有意义;如果长时间没有
state 变化且 sent_lsn 等于
write_lsn 且长时间不变,说明 standby
可能已经失联。
四、同步复制的语义与等待机制
synchronous_commit 的五个级别
synchronous_commit 控制 Primary 上一个事务的
COMMIT 什么时候返回给客户端。它不是”同步复制 vs
异步复制”的二元开关,而是一个延迟-持久性的连续谱:
| 设置 | COMMIT 返回时机 | 持久性保证 | 对应 lag 指标 |
|---|---|---|---|
off |
WAL record 写入 Primary 本地 WAL buffer 即返回 | 最多丢 wal_writer_delay(默认
200ms)的事务 |
无 |
local |
WAL record 在 Primary 本地刷盘 | Primary 不丢,standby 可能丢 | flush_lag(Primary 自己的) |
remote_write |
WAL 已经到 standby 的 OS buffer(write 完成) | Primary 和 standby 缓存中有,standby 掉电可能丢 | write_lag |
on |
WAL 在 standby 上已刷盘(fsync 完成) | Primary 和 standby 磁盘都有 | flush_lag |
remote_apply |
WAL 在 standby 上已 redo 完毕(可见) | 写进去且能在 standby 上读到 | replay_lag |
remote_write、on、remote_apply
都涉及同步等待——Primary 的 COMMIT 必须等 standby
确认。这个等待是通过 SyncRepWaitForLSN()
实现的:
// src/backend/replication/syncrep.c, SyncRepWaitForLSN()
void SyncRepWaitForLSN(XLogRecPtr lsn, bool commit) {
// 1. 检查 sync_standbys_names 里有没有同步 standby
if (!SyncRepRequestedBySyncReplication())
return;
// 2. 把 COMMIT 的 LSN 注册到共享内存的同步复制队列
SyncRepQueueInsert(lsn);
// 3. 循环等待:被 WAL Sender 的信号量唤醒
for (;;) {
// 检查同步 standby 是否已经确认了这个 LSN
if (SyncRepCheckForStandby(lsn))
break;
// 类似 LWLock 等待:先自旋,再 sleep
WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH,
SYNCHRONOUS_COMMIT_TIMEOUT); // 默认 10 秒
}
}
```text
关键细节:同步复制的等待不是在 backend 的查询循环中完成的——backend 在 `COMMIT` 时通过等待信号量,等 WAL Sender 收到 standby 的 feedback 消息后把它唤醒。WAL Sender 收到 standby 发来的 feedback(包含 standby 的最新 `flush_lsn` 和 `replay_lsn`),然后检查是否满足了同步复制要求,如果满足就 `SetLatch()` 唤醒所有等待在这个 LSN 上的 backend。
### synchronous_standby_names 的语义
`GUC synchronous_standby_names` 指定哪些 standby 是同步 standby。它的格式支持两种策略:
- `FIRST N (standby1, standby2, ...)`:前 N 个 standby 确认就算同步完成。
- `ANY N (standby1, standby2, ...)`:任意 N 个 standby 确认就算同步完成。
如果所有同步 standby 都不可达,Primary 的事务 `COMMIT` 会阻塞直到有一个同步 standby 恢复或超时(`wal_sender_timeout`,默认 60 秒)。这意味着一件事:**同步复制的高可用不等于高持久——如果唯一同步 standby 宕机,Primary 的写操作会被阻塞。** 这就是为什么生产环境通常配 `synchronous_commit = remote_write` 或 `on` 但 `synchronous_standby_names` 至少包含两个 standby 的原因。
---
## 五、Failover 与 Timeline 协商
### 什么是 Timeline
每当 PG 集群经历一次 promote(standby 提升为 primary),它就会创建一个新的 Timeline。Timeline 是一个单调递增的整数,初始值是 1。Promote 之后变成 2,再次 promote 变成 3,以此类推。
WAL 文件名的格式是 `XXXXXXXXXXXXYYYYYYYY`,其中 `XXXXXXXX` 是 Timeline 编号,`YYYYYYYY` 是 WAL 段号。不同 Timeline 的 WAL 段可能有相同的段号,这意味着 PG 需要区分"在原 Timeline 上产生的 WAL"和"在新 Timeline 上产生的 WAL"。
### History 文件
每当 pg 离开一个旧 Timeline 进入新 Timeline 时,它会写入一个 History 文件。文件名为 `XXXXXXXX.history`(`XXXXXXXX` 是新 Timeline 编号),内容格式:原始 Timeline 1 到 XXX 位置的 WAL 历史
1
最典型的内容:
1 0/9000000 no recovery target specified
它的含义是:Timeline 1 在 LSN `0/9000000` 处结束——在此之前的 WAL 属于 Timeline 1,在此之后的 WAL 属于 Timeline 2。History 文件的作用是在集群重新整合时(例如老 Primary 重新加入为新 Standby),新集群能识别出在老 Primary 上 Timeline 发生了分叉,从而拒绝直接复制。
### Promote 的流程与 split-brain 风险
当执行 `pg_ctl promote` 或调用 `pg_promote()` 函数时,启动流程如下:
- Postmaster 发 SIGUSR1 给 Startup 进程
- Startup 收到 promote 信号:
- 暂停 WAL 恢复
- 把当前的 WAL 刷到磁盘
- 写入一个 checkpoint record(标记 timeline 变化)
- 创建新 timeline 的 history 文件
- XLogCtl→ThisTimeLineID++(递增 Timeline)
- Startup 进程退出,Postmaster fork 新的 backend
- 新 timeline 上的 WAL 从 promote 的 LSN 开始写入
**split-brain 风险**:如果在 Promote 之后,老 Primary 仍然可写(没有被 fence 或隔离),两个 Primary 会各自产生自己的 WAL 历史。它们从 promote 的 LSN 点分叉,在此之后的 WAL 是不可调和的。`pg_rewind` 只能处理"新 Primary 有老 Primary 的所有数据"的场景——如果 split-brain 已经发生(两边都接受了写入),没有自动工具能合并这两条分叉的 WAL 历史。
这就是为什么在自动化 failover 中,**fencing 是比 promote 更重要的一步**:必须确保老 Primary 不能接受任何写入,然后才能 promote standby。常用的 fencing 手段包括:STONITH(关机)、`pg_ctl stop -m immediate`、网络分区、或通过 `patroni`/`repmgr` 等工具停止老 Primary。
### pg_rewind 的机制边界
`pg_rewind` 的工作机制是:比较老 Primary(被 rewind 目标)和新 Primary(source)的数据文件差异,只复制被修改的页面。在 rewind 完成后,老 Primary 变成与新 Primary 数据一致的状态,可以作为 standby 重新加入。
它的边界:
1. **只比较数据文件,不比较 WAL 文件**。`pg_rewind` 通过扫描 source 和 target 的 WAL 来确定差异页面,但最终只复制数据文件。
2. **需要 `wal_log_hints = on` 或启用了 checksum**。`pg_rewind` 需要区分"页面被修改了"和"页面本身就有差异"。`wal_log_hints` 保证每次 hint bit 的写入也产生 WAL record,这样 `pg_rewind` 能完整追踪页面的修改历史。
3. **不能处理 split-brain**。如果老 Primary 在 promote 后还接受过写入,`pg_rewind` 只能让它回到与 source 一致的状态——那些在老 Primary 上产生的獨有数据会被丢弃。
4. **没有 `wal_log_hints` 且没有 checksum 时无法使用**。这是最常见的坑:生产环境为了性能关掉了这两个选项,等到需要 `pg_rewind` 时发现根本没有足够的元数据来完成差异比较。
---
## 六、Primary-Standby 冲突:max_standby_streaming_delay
### 冲突的本质
在 standby 上可以执行只读查询(hot standby)。问题在于:standby 的 recovery 进程需要回放 WAL,而某些 WAL record 的回放与正在执行的查询冲突。典型的冲突场景:
- **Access Exclusive 锁冲突**:WAL 中记录了一个 `DROP TABLE`,回放时需要排他锁;但 standby 上有一个查询正在 scan 这个表。
- **Buffer pin 冲突**:WAL record 需要删除一个页面上的 dead tuple,但这个页面正在被一个查询 pin 住。
- **Snapshot 冲突**:WAL 中的 VACUUM record 需要清理 dead tuple,但 standby 上的查询的快照还能看到这些 tuple。
当 Recovery 进程遇到这种冲突时,它有两种选择:
1. **等查询结束**:recovery 挂起,等待冲突的查询完成。但 recovery 暂停意味着 WAL apply 停止,复制延迟增加。
2. **取消查询**:发出 `SIGINT` 给冲突的查询,让 recovery 继续。
`max_standby_streaming_delay` 正是控制这个选择的时间阈值:
```c
// src/backend/access/transam/xlog.c, ResolveRecoveryConflictWithBufferPin()
if (GetCurrentTimestamp() > ltime + max_standby_streaming_delay) {
// 超时了,取消冲突的查询
SendRecoveryConflictWithBufferPin(PROCSIG_RECOVERY_CONFLICT_BUFFERPIN);
}
Trade-off
| max_standby_streaming_delay 设置 | 效果 | 适用场景 |
|---|---|---|
| -1(默认) | 永远等待查询结束 | standby 上只有很短的查询,或不在乎复制延迟 |
| 0 | 立即取消所有冲突查询 | 复制延迟比 standby 查询更重要 |
| 30s | 等 30 秒,查询还没结束就取消 | 典型的在线报表 standby |
容易踩的坑:长查询(比如一个跑到 standby
上的大表
SELECT COUNT(*))可以在整个运行期间持有 buffer
pin,这意味着 recovery 可能在
max_standby_streaming_delay
后取消这个查询——而且是一次又一次地取消,每次都重新开始。这就是为什么
standby 上的查询被反复 cancel 时要检查的经典根因:表在
primary 上有高频 DDL 或 VACUUM,导致 WAL 中有冲突的
record,而 standby 上有长时间运行的查询。
conflict_reason 解读
Standby 查询被 cancel 后,PostgreSQL 会在日志中写入原因,例如:
ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
对应 pg_stat_database_conflicts
视图中的冲突计数器:
| 冲突列 | 含义 |
|---|---|
confl_tablespace |
表空间操作冲突 |
confl_lock |
锁冲突(如 ACCESS EXCLUSIVE) |
confl_snapshot |
快照冲突(VACUUM 清理了查询需要看到的 tuple) |
confl_bufferpin |
Buffer pin 冲突 |
confl_deadlock |
死锁(recovery 与查询之间) |
在排查 standby 查询被 cancel 的问题时,先看
pg_stat_database_conflicts
中哪个计数器在增长,再针对性地分析 primary 上是否有对应的
DDL 或 VACUUM 操作。
七、Replication Slot 的内部结构
Slot 的设计目的
Replication Slot 是 PG 9.4 引入的机制。在 Slot
出现之前,primary 靠 wal_keep_segments(PG 13
前)或 wal_keep_size(PG 13+)来决定保留多少
WAL 段。这个机制有一个根本性的缺陷:primary 不知道 standby
需要什么 WAL——如果 standby
因为网络故障或负载峰值临时落后,primary 可能已经把 standby
需要的 WAL 段回收掉了。此时 standby
只能重做基础备份,无法追上。
Slot 解决了这个问题:每个 Slot 记录一个
restart_lsn,Primary 绝不会回收这个 LSN 之后的
WAL 段,保证 standby 始终有足够的 WAL 来恢复。
pg_replication_slots 的内部结构
// src/include/replication/slot.h
typedef struct ReplicationSlot {
ReplicationSlotPersistentData data;
// 内存中的状态
pid_t active_pid; // 当前使用此 slot 的 WAL Sender 的 PID
int active_pid; // 如果为 0,slot 是 inactive 状态
bool in_use; // slot 是否被占用
bool just_dirtied; // 是否需要持久化
// 其他字段...
} ReplicationSlot;
// 持久化部分(写入 pg_replslot/<name>/state 文件)
typedef struct ReplicationSlotPersistentData {
NameData name; // slot 名称
ReplicationSlotPersistency persistency; // 持久化策略
XLogRecPtr restart_lsn; // WAL 回收保护起点
XLogRecPtr confirmed_flush; // 消费端确认的最后 LSN
Oid database; // 如果是逻辑 slot,关联的数据库
PluginPointer plugin; // 逻辑 slot 的输出插件
// ...
} ReplicationSlotPersistentData;
```text
关键字段 `restart_lsn` 是 WAL 回收的保护线——primary 的 checkpoint 进程在做 WAL 回收时,会遍历所有 slot 找到最老的 `restart_lsn`,然后回收比这个还老的 WAL 段。如果某个 slot 的 `restart_lsn` 永远不推进,对应的 WAL 段也永远不会被回收。
### active 与 inactive
`pg_replication_slots` 视图中的 `active` 列表示当前是否有 WAL Sender 进程在使用这个 slot:
- `active = t`:有一个 WAL Sender 正在发送 WAL,standby 在正常消费。
- `active = f`:没有 WAL Sender 使用此 slot。可能 standby 已断开,或者在重启中。
**关键风险点**:即使 slot 是 inactive(`active = f`),它的 `restart_lsn` 仍然保护 WAL 段不被回收。Primary 的 checkpoint 进程不会因为 slot inactive 就回收它保护的 WAL——它只看 `restart_lsn` 的值,不管 `active` 状态。
---
## 八、Slot 溢出的多米诺效应
这是 PG 流复制中最危险但最常见的高可用故障模式。事件链可以一直追溯到"standby 宕机"这个看似不影响 primary 的事件。
### 完整事件链
```mermaid
sequenceDiagram
participant S as Standby
participant Slot as Replication Slot
participant WAL as pg_wal (Primary)
participant CKPT as Checkpointer
participant Disk as 磁盘
Note over S: Standby 宕机或网络断开
S--xSlot: WAL Sender 断开连接
Note over Slot: Slot 变为 inactive<br/>但 restart_lsn 不推进
Slot->>Slot: active = f<br/>restart_lsn 锁定在原位置
Note over WAL: Primary 正常接受写入
loop Primary 每产生一个 WAL 段
WAL->>WAL: 新 WAL 段持续产生
end
Note over CKPT: Checkpoint 尝试回收旧 WAL
CKPT->>Slot: 检查所有 slot 的 restart_lsn
Slot-->>CKPT: restart_lsn 还是 N 小时前的值
CKPT->>WAL: 不能回收 restart_lsn 之后的任何 WAL
Note over WAL: WAL 段只增不减
loop 每产生 16MB WAL 段
WAL->>WAL: pg_wal 目录持续膨胀
end
Note over Disk: 磁盘空间逼近耗尽
Disk->>Disk: 使用率达到 95% → 98% → 99%
Note over Disk: 磁盘满 → Primary PANIC
Disk--xWAL: 无法创建新 WAL 段
WAL--xDisk: PANIC: could not write to file "pg_wal/..."
Note over Disk: Primary crash → 所有连接断开每个环节的详细解释:
第一环——standby 宕机:Standby
因硬件故障、网络分区、云实例回收等原因停止消费 WAL。Primary
上的 WAL Sender 检测到 TCP 断开(或
wal_sender_timeout 超时),释放连接,Slot 变为
inactive。
第二环——restart_lsn 冻结:虽然 Slot
已经是 inactive,但它的 restart_lsn
没有推进。Standby 没有在消费 WAL,自然也不会发 feedback 更新
restart_lsn。
第三环——WAL 回收被阻止:Primary 的
Checkpointer 在每次做 Checkpoint 时调用
KeepLogSeg(),检查所有 Replication Slot 的
restart_lsn,然后只回收
restart_lsn 之前的 WAL 段。如果某个 Slot 的
restart_lsn 停留在 3 小时前,这 3
小时内产生的所有 WAL 段都不能被回收。
// src/backend/access/transam/xlog.c, KeepLogSeg()
static void KeepLogSeg(XLogRecPtr *recptr, ...) {
// 遍历所有 replication slot
for (int i = 0; i < max_replication_slots; i++) {
if (slot->data.invalidated == RS_INVAL_NONE) {
// 保护:不能回收比 restart_lsn 更新 WAL
if (slot->data.restart_lsn < *recptr)
*recptr = slot->data.restart_lsn;
}
}
// 同样处理 wal_keep_size
if (wal_keep_size_mb > 0)
*recptr = Min(*recptr, ...);
}
```text
**第四环——磁盘不断膨胀**:PG 不会再回收受保护 WAL 段。随着 Primary 正常的写入负载,每个新的 16MB WAL 段都被追加到 `pg_wal` 目录。磁盘使用率持续攀升——可能只需要几十分钟到一个小时,取决于写入负载。在高负载 OLTP 环境中(每秒 100MB WAL),半小时就能产生 10GB+ 无法回收的 WAL。
**第五环——磁盘满,Primary PANIC**:当磁盘空间耗尽时,PG 无法创建新的 WAL 段。此时 PG 的 `XLogWrite()` 函数会调用 `PANIC`:
```c
// src/backend/access/transam/xlog.c, XLogWrite()
if (errno != 0) {
ereport(PANIC,
(errcode_for_file_access(),
errmsg("could not write to log file %s at offset %u: %m",
path, offset)));
}PANIC 级别的错误导致 PG 立即终止所有 backend
进程,数据库进入恢复模式。在恢复启动时,PG 仍然需要读写
WAL——但磁盘已满——恢复也会失败。整个集群陷入不可用状态,唯一的恢复路径是手动清理磁盘空间(删除受保护的
WAL 段或删除 Slot)。
wal_keep_size 与 Slot 的叠加陷阱
wal_keep_size 和 Replication Slot 的 WAL
保留策略是取更保守者(即更旧的重启点)的叠加关系,而不是取最大值。如果
wal_keep_size = 10GB 要保留 10GB WAL,而 Slot
的 restart_lsn 对应 50GB WAL,最终保留的是
50GB——不是 50+10=60GB,而是 Slot 要求保留的 50GB 包含了
wal_keep_size 要求的 10GB
覆盖范围,所以取更保守的 50GB。
但这里的真正风险不是数值叠加,而是错误理解:很多
DBA 认为设置了 wal_keep_size 就能控制 WAL
膨胀的上限,实际上 Slot 的保留要求绕过了
wal_keep_size 的限制。
恢复路径
发现 Slot 溢出后的处理步骤:
-- 1. 确认是哪个 Slot 卡住了
SELECT slot_name,
active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag,
restart_lsn,
(SELECT state FROM pg_stat_replication
WHERE slot_name = ps.slot_name) AS repl_state
FROM pg_replication_slots
WHERE active = false
ORDER BY restart_lsn;
```text
如果确认 standby 可以恢复(比如只是临时网络中断),让 standby 重新连接即可。如果 standby 已经永久不可恢复(比如云实例已被销毁):
```sql
-- 2. 删除没有 standby 的 slot(危险操作——standby 将无法追赶)
SELECT pg_drop_replication_slot('stale_slot_name');删除 Slot 后,PG 的 checkpoint 进程可以立即开始回收该 Slot 之前保护的 WAL 段。磁盘空间会随着下一个 checkpoint 回收而释放。
九、排查:pg_stat_replication 的三层延迟指标
完整监控 SQL
-- Primary 端:查看复制状态
SELECT
application_name,
client_addr,
state,
sync_state,
-- 三层延迟(微秒 → 格式化为可读)
CASE WHEN write_lag IS NULL THEN 'N/A'
ELSE (EXTRACT(epoch FROM write_lag) * 1000)::text || ' ms'
END AS write_lag_ms,
CASE WHEN flush_lag IS NULL THEN 'N/A'
ELSE (EXTRACT(epoch FROM flush_lag) * 1000)::text || ' ms'
END AS flush_lag_ms,
CASE WHEN replay_lag IS NULL THEN 'N/A'
ELSE (EXTRACT(epoch FROM replay_lag) * 1000)::text || ' ms'
END AS replay_lag_ms,
-- LSN 差距(字节)
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn)
) AS send_pending,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), write_lsn)
) AS write_pending,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn)
) AS flush_pending,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)
) AS replay_pending
FROM pg_stat_replication;
-- 查看 Slot 状态(包括 inactive slot)
SELECT
slot_name,
slot_type,
active,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal_size,
restart_lsn,
confirmed_flush_lsn,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) > 10 * 1024 * 1024 * 1024
AS danger_10gb
FROM pg_replication_slots
ORDER BY restart_lsn;
-- Standby 端:查看 recovery 进度
SELECT
pg_is_in_recovery() AS is_standby,
pg_last_wal_receive_lsn() AS last_recv_lsn,
pg_last_wal_replay_lsn() AS last_replay_lsn,
pg_last_xact_replay_timestamp() AS last_xact_replay,
pg_size_pretty(
pg_wal_lsn_diff(pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn())
) AS replay_pending,
-- 从接收 LSN 到 replay LSN 的时间差
EXTRACT(epoch FROM (now() - pg_last_xact_replay_timestamp()))::text || ' s'
AS seconds_since_last_replay
;
```bash
### 三层延迟的溯源排查
| 现象 | 根因方向 | 排查手段 |
|------|---------|---------|
| `write_lag` 大 | 网络带宽不足或 standby 磁盘顺序写慢 | `iperf` 测网络吞吐;`iostat` 看 standby 磁盘 `w_await` |
| `flush_lag` 大(`write_lag` 小) | standby 的 fsync 慢 | 检查 standby 磁盘的 sync 延迟;`wal_sync_method` 是否为 `open_datasync` vs `fdatasync` |
| `replay_lag` 大(`flush_lag` 小) | standby redo 慢 | `pg_stat_activity` 看 standby 是否有冲突查询被不断 cancel;`iostat` 看 standby 磁盘随机读延迟;`max_standby_streaming_delay` 是否设为 0 导致长查询反复被 cancel 又重试 |
| 三层 lag 都是 0 但 `state` 列长时间不变 | standby 可能已失联 | 检查 `sent_lsn` 与 `write_lsn` 是否长时间不变——如果 WAL 在持续产生但这两个值不动,说明 standby 已断开 |
| `flush_lsn` 与 `pg_current_wal_lsn()` 的差距持续扩大 | 复制完全跟不上 | 检查 standby 上的 `pg_last_wal_receive_lsn()` 是否在推进;如果不推进,standby 的 WAL Receiver 可能已经 crash 或被 OOM kill |
### Slot 警告阈值 SQL
```sql
-- 监控用:WAL 保留超过阈值的 slot 告警
WITH slot_wal AS (
SELECT
slot_name,
active,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes
FROM pg_replication_slots
WHERE slot_type = 'physical'
)
SELECT
slot_name,
active,
pg_size_pretty(retained_bytes) AS retained_size,
CASE
WHEN retained_bytes > 50 * 1024 * 1024 * 1024 THEN 'CRITICAL'
WHEN retained_bytes > 20 * 1024 * 1024 * 1024 THEN 'WARNING'
WHEN retained_bytes > 5 * 1024 * 1024 * 1024 THEN 'NOTICE'
ELSE 'OK'
END AS alert_level
FROM slot_wal
ORDER BY retained_bytes DESC;十、关键要点
WAL Sender 的生产-消费模型:
WalSndLoop()中XLogSendPhysical()是生产者,pq_flush_if_writable()是消费者。Standby 消费慢时,WAL Sender 会阻塞在 socket 发送上,而不是在读取端堆积内存。同步复制的 COMMIT 等待是信号量驱动的:Backend 在同步复制中等待的不是 WAL Sender,而是 WAL Sender 收到 standby 的 feedback 消息后通过
SetLatch()唤醒的。synchronous_commit=on只是确认 standby flush——不是确认 standby apply,更不是确认 standby 查询可见。Timeline 的 fork 是物理不可逆的:Promote 之后 WAL 历史在 promote 的 LSN 处分叉,两个分支的数据差异只能通过
pg_rewind覆盖一方的数据文件来解决——不是合并,是丢弃一方的数据。Split-brain 发生后,至少有一边的数据会永久丢失。Replication Slot 的
restart_lsn是 WAL 回收的硬约束:不管 Slot 是 active 还是 inactive,只要它的restart_lsn没推进,受保护的 WAL 段就不回收。Checkpoint 进程在每次检查时无条件遵守这个约束。max_standby_streaming_delay不是缓冲,是定时炸弹:长查询在 standby 上运行时,recovery 会在超时后取消它——如果查询被取消后重新执行(比如应用层重试),会反复进入冲突-取消循环。三层延迟的零值陷阱:
write_lag/flush_lag/replay_lag为 0 可能代表 standby 失联而非无延迟——必须配合state列和 LSN 差值来判断。
上一章:BRIN 与其他索引 下一章:逻辑复制与逻辑解码
参考资料
源码(PG 17)
src/backend/replication/walsender.c:WalSndMain(),WalSndLoop(),XLogSendPhysical(),ProcessRepliesIfAny()src/backend/replication/walreceiver.c:WalReceiverMain(),WalRcvLoop(),XLogWalRcvWrite(),XLogWalRcvSendReply()src/backend/replication/syncrep.c:SyncRepWaitForLSN(),SyncRepQueueInsert(),SyncRepCheckForStandby()src/backend/access/transam/xlog.c:KeepLogSeg(),XLogWrite(),ResolveRecoveryConflictWithBufferPin()src/backend/replication/slot.c:ReplicationSlotCreate(),ReplicationSlotRelease(),ReplicationSlotPersist()src/include/replication/slot.h:ReplicationSlot,ReplicationSlotPersistentDatasrc/backend/access/transam/xlogrecovery.c:recovery 主循环,StartupXLOG()src/backend/replication/libpqwalreceiver/libpqwalreceiver.c:libpq 实现的 WAL 连接与接收
官方文档
- PostgreSQL Documentation, Chapter 27: High Availability, Load Balancing, and Replication
- PostgreSQL Documentation, Chapter 49: Replication
Progress Tracking(
pg_stat_replication各列定义) - PostgreSQL Documentation, Section 20.5: Write Ahead Log(WAL 内部运作)
- PostgreSQL Documentation, Section 29.3: Asynchronous
Behavior(
synchronous_commit各取值语义) - PostgreSQL Documentation, Section 30.4: Hot
Standby(
max_standby_streaming_delay与冲突处理)
PG 开发者讨论
- Andres Freund, “Replication slot improvements in PG 12”, PGConf.EU 2019 — slot 的持久化与 crash-safe 改进
- Heikki Linnakangas, “History of Hot Standby and Streaming Replication”, pgsql-hackers archives — hot standby 引入时的设计决策
- Robert Haas, “Synchronous Replication: Quorum Commit and
Multiple Standbys”, pgsql-hackers 2015 —
ANY/FIRST语法的设计讨论
工具
patroni(https://github.com/zalando/patroni) — PG 高可用管理,含自动 failover 和 fencingpg_rewind— PG 自带工具,用于将老 Primary 回退到与新 Primary 一致的状态
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】逻辑复制与逻辑解码:冲突处理与延迟放大
拆解 PostgreSQL 逻辑复制的完整内核路径:LogicalDecodingContext 从 WAL 解码出逻辑变更的内部流程、Reorder Buffer 按 COMMIT 顺序重排事务与 snapshot 重建机制、pgoutput 输出插件的二进制协议与行过滤变换、Publication/Subscription 模型的内核实现。重点剖析四种冲突类型的根因与修复边界——update_missing/delete_missing 为什么静默跳过而 duplicate_key 直接停摆、subscription 被 disable 后的数据追平策略、序列不在逻辑复制范围内的自增主键冲突陷阱、大事务在 reorder buffer 中的延迟放大效应。
【PG 内核】监控体系与告警设计:从内核机制出发定义该监控什么
不从 Grafana 模板照抄,而是从 PG 内核机制推导出必须监控的六个维度:连接与 wait_event、存储膨胀与 XID wraparound、WAL 与复制延迟、查询性能突变、锁等待链、以及 shared_buffers 命中率骗局。每个维度配具体 SQL 和指标解读,告警阈值给出内核依据而非拍脑袋数字,同时盘点 pg_stat_statements queryid 冲突、track_io_timing 开销、pg_stat_activity 自身代价等监控工具本身的陷阱。
【PG 内核】经典故障模式与排查手册:五个真实事故的内核根因
拆解 PG 生产环境中最危险的五种故障模式——连接风暴与 work_mem 连锁效应、事务 ID wraparound 危机完整时间线、replication slot 溢出多米诺效应、OOM 连锁 kill、长事务 idle in transaction 隐性破坏。每个故障给出可复现的触发方法、Mermaid 时序图标注事件节点和排查断点、排查 SQL 脚本和修复边界,以及监控埋点策略让下次提前发现而非事后救火。
【PG 内核】数据恢复与损坏应对:PITR、pg_resetwal 和页面损坏的边界
拆解 PostgreSQL 数据恢复路径的内部机制与操作边界:PITR 的三个关键窗口与 timeline fork 原理、pg_checksums 的校验粒度与盲区、pg_resetwal 的 hint bit 代价与 VACUUM FULL 陷进、pg_dump 并行调度的内部策略。重点在于每种操作做什么、不做什么、哪些后果不可逆。