PostgreSQL Streaming Replication、读副本与同步复制
从 WAL Sender、WAL Receiver、Startup Process、LSN、复制槽、异步复制、同步复制、Recovery Conflict、Promotion 与读副本一致性出发,理解 PostgreSQL 物理流复制。
第 21 章:Streaming Replication、读副本与同步复制
1. 本章定位
物理流复制把 Primary 产生的 WAL 持续传输到 Standby,由 Standby 重放 WAL,形成与 Primary 接近实时的物理副本。它同时承担三类任务:
- 为 Primary 故障后的接管提供候选节点;
- 为最终一致或受控一致的只读流量提供读副本;
- 为跨机房灾备、备份卸载和级联复制提供基础。
本章建立在第 13 章 WAL、Checkpoint 与 Crash Recovery,第 16 章 Go 与 pgx,以及第 20 章 Backup、PITR 和 Timeline 之上。下一章将讨论逻辑复制和 CDC,第 23 章再展开 Patroni、分布式一致性、Fencing 和自动故障转移。
本章不把“有一个副本”误认为“已经高可用”。复制只解决数据副本问题;节点选主、脑裂防护、客户端路由、旧 Primary 隔离和恢复验证仍需要独立设计。
2. 可验证的学习目标
完成本章后,你应当能够:
- 从事务提交开始,解释 WAL Sender、WAL Receiver 和 Startup Process 的完整路径。
- 使用
pg_stat_replication、pg_stat_wal_receiver和 LSN 函数区分发送、写入、刷盘与重放延迟。 - 判断异步复制在一次 Failover 中可能丢失哪些已确认事务。
- 根据业务 RPO、提交延迟和可用性要求选择
remote_write、on、remote_apply或local。 - 配置并解释
FIRST优先级同步复制与ANYQuorum 同步复制。 - 复现 WAL Replay 暂停、Recovery Conflict 和 Slot WAL 堆积。
- 解释
hot_standby_feedback如何减少查询取消,又为何可能导致 Primary 膨胀。 - 设计带超时和 Primary 回退的 LSN Read-After-Write。
- 执行一次安全的 Promotion,并使用
pg_rewind将旧 Primary 重新加入为 Standby。 - 为读副本延迟、同步复制阻塞和
pg_wal空间增长建立监控与 Runbook。
3. 核心术语
| 中文名称 | 英文名称 | 准确定义 | 容易混淆的概念 | 所属层次 |
|---|---|---|---|---|
| 物理复制 | Physical Replication | 按整个数据库集群的物理 WAL 变化复制数据页状态 | 逻辑复制、表级复制 | 存储/复制 |
| 主库 | Primary | 接受正常读写并生成 WAL 的节点 | 当前路由目标不一定就是 Primary | 拓扑 |
| 备库 | Standby | 持续恢复并重放上游 WAL 的节点 | Backup 不是 Standby | 拓扑 |
| 热备 | Hot Standby | 在恢复期间允许执行只读查询的 Standby | 热备仍然可能取消查询 | 查询/复制 |
| 基础备份 | Base Backup | 创建 Standby 或进行物理恢复的集群级文件基线 | pg_dump 是逻辑备份 | 备份 |
| WAL Sender | WAL Sender | 上游为复制连接发送 WAL 的进程 | 普通客户端 Backend | 进程 |
| WAL Receiver | WAL Receiver | Standby 接收并写入 WAL 的进程 | Startup Process 不负责网络接收 | 进程 |
| Startup Process | Startup Process | 在恢复状态下读取并应用 WAL 的进程 | Checkpointer、WAL Receiver | 恢复 |
| LSN | Log Sequence Number | WAL 字节流中的逻辑位置 | 时间戳、事务 ID | WAL |
sent_lsn | Sent LSN | WAL Sender 已发送的最高位置 | 不表示 Standby 已收到 | 网络 |
write_lsn | Write LSN | Standby 已写入操作系统的最高位置 | 不一定持久化 | Standby |
flush_lsn | Flush LSN | Standby 已持久化的最高位置 | 不等于已经可查询 | 持久性 |
replay_lsn | Replay LSN | Standby 已应用到数据页的最高位置 | 不保证旧 Snapshot 能看到 | 可见性 |
| 复制槽 | Replication Slot | 记录消费者仍需要哪些 WAL 或旧元组的持久状态 | 不是复制连接本身 | 保留机制 |
restart_lsn | Restart LSN | Slot 消费者仍可能需要的最旧 WAL 位置 | 不是当前 Replay LSN | Slot |
| Timeline | Timeline | 一条独立 WAL 历史分支 | LSN 数值本身不能唯一表示分支 | 恢复 |
| 异步复制 | Async Replication | Primary 提交不等待 Standby 确认 | 不等于 Standby 一定很慢 | 提交语义 |
| 同步复制 | Sync Replication | 提交等待指定 Standby 达到某个 WAL 阶段 | 不等于备份或绝对零损失 | 提交语义 |
| Recovery Conflict | 恢复冲突 | Standby 查询与必须应用的 WAL 操作发生冲突 | 不完全等同于普通锁等待 | 并发 |
| Promotion | 提升 | 结束恢复,将 Standby 转换为可写 Primary | Promotion 不自动完成 Fencing | 高可用 |
| Switchover | 计划切换 | 原 Primary 可控时执行的角色切换 | Failover 是故障下的切换 | 高可用 |
| Failover | 故障转移 | 原 Primary 不可用时提升候选 Standby | Failback 不是简单反向切换 | 高可用 |
| Stale Read | 陈旧读 | 查询未看到已经在 Primary 提交的数据 | 也可能由旧 Snapshot 或缓存造成 | 一致性 |
| Read-After-Write | 写后读一致性 | 写成功后,同一因果链中的读取必须看到该写入 | 不必意味着全局线性一致 | 应用语义 |
4. 整体心智模型

