PostgreSQL WAL & Streaming Replication

PostgreSQL WAL & Streaming Replication Deep Engineering: 从零丢数据到跨机房高可用架构

在分布式系统中,持久性与高可用性是数据库架构的终极挑战。PostgreSQL 凭借其 Write-Ahead Log(WAL)机制,实现了 ACID 中的 Durability,并基于此构建了从单机物理复制到跨机房逻辑复用的完整高可用方案。本文将深入 WAL 引擎内部,从 XLOG 记录格式、Page-Level Logging、Checkpoint 机制,逐步展开到 Physical Streaming Replication 的协议细节、Synchronous Commit 的 CAP 权衡、Replication Slot 与逻辑复制的应用,最终给出生产级零丢数据 HA 架构的设计方案与 Checklist。


一、WAL 引擎核心:为什么数据库必须先写日志

1.1 WAL 的设计原理

WAL 遵循一个铁律:在任何脏页被刷入数据文件之前,描述该页面变更的日志记录必须已持久化到磁盘。这是 ACID 中 Durability 的实现基石。PostgreSQL 中几乎所有状态变更——无论是 DDL 还是 DML——都首先以 XLOG 记录的形式追加到 WAL 段文件(默认 16MB),页面本身则在后台异步刷盘。

这种设计带来了三个关键优势:

  • 顺序写入加速:WAL 是追加写(append-only),顺序 IO 远优于数据页面的随机写,事务提交延迟大幅降低。
  • 崩溃恢复能力:实例崩溃后,PostgreSQL 通过重放(replay)WAL 中最后一次 checkpoint 之后的所有记录,即可恢复到一致性状态。
  • 时间点恢复(PITR):基于基础备份与连续归档的 WAL,可以精确恢复到任意历史事务点。

1.2 WAL 的段文件结构与 LSN

PostgreSQL 的 WAL 由固定大小的段文件组成(默认 16MB,编译时可调整)。每个 WAL 段的命名规则为:

000000010000000000000001
  时间线ID  逻辑段号   物理段号

Log Sequence Number(LSN) 是 WAL 中的核心概念。它是一个 64 位整数,指向 WAL 字节流中的唯一位置。每当 XLOG 记录被写入,LSN 就会推进。PostgreSQL 在页面头部的 pd_lsn 字段记录了该页面最后一次被修改时的 WAL 位置。

-- 查看当前 WAL 写入位置
SELECT pg_current_wal_lsn();

-- 查看页面的最新修改 LSN
SELECT relname, pg_relation_filepath(oid) 
FROM pg_class 
WHERE relname = 'orders';

-- 使用 pageinspect 查看页面头信息
SELECT * FROM page_header(get_raw_page('orders', 0));
-- lsn 字段即为该页面的最新 WAL 位置

1.3 XLOG 记录的内部格式

每条 XLOG 记录由以下结构组成:

┌─────────────────────────────────────────────────────────┐
│                XLogRecord 通用头部                       │
├─────────────────────────────────────────────────────────┤
│ xid         │ 事务 ID                                    │
│ prev        │ 同事务上一条记录的 LSN                      │
│ info        │ 分支标志与操作类型                          │
│ XLR: 资源管理器 ID (rmid) + 操作码                      │
├─────────────────────────────────────────────────────────┤
│ Block Data  │ 0 至 N 个 Block 引用(block_id + fork + blk)│
├─────────────────────────────────────────────────────────┤
│ Block Image │ Full Page Image 或增量变更                  │
└─────────────────────────────────────────────────────────┘

PostgreSQL 引入了 Full Page Image(FPI) 机制:在某个页面自上次 checkpoint 后的第一次修改时,WAL 日志中会写入该页面的完整图片,而不仅仅是增量变更。这保证了即便 checkpoint 之前的数据文件页面存在不一致(如部分写),也能通过 FPI 完全恢复页面状态。

// PostgreSQL 源码中 XLogRecord 结构简化示意
typedef struct XLogRecord
{
    uint32          xtot_len;   // 记录总长度
    TransactionId   xid;        // 事务 ID
    XLogRecPtr      prev;       // 同事务前一条记录的 LSN
    uint8           info;       // 分支标志与操作类型
    RmgrId          rmid;       // 资源管理器 ID
    uint8           info2;      // 第二标志字
    uint8           info3;      // 第三标志字(PG17+)
    pg_crc32c       crc;        // CRC32 校验和
    // BlockData 和 XLogRecordBlockHeader 跟随其后
} XLogRecord;

1.4 WAL Buffer 与写入流程

