土法炼钢兴趣小组的算法知识备份

【PG 内核】流复制:从 WAL Sender 到 Slot 溢出的多米诺效应

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#streaming-replication#wal-sender#wal-receiver#synchronous-replication#replication-slot#failover#timeline#split-brain#pg-rewind#wal-recovery#slot-overflow#pg-stat-replication

目录

流复制:从 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 段已经被回收时,有三种后备路径:

  1. 当前 WAL 段:最直接的路径,从 pg_wal 目录中当前正在写入的 WAL 文件读取。
  2. 历史 WAL 段:如果 standby 需要仍存在于 pg_wal 中的早期 WAL 段,直接从对应文件读取。
  3. 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, &current_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_replicationwrite_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 有两种完全不同的含义:

区分的方法是看 pg_stat_replicationstate 列: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_writeonremote_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()` 函数时,启动流程如下:
  1. Postmaster 发 SIGUSR1 给 Startup 进程
  2. Startup 收到 promote 信号:
    1. 暂停 WAL 恢复
    2. 把当前的 WAL 刷到磁盘
    3. 写入一个 checkpoint record(标记 timeline 变化)
    4. 创建新 timeline 的 history 文件
    5. XLogCtl→ThisTimeLineID++(递增 Timeline)
  3. Startup 进程退出,Postmaster fork 新的 backend
  4. 新 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;

十、关键要点

  1. WAL Sender 的生产-消费模型WalSndLoop()XLogSendPhysical() 是生产者,pq_flush_if_writable() 是消费者。Standby 消费慢时,WAL Sender 会阻塞在 socket 发送上,而不是在读取端堆积内存。

  2. 同步复制的 COMMIT 等待是信号量驱动的:Backend 在同步复制中等待的不是 WAL Sender,而是 WAL Sender 收到 standby 的 feedback 消息后通过 SetLatch() 唤醒的。synchronous_commit=on 只是确认 standby flush——不是确认 standby apply,更不是确认 standby 查询可见。

  3. Timeline 的 fork 是物理不可逆的:Promote 之后 WAL 历史在 promote 的 LSN 处分叉,两个分支的数据差异只能通过 pg_rewind 覆盖一方的数据文件来解决——不是合并,是丢弃一方的数据。Split-brain 发生后,至少有一边的数据会永久丢失。

  4. Replication Slot 的 restart_lsn 是 WAL 回收的硬约束:不管 Slot 是 active 还是 inactive,只要它的 restart_lsn 没推进,受保护的 WAL 段就不回收。Checkpoint 进程在每次检查时无条件遵守这个约束。

  5. max_standby_streaming_delay 不是缓冲,是定时炸弹:长查询在 standby 上运行时,recovery 会在超时后取消它——如果查询被取消后重新执行(比如应用层重试),会反复进入冲突-取消循环。

  6. 三层延迟的零值陷阱write_lag/flush_lag/replay_lag 为 0 可能代表 standby 失联而非无延迟——必须配合 state 列和 LSN 差值来判断。

上一章:BRIN 与其他索引 下一章:逻辑复制与逻辑解码


参考资料

源码(PG 17)

官方文档

PG 开发者讨论

工具

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】逻辑复制与逻辑解码:冲突处理与延迟放大

拆解 PostgreSQL 逻辑复制的完整内核路径:LogicalDecodingContext 从 WAL 解码出逻辑变更的内部流程、Reorder Buffer 按 COMMIT 顺序重排事务与 snapshot 重建机制、pgoutput 输出插件的二进制协议与行过滤变换、Publication/Subscription 模型的内核实现。重点剖析四种冲突类型的根因与修复边界——update_missing/delete_missing 为什么静默跳过而 duplicate_key 直接停摆、subscription 被 disable 后的数据追平策略、序列不在逻辑复制范围内的自增主键冲突陷阱、大事务在 reorder buffer 中的延迟放大效应。

2026-06-16 · database / kernel

【PG 内核】监控体系与告警设计:从内核机制出发定义该监控什么

不从 Grafana 模板照抄,而是从 PG 内核机制推导出必须监控的六个维度:连接与 wait_event、存储膨胀与 XID wraparound、WAL 与复制延迟、查询性能突变、锁等待链、以及 shared_buffers 命中率骗局。每个维度配具体 SQL 和指标解读,告警阈值给出内核依据而非拍脑袋数字,同时盘点 pg_stat_statements queryid 冲突、track_io_timing 开销、pg_stat_activity 自身代价等监控工具本身的陷阱。

2026-06-16 · database / kernel

【PG 内核】经典故障模式与排查手册:五个真实事故的内核根因

拆解 PG 生产环境中最危险的五种故障模式——连接风暴与 work_mem 连锁效应、事务 ID wraparound 危机完整时间线、replication slot 溢出多米诺效应、OOM 连锁 kill、长事务 idle in transaction 隐性破坏。每个故障给出可复现的触发方法、Mermaid 时序图标注事件节点和排查断点、排查 SQL 脚本和修复边界,以及监控埋点策略让下次提前发现而非事后救火。

2026-06-16 · database / kernel

【PG 内核】数据恢复与损坏应对:PITR、pg_resetwal 和页面损坏的边界

拆解 PostgreSQL 数据恢复路径的内部机制与操作边界:PITR 的三个关键窗口与 timeline fork 原理、pg_checksums 的校验粒度与盲区、pg_resetwal 的 hint bit 代价与 VACUUM FULL 陷进、pg_dump 并行调度的内部策略。重点在于每种操作做什么、不做什么、哪些后果不可逆。


By .