4.1 数据流
- Primary Backend 修改缓冲页,同时生成 WAL。
- 提交记录进入 WAL,并根据
synchronous_commit在 Primary 刷盘。 - WAL Sender 将 WAL 流发送给 Standby。
- WAL Receiver 接收 WAL,先写入 Standby 操作系统,再持久化到
pg_wal。 - Startup Process 读取 WAL,修改 Standby 的缓冲页和数据文件。
- Hot Standby 查询通过新的 MVCC Snapshot 读取已经 Replay 的状态。
4.2 控制流
primary_conninfo决定 Standby 连接哪个上游。primary_slot_name决定是否使用持久 Slot。synchronous_standby_names决定哪些连接可能承担同步确认。synchronous_commit决定当前事务等待到哪一个阶段。max_standby_streaming_delay决定 Replay 最多为冲突查询等待多久。- Promotion 信号使 Standby 结束恢复并创建新的 Timeline。
4.3 状态变化
典型 Standby 状态为:
启动
→ 从 Archive / 本地 pg_wal 恢复
→ 连接上游
→ catchup
→ streaming
→ 持续 receive/write/flush/replay
→ promotion requested
→ replay 可用 WAL
→ 创建新 Timeline
→ normal primary
Standby 启动时存在 standby.signal,它会依次尝试 Archive、本地 pg_wal 和 Streaming Replication,直到停止或被提升。pg_promote() 或 pg_ctl promote 会结束 Standby 模式。(PostgreSQL)
4.4 故障路径
网络断开
├─ Slot/Archive 中仍有 WAL → 重连后追赶
└─ 所需 WAL 已回收 → Standby 无法继续,需要 Archive 或重建
同步 Standby 失联
├─ 所需同步数量仍满足 → 继续提交
└─ 所需同步数量不足 → 提交等待,锁和连接逐步堆积
Primary 故障
├─ 成功 Fence 原 Primary
├─ 选择最合适 Standby
├─ Promotion
├─ 客户端重连
└─ 旧 Primary 使用 pg_rewind 或 Base Backup 重建
未 Fence 就 Promotion
└─ 两个可写节点 → Split Brain → Timeline 分叉和业务冲突
5. 使用方式
5.1 建立最小 Primary/Standby
以下值只是实验样例,不是生产环境通用参数。生产取值必须结合 WAL 生成速率、最长故障时间、磁盘余量、网络带宽、RPO 和重建时间计算。
Primary 的主要参数:
listen_addresses = '*'
wal_level = replica
max_wal_senders = 10
max_replication_slots = 10
# 只是额外保留的最低 WAL 量,不是严格上限
wal_keep_size = '512MB'
# Slot 最多允许保留多少 WAL;超限后可能使 Slot 失效
max_slot_wal_keep_size = '2GB'
# pg_rewind 前置条件之一;也可以依赖初始化时启用的数据校验和
wal_log_hints = on
full_page_writes = on
# [PG18] 生产环境可按 Slot 生命周期设置,例如:
# idle_replication_slot_timeout = '24h'
创建复制用户:
CREATE ROLE repl
WITH LOGIN REPLICATION
PASSWORD 'replace-with-a-managed-secret';
Primary 的 pg_hba.conf:
host replication repl 10.0.0.11/32 scram-sha-256
生产环境优先使用专用复制账号、TLS、证书或受管 Secret,并限制源地址。复制流包含能够重建数据库的信息,不应开放给非可信用户。
使用 pg_basebackup 初始化 Standby:
pg_basebackup \
--dbname="host=10.0.0.10 port=5432 user=repl application_name=standby_az_b" \
--pgdata="$PGDATA_STANDBY" \
--write-recovery-conf \
--wal-method=stream \
--create-slot \
--slot=standby_az_b \
--progress
--write-recovery-conf 会创建 standby.signal,并向目标的 postgresql.auto.conf 写入连接配置;如果指定了 Slot,也会记录对应设置。(PostgreSQL)
验证角色:
-- Primary 应返回 false
SELECT pg_is_in_recovery();
-- Standby 应返回 true
SELECT pg_is_in_recovery();
-- [PG14+] 更精确地区分普通只读状态和 Hot Standby 状态
SHOW in_hot_standby;
物理复制通常要求 Primary 与 Standby 使用相同大版本和兼容的物理架构;它不是跨大版本升级方案。Standby 的起点是 Base Backup,随后通过 WAL 追赶。(PostgreSQL)
5.2 primary_conninfo 和 Slot
Standby 的核心恢复设置:
primary_conninfo = '
host=10.0.0.10
port=5432
user=repl
application_name=standby_az_b
sslmode=verify-full
'
primary_slot_name = 'standby_az_b'
recovery_target_timeline = 'latest'
不要把明文密码长期写在配置文件中。可使用权限受控的 .pgpass、证书或 Secret 管理系统。
手动创建 Physical Replication Slot:
SELECT *
FROM pg_create_physical_replication_slot(
'standby_az_b',
true
);
查看 Slot:
SELECT
slot_name,
slot_type,
active,
active_pid,
restart_lsn,
wal_status,
safe_wal_size,
inactive_since,
invalidation_reason
FROM pg_replication_slots;
restart_lsn 是该 Slot 仍可能需要的最旧 WAL 位置。Slot 不活跃或消费过慢时,它会长期停留在较旧位置,从而阻止相关 WAL 回收。wal_status 可能为 reserved、extended、unreserved 或 lost,safe_wal_size 表示距离进入危险状态还可生成多少 WAL。(PostgreSQL)
5.3 异步与同步配置
默认 Streaming Replication 是异步的。Primary 的事务提交不等待 Standby,因此客户端已收到成功的事务可能尚未到达最终被提升的 Standby。(PostgreSQL)
优先级同步复制:
synchronous_standby_names = 'FIRST 1 (standby_az_b, standby_az_c)'
含义:
- 优先选择
standby_az_b。 - 如果它不可用,则使用
standby_az_c。 - 提交只需一个当前同步 Standby 确认。
Quorum 同步复制:
synchronous_standby_names = 'ANY 2 (standby_az_b, standby_az_c, standby_az_d)'
含义:
- 三个候选 Standby 中任意两个确认即可。
- 不依赖固定优先顺序。
- 只能容忍一个候选失联;若只剩一个,要求同步的提交会等待。
FIRST 是优先级选择,ANY 是 Quorum 选择。被计入同步确认的 Standby 必须直接连接 Primary;级联下游不能被 Primary 直接计入同步确认。(PostgreSQL)
synchronous_commit 语义
| 值 | Primary 本地 WAL | Standby 阶段 | Standby 查询可见 | 主要代价 |
|---|---|---|---|---|
remote_apply | 已持久化 | 已 Replay | 是,新 Snapshot 可见 | RTT、Standby I/O、Replay 延迟 |
on | 已持久化 | 已持久化 | 不保证已 Replay | RTT、Standby WAL Flush |
remote_write | 已持久化 | 已写入 Standby OS | 不保证 | RTT;不能抵抗 Standby OS Crash |
local | 已持久化 | 不等待复制 | 不保证 | 异步复制 RPO |
off | 不等待当前事务本地刷盘 | 不等待复制 | 不保证 | Primary Crash 可丢近期成功事务 |
按事务控制:
BEGIN;
SET LOCAL synchronous_commit = 'remote_apply';
INSERT INTO critical_ledger(account_id, delta_cents)
VALUES ($1, $2);
COMMIT;
remote_apply 等待同步 Standby Replay 提交记录,因此能在简单拓扑中提供因果一致的读负载均衡,但会比其他模式增加更多提交延迟。on 等待同步 Standby 持久化,remote_write 只等待写入其操作系统,local 不等待远端。(PostgreSQL)
5.4 复制监控 SQL
Primary:观察四阶段 LSN
SELECT
application_name,
client_addr,
state,
sync_state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(sent_lsn, write_lsn)::bigint
AS network_or_receiver_write_gap_bytes,
pg_wal_lsn_diff(write_lsn, flush_lsn)::bigint
AS standby_flush_gap_bytes,
pg_wal_lsn_diff(flush_lsn, replay_lsn)::bigint
AS standby_replay_gap_bytes,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::bigint
AS total_replay_gap_bytes,
write_lag,
flush_lag,
replay_lag
FROM pg_stat_replication
ORDER BY application_name;
重要字段:
state:常见值包括catchup和streaming。sync_state:async、potential、sync或quorum。sent_lsn:WAL Sender 已发送。write_lsn:Standby 已写入操作系统。flush_lsn:Standby 已刷盘。replay_lsn:Standby 已应用。write_lag、flush_lag、replay_lag:历史提交路径延迟,不是“还需要多久追完”的 ETA;空闲时也不保证立即变成零。(PostgreSQL)
Standby:观察 Receiver 和 Replay
SELECT
pg_is_in_recovery() AS is_standby,
pg_last_wal_receive_lsn() AS receive_lsn,
pg_last_wal_replay_lsn() AS replay_lsn,
pg_wal_lsn_diff(
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn()
)::bigint AS receive_replay_gap_bytes,
clock_timestamp() - pg_last_xact_replay_timestamp()
AS last_replayed_transaction_age;
last_replayed_transaction_age 在 Primary 没有新事务时会持续增长,因此不能单独作为复制故障指标。
查看 WAL Receiver:
SELECT
pid,
status,
receive_start_lsn,
receive_start_tli,
written_lsn,
flushed_lsn,
received_tli,
latest_end_lsn,
latest_end_time,
slot_name,
sender_host,
sender_port
FROM pg_stat_wal_receiver;
written_lsn 只表示已写入但未必持久化;数据完整性判断应看 flushed_lsn。(PostgreSQL)
Slot WAL 保留量
SELECT
slot_name,
active,
restart_lsn,
wal_status,
safe_wal_size,
inactive_since,
invalidation_reason,
pg_wal_lsn_diff(
pg_current_wal_lsn(),
restart_lsn
)::bigint AS retained_wal_bytes
FROM pg_replication_slots
ORDER BY retained_wal_bytes DESC NULLS LAST;
Recovery Conflict
SELECT
datname,
confl_tablespace,
confl_lock,
confl_snapshot,
confl_bufferpin,
confl_deadlock,
confl_active_logicalslot
FROM pg_stat_database_conflicts
ORDER BY datname;
同步提交等待
PostgreSQL 18 可检查:
SELECT
pid,
usename,
application_name,
xact_start,
state,
wait_event_type,
wait_event,
query
FROM pg_stat_activity
WHERE wait_event = 'WaitForStandbyConfirmation'
ORDER BY xact_start;
WaitForStandbyConfirmation 表示 Backend 正在等待物理 Standby 接收和刷盘确认。(PostgreSQL)
5.5 版本提示
| 版本 | 本章相关说明 |
|---|---|
| PostgreSQL 14 | 可使用 in_hot_standby 区分 Hot Standby 状态 |
| PostgreSQL 15 | 可结合恢复预取相关视图观察 Replay I/O |
| PostgreSQL 16 | pg_stat_io 提供更系统的 I/O 观测 |
| PostgreSQL 17 | 本章核心复制语义与 18 基本一致,但没有 PG18 Slot 闲置超时 |
| PostgreSQL 18 | 新增 idle_replication_slot_timeout;AIO 和 I/O 监控进一步增强 |
[PG18] idle_replication_slot_timeout 默认值为 0,即不自动失效闲置 Slot。失效检查发生在 Checkpoint,因此超过超时后不一定立即触发。(PostgreSQL)
6. 底层原理
6.1 一次提交的复制时间线
设事务生成的提交记录位置为 L_commit。
T0 Backend 修改数据页并生成 WAL
T1 写入 Commit WAL Record
T2 Primary WAL 写入并 Flush 到 L_commit
T3 WAL Sender 发送到 Standby
T4 WAL Receiver 接收并写入 Standby 文件系统
T5 Standby Flush 到持久存储
T6 Startup Process Replay 到 L_commit
T7 Standby 新查询取得新 Snapshot,看到事务
不同模式的返回点:
local / 无同步 Standby的 on → T2
remote_write → T4
on → T5
remote_apply → T6
remote_apply 并不是“让查询更快”,而是把 Replay 延迟加入提交路径。若 Standby 正在处理大量 WAL、发生 I/O 抖动、CPU 饱和或 Replay 被查询冲突拖延,Primary 的提交 P95/P99 都会受到影响。
6.2 receive、write、flush、replay 的区别
Primary current WAL
↓ sender gap
sent_lsn
↓ network / receiver scheduling
write_lsn
↓ standby WAL fsync
flush_lsn
↓ startup replay CPU/I/O/conflict
replay_lsn
发送差距
pg_current_wal_lsn() - sent_lsn
可能表示:
- WAL Sender 调度不足;
- Primary CPU 饱和;
- 网络发送阻塞;
- 下游读取太慢导致 Socket Backpressure。
网络或 Receiver 写入差距
sent_lsn - write_lsn
可能表示:
- 网络丢包或带宽不足;
- Standby WAL Receiver 调度不及时;
- Standby 文件系统写入变慢。
Flush 差距
write_lsn - flush_lsn
可能表示:
- Standby WAL 设备 Flush 延迟高;
- 存储队列过深;
- 虚拟化存储抖动。
Replay 差距
flush_lsn - replay_lsn
可能表示:
- Startup Process CPU 不足;
- 数据文件随机 I/O 较慢;
- Primary 生成 WAL 的速率高于 Standby Replay 能力;
- Recovery Conflict 正在等待查询;
- Standby 分析查询和 Replay 争用 CPU、内存或磁盘。
6.3 异步复制可能丢失什么
异步复制可能丢失的是:
客户端已经从旧 Primary 收到提交成功,但尚未到达并持久化到最终被提升 Standby 的事务。
不是所有“Replay 之前的事务”都会丢失。已经持久化在 Standby pg_wal 中但尚未 Replay 的 WAL,Promotion 前仍可以被恢复和应用。真正的安全边界取决于候选节点已经接收并持久化了多少 WAL,以及 Failover 选择了哪一个节点。
异步 RPO 不能只用“相差几秒”描述,应同时记录:
- Primary 当前 Flush LSN;
- 候选 Standby Flush LSN;
- 两者字节差;
- 提交速率和业务事务分布;
- 选择候选节点时的实际状态。
6.4 同步复制是否在所有故障中都等于零数据丢失
不是。
同步复制只能在明确的配置和故障模型中提供更强保证。例如 synchronous_commit=on 且提交确实获得了指定同步 Standby 的持久化确认时,单独丢失 Primary 通常不会丢失该事务。
以下情况仍可能造成数据丢失或业务损坏:
- Primary 与所有确认节点同时丢失或存储同时损坏。
- Failover 错误地选择了一个更落后的异步 Standby。
- 某些事务使用了
local或off。 - 运维期间降低了
synchronous_standby_names的要求。 - 存储设备或控制器错误地报告持久化完成。
- 逻辑误删、恶意操作和应用 Bug 被同步复制到所有节点。
- Primary 在事务等待同步确认期间崩溃:恢复后 Primary 可能认为事务已提交,而 Standby 上不一定存在;客户端也可能没有收到明确结果。
- 两个节点同时可写,产生 Split Brain。
PostgreSQL 官方也明确指出,Primary 在等待同步确认时重启后,相关事务可在 Primary 恢复时被标记为已提交,但不能确定所有 Standby 都已收到对应 WAL。(PostgreSQL)
因此:
同步复制不是备份,不替代 PITR,也不替代 Fencing、候选选择和恢复验证。
6.5 同步 Standby 失联时写请求会怎样
设配置:
synchronous_standby_names = 'ANY 2 (s1, s2, s3)'
当只有一个候选可用时:
- DML 本身仍可能执行。
- Commit WAL 可以先在 Primary 持久化。
- Backend 在提交返回阶段等待足够的 Standby 确认。
- 事务持有的行锁、表锁及其他资源可能继续占用。
- 后续事务形成锁队列。
- Writer Pool 连接逐步耗尽。
- 应用 goroutine 在 Pool 外继续排队。
- 超时、取消和重试可能形成重试风暴。
恢复方式包括:
- 恢复足够数量的同步 Standby;
- 由经过授权的 HA 控制面降低同步要求;
- 在接受更大 RPO 的前提下临时切换关键事务的同步级别;
- 对入口执行 Admission Control,而不是无限积压。
不要把“同步节点掉线后自动退化为异步”视为 PostgreSQL 默认行为。未经重配置,要求同步确认的提交可能一直等待。(PostgreSQL)
6.6 FIRST 与 ANY
FIRST
synchronous_standby_names = 'FIRST 2 (s1, s2, s3)'
s1、s2优先成为同步 Standby。s3是候补。- 适合明确的机房优先级和确定性故障域选择。
- 风险是高优先级节点性能较差时,提交延迟仍被它决定。
ANY
synchronous_standby_names = 'ANY 2 (s1, s2, s3)'
- 任意两个完成即可。
- 适合多个相近节点的 Quorum。
- 较慢节点不一定处于每次提交的关键路径。
- 仍需保证候选集合具有合理故障域;同一机架的三个节点不等于三个独立故障域。
6.7 Slot、wal_keep_size 和磁盘
wal_keep_size:
- 只规定为 Standby 额外保留的最低 WAL 量;
- 不是精确保留时间;
- 不是磁盘使用上限;
- Standby 落后超过该范围后,所需 WAL 可能被回收;
- 若 Archive 仍有对应 WAL,Standby 可以从 Archive 追赶。
Replication Slot:
- 根据消费者进度精确保留所需 WAL;
- 由
restart_lsn表示最旧保护位置; - 消费者失联不会自动释放保护;
- 因此可能无限扩大
pg_wal。
max_slot_wal_keep_size:
- 默认
-1,即 Slot 可以无限保留 WAL; - 设置上限后,超出上限可能使所需 WAL 被删除;
- 它保护 Primary 磁盘,但可能牺牲 Standby 的可继续复制能力;
- 不是“让 Standby 自动追上”的限流器。(PostgreSQL)
[PG18] idle_replication_slot_timeout:
- 只针对长时间不活跃的 Slot;
- 检查在 Checkpoint 时发生;
- 不能代替 Lag、
safe_wal_size和磁盘告警; - 合法的长期离线灾备 Slot 可能被误判,因此必须结合恢复策略设置。(PostgreSQL)
6.8 为什么 Standby 查询会被取消
Primary 已经完成的操作会以 WAL 形式到达 Standby。例如:
DROP TABLE取得了 Access Exclusive Lock;VACUUM删除了旧 Tuple;- 删除了 Database 或 Tablespace;
- WAL Replay 需要修改某个正被查询固定的 Buffer。
Standby 不能要求 Primary 回滚已经提交的操作,只能:
- 暂停 Replay,等待查询结束;或
- 取消冲突查询,让 Replay 继续。
若等待超过 max_standby_streaming_delay,冲突查询会被取消。这个参数是接收到的 WAL 最多允许被延迟应用多久,不是单条查询固定可运行多久。此前其他查询已经消耗过延迟预算时,后续查询可能更快被取消。(PostgreSQL)
6.9 hot_standby_feedback 为何会导致 Primary 膨胀
启用:
hot_standby_feedback = on
Standby 会把活跃查询所需的 MVCC Horizon 反馈给上游。Primary 的 VACUUM 因而不能删除这些查询仍可能看到的旧 Tuple。
收益:
- 降低因 VACUUM Cleanup Record 造成的 Standby 查询取消。
代价:
- Primary Dead Tuple 持续存在;
- 表和索引膨胀;
- Cache 命中率下降;
- 查询扫描更多页面;
- VACUUM 工作量增加;
- WAL、I/O 和存储使用进一步上升。
它也不能解决所有冲突,例如 Primary DDL、Database Drop 和 Tablespace Drop。级联拓扑中,反馈会继续向上游传播。(PostgreSQL)
6.10 复制延迟为零为何不必然等于业务一致
即使观测到:
sent_lsn = write_lsn = flush_lsn = replay_lsn
仍可能出现业务上的陈旧读:
- 监控采样与业务读取不是同一时刻。
- 业务读取被路由到另一台更落后的 Replica。
- 读事务在 Replay 之前已经取得 Repeatable Read Snapshot。
- 连接池仍复用故障切换前的旧连接。
- 缓存、搜索索引、CDC 消费者或异步任务尚未更新。
- LSN 状态反馈存在上报间隔。
- 监控事务缓存了统计视图 Snapshot。
- 业务一致性包含多个数据库、消息系统或外部服务。
- Failover 后 Timeline 已变化,仅比较数值 LSN 可能错误。
可靠的单集群 Read-After-Write 至少应满足:
写事务成功
→ 获得写后 LSN Barrier
→ 在目标 Replica 上确认 replay_lsn >= Barrier
→ 随后取得一个新 Snapshot
→ 在同一 Replica 上执行读取
6.11 Timeline、Promotion 和 pg_rewind
Promotion 后会创建新的 Timeline。Timeline ID 进入 WAL 文件名,并生成 Timeline History File,记录从哪条历史、哪个位置分叉。(PostgreSQL)
假设:
Timeline 1:
A ─ B ─ C ─ D 旧 Primary 继续误写
\
Timeline 2:
E ─ F 新 Primary Promotion 后写入
旧 Primary 不能直接作为可写节点重新加入,因为:
- 两边都可能已经接受新事务;
- 相同 LSN 数值可能属于不同 Timeline;
- 数据页、事务状态、序列和业务约束已发生分叉;
- 直接接入会形成 Split Brain;
- PostgreSQL 不会自动合并两条物理历史。
正确方式:
- 保持旧 Primary 被 Fence;
- 使用
pg_rewind将其回退到共同分叉点并同步差异; - 或销毁旧数据目录,重新执行 Base Backup。
pg_rewind 要求目标节点停止;目标集群需要启用数据校验和或 wal_log_hints=on,同时保持 full_page_writes=on。执行中失败可能使目标数据目录不可恢复,此时应重新 Base Backup。(PostgreSQL)
7. 内部数据结构和状态
| 对象或状态 | 作用 | 关键变化 |
|---|---|---|
| WAL Record | 描述数据页、事务和系统状态变化 | Primary 生成,Standby Replay |
| WAL Segment | WAL Record 的磁盘容器 | 受 Checkpoint、Archive、Slot 和 wal_keep_size 影响 |
| Commit Record | 表示事务提交 | 同步复制等待围绕该位置展开 |
standby.signal | 指示实例进入 Standby Mode | Promotion 后退出恢复 |
| Control File | 保存 Checkpoint、Timeline 等控制信息 | Promotion、恢复和 Checkpoint 时更新 |
| Timeline History | 描述 Timeline 分叉关系 | Promotion/PITR 完成后创建 |
| WAL Sender State | 保存已发送及 Standby 回报位置 | 展示于 pg_stat_replication |
| WAL Receiver State | 保存接收、写入、刷盘位置 | 展示于 pg_stat_wal_receiver |
| Startup Process | 执行 Redo 和恢复冲突处理 | 推进 Replay LSN |
| Replication Slot | 持久记录 WAL/Xmin 保留要求 | 进度落后时阻止资源回收 |
restart_lsn | Slot 仍需要的最旧 WAL | 消费推进后才能前移 |
shared_buffers | 同时承载查询访问和 WAL Replay 修改 | 分析查询可能与 Replay 争用 |
| OS Page Cache | 缓存 WAL 和数据文件页面 | 不能把“写入 Page Cache”当作持久化 |
| Standby Snapshot | 确定只读事务可见数据 | 长 Snapshot 可与 Cleanup WAL 冲突 |
| Buffer Pin | 查询固定页面时的局部资源 | 可能导致 Replay Conflict |
| Lock State | Primary DDL 对象锁通过 WAL 影响 Standby | 冲突时 Standby 查询可能被取消 |
| Restartpoint | Standby 恢复过程中的 Checkpoint 类机制 | 影响恢复时间和 I/O 波动 |
| 统计状态 | LSN、Lag、Conflict、I/O、Pool 数据 | 有采样延迟,不能视为原子真相 |
8. 场景和选型决策
| 业务场景 | 推荐方案 | 不推荐方案 | 原因 | 性能代价 | 并发代价 | 一致性代价 | 高可用代价 | 运维复杂度 |
|---|---|---|---|---|---|---|---|---|
| 同城关键账务,单节点故障 RPO≈0 | 跨故障域同步 Standby,on | 仅异步 Replica | 等待远端持久化 | 增加 RTT 和 Flush P99 | Commit 持锁更久 | 强于异步 | 同步节点不足会阻塞写入 | 高 |
| 写后立即从 Replica 读取 | LSN Barrier,超时回 Primary;或关键事务 remote_apply | 固定 Sleep 后读 Replica | Sleep 无法证明 Replay | Barrier 增加读延迟;remote_apply 增加写延迟 | Pool 中会产生等待 | 可提供因果一致性 | Failover 后需失效旧 Token | 中高 |
| 跨区域灾备 | 异步复制加 WAL Archive | 跨洲所有事务 remote_apply | WAN RTT 和抖动过大 | 复制带宽与存储 | 较小 | 接受可量化 RPO | 低提交延迟,高灾备 RPO | 中 |
| BI 长查询 | 独立异步分析 Replica | 与同步 HA Replica 混用 | 长查询会冲突或拖慢 Replay | 需要额外硬件 | 分析查询需准入控制 | 数据可能陈旧 | 分析节点不作为首选 Failover | 高 |
| 普通列表、报表读扩展 | 异步读副本 | 对所有查询要求 Primary 强一致 | 可接受短暂陈旧 | 增加副本读取能力 | Reader Pool 独立排队 | 最终一致 | Replica 不能扩展写入 | 中 |
| 三个相近 AZ | ANY 2 Quorum | 把三台同机架节点当三故障域 | 任意两台确认 | 两个最快候选决定路径 | 少一个节点仍可提交 | 强持久性 | 需保证两个确认可用 | 高 |
| 明确主备优先级 | FIRST 1/2 | 候选顺序随意 | 行为可预测 | 高优先级慢节点影响延迟 | 候补切换期间可能抖动 | 与确认级别相关 | 简单候补模型 | 中 |
| 多地域下游读节点 | Cascading Replication | 所有节点直连 Primary | 减少 Primary 连接和跨区流量 | 级联 Lag 累加 | 上游故障影响下游 | 下游更陈旧 | 级联复制本身是异步 | 高 |
| Standby 短时中断后必须追赶 | Slot + WAL Archive + 上限监控 | 只设巨大 wal_keep_size | Slot 更精确,Archive 提供兜底 | 需要磁盘和 Archive 带宽 | 无直接影响 | 不影响查询语义 | 降低重建概率 | 中高 |
| 可随时重建的临时 Replica | 有限 wal_keep_size 或受控 Slot | 永久无上限 Slot | 避免废弃节点填满磁盘 | 可能需要重建 | 无直接影响 | 重建期间无读能力 | RTO 增加 | 低 |
内置 Cascading Replication 当前为异步;同步设置不能使下游级联节点直接成为 Primary 的同步确认者。(PostgreSQL)
9. 高性能分析
所有参数决策都必须先收集:
- 数据规模和增长速度;
- 平均与 P99 行宽;
- WAL 每秒生成量及峰值;
- 读写比例;
- 事务大小和并发数;
- Primary 与 Standby CPU、内存和存储;
- 网络 RTT、抖动、吞吐和丢包;
- 正常与故障期间的 RPO/RTO;
- 提交、读请求和 Failover 的 SLO。
9.1 资源维度
| 资源 | 复制影响 | 主要指标 |
|---|---|---|
| CPU | Primary 生成 WAL、Sender 发送;Standby Receiver、Replay 与查询竞争 CPU | CPU 使用率、Run Queue、Replay Bytes/s |
| 内存 | 每台 Replica 都有独立 shared_buffers、连接和工作内存 | Cache 命中率、Pool 连接、临时文件 |
shared_buffers | Replay 写入的页面和分析查询读取页面相互驱逐 | Buffer Read、Hit、Replay Lag |
| OS Page Cache | WAL 和数据页可能先进入内核缓存;不代表已经持久化 | Flush 延迟、设备队列、脏页 |
| 顺序 I/O | WAL 写入和传输通常接近顺序路径 | WAL Write/Flush 吞吐 |
| 随机 I/O | Replay 更新数据页、索引页;读查询访问数据文件 | DataFileRead/Write、IOPS |
| 网络 RTT | 同步复制提交至少包含网络往返 | RTT、重传、P95/P99 |
| 网络带宽 | 必须长期高于 WAL 生成速率,并留峰值余量 | WAL Bytes/s、出口利用率 |
| 索引维护 | Primary 每次索引更新也会生成 WAL,所有 Replica 都必须 Replay | WAL/事务、索引数 |
| Checkpoint | 首次修改页可能产生 Full Page Image,增加 WAL;Standby 有 Restartpoint | Checkpoint 时长、FPW、WAL 峰值 |
| VACUUM | 生成 Cleanup WAL,可能触发 Recovery Conflict | Dead Tuple、Conflict、Replay Delay |
| Temporary File | Replica 报表 Spill 会占用磁盘带宽和空间 | Temp Bytes、磁盘队列 |
| 空间 | 每个物理 Replica 保存完整集群,加上 WAL 和临时文件 | Data、Index、WAL、Temp 总量 |
9.2 PostgreSQL 18 AIO
[PG18] AIO 能让 Backend 对顺序扫描、Bitmap Heap Scan、VACUUM 等路径排队多个读请求,也提供相关等待事件和 pg_aios 视图。它可能改善 Standby 读查询或维护操作的 I/O 效率,但不会消除:
- WAL Flush 的持久化要求;
- 同步复制网络 RTT;
- WAL Replay 的因果顺序;
- Recovery Conflict;
- 存储设备本身的尾延迟。
因此不能因为启用 AIO,就预期 remote_apply 的 Commit P99 自动显著下降。(PostgreSQL)
9.3 读写放大
写放大
一次业务写入可能产生:
Heap 修改
+ 多个 Index 修改
+ WAL
+ Full Page Image
+ Archive 写入
+ N 个 Replica WAL 写入
+ N 个 Replica 数据页 Replay
增加 Replica 并不会扩展 Primary 的写入能力;它会增加整个系统的写入和存储成本。
读放大
同一热点数据可能分别存在于:
- Primary
shared_buffers; - 每台 Replica 的
shared_buffers; - 每台服务器 OS Page Cache;
- 应用缓存。
读副本扩展吞吐的同时,也增加内存和缓存预热成本。
9.4 尾延迟
同步复制的 Commit 延迟近似由以下较慢路径决定:
Primary WAL Flush
+ 网络 RTT
+ Standby Write/Flush
+ 可选 Replay
+ 调度抖动
remote_apply 还会包含:
- Startup Process 排队;
- 数据文件 I/O;
- Standby CPU 竞争;
- Recovery Conflict 等待;
- 可能的 Checkpoint/Restartpoint 抖动。
性能评估必须同时记录吞吐量和 P50/P95/P99,不能只记录平均 Commit Latency。
10. 高并发分析
10.1 同步等待会延长锁持有时间
事务在等待同步确认时,数据库更改通常已经在 Primary 本地提交路径中推进,但客户端尚未收到结果。事务相关锁可能持续占用,造成:
同步 Standby 变慢
→ Commit 等待变长
→ 热点行锁持有时间变长
→ 后续事务排队
→ Writer Pool 被占满
→ 应用 goroutine 在 Pool 外排队
→ 超时与重试增加
→ WAL 和系统负载进一步上升
同步等待本身不一定是普通的数据库 Blocker PID,因此 pg_blocking_pids() 可能找不到“阻塞 Commit 的 Standby”。应结合 wait_event 与 pg_stat_replication 分析。
10.2 MVCC 与长事务
Standby 查询也使用 MVCC Snapshot。
READ COMMITTED每条语句取得新 Snapshot。REPEATABLE READ事务长期保留同一 Snapshot。- 长 Snapshot 更容易与 Primary VACUUM Cleanup Record 冲突。
- 启用
hot_standby_feedback后,长 Snapshot 又可能把膨胀压力传回 Primary。
10.3 热点行和热点索引页
热点更新会同时造成:
- Primary 行锁竞争;
- Heap 和 Index Page 高频修改;
- WAL 生成速率上升;
- Replica Replay 压力上升;
- 同步复制确认次数增加。
读副本不能消除写热点。它只可能把不要求最新状态的读取从 Primary 移走。
10.4 连接、活跃查询、TPS 和排队请求
必须区分:
| 指标 | 含义 |
|---|---|
| 应用 goroutine 数 | 有多少并发业务任务 |
| Pool 最大连接数 | 应用最多可占用多少数据库连接 |
| 数据库连接数 | 当前节点建立的连接总数 |
| Active Query 数 | 真正在执行或等待数据库资源的查询 |
| TPS | 每秒完成的事务数 |
| Pool Acquire 等待数 | 等待可用连接的请求 |
| 同步提交等待数 | 已进入数据库但等待 Standby 确认的请求 |
| Replica Replay Lag | WAL 应用落后程度 |
增加 goroutine 不会增加数据库容量,只会把排队从数据库移到应用内存或反向移动。
10.5 Backpressure 和 Admission Control
当出现同步复制故障或 Replica Lag 时:
- 对非关键读请求回退 Primary 前应设置并发上限,避免压垮 Primary。
- 对分析查询设置并发配额和超时。
- Writer Pool 耗尽前应在 API 层拒绝或延后低优先级写入。
- 重试必须有最大次数、指数退避和随机抖动。
- Commit 结果不确定的写入必须依赖 Idempotency Key 或业务查询确认,不能盲目重试。
11. 高可用分析
11.1 RPO
| 复制模式 | 典型 RPO 含义 |
|---|---|
| 异步 | 可能丢失尚未持久化到被提升节点的已确认事务 |
remote_write | 可抵抗 Standby PostgreSQL 进程故障,但 Standby OS Crash 可能丢数据 |
on | 被确认事务已在同步 Standby 持久化 |
remote_apply | 与 on 类似的远端持久性,并已可被新查询看到 |
| 多节点 Quorum | 取决于确认数量、故障域和 Failover 候选选择 |
11.2 RTO
RTO 包括:
故障检测
+ 确认 Primary 不可继续服务
+ Fencing
+ 选择候选节点
+ Promotion
+ DNS/VIP/Proxy/Service 更新
+ 应用连接池失效与重连
+ 健康检查
+ 业务验证
仅测量 pg_promote() 的执行时间会严重低估真实 RTO。
11.3 Planned Switchover
建议顺序:
- 确认目标 Standby 健康并可承担容量。
- 暂停或排空写流量。
- 在旧 Primary 记录最终 Flush LSN。
- 等待目标 Standby Replay 至该位置。
- 停止或 Fence 旧 Primary。
- Promotion。
- 验证 Timeline 和读写。
- 更新路由。
- 主动关闭旧连接池并重新建立连接。
- 使用
pg_rewind或重建将旧 Primary 加回为 Standby。 - 验证数据和业务不变量。
- 恢复写流量。
11.4 Unplanned Failover
必须回答:
- 原 Primary 是否真的不可访问,还是只对控制节点不可见?
- 能否通过存储隔离、节点断电、网络 ACL 或 STONITH 完成 Fencing?
- 哪个 Standby 的 Flush LSN 最先进?
- 哪个 Standby 位于独立故障域?
- 提升它会产生多少 RPO?
- 旧客户端连接是否仍可能写旧 Primary?
- Commit 返回错误的请求如何对账?
没有 Fencing 的 Promotion 不应被描述为安全高可用。
11.5 Failback
Failback 不是“把旧 Primary 开回来”。
正确流程是:
旧 Primary 保持停止
→ 确认 Timeline 分叉
→ pg_rewind 或新 Base Backup
→ 配置为当前 Primary 的 Standby
→ 追赶并验证
→ 未来再执行一次独立的 Planned Switchover
11.6 复制不是备份
物理复制会忠实复制:
- 误删;
- 错误 UPDATE;
- DROP TABLE;
- 逻辑数据损坏;
- 恶意操作;
- 某些页面级损坏。
因此仍需:
- 独立 WAL Archive;
- 可恢复的 Base Backup;
- PITR;
- 不可变或隔离存储;
- 定期恢复演练;
- 业务校验。
12. 三维影响矩阵
| 维度 | 相关度 | 核心收益 | 主要风险 | 关键指标 |
|---|---|---|---|---|
| 高性能 | 高 | 读扩展、备份卸载、故障后快速恢复 | 同步 RTT、Replay 资源争用、读写与空间放大 | Commit P95/P99、WAL/s、Replay Bytes Lag、I/O |
| 高并发 | 高 | 分离部分只读请求 | 同步 Commit 持锁、长查询冲突、Pool 排队与重试风暴 | Active Query、Pool Acquire Wait、Conflict、Sync Wait |
| 高可用 | 高 | 提供可提升的物理副本 | 异步数据丢失、脑裂、错误候选、Slot 填盘、旧连接 | RPO、RTO、Timeline、Slot WAL、Failover 验证 |
13. 实验
以下实验只能在可丢弃环境执行。不要在生产环境暂停 Replay、缩短冲突等待、Promote 节点或运行未经演练的 pg_rewind。
实验一:建立 Primary 和 Standby,观察各阶段 LSN
13.1 实验目标
- 建立一主一备物理流复制。
- 观察
sent_lsn、write_lsn、flush_lsn、replay_lsn。 - 理解提交与 Standby 可见性之间的时间线。
13.2 版本和扩展
- PostgreSQL 18。
- 不需要扩展。
- 两个可丢弃实例。
- Primary:
10.0.0.10 - Standby:
10.0.0.11
13.3 准备
按照 5.1 节初始化 Standby,然后在 Primary 执行:
CREATE TABLE replication_probe (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
确认连接:
-- Primary
SELECT application_name, state, sync_state
FROM pg_stat_replication;
-- Standby
SELECT pg_is_in_recovery();
SELECT status, slot_name, sender_host
FROM pg_stat_wal_receiver;
13.4 Session A:Primary 写入
BEGIN;
INSERT INTO replication_probe(payload)
VALUES ('chapter-21-lsn-probe')
RETURNING id;
SELECT
pg_current_wal_insert_lsn(),
pg_current_wal_write_lsn(),
pg_current_wal_flush_lsn();
COMMIT;
13.5 Session B:Primary 监控
提交前后反复执行:
SELECT
application_name,
state,
sync_state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(
pg_current_wal_lsn(),
replay_lsn
)::bigint AS total_gap_bytes
FROM pg_stat_replication;
13.6 Session C:Standby 监控和查询
SELECT
written_lsn,
flushed_lsn,
received_tli,
latest_end_lsn
FROM pg_stat_wal_receiver;
SELECT
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn();
SELECT *
FROM replication_probe
ORDER BY id DESC
LIMIT 5;
13.7 明确时间线
| 时间 | 操作 | 等待/失败/提交 |
|---|---|---|
| T0 | Session A BEGIN | 不等待复制 |
| T1 | INSERT | 未提交,其他会话不可见 |
| T2 | COMMIT | 异步模式只等待 Primary 本地提交路径 |
| T3 | WAL Sender 发送 | Session B 的 sent_lsn 前移 |
| T4 | WAL Receiver 写入和 Flush | write_lsn、flush_lsn 前移 |
| T5 | Startup Process Replay | replay_lsn 前移 |
| T6 | Standby 新查询 | 记录可见 |
本实验没有预期失败。唯一明确提交点是 Session A 的 COMMIT。
13.8 预期结果
- LSN 值总体满足:
sent_lsn >= write_lsn >= flush_lsn >= replay_lsn
采样和状态上报并非原子操作,极短时间观测不必机械依赖每一列严格同步。
- Standby 必须在 Replay 后才能通过新 Snapshot 看到记录。
flush_lsn追上不等于查询已经可见。
13.9 统计指标
记录:
pg_stat_replication四阶段 LSN;pg_stat_wal_receiver的写入和 Flush 位置;- Primary 和 Standby WAL 设备延迟;
- 测试时的 PostgreSQL 配置、并发数和网络 RTT。
本实验不是查询计划实验,EXPLAIN 不是主要诊断手段。
13.10 清理
DROP TABLE replication_probe;
13.11 生产安全警告
不要仅凭一次 LSN 相等就宣告复制健康。必须持续监控字节 Lag、时间 Lag、Receiver 状态、Slot、磁盘和业务探针。
实验二:暂停 Replay 并观察 Lag
13.12 实验目标
区分“WAL 已收到”和“WAL 已应用”。
13.13 版本和扩展
- PostgreSQL 18。
- 不需要扩展。
- 使用实验一的拓扑。
13.14 准备数据
Primary:
CREATE TABLE replay_pause_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload text NOT NULL
);
13.15 Session A:Standby 暂停 Replay
SELECT pg_wal_replay_pause();
SELECT pg_get_wal_replay_pause_state();
只有返回 paused 才表示真正暂停。
13.16 Session B:Primary 生成 WAL
INSERT INTO replay_pause_demo(payload)
SELECT repeat(md5(g::text), 8)
FROM generate_series(1, 100000) AS g;
COMMIT;
每条独立 INSERT 语句默认已经在语句结束时提交;上面的显式 COMMIT 若不在事务中只会给出提示。需要单事务时应显式:
BEGIN;
INSERT INTO replay_pause_demo(payload)
SELECT repeat(md5(g::text), 8)
FROM generate_series(1, 100000) AS g;
COMMIT;
13.17 Session C:观察 Lag
Primary:
SELECT
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(flush_lsn, replay_lsn)::bigint
AS flush_replay_gap_bytes
FROM pg_stat_replication;
Standby:
SELECT
pg_get_wal_replay_pause_state(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_wal_lsn_diff(
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn()
)::bigint AS receive_replay_gap_bytes;
SELECT written_lsn, flushed_lsn
FROM pg_stat_wal_receiver;
13.18 时间线
| 时间 | 操作 | 等待/失败/提交 |
|---|---|---|
| T0 | Standby 请求暂停 | 可能短暂处于 pause requested |
| T1 | 状态变为 paused | Replay 停止 |
| T2 | Primary 大量写入并提交 | WAL 正常发送 |
| T3 | Standby Receiver 继续接收/Flush | Receive LSN 前移 |
| T4 | Replay LSN 停止 | Replay Gap 增长 |
| T5 | 恢复 Replay | Startup Process 追赶 |
| T6 | Replay LSN 追上 | 数据重新可见 |
13.19 恢复
SELECT pg_wal_replay_resume();
SELECT pg_get_wal_replay_pause_state();
13.20 预期结果
暂停期间:
receive_lsn / flushed_lsn 持续前移
replay_lsn 基本不动
恢复后,Replay Gap 应逐步缩小。
WAL Replay 暂停时,Streaming Receiver 仍可继续接收 WAL,因此磁盘最终可能被写满。(PostgreSQL)
13.21 清理
DROP TABLE replay_pause_demo;
13.22 生产安全警告
- 操作前必须计算磁盘余量:
可暂停时间 ≈ 可用 pg_wal 空间 / 峰值 WAL 生成速率
- 必须设置独立的自动恢复机制。
- 不要让人工维护会话成为唯一的 Resume 手段。
实验三:Standby 长查询与 Primary VACUUM 的 Recovery Conflict
13.23 实验目标
复现 Primary VACUUM Cleanup WAL 导致 Standby 查询被取消。
13.24 版本和扩展
- PostgreSQL 18。
- 不需要扩展。
- 可丢弃 Standby。
13.25 Standby 参数
ALTER SYSTEM SET hot_standby_feedback = 'off';
ALTER SYSTEM SET max_standby_streaming_delay = '5s';
SELECT pg_reload_conf();
SHOW hot_standby_feedback;
SHOW max_standby_streaming_delay;
13.26 Primary 准备数据
CREATE TABLE conflict_demo (
id bigint PRIMARY KEY,
payload text NOT NULL
) WITH (
autovacuum_enabled = false
);
INSERT INTO conflict_demo
SELECT
g,
repeat(md5(g::text), 10)
FROM generate_series(1, 300000) AS g;
ANALYZE conflict_demo;
等待 Standby 追上。
13.27 Session B:Standby 长 Snapshot
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
-- 建立旧 Snapshot
SELECT count(*)
FROM conflict_demo;
-- 保持查询活跃
SELECT pg_sleep(120);
13.28 Session A:Primary 删除并 VACUUM
在 Standby 开始 pg_sleep 后执行:
DELETE FROM conflict_demo
WHERE id <= 200000;
VACUUM (VERBOSE, ANALYZE) conflict_demo;
DELETE 提交后产生 Dead Tuple,VACUUM 清理这些 Tuple 并生成相关 WAL。
13.29 Session C:Standby 诊断
SELECT
datname,
confl_snapshot,
confl_lock,
confl_bufferpin,
confl_deadlock
FROM pg_stat_database_conflicts
WHERE datname = current_database();
查看日志,预期出现类似语义:
canceling statement due to conflict with recovery
不要依赖错误文本编写程序逻辑;程序应根据 SQLSTATE 和请求语义处理。
13.30 时间线
| 时间 | 操作 | 等待/失败/提交 |
|---|---|---|
| T0 | Standby 开启 Repeatable Read | 事务未提交 |
| T1 | count(*) 建立 Snapshot | Snapshot 可见旧行 |
| T2 | Standby pg_sleep | 查询活跃 |
| T3 | Primary DELETE | 提交成功 |
| T4 | Primary VACUUM | 生成 Cleanup WAL |
| T5 | Standby Startup Process 遇到冲突 | 最多等待约配置的 Replay 延迟预算 |
| T6 | 延迟预算耗尽 | Standby 查询被取消 |
| T7 | Replay 继续 | Conflict 计数增加 |
预期失败点是 Standby 的长查询。Primary 的 DELETE 和 VACUUM 应正常完成。
13.31 可选对比:启用反馈
ALTER SYSTEM SET hot_standby_feedback = 'on';
SELECT pg_reload_conf();
重新实验时,VACUUM Cleanup 类查询取消可能减少,但应同时观察 Primary:
SELECT
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'conflict_demo';
SELECT
pg_size_pretty(pg_total_relation_size('conflict_demo'))
AS total_size;
反馈存在上报间隔,不保证立即生效,也不能消除 DDL 等其他冲突。
13.32 清理
Standby:
ROLLBACK;
ALTER SYSTEM RESET hot_standby_feedback;
ALTER SYSTEM RESET max_standby_streaming_delay;
SELECT pg_reload_conf();
Primary:
DROP TABLE conflict_demo;
13.33 生产安全警告
不要为了让所有分析查询永不取消而直接设置:
max_standby_streaming_delay = -1
在 HA Standby 上无限等待会把 Replay Lag 和 Failover RPO/RTO 推高。长分析查询应使用独立分析 Replica,并设置查询准入和资源限制。
实验四:Promotion 和 pg_rewind
13.34 实验目标
- 安全提升 Standby。
- 观察 Timeline 分叉。
- 使用
pg_rewind将旧 Primary 变为当前 Primary 的 Standby。
13.35 版本和前置条件
- PostgreSQL 18。
- 不需要扩展。
- 目标旧 Primary 必须停止。
- 集群初始化时启用数据校验和,或事先设置
wal_log_hints=on。 full_page_writes=on。- 必须是可丢弃环境。
13.36 准备
旧 Primary:
CREATE TABLE failover_probe (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
node_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
INSERT INTO failover_probe(node_name)
VALUES ('old-primary-before-switchover');
SELECT pg_current_wal_flush_lsn();
记录返回的 LSN,例如 0/50001A0。
Standby:
SELECT
pg_last_wal_replay_lsn(),
pg_last_wal_replay_lsn() >= '0/50001A0'::pg_lsn
AS caught_up;
确认 caught_up=true。
13.37 Session A:停止并 Fence 旧 Primary
pg_ctl -D "$OLD_PRIMARY_PGDATA" stop -m fast
真实生产还需要:
- 从负载均衡移除;
- 阻断客户端网络;
- 撤销 VIP;
- 隔离共享存储;
- 或通过 HA 控制面完成 STONITH。
仅停止数据库进程不一定构成充分 Fencing。
13.38 Session B:提升 Standby
SELECT pg_promote(true, 60);
SELECT pg_is_in_recovery();
预期第二条返回 false。
在新 Primary 写入:
INSERT INTO failover_probe(node_name)
VALUES ('new-primary-after-promotion');
SELECT pg_walfile_name(pg_current_wal_lsn());
WAL 文件名前八位十六进制数字表示 Timeline ID。
13.39 Session C:运行 pg_rewind
确保旧 Primary 仍停止:
pg_rewind \
--target-pgdata="$OLD_PRIMARY_PGDATA" \
--source-server="host=10.0.0.11 port=5432 dbname=postgres user=rewind_operator sslmode=verify-full" \
--write-recovery-conf \
--progress
实验环境可临时使用高权限用户;生产应根据官方要求创建最小权限的专用 Rewind 角色。
检查 postgresql.auto.conf、primary_conninfo、认证设置和 Slot,再启动旧 Primary:
pg_ctl -D "$OLD_PRIMARY_PGDATA" start
13.40 验证
旧 Primary 现在应是 Standby:
SELECT pg_is_in_recovery();
SELECT *
FROM failover_probe
ORDER BY id;
应同时看到 Promotion 前后的两条记录。
新 Primary:
SELECT
application_name,
state,
sent_lsn,
flush_lsn,
replay_lsn
FROM pg_stat_replication;
13.41 时间线
| 时间 | 操作 | 等待/失败/提交 |
|---|---|---|
| T0 | 记录旧 Primary Flush LSN | 无 |
| T1 | 等待 Standby Replay 到该 LSN | 明确等待 |
| T2 | 停止并 Fence 旧 Primary | 旧 Primary 不再可写 |
| T3 | pg_promote() | 创建新 Timeline |
| T4 | 新 Primary 写入 | 提交成功 |
| T5 | 旧 Primary 仍处于旧历史 | 不能启动为可写节点 |
| T6 | pg_rewind | 目标必须停止 |
| T7 | 旧节点以 Standby 启动 | Replay 新 Timeline |
| T8 | 验证 | 拓扑恢复 |
13.42 失败处理
若 pg_rewind 报缺少 WAL、前置条件不满足或中途失败:
- 不要尝试直接启动目标为 Primary。
- 保存日志和原数据目录。
- 重新执行完整 Base Backup。
- 配置为 Standby。
- 验证数据和 Replay 状态。
13.43 清理
该实验已经改变拓扑。最安全的清理方式是销毁整个实验集群并重新初始化,而不是连续双向 Promotion。
14. Go:Writer Pool、Reader Pool 和 LSN Read-After-Write
14.1 数据表
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
request_id text NOT NULL UNIQUE,
customer_id bigint NOT NULL,
amount_cents bigint NOT NULL CHECK (amount_cents >= 0),
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
环境变量:
export DATABASE_URL='postgres://app:[email protected]:5432/app'
export READER_DATABASE_URL='postgres://app:[email protected]:5432/app'
export QUERY_TIMEOUT='2s'
export READ_BARRIER_TIMEOUT='300ms'
export READ_BARRIER_POLL_INTERVAL='10ms'
export MAX_CONCURRENCY='16'
生产环境应根据请求 SLO 和故障预算调整这些值。连接池上限可通过 pgxpool 连接串参数设置,不能只根据 CPU 核数套用固定值。
14.2 可编译示例
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type CommitOutcome string
const (
CommitCommitted CommitOutcome = "committed"
CommitRolledBack CommitOutcome = "rolled_back"
CommitUnknown CommitOutcome = "unknown"
)
type Order struct {
ID int64
RequestID string
CustomerID int64
AmountCents int64
CreatedAt time.Time
}
type WriteResult struct {
Order Order
ReplayBarrier string
Outcome CommitOutcome
}
type ReadResult struct {
Order Order
Source string
FallbackReason string
}
type Store struct {
writer *pgxpool.Pool
reader *pgxpool.Pool
queryTimeout time.Duration
barrierTimeout time.Duration
pollInterval time.Duration
}
func main() {
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
store, err := newStore(ctx)
if err != nil {
log.Fatal(err)
}
defer store.Close()
jobs := []func(context.Context) error{
func(ctx context.Context) error {
writeCtx, cancel := context.WithTimeout(
ctx,
store.queryTimeout,
)
defer cancel()
wr, err := store.CreateOrder(
writeCtx,
"demo-request-001",
42,
1299,
)
if err != nil {
// CommitCommitted 表示写入已经成功。
// 此时即使 Barrier 获取失败,也不能重试写事务。
if wr.Outcome != CommitCommitted {
return err
}
log.Printf(
"write committed, barrier unavailable: %v",
err,
)
}
readCtx, cancelRead := context.WithTimeout(
ctx,
store.queryTimeout+store.barrierTimeout,
)
defer cancelRead()
rr, err := store.GetOrderReadAfterWrite(
readCtx,
wr.Order.ID,
wr.ReplayBarrier,
)
if err != nil {
return err
}
log.Printf(
"order=%d source=%s fallback=%q",
rr.Order.ID,
rr.Source,
rr.FallbackReason,
)
return nil
},
}
if err := runBounded(
ctx,
envInt("MAX_CONCURRENCY", 4),
jobs,
); err != nil {
log.Printf("workload failed: %v", err)
}
}
func newStore(ctx context.Context) (*Store, error) {
writerURL := os.Getenv("DATABASE_URL")
readerURL := os.Getenv("READER_DATABASE_URL")
if writerURL == "" || readerURL == "" {
return nil, errors.New(
"DATABASE_URL and READER_DATABASE_URL are required",
)
}
writer, err := openPool(
ctx,
writerURL,
"orders-writer",
false,
)
if err != nil {
return nil, fmt.Errorf("open writer pool: %w", err)
}
reader, err := openPool(
ctx,
readerURL,
"orders-reader",
true,
)
if err != nil {
writer.Close()
return nil, fmt.Errorf("open reader pool: %w", err)
}
store := &Store{
writer: writer,
reader: reader,
queryTimeout: envDuration(
"QUERY_TIMEOUT",
2*time.Second,
),
barrierTimeout: envDuration(
"READ_BARRIER_TIMEOUT",
300*time.Millisecond,
),
pollInterval: envDuration(
"READ_BARRIER_POLL_INTERVAL",
10*time.Millisecond,
),
}
checkCtx, cancel := context.WithTimeout(
ctx,
store.queryTimeout,
)
defer cancel()
if err := store.validateRoles(checkCtx); err != nil {
store.Close()
return nil, err
}
return store, nil
}
func openPool(
ctx context.Context,
url string,
applicationName string,
readOnly bool,
) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(url)
if err != nil {
return nil, err
}
cfg.ConnConfig.RuntimeParams["application_name"] =
applicationName
if readOnly {
cfg.ConnConfig.RuntimeParams[
"default_transaction_read_only"
] = "on"
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}
func (s *Store) Close() {
s.reader.Close()
s.writer.Close()
}
func (s *Store) validateRoles(ctx context.Context) error {
var writerInRecovery bool
if err := s.writer.QueryRow(
ctx,
`SELECT pg_is_in_recovery()`,
).Scan(&writerInRecovery); err != nil {
return fmt.Errorf("validate writer: %w", err)
}
if writerInRecovery {
return errors.New("DATABASE_URL points to a standby")
}
var readerInRecovery bool
if err := s.reader.QueryRow(
ctx,
`SELECT pg_is_in_recovery()`,
).Scan(&readerInRecovery); err != nil {
return fmt.Errorf("validate reader: %w", err)
}
if !readerInRecovery {
return errors.New(
"READER_DATABASE_URL does not point to a standby",
)
}
return nil
}
func (s *Store) CreateOrder(
ctx context.Context,
requestID string,
customerID int64,
amountCents int64,
) (result WriteResult, err error) {
conn, err := s.writer.Acquire(ctx)
if err != nil {
return result, fmt.Errorf("acquire writer: %w", err)
}
defer conn.Release()
tx, err := conn.BeginTx(
ctx,
pgx.TxOptions{IsoLevel: pgx.ReadCommitted},
)
if err != nil {
return result, fmt.Errorf("begin: %w", err)
}
defer func() {
rollbackCtx, cancel := context.WithTimeout(
context.Background(),
s.queryTimeout,
)
defer cancel()
_ = tx.Rollback(rollbackCtx)
}()
err = tx.QueryRow(ctx, `
INSERT INTO orders (
request_id,
customer_id,
amount_cents
)
VALUES ($1, $2, $3)
ON CONFLICT (request_id) DO UPDATE
SET request_id = EXCLUDED.request_id
RETURNING
id,
request_id,
customer_id,
amount_cents,
created_at
`,
requestID,
customerID,
amountCents,
).Scan(
&result.Order.ID,
&result.Order.RequestID,
&result.Order.CustomerID,
&result.Order.AmountCents,
&result.Order.CreatedAt,
)
if err != nil {
return result, classify("insert order", err)
}
if err = tx.Commit(ctx); err != nil {
result.Outcome = CommitUnknown
if errors.Is(err, pgx.ErrTxCommitRollback) {
result.Outcome = CommitRolledBack
}
return result, fmt.Errorf(
"commit order (outcome=%s): %w",
result.Outcome,
err,
)
}
result.Outcome = CommitCommitted
// 在 Commit 成功后、同一 Writer Connection 上采样
// 当前已经持久化的 WAL 位置。
//
// 该位置可能包含其他并发事务的 WAL,因此可能多等一段;
// 但不会早于刚刚成功提交的事务。
barrierCtx, cancel := context.WithTimeout(
ctx,
s.queryTimeout,
)
defer cancel()
err = conn.QueryRow(
barrierCtx,
`SELECT pg_current_wal_flush_lsn()::text`,
).Scan(&result.ReplayBarrier)
if err != nil {
return result, fmt.Errorf(
"write committed but replay barrier could not be collected: %w",
err,
)
}
return result, nil
}
func (s *Store) GetOrderReadAfterWrite(
ctx context.Context,
orderID int64,
requiredLSN string,
) (ReadResult, error) {
if requiredLSN == "" {
return s.readFromPrimary(
ctx,
orderID,
"no replay barrier available",
)
}
waitCtx, cancel := context.WithTimeout(
ctx,
s.barrierTimeout,
)
readerConn, err := s.reader.Acquire(waitCtx)
if err == nil {
err = waitForReplay(
waitCtx,
readerConn,
requiredLSN,
s.pollInterval,
)
if err == nil {
queryCtx, queryCancel := context.WithTimeout(
ctx,
s.queryTimeout,
)
order, queryErr := fetchOrder(
queryCtx,
readerConn,
orderID,
)
queryCancel()
readerConn.Release()
cancel()
if queryErr == nil {
return ReadResult{
Order: order,
Source: "reader",
}, nil
}
err = queryErr
} else {
readerConn.Release()
cancel()
}
} else {
cancel()
}
return s.readFromPrimary(
ctx,
orderID,
fmt.Sprintf("reader barrier failed: %v", err),
)
}
func waitForReplay(
ctx context.Context,
conn *pgxpool.Conn,
requiredLSN string,
poll time.Duration,
) error {
ticker := time.NewTicker(poll)
defer ticker.Stop()
for {
var ready bool
err := conn.QueryRow(ctx, `
SELECT pg_is_in_recovery()
AND COALESCE(
pg_last_wal_replay_lsn() >= $1::pg_lsn,
false
)
`, requiredLSN).Scan(&ready)
if err != nil {
return classify("check replay barrier", err)
}
if ready {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
type rowQuerier interface {
QueryRow(
context.Context,
string,
...any,
) pgx.Row
}
func fetchOrder(
ctx context.Context,
q rowQuerier,
orderID int64,
) (Order, error) {
var order Order
err := q.QueryRow(ctx, `
SELECT
id,
request_id,
customer_id,
amount_cents,
created_at
FROM orders
WHERE id = $1
`, orderID).Scan(
&order.ID,
&order.RequestID,
&order.CustomerID,
&order.AmountCents,
&order.CreatedAt,
)
if err != nil {
return Order{}, classify("fetch order", err)
}
return order, nil
}
func (s *Store) readFromPrimary(
ctx context.Context,
orderID int64,
reason string,
) (ReadResult, error) {
queryCtx, cancel := context.WithTimeout(
ctx,
s.queryTimeout,
)
defer cancel()
order, err := fetchOrder(
queryCtx,
s.writer,
orderID,
)
if err != nil {
return ReadResult{}, err
}
return ReadResult{
Order: order,
Source: "primary",
FallbackReason: reason,
}, nil
}
func classify(operation string, err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return fmt.Errorf(
"%s failed: sqlstate=%s constraint=%s: %w",
operation,
pgErr.SQLState(),
pgErr.ConstraintName,
err,
)
}
return fmt.Errorf("%s failed: %w", operation, err)
}
func runBounded(
ctx context.Context,
limit int,
jobs []func(context.Context) error,
) error {
if limit < 1 {
return errors.New(
"concurrency limit must be positive",
)
}
sem := make(chan struct{}, limit)
errCh := make(chan error, len(jobs))
var wg sync.WaitGroup
for _, job := range jobs {
job := job
select {
case <-ctx.Done():
errCh <- ctx.Err()
goto wait
case sem <- struct{}{}:
}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
if err := job(ctx); err != nil {
errCh <- err
}
}()
}
wait:
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func envDuration(
name string,
fallback time.Duration,
) time.Duration {
value := os.Getenv(name)
if value == "" {
return fallback
}
d, err := time.ParseDuration(value)
if err != nil || d <= 0 {
return fallback
}
return d
}
func envInt(name string, fallback int) int {
value := os.Getenv(name)
if value == "" {
return fallback
}
n, err := strconv.Atoi(value)
if err != nil || n <= 0 {
return fallback
}
return n
}
pgxpool.NewWithConfig、Acquire、BeginTx、Ping 和 Close 均属于当前 pgx/v5/pgxpool API。PostgreSQL 服务端错误应通过 errors.As 提取 *pgconn.PgError,再读取 SQLState(),不能依赖错误文本。(Go Packages)
14.3 实现中的关键语义
必须在 Commit 后取 Barrier
错误方式:
事务提交前读取 LSN
→ Commit WAL 可能位于该 LSN 之后
→ Replica 到达旧 LSN
→ 业务写入仍不可见
代码在 Commit 成功后读取 pg_current_wal_flush_lsn()。它可能包含其他并发事务的 WAL,因此可能“多等”,但不会比本事务 Commit WAL 更早。
Barrier 和读取必须绑定同一 Replica
Reader Pool 可能包含多个 Replica。若:
在 Replica A 检查 Replay
→ 从 Pool 重新取连接
→ 实际在 Replica B 读取
则 Replica B 可能尚未追上。
示例因此在同一个 pgxpool.Conn 上完成 Barrier 检查和读取。
超时回退 Primary
LSN Barrier 的目标是提高读扩展比例,不应把 Replica 故障转换成无限等待:
Replica 在预算内追上 → 从 Replica 读
Replica 未追上或报错 → 从 Primary 读
Primary 回退也必须有独立超时和并发限制,否则 Replica 故障会把全部读流量瞬间压向 Primary。
Commit 错误不等于未提交
pgx.ErrTxCommitRollback表示事务实际按回滚结束。- 网络中断、超时等其他 Commit 错误可能使结果不确定。
- 结果不确定时不能直接重试写事务。
- 使用
request_id、唯一约束和业务查询确认结果。
Failover 后不能只携带裸 LSN
LSN Token 只在同一个复制历史和拓扑 Epoch 内可靠。Promotion 后 Timeline 分叉,数值 LSN 可能不足以描述因果位置。
生产 Token 应至少考虑:
cluster_id
+ topology_epoch / primary_generation
+ timeline-aware metadata
+ LSN
发生 Failover 时,应使旧 Epoch Token 失效或直接回退当前 Primary。
15. 生产排障 Runbook
15.1 首先确认角色和拓扑
在每个节点执行:
SELECT
inet_server_addr(),
inet_server_port(),
pg_is_in_recovery(),
current_setting('cluster_name', true);
确认:
- 哪个节点真正可写;
- 应用 Writer Endpoint 指向哪里;
- Reader Endpoint 包含哪些节点;
- 是否刚发生 Promotion;
- 旧 Primary 是否已 Fence;
- Timeline 是否发生变化。
15.2 查看核心指标
Primary:
SELECT *
FROM pg_stat_replication;
重点:
statesync_statesent_lsnwrite_lsnflush_lsnreplay_lsnwrite_lagflush_lagreplay_lag
Standby:
SELECT *
FROM pg_stat_wal_receiver;
SELECT
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_last_xact_replay_timestamp();
Slot:
SELECT
slot_name,
active,
restart_lsn,
wal_status,
safe_wal_size,
inactive_since,
invalidation_reason
FROM pg_replication_slots;
Conflict:
SELECT *
FROM pg_stat_database_conflicts;
15.3 判断问题位于哪一段
| 现象 | 优先判断 |
|---|---|
Primary current LSN 与 sent_lsn 差距大 | WAL Sender、Primary CPU、网络发送 |
sent_lsn 与 write_lsn 差距大 | 网络、Receiver、Standby WAL 写入 |
write_lsn 与 flush_lsn 差距大 | Standby WAL 存储 Flush |
flush_lsn 与 replay_lsn 差距大 | Replay CPU/I/O、Conflict、分析负载 |
| LSN 接近但应用仍陈旧 | 路由、旧 Snapshot、缓存、跨 Replica |
| Commit P99 暴涨 | 同步等待、网络 RTT、Standby Flush/Replay |
pg_wal 快速增长 | Slot、Archive 失败、Checkpoint/WAL 峰值 |
| Replica 查询频繁取消 | Recovery Conflict、延迟参数、长 Snapshot |
| Writer Pool 耗尽 | 同步 Commit 等待、锁链、数据库变慢 |
15.4 找 Blocker
普通锁:
SELECT
a.pid,
a.usename,
a.application_name,
a.xact_start,
a.wait_event_type,
a.wait_event,
pg_blocking_pids(a.pid) AS blocking_pids,
a.query
FROM pg_stat_activity AS a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY a.xact_start;
同步复制等待:
SELECT
pid,
xact_start,
wait_event_type,
wait_event,
query
FROM pg_stat_activity
WHERE wait_event = 'WaitForStandbyConfirmation'
ORDER BY xact_start;
后者的“阻塞者”通常不是另一个普通 Backend,而是尚未满足的 Standby 确认条件。
15.5 CPU、内存和 I/O
数据库侧:
SELECT *
FROM pg_stat_io
ORDER BY backend_type, object, context;
SELECT *
FROM pg_stat_recovery_prefetch;
操作系统侧同时检查:
iostat -xz 1
vmstat 1
pidstat -dur 1
ss -tin
关注:
- Standby WAL 设备和数据设备是否共用一个饱和队列;
- 临时文件是否占满分析 Replica 的磁盘带宽;
- Startup Process 是否长期占用 CPU;
- Receiver 是否等待网络;
- 内存不足是否造成 Page Cache 抖动或 Swap。
15.6 连接池
应用记录:
- Writer Pool 总连接、空闲连接、已获取连接;
- Reader Pool 总连接、空闲连接、已获取连接;
- Acquire Count;
- Empty Acquire Count;
- Acquire Duration;
- 等待队列长度;
- 超时和回退 Primary 次数。
Reader 故障时特别观察 Primary Pool 是否被回退流量打满。
15.7 WAL 与 Slot
估算 Slot 保留:
SELECT
slot_name,
active,
pg_size_pretty(
pg_wal_lsn_diff(
pg_current_wal_lsn(),
restart_lsn
)::bigint
) AS retained,
wal_status,
pg_size_pretty(safe_wal_size)
FROM pg_replication_slots;
同时检查:
SELECT
count(*) AS wal_files,
pg_size_pretty(sum(size)) AS wal_size
FROM pg_ls_waldir();
pg_ls_waldir() 需要适当权限。
15.8 VACUUM 与 Conflict
Primary:
SELECT
relname,
n_live_tup,
n_dead_tup,
last_autovacuum,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
Standby:
SELECT *
FROM pg_stat_database_conflicts
ORDER BY
confl_snapshot
+ confl_lock
+ confl_bufferpin DESC;
如果开启 hot_standby_feedback,同时检查 Primary 表和索引膨胀,不要只庆祝 Conflict 下降。
15.9 执行计划问题
复制延迟有时由 Standby 上异常昂贵的分析查询间接造成。可使用 pg_stat_statements 找出:
- 总执行时间高;
- 临时文件多;
- Buffer Read 多;
- 并发高;
- 执行时间突增的查询。
PostgreSQL 核心不会为每次执行永久保存完整 EXPLAIN。若没有提前启用计划采集或 auto_explain 日志,就无法可靠重建“最早一次估算错误”。生产系统应提前保存 Query ID、计划、实际行数和版本变更时间线。
15.10 在线低风险操作
通常可在线执行:
- 查询统计视图;
- 查看配置;
- 取消单个明显失控的分析查询;
- 调整应用读路由和准入;
- 通过受控流程 Reload 可重载参数;
- 临时将非关键读取回退 Primary,前提是有容量保护。
15.11 高风险操作
必须经过变更审批:
pg_promote();- 删除 Replication Slot;
- 强制 Checkpoint 以触发 Slot 失效;
- 直接修改
synchronous_standby_names; - 将同步事务切为
local/off; - 设置无限 Standby Delay;
pg_rewind;- 删除
pg_wal文件; - 未 Fence 的 Failover;
- 把旧 Primary 直接重新开放写入。
绝不能手工删除 pg_wal 中的文件来“释放空间”。
15.12 临时止损
按场景选择:
- 取消 Standby 上非关键长查询;
- 限制分析查询并发;
- Reader Lag 超阈值时从路由摘除;
- 暂停低优先级写入以降低 WAL 速率;
- 对 Reader 回退 Primary 设置比例和并发上限;
- 修复 WAL Archive;
- 恢复失联 Standby;
- 经授权临时调整同步候选,而不是无限阻塞;
- 废弃 Slot 前先确认对应消费者已永久下线。
15.13 根本修复
可能包括:
- 将 HA Replica 与 BI Replica 分离;
- 为 WAL 和数据文件提供更合适的存储;
- 优化高 WAL 写入、索引数量和大事务;
- 建立 Slot 生命周期管理;
- 增加 WAL Archive 兜底;
- 重新设计同步故障域和 Quorum;
- 建立 LSN Read-After-Write;
- 在 HA 控制面实现 Fencing;
- 为 Commit 不确定结果建立幂等与对账。
15.14 验证修复
至少验证:
- 四阶段 LSN Gap 持续缩小或稳定在 SLO 内。
- Commit P95/P99 恢复。
- 同步等待数量归零或符合预期。
- Slot
safe_wal_size恢复安全区。 pg_wal不再异常增长。- Recovery Conflict 速率下降。
- Primary 膨胀没有继续扩大。
- Writer/Reader Pool Acquire 延迟恢复。
- 业务 Read-After-Write 探针通过。
- Failover 候选可启动、可查询、可恢复。
15.15 告警建议
按基线和 SLO 设置,不使用全行业固定阈值:
- Replica 不在
streaming状态; - LSN 字节 Lag 超预算;
- Replay 时间 Lag 超预算;
- 同步 Standby 数量不足;
- Commit 同步等待持续存在;
- Slot
wal_status进入unreserved/lost; safe_wal_size低于预测故障窗口;pg_wal磁盘剩余空间不足;inactive_since超 Slot 生命周期;- Recovery Conflict 速率异常;
- Reader Primary Fallback 比例上升;
- Reader/Writer Pool Acquire P99 上升;
- Timeline 或 Primary Generation 非预期变化。
16. 常见错误与反模式
-
把异步读副本当成强一致数据库。 写入后立刻读 Replica,却没有 Barrier 或 Primary 回退。
-
只看
replay_lag一个时间字段。 忽略字节 Gap、Receiver 状态、Slot 和 Primary 空闲造成的时间误导。 -
给每个临时 Replica 永久创建无上限 Slot。 节点下线后 Slot 继续阻止 WAL 回收。
-
认为
max_slot_wal_keep_size会自动保护 Standby。 它保护的是 Primary 磁盘,上限触发后 Standby 可能必须重建。 -
启用
hot_standby_feedback后不监控 Bloat。 查询不再取消,但 Primary 表和索引持续膨胀。 -
在 HA Standby 上设置无限 Replay Delay。 分析查询稳定了,Failover 候选却越来越落后。
-
所有事务跨远距离使用
remote_apply。 把 WAN 抖动、Replay 和分析负载全部放入 Commit P99。 -
把同步复制等同于备份。 误删和逻辑损坏会同步到所有节点。
-
同步 Standby 数量要求高于可用故障域。 一次维护就让全部写请求卡在 Commit。
-
Promotion 前不做 Fencing。 形成两个可写节点和不可自动合并的 Timeline。
-
把旧 Primary 直接启动并加入写路由。 忽略 Timeline 分叉和 Split Brain。
-
在 Commit 前采集 Read-After-Write LSN。 Token 可能早于事务 Commit Record。
-
在 Replica A 检查 LSN,却从 Replica B 读取。 Pool 重新选连接后丢失一致性保证。
-
Commit 超时就直接重试写事务。 可能造成重复扣款、重复订单或重复消息。
-
Replica 故障时所有读请求无条件回退 Primary。 回退风暴会让原本健康的 Primary 也失效。
-
手工删除
pg_wal文件释放磁盘。 可能直接破坏实例恢复能力。
17. 模拟生产事故案例
案例一:废弃 Slot 写满 Primary 磁盘
系统背景
- Primary 每小时生成大量 WAL。
- 有两个正常 Standby。
- 一个月前下线了一台临时报表 Replica。
- 对应 Physical Slot 未删除。
max_slot_wal_keep_size=-1。
故障现象
pg_wal持续增长。- 根文件系统剩余空间快速下降。
- 数据文件大小并无明显增长。
- 正常 Standby Lag 接近零。
错误假设
“既然两个 Standby 都追上了,WAL 应该会自动回收。”
排查过程
SELECT
slot_name,
active,
restart_lsn,
inactive_since,
pg_size_pretty(
pg_wal_lsn_diff(
pg_current_wal_lsn(),
restart_lsn
)::bigint
) AS retained
FROM pg_replication_slots
ORDER BY
pg_wal_lsn_diff(
pg_current_wal_lsn(),
restart_lsn
) DESC;
发现一个 active=false 的废弃 Slot 保留了数百 GB WAL。
根因
Slot 是持久状态,不会因为对应服务器被删除而自动消失。它仍声明旧 WAL 可能被消费者需要。
临时止损
- 确认该 Replica 已永久下线。
- 保存拓扑和变更证据。
- 删除废弃 Slot:
SELECT pg_drop_replication_slot('retired_bi_replica');
- 持续监控 Checkpoint 后 WAL 回收。
- 不手工删除 WAL 文件。
最终修复
- 所有 Replica 生命周期操作绑定 Slot 创建和删除。
- 设置
max_slot_wal_keep_size。 [PG18]对临时 Slot 设置合理idle_replication_slot_timeout。- 为
inactive_since、safe_wal_size、wal_status和磁盘预测耗尽时间告警。 - 使用 WAL Archive 作为追赶兜底。
防止复发
资产管理系统中的 Replica、Slot、DNS 和监控对象必须一一对应;节点下线流程未删除 Slot 时不得关闭工单。
案例二:remote_apply 与 BI 查询拖垮交易写入
系统背景
- 一个 Primary,一个同步 Standby。
- 全局
synchronous_commit=remote_apply。 - 同一 Standby 同时运行长时间 BI 查询。
max_standby_streaming_delay设置较大。
故障现象
- Primary CPU 正常。
- Commit P99 从几十毫秒升至数秒。
- 热点订单行锁等待增加。
- Writer Pool Acquire Timeout。
- Standby Replay Lag 持续增加。
- WAL Receiver Flush LSN 正常前移。
错误假设
“Standby 已经收到并 Flush WAL,因此同步复制不可能影响 Commit。”
排查过程
Primary:
SELECT
pid,
xact_start,
wait_event,
query
FROM pg_stat_activity
WHERE wait_event = 'WaitForStandbyConfirmation';
SELECT
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(flush_lsn, replay_lsn)::bigint
FROM pg_stat_replication;
Standby:
SELECT
pid,
query_start,
state,
query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY query_start;
发现 WAL 已 Flush,但 Replay 被长分析查询和 I/O 竞争拖慢;remote_apply 使 Primary Commit 必须等待 Replay。
根因
把“同步 HA Replica”和“长查询分析 Replica”混为一个故障域与性能域。
临时止损
- 取消非关键 BI 查询。
- 对 BI 入口实施并发限制。
- 经业务授权,仅对不要求 Replica 立即可见的事务调整同步级别。
- 限制 Reader 回退 Primary 的并发。
最终修复
- HA 同步 Standby 只承担受控短查询。
- 新建独立异步 BI Replica。
- 关键 Read-After-Write 使用 LSN Barrier,避免所有写事务都使用
remote_apply。 - 对 BI 查询设置超时、临时文件和并发预算。
- 对 Replay Gap 和同步 Commit Wait 建立联合告警。
防止复发
容量评审同时检查:
- Standby Replay 峰值能力;
- BI 查询资源;
- Commit P99;
- Recovery Conflict;
- 同步等待锁放大效应。
18. 面试题
18.1 核心概念题
1. 物理复制和逻辑复制的核心区别是什么?
**30 秒回答:**物理复制传输整个集群的 WAL 物理变化,适合同大版本 HA;逻辑复制传输表级逻辑变更,适合 CDC、选择性复制和跨版本迁移。
**深入回答:**物理复制的优点是完整、低侵入、可直接 Promotion;缺点是粒度粗、通常要求相同大版本。逻辑复制更灵活,但 DDL、Sequence、冲突和一致性边界更复杂。
**考察:**能否区分 HA 与数据集成。 常见误答:“物理复制更快,所以永远更好。” **追问:**跨大版本在线升级选什么? **追问答案:**通常选择逻辑复制或其他官方升级路径,不使用物理流复制跨大版本。
2. sent_lsn、write_lsn、flush_lsn、replay_lsn 分别是什么?
**30 秒回答:**分别表示已发送、已写入 Standby OS、已在 Standby 持久化、已在 Standby 应用。
**深入回答:**四者把 Lag 分解为 Sender、网络/Receiver、存储 Flush 和 Replay 四段。flush_lsn 追上不代表查询可见,查询可见需要 Replay 和新 Snapshot。
**考察:**是否真正理解复制流水线。
**常见误答:**把 write_lsn 当成持久化。
**追问:**哪一列对应 remote_apply?
**追问答案:**提交等待语义主要对应 Standby Replay 进度。
3. Physical Replication Slot 的 restart_lsn 是什么?
**30 秒回答:**它是 Slot 消费者仍可能需要的最旧 WAL 位置,因此之前相关 WAL 不能正常回收。
**深入回答:**它不是当前 Replay LSN。消费者不活跃时,restart_lsn 可能长期不前移并填满 pg_wal。应监控 wal_status 和 safe_wal_size。
**考察:**Slot 的保护与风险。
常见误答:“Slot 会把 WAL 传给 Replica。”
**追问:**设置 max_slot_wal_keep_size 后是否绝对安全?
**追问答案:**Primary 磁盘更安全,但 Slot 可能丢失所需 WAL,Replica 需要重建。
4. FIRST 和 ANY 有什么区别?
30 秒回答:FIRST 按列表优先级选择指定数量,ANY 等待候选集合中的任意指定数量。
深入回答:FIRST 行为确定但容易被高优先级慢节点拖累;ANY 更适合相近节点 Quorum,但仍需设计真实故障域。
**考察:**同步拓扑设计。
**常见误答:**认为 ANY 2 是三个节点多数派选主。
**追问:**它能防止脑裂吗?
**追问答案:**不能。它控制提交确认,不负责选主和 Fencing。
5. Timeline 是什么?
**30 秒回答:**Timeline 是一条独立 WAL 历史分支,Promotion 或 PITR 完成后会创建新的 Timeline。
**深入回答:**Timeline 防止新历史覆盖旧 WAL,并通过 History File 描述分叉点。旧 Primary 与新 Primary 分叉后不能直接合并。
**考察:**Failover 后数据历史。 **常见误答:**把 Timeline 当成时间戳。 **追问:**为什么裸 LSN Token 跨 Failover 不可靠? **追问答案:**相同数值 LSN 可能属于不同 Timeline 和历史分支。
18.2 原理与排障题
6. 为什么复制 Lag 为零,业务仍可能读到旧数据?
**30 秒回答:**可能读到另一台 Replica、使用旧 Snapshot、统计采样过期,或缓存与外部系统未更新。
**深入回答:**数据库因果读需在目标 Replica 上确认 Replay 到写后 Barrier,再取得新 Snapshot,并保持读取节点不变。
**考察:**数据库指标与业务一致性的区别。 常见误答:“LSN 相等就一定强一致。” **追问:**最简单的安全回退是什么? **追问答案:**Barrier 超时后读取当前 Primary。
7. 为什么 Standby 查询会被取消?
**30 秒回答:**因为它与必须应用的 WAL 操作冲突,Standby 只能延迟 Replay 或取消查询,不能撤销 Primary 已提交操作。
**深入回答:**常见冲突包括 VACUUM Cleanup、DDL Access Exclusive Lock、Database/Tablespace Drop 和 Buffer Pin。
**考察:**Recovery Conflict 与普通锁的区别。 常见误答:“Replica 上有死锁。” **追问:**无限增大 Delay 的风险? **追问答案:**Replay Lag、RPO 和 Failover 时间上升。
8. 为什么 hot_standby_feedback 会导致 Bloat?
**30 秒回答:**它通知 Primary 保留 Standby 查询仍可能看到的旧 Tuple,使 VACUUM 不能及时清理。
**深入回答:**收益是减少 Snapshot Cleanup Conflict,代价是 Primary Heap/Index 膨胀、Cache 命中下降和 WAL/I/O 增加。
**考察:**MVCC Horizon 传播。 常见误答:“反馈只影响网络。” **追问:**它能解决 DDL Conflict 吗? **追问答案:**不能。
9. 同步 Standby 失联为什么会拖垮整个写服务?
**30 秒回答:**要求同步确认的 Commit 等待,而事务锁和连接继续占用,最终形成锁队列和 Pool 耗尽。
**深入回答:**数据库等待向上放大为 goroutine 排队、超时和重试风暴。需要同步候选冗余、Admission Control 和受控降级。
**考察:**复制与并发的耦合。
常见误答:“PostgreSQL 会自动切异步。”
**追问:**如何定位?
**追问答案:**检查同步 Wait Event、sync_state、四阶段 LSN 和 Pool Acquire 延迟。
10. Slot 导致磁盘满,能否直接删除 Slot?
**30 秒回答:**必须先确认消费者身份和是否永久废弃。删除活跃或仍需恢复的 Slot 会使 Replica 失去所需 WAL。
**深入回答:**先检查 active_pid、restart_lsn、inactive_since、wal_status 和资产记录,再决定恢复消费者、调整上限、删除 Slot 或重建 Replica。
**考察:**止损安全性。
**常见误答:**直接删除最大的 Slot。
**追问:**为什么不能删 pg_wal 文件?
**追问答案:**会破坏 Crash Recovery、复制和实例一致性。
11. Commit 返回超时,事务一定回滚了吗?
**30 秒回答:**不一定。服务端可能已经提交,只是客户端没有收到响应。
**深入回答:**网络中断、同步确认等待或 Failover 都可能造成结果不确定。应使用 Idempotency Key、唯一约束和状态查询确认,不盲目重试。
**考察:**分布式提交不确定性。
常见误答:“收到 Error 就重试。”
**追问:**pgx 中如何分类服务端错误?
追问答案:errors.As 到 *pgconn.PgError,读取 SQLSTATE;Commit 网络错误仍需单独判断结果。
18.3 架构设计题
12. 如何设计同城双 AZ、跨区域灾备?
**30 秒回答:**Primary 和同步 Standby 分布在同城独立 AZ,跨区域使用异步 Standby 和 WAL Archive。
**深入回答:**同城 RTT 满足 Commit SLO;跨区避免把 WAN 放入每次提交。必须有 Fencing、候选优先级、PITR 和演练。
**考察:**RPO、RTO、延迟和故障域。
**常见误答:**所有区域都 remote_apply。
**追问:**跨区 RPO 如何度量?
**追问答案:**使用候选 Flush LSN 与 Primary Flush LSN 的差距,结合业务事务速率。
13. 如何设计写后立即读取?
**30 秒回答:**写成功后获取 LSN Barrier,在同一 Replica 上等待 Replay 到 Barrier,超时回退 Primary。
**深入回答:**Barrier 必须在 Commit 后获取;检查和读取必须绑定同一 Replica;Failover 后 Token 需带拓扑 Epoch 或失效。
**考察:**因果一致性。
**常见误答:**固定 Sleep 100ms。
**追问:**替代方案?
**追问答案:**关键事务使用 remote_apply,或者在一定时间内粘住 Primary。
14. HA Replica 能否兼作 BI Replica?
**30 秒回答:**可以运行受控短查询,但不应无边界混用。
**深入回答:**长查询会争用 CPU/I/O、引发 Recovery Conflict,或通过 Feedback 造成 Primary Bloat。推荐独立分析 Replica。
**考察:**工作负载隔离。 常见误答:“Replica 是免费的计算资源。” **追问:**分析 Replica 是否应参与首选 Failover? **追问答案:**通常不应,除非其 Lag、配置和容量持续满足 HA SLO。
15. 设计一次零误操作的 Planned Switchover
**30 秒回答:**排空写入、确认目标 Replay 到最终 LSN、Fence 旧 Primary、Promote、更新路由、重建连接、Rewind 旧 Primary、验证业务。
**深入回答:**还要处理 Commit 不确定请求、旧连接、Timeline、Slot、配置差异和回滚条件。
**考察:**端到端 RTO,而不只是数据库命令。
**常见误答:**直接执行 pg_promote()。
**追问:**最关键的顺序约束是什么?
**追问答案:**旧 Primary 必须停止接受写入或被可靠 Fence,才能安全开放新 Primary。
19. 练习与参考答案
19.1 理论题
题 1
Primary 当前 Flush LSN 为 0/A000000,某 Standby:
sent_lsn = 0/9F00000
write_lsn = 0/9E00000
flush_lsn = 0/9D00000
replay_lsn = 0/9800000
哪一段最可能是主要瓶颈?
**参考答案:**应计算每段字节差。直观上 flush_lsn 到 replay_lsn 的差距最大,应优先检查 Startup Replay、Standby CPU/I/O、Recovery Conflict 和分析查询。但仍需实际用 pg_wal_lsn_diff 计算,不能只按十六进制字符串长度判断。
题 2
synchronous_standby_names='ANY 2 (s1,s2,s3)',只剩 s1 可用,synchronous_commit=on 的事务会怎样?
**参考答案:**写事务可执行并在 Primary 生成和持久化 WAL,但 Commit 无法获得两个 Standby 的确认,因此会等待。锁、连接和上游请求可能持续堆积,直到足够 Standby 恢复、配置被受控修改或请求被中断。
题 3
为何 remote_apply 不一定适合所有 Read-After-Write?
**参考答案:**它把所有使用该设置的写事务都绑定到 Standby Replay 延迟。若只有少数读需要因果一致性,应用侧 LSN Barrier 加超时回退通常能避免让全部写入承担 Replay P99。remote_apply 仍适合一致性优先且延迟可接受的关键链路。
题 4
wal_keep_size 和 Physical Slot 的主要区别是什么?
参考答案:wal_keep_size 是额外保留的最低量,不跟踪具体消费者,也不保证超过范围后的 WAL。Slot 根据消费者进度保留准确需要的 WAL,但消费者停止会导致无界堆积风险。Archive 可作为两者之外的追赶兜底。
题 5
为什么同步复制仍需要 PITR?
**参考答案:**同步复制会同步逻辑误删、错误更新和恶意操作,也不能覆盖所有节点同时损坏、错误 Failover、存储谎报和长期历史恢复。PITR 提供独立时间维度和恢复点。
19.2 实验题
题 6
修改实验二,使 Primary 持续每秒写入一批记录,绘制:
- Receive LSN;
- Replay LSN;
- Receive-Replay Gap;
- Standby 磁盘剩余空间。
参考答案:
- Standby 执行
pg_wal_replay_pause()。 - Primary 使用有界脚本每秒提交固定批次。
- 每秒采集
pg_last_wal_receive_lsn()和pg_last_wal_replay_lsn()。 - 使用
pg_wal_lsn_diff记录字节差。 - 同时采集文件系统空间。
- 达到预设安全阈值前执行 Resume。
- 验证 Gap 追平时间是否符合 RTO。
题 7
比较以下三种配置的 Commit P50/P95/P99:
local
on
remote_apply
参考答案:
必须记录:
- PostgreSQL 版本和所有相关配置;
- Primary/Standby 硬件;
- RTT;
- WAL 设备;
- 事务大小;
- 并发数;
- 缓存冷热;
- 测试时长;
- WAL/s;
- Replay Lag;
- Wait Event。
预期顺序通常是 local 延迟最低,remote_apply 最高,但不能伪造固定数值。
题 8
启用和关闭 hot_standby_feedback,重复实验三,比较:
- Conflict 次数;
- Primary
n_dead_tup; - 表和索引大小;
- Replay Lag。
**参考答案:**关闭 Feedback 时更容易出现 Cleanup Conflict;开启后 Conflict 可能减少,但 Primary Dead Tuple 和物理空间更可能增长。结果还受反馈上报间隔、查询 Snapshot 和 VACUUM 时机影响。
19.3 排障题
题 9
现象:
sent_lsn ≈ write_lsn ≈ flush_lsn
replay_lsn 明显落后
Standby CPU 95%
Conflict 没有增长
如何排查?
参考答案:
- 检查 Startup Process CPU。
- 检查 Standby 同时运行的查询及并发数。
- 检查数据文件随机 I/O、Buffer Cache 和 Page Cache。
- 查看
pg_stat_recovery_prefetch和pg_stat_io。 - 判断 Primary WAL 生成率是否长期高于 Replay 能力。
- 检查大事务、批量写、索引创建和 VACUUM WAL。
- 临时限制分析查询并发。
- 根本修复可能是提高 Standby 计算/I/O、降低 WAL、拆分分析 Replica。
题 10
现象:
所有 Replica Lag 很低
Primary Commit P99 很高
大量会话 wait_event=WaitForStandbyConfirmation
可能原因是什么?
参考答案:
- 同步反馈状态上报或网络 RTT 抖动;
- 某个被选中同步 Standby 的 Flush 尾延迟;
ANY/FIRST所需同步数量暂时不足;remote_apply下 Replay 短暂卡顿;- 监控采样时 Lag 已追上,但事务曾经等待;
- Standby 在
catchup,尚未成为可用同步候选。
应关联事务时间、网络和存储时序,而不是只看当前 Lag 快照。
19.4 系统设计题
题 11
设计一个订单系统:
- Primary 在东京 AZ-A;
- 东京 AZ-B、AZ-C 各一台 Standby;
- 大阪一台灾备;
- 订单创建要求同城单节点故障不丢数据;
- 订单详情写后立即读;
- 报表允许延迟 30 秒;
- Commit P99 预算严格。
参考答案
推荐拓扑:
Tokyo AZ-A Primary
├─ Tokyo AZ-B HA Standby
├─ Tokyo AZ-C HA Standby
└─ Osaka DR Standby(异步)
同步配置可选:
synchronous_standby_names =
'ANY 1 (tokyo_b, tokyo_c)'
含义:
- 任一同城 AZ 持久化确认即可。
- 同时失去两台东京 Standby 时,要求同步的写会等待。
- 大阪不进入同步 Commit 路径,避免 WAN P99。
订单创建:
- 使用
synchronous_commit=on。 - Commit 后生成 LSN Barrier。
- 详情读取在同一东京 Reader 上等待 Replay。
- Barrier 超时回 Primary。
- Token 携带 Primary Generation。
报表:
- 使用独立异步 Replica,避免与 HA Standby 混用。
- 设置查询并发、超时和资源预算。
- 超过 30 秒 Lag 时从报表路由摘除。
高可用:
- HA 控制面必须 Fence 旧 Primary。
- 选择 Flush LSN 最先进且故障域合适的节点。
- Promotion 后重建连接池。
- 旧 Primary 使用
pg_rewind或 Base Backup。 - 大阪承担区域级灾备,接受明确的异步 RPO。
备份:
- 独立 WAL Archive;
- Base Backup;
- PITR;
- 定期恢复演练。
20. 检查清单
- 我能够解释 WAL Sender、WAL Receiver 和 Startup Process 的职责。
- 我能够区分 receive、write、flush 和 replay LSN。
- 我能够计算并解释复制流水线各阶段的字节 Lag。
- 我能够说明异步复制可能丢失哪些已确认事务。
- 我能够说明同步复制为何不等于所有故障下的绝对零损失。
- 我能够解释同步 Standby 失联为何可能阻塞写请求。
- 我能够选择
remote_write、on、remote_apply或local。 - 我能够配置并解释
FIRST与ANY。 - 我能够诊断 Physical Slot 导致的 WAL 堆积。
- 我能够正确使用
[PG18] idle_replication_slot_timeout。 - 我能够复现 Replay 暂停和 Recovery Conflict。
- 我能够分析
hot_standby_feedback对 Conflict 与 Bloat 的取舍。 - 我能够解释复制 Lag 为零为何不保证业务一致。
- 我能够在 Go 中实现 Writer Pool、Reader Pool 和 LSN Barrier。
- 我能够在 Barrier 超时后有界地回退 Primary。
- 我不会因 Commit 返回错误就武断认定事务未提交。
- 我能够执行安全的 Planned Switchover。
- 我能够解释 Promotion 后的 Timeline 分叉。
- 我能够使用
pg_rewind或 Base Backup 重建旧 Primary。 - 我能够把 Fencing、旧连接处理和业务验证纳入真实 RTO。
- 我能够为 Lag、Slot、Conflict、同步等待和 Pool 建立告警。
21. 官方资料来源
- PostgreSQL 18:Log-Shipping、Streaming Replication、Replication Slot、Cascading 和 Synchronous Replication。(PostgreSQL)
- PostgreSQL 18:复制参数、
wal_keep_size、max_slot_wal_keep_size、idle_replication_slot_timeout、Standby Delay 和 Feedback。(PostgreSQL) - PostgreSQL 18:
synchronous_commit各模式的精确定义。(PostgreSQL) - PostgreSQL 18:Hot Standby 与 Recovery Conflict。(PostgreSQL)
- PostgreSQL 18:复制统计、WAL Receiver 和等待事件。(PostgreSQL)
- PostgreSQL 18:Replication Slot 系统视图。(PostgreSQL)
- PostgreSQL 18:Timeline 和 Timeline History。(PostgreSQL)
- PostgreSQL 18:
pg_basebackup --write-recovery-conf。(PostgreSQL) - PostgreSQL 18:
pg_promote、Replay Pause/Resume。(PostgreSQL) - PostgreSQL 18:
pg_rewind前置条件与失败风险。(PostgreSQL) - PostgreSQL 18:AIO 能力和适用路径。(PostgreSQL)
- pgx/v5:
pgxpool和pgconn.PgErrorAPI。(Go Packages)