WAL 并非直接写到磁盘,而是先缓存在共享内存的 WAL Buffer 环形缓冲区(默认大小由 wal_buffers 控制,通常为 shared_buffers 的 1/32,最小 64KB)。写入流程:

  1. 事务执行数据修改 → 在 shared buffer 中标记页面为脏。
  2. 构造 XLOG 记录 → 将其写入 WAL Buffer。
  3. 构造新页面的 FPI 或增量变更 → 追加到同一记录。
  4. 准备提交 → 调用 XLogFlush(recptr) 强制将 WAL Buffer 推送到磁盘。
  5. WAL Writer 后台进程定期异步刷盘,减少同步刷盘压力。

关键路径是:事务 commit 时,必须等待对应 LSN 之前的 WAL 数据落盘。这也是为什么 fsync 和 wal_sync_method 配置如此关键。

-- 查看 WAL 相关参数
SHOW wal_level;
SHOW wal_buffers;
SHOW wal_sync_method;
SHOW checkpoint_timeout;
SHOW max_wal_size;

二、Checkpoint 机制与 WAL 生命周期管理

2.1 Checkpoint 触发条件

Checkpoint 是 PostgreSQL 中将脏页写回数据文件的关键操作。触发条件有三种:

  • 基于时间:checkpoint_timeout(默认 5min)到期。
  • 基于 WAL 容量:自上次 checkpoint 以来产生的 WAL 量达到 max_wal_size(默认 1GB)。
  • 手动执行:CHECKPOINT SQL 命令。

现代 PostgreSQL 推荐使用 max_wal_size + checkpoint_timeout 的双重限制策略,而非固定 checkpoint_segments(已废弃)。

2.2 Checkpoint 内部过程

一次 Checkpoint 大致经历三个阶段:

  1. checkpoint Begin:在内部插入一条 CHECKPOINT_ONLINE 的 XLOG 记录,标记 checkpoint 开始,记录 checkpoint_lsn(即检查点时刻的 REDO 起始位置)。
  2. Buffer 脏页扫描扫描:后台进程扫描 shared_buffers 中的所有脏页,根据页面 pd_lsn 筛选需要刷盘的页面(优于 checkpoint_lsn 的页面需要刷盘),加入写回队列。
  3. 批量刷盘(Checkpointer):调用 sync_file_range(Linux)或 write() 批量将脏页写回数据文件,然后调用 fsync 确保落盘。
  4. Checkpoint End:记录 checkpoint 完成,写入 checkpoint 记录到 pg_control 文件。

2.3 WAL 段的回收与归档

当 WAL 段文件不再需要时(即其中的所有记录已被 checkpoint 持久化到数据文件),PostgreSQL 会将其标记为可回收,并在下次 checkpoint 时通过 XLogRemoveFile() 重命名或删除旧段文件。

归档模式下,PostgreSQL 会调用 archive_command 将 WAL 段复制到归档目录:

# postgresql.conf 归档配置
archive_mode = on
archive_command = 'test ! -f /archive/pg17/%f && cp %p /archive/pg17/%f'

三、Physical Streaming Replication 协议与实现

3.1 复制架构总览

PostgreSQL 的 Physical Streaming Replication 基于 WAL Shipping 模式:

┌──────────────────┐                    ┌──────────────────────┐
│     Primary       │                    │      Standby          │
│                    │                    │                        │
│  WAL Sender Process│─────WAL Stream────▶│  WAL Receiver Process │
│  (wal_sender)     │                    │  (wal_receiver)        │
│                    │                    │                        │
│  Shared Buffers   │                    │  Startup Process       │
│  Data Pages       │                    │  + Hot Standby Apply  │
└──────────────────┘                    └──────────────────────┘

Primary 端的 wal_sender 进程从磁盘读取 WAL 段(或从 shared 内存的 WAL 缓冲区读取热点数据),通过 TCP 连接流式传输到 Standby 端的 wal_receiver 进程,后者再写入 Standby 的 WAL 段文件,最后由 startup 进程重放 WAL。

3.2 复制槽(Replication Slot)

Replication Slot 是一个简单的机制:确保 Primary 在 Standby消费掉对应的 WAL 之前,不会删除这些 WAL 段文件。

-- 在 Primary 上创建物理复制槽
SELECT * FROM pg_create_physical_replication_slot('standby1_slot', true);

-- 查看复制槽状态
SELECT slot_name, slot_type, restart_lsn, confirmed_flush_lsn, active 
FROM pg_replication_slots;

-- Standby 连接时指定复制槽名称
-- 在 recovery.conf 或 postgresql.conf 中配置
-- primary_slot_name = 'standby1_slot'

复制槽有活跃和非活跃之分。当复制槽变为不活跃状态(Standby 断开连接)时,Primary 将面临 WAL 段堆积耗尽磁盘空间的风险——这是一个常见的运维坑点。

3.3 Hot Standby 模式

PostgreSQL 通过将 Standby 提升为 Hot Standby 模式,实现了读写分离的架构——Standby 在应用 WAL 的同时,可以接受只读查询:

# postgresql.conf on Standby
hot_standby = on

Hot Standby 背后的挑战是查询冲突 resolution:当 Primary 上执行了 UPDATE table SET ... WHERE id = 1,Standby 正在扫描该表,WAL 重放进程需要修改 id=1 的行。为了避免破坏正在进行的只读查询的快照,PostgreSQL 会:

  1. 自动延迟 WAL 应用的推进(max_standby_streaming_delay,默认 30s)。
  2. 如果超过延迟且冲突仍在,将回滚冲突的只读查询,报错 canceling statement due to conflict with recovery。
-- 在 Standby 上查看冲突统计
SELECT * FROM pg_stat_database_conflicts;
-- conflict_snapshot | conflict_lock | conflict_bufferpin | conflict_startup_deadlock

3.4 WAL Sender 与 Receiver 协议详解

WAL Streaming Replication 协议基于 PostgreSQL 的前端/后端协议(FE/BE),通过 START_REPLICATION 命令启动流式复制。协议流程:

Primary                                  Standby
    │                                        │
    ◄──────────── StartupMessage + SSL ──────│
    │─────────── AuthenticationOk ──────────▶│
    │                                        │
    │◄═══ "START_REPLICATION xlogpos ════════│
    │                                        │
    │═══ XLogData (WAL record data) ═══════▶│
    │═══ XLogData (WAL record data) ═══════▶│
    │═══ XLogData (WAL record data) ═══════▶│
    │                                        │
    │◄═══ StandbackReply (flush_lsn) ═════════│
    │                                        │

关键的消息类型包括:

  • XLogData('w'):携带实际的 WAL 数据块。
  • Primary keepalive('k'):心跳消息,防止 TCP 连接超时。
  • Standby status update:Standby 向 Primary 报告已写(write_lsn)、已刷新(flush_lsn)、已重放(apply_lsn)的最新 LSN。

四、Synchronous Commit 与 CAP 权衡

4.1 三种同步级别

PostgreSQL 的 synchronous_commit 提供了从强一致到异步的多个级别:

值 行为 数据丢失风险 性能影响
on(默认) 事务 commit 等待 WAL 落盘 无 中等(每次 commit 一次 fsync)
remote_write 等待 Standby 写入(未 flush 到磁盘) 极低(OS crash 风险) 较小(网络 RTT)
remote_apply 等待 Standby 重放完成 无 最大(重放延迟 + 网络 RTT)
local 仅本地 WAL flush 丢事务 等同于单机
off 不等待用户事务 commit 无(异步提交) 无(WAL writer 异步刷盘)

4.2 零丢数据的同步复制实现

要实现真正的零数据丢失(Zero Data Loss),推荐配置:

# Primary postgresql.conf
synchronous_commit = remote_apply
synchronous_standby_names = 'FIRST 1 (s1, s2)'
# 或 ANY 1 (优先第一台,其他自动降级)

wal_level = replica
max_wal_senders = 10
wal_keep_size = 1024  # MB,保留更多(备用)

注意:synchronous_commit = remote_apply 是 PostgreSQL 15 引入的,它等到 Standby 完成 apply 后才向客户端确认 commit,这意味着即便 Primary 在 commit 后立即崩溃,Standby 中一定存在完整的事务数据,无需等待 WAL 重放。

4.3 同步复制的性能优化

同步复制的主要延迟来源是网络 RTT 和 Standby 的重放延迟。优化策略:

  1. 跨可用区部署但 low latency:Standby 与 Primary 之间的网络延迟 < 2ms(同机房最佳)。
  2. 提升 Standby I/O 性能:Standby 需要足够的 IOPS 来支撑 WAL 重放(SSD 必备)。
  3. 配置 wal_compression:在 WAL 传输到 Standby 时压缩数据,减少网络带宽(PG15+)。
-- 查看同步复制延迟
SELECT 
    client_addr, 
    state, 
    sent_lsn, 
    write_lsn, 
    flush_lsn, 
    replay_lsn,
    sent_lsn - replay_lsn AS replay_lag_bytes,
    pg_size_pretty(sent_lsn - replay_lsn) AS replay_lag
FROM pg_stat_replication;

五、逻辑复制(Logical Replication)与变更数据捕获

5.1 逻辑复制的原理

Physical Streaming Replication 复制的是 WAL 级别的物理变更(页面修改),而逻辑复制(Logical Replication) 复制的是逻辑层面的事务变更(SQL 行级别),核心能力:

  • 可以直接在 Standby 看到表级别的 INSERT/UPDATE/DELETE。
  • 可以在 PDB 之间、PostgreSQL 与其他系统(如 Kafka、ES)之间复制。
  • 支持单表级别的复制(行过滤、列过滤)。

逻辑复制依赖 Logical Decoding——PostgreSQL 内置的、将 WAL 变更解码为逻辑变更的机制。

5.2 Publication & Subscription 实战

-- 创建 Publication(在 Primary 端)
CREATE PUBLICATION orders_pub 
FOR TABLE orders, customers
WITH (publish = 'insert, update, delete, truncate');

-- 查看 Publication
SELECT * FROM pg_publication;
SELECT * FROM pg_publication_tables;

-- 在目标端创建 Subscription
CREATE SUBSCRIPTION orders_sub 
CONNECTION 'host=primary_host dbname=mydb user=repuser password=secret'
PUBLICATION orders_pub
WITH (copy_data = true, create_slot = true, slot_name = 'orders_slot');

-- 查看 Subscription 状态
SELECT * FROM pg_stat_subscription \gx

5.3 逻辑复制与基于 WAL 的 CDC

对于需要将 PostgreSQL 变更同步到 Kafka 的场景,需要使用 Logical Decoding Plugin(如 pgoutput、decoderbufs、wal2json):

-- 使用 pgoutput(PostgreSQL 10+ 内置)
CREATE PUBLICATION kafka_pub FOR ALL TABLES;

-- Kafka Connect 配置
{
  "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
  "database.hostname": "primary_host",
  "plugin.name": "pgoutput",
  "slot.name": "debezium_slot",
  "publication.name": "kafka_pub",
  "database.server.name": "pg-primary"
}

六、Failover 架构与 Patroni + etcd

6.1 自动故障转移的挑战

数据库的自动 Failover 并非容易之事,面临三个典型问题:

  • 脑裂(Split-Brain):Primary 不响应但被判定为宕机,Standby 提升为新的 Primary,原 Primary 恢复后仍接受写入。
  • WAL 一致性:确保新 Primary 的数据不落后于旧 Primary。
  • 客户端如何感知 Primary 变化?(VIP、DNS、Sidecar proxy)

6.2 Patroni 架构

Patroni 是 Zalando 开发的、基于分布式共识(etcd/ZooKeeper/Consul)的 PostgreSQL 高可用方案。其核心思想:

  • 每个 PostgreSQL 实例由 Patroni 守护进程管理。
  • Patroni 在 etcd 中注册 Leader Key,通过 lease 机制检测实例健康。
  • 当 Primary 心跳超时时,Patroni 通过 Raft 协议在 Standby 中选举新的 Primary,并将原 Primary 强制关闭(fencing)。
# patroni.yml 示例
scope: pg-cluster
name: pg-node-1

restapi:
  listen: 0.0.0.0:8008
  connect_address: 10.0.1.1:8008

etcd3:
  hosts: 10.0.1.1:2379,10.0.1.2:2379,10.0.1.3:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576  # 最大 1MB LAG
    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        wal_level: replica
        hot_standby: "on"
        max_wal_senders: 5
        max_replication_slots: 5
        wal_log_hints: "on"

6.3 Pg_rewind:快速将原 Primary 降级为 Standby

PostgreSQL 9+ 引入了 pg_rewind 工具,它可以在不需要全量重建的情况下将原 Primary 的数据状态拨回与新 Primary 分叉点之前的位置:

# 在旧 Primary 上执行(确保 postgresql 已停止)
pg_rewind --target-pgdata=/var/lib/postgresql/17/main \
          --source-server="host=new_primary_host user=postgres"

# 执行成功后,旧 Primary 即变为新 Primary 的 Standby
# 可以重新启动并配置 recovery.conf / standby.signal

pg_rewind 依赖 wal_log_hints = on(需要在 postgresql.conf 中开启),以使 WAL 记录包含足够信息重建非关键页面。


七、生产级零丢数据 HA 架构

7.1 同城三机房部署模式

最经典的跨机房高可用架构通常采用:两台同机房同步节点 + 一台跨机房异步节点(或跨机房 Quorum)。

          ┌─────────────────┐
          │   etcd Cluster   │ (Quorum, 如3个可用区)
          └────────┬─────────┘
                   │
    ┌──────────────┼──────────────┐
    │              │              │
┌───▼───┐     ┌───▼───┐     ┌───▼───┐
│Primary │◄───►│Sync   │     │Async  │
│  AZ1   │sync │Standby │async│Standby│
│        │rep  │  AZ1   │rep  │  AZ2  │
└────────┘     └───────┘     └───────┘

配置建议:

# synchronous_standby_names 使用 ANY Quorum 模式(PG10+)
synchronous_standby_names = 'ANY 1 (s_sync, s_async)'
# 其中 s_sync 为同机房同步节点,s_async 为跨机房异步节点

7.2 监控与告警体系

-- 1. 监控复制延迟
SELECT 
    client_addr,
    state,
    pg_size_pretty(sent_lsn - replay_lsn) AS replication_lag,
    sent_lsn, flush_lsn, replay_lsn,
    reply_time
FROM pg_stat_replication
WHERE state = 'streaming';

-- 2. 监控 Slot 状态
SELECT slot_name, 
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal_size,
       active
FROM pg_replication_slots;

-- 3. 监控 checkpoint 频率与持续时间
SELECT * FROM pg_stat_bgwriter \gx

-- 4. 监控连接与事务
SELECT state, count(*) 
FROM pg_stat_activity 
GROUP BY state;

核心告警项: - 复制延迟 > 10s(warning),> 30s(critical) - WAL 段堆积数 > max_wal_size 的 200% - 复制槽不活跃 + WAL 堆积 - Hot Standby 冲突频率突增

7.3 备份策略

完整的备份策略应包括:

  1. 全量基础备份(Base Backup):推荐使用 pg_basebackup 每周执行一次,作为 PITR 基准。
  2. 连续 WAL 归档:通过 archive_command 实时归档 WAL 段到对象存储(S3/MinIO)。
  3. 定期 PITR 演练:在隔离环境恢复验证备份可用性。
# 基础备份示例
pg_basebackup -h primary_host -D /backup/base/20260930 \
             -X stream -P -Z5 -Fp \
             --wal-method=stream \
             --checkpoint=fast

# 归档到 S3(推荐 wal-g 或 pgbackrest)
archive_command = 'wal-g wal-push %p'

八、常见陷阱与最佳实践总结

8.1 WAL 堆积的五大根因

根因 现象 解决方案
复制槽不活跃 active = false + slot WAL 堆积 删除不用的 replication slot,监控 slot 状态
Standby 长时间离线 wal_receiver 断开 配置 wal_keep_size 增大保留 WAL 大小
大事务 / 长事务 单次超大事务产生巨量 WAL 大事务拆分为 batch,监控 pg_stat_activity
checkpoint 频率过低 max_wal_size 设太大导致堆积 合理设置 max_wal_size (2-4GB),保障 checkpoint 完成
归档失败 archive_command 持续报错 监控日志,使用可靠的归档工具(如 WAL-G)

8.2 关于 synchronous_commit 的最终建议

  • 金融级零丢数据:synchronous_commit = remote_apply + 同步复制,容忍 10-20ms 额外延迟。
  • 高吞吐写入场景:synchronous_commit = off 或 local,结合半同步(remote_write)+ 备份归档补偿。
  • 折中方案:synchronous_commit = on + 异步 Standby,手动切换时可能有数秒数据差(但通常 99% 场景可接受)。

五、结论

PostgreSQL 的 WAL 引擎是一个优雅的工程设计:它仅凭一个"先写日志"的原则,就同时解决了持久性、崩溃恢复、时间点恢复、物理复制、逻辑复制和 CDC 等一系列问题。理解 WAL 的内部机制——从 XLOG 记录格式、FPI 机制、Checkpoint 时机,到 Streaming Replication 的通信协议——是构建高可用 PostgreSQL 数据库系统的必经之路。

在工程实践中,生产环境最常出问题的往往不在于机制本身,而在于配置与运维的疏忽:复制槽忘记删除导致 WAL 撑爆磁盘、wal_log_hints 未开启导致 pg_rewind 失效、同步复制节点故障后主库卡死导致整个集群不可用。唯有理解底层机制,才能在面对这些陷阱时游刃有余。


参考资源: - PostgreSQL 官方文档:Write-Ahead Log, Streaming Replication, High Availability - Patroni 文档:https://patroni.readthedocs.io/ - Debezium PostgreSQL Connector:https://debezium.io/documentation/reference/stable/connectors/postgresql.html

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部