读写分离架构
随着业务增长,数据库的读请求往往远多于写请求——典型互联网业务的读写比在 10:1 甚至更高。流复制让备库与主库保持近乎实时的一致,备库又支持只读查询(hot standby),于是水到渠成的优化出现了:把写流量发给主库,把读流量分散给一台或多台备库。这就是读写分离(read-write splitting / read/write split)。
读写分离是 PostgreSQL 高可用体系里「性价比最高」的横向扩展手段:它不需要分库分表,不需要应用大改,备库本来就是高可用架构的既有组件,把读流量导过去就是纯增量收益。但它也有一个绕不开的代价:复制延迟导致的一致性问题——刚写完的数据,在备库上可能还读不到。这个问题的解法决定了读写分离的架构形态与适用边界。
本页讲清读写分离的动机、三种架构模式(应用层直连、代理层、驱动层)、一致性挑战与保障手段、流量分配策略、与连接池的配合、延迟监控与降级,以及常见的坑与选型建议。备库只读查询的底层机制(查询冲突、参数调优)在下一页展开,中间件的选型与配置在第三页展开。
前置知识
建议先阅读:repmgr 自动故障转移
基础概念
读写分离横跨「复制」「连接」「路由」三个领域,先把这些概念对齐。
| 概念 | 英文 | 说明 |
|---|---|---|
| 读写分离 | read-write splitting | 写走主库、读走备库的流量分配方式 |
| 复制延迟 | replication lag | 备库落后主库的时间 / 字节数 |
| read-your-writes | 读己之写 | 用户应能立刻读到刚提交的自己的写入 |
| 会话粘滞 | session stickiness | 同一会话的请求固定路由到同一节点 |
| 写后读主 | read-after-write routing | 写操作后的一段时间内,该用户的读强制走主库 |
| LSN 等待 | wait for LSN | 应用等到备库重放到指定 LSN 再读 |
remote_apply | 同步复制等级 | 主库等备库重放完成才返回提交成功 |
| 负载均衡 | load balancing | 多备库之间的读流量分配 |
| 降级 | fallback / degrade | 备库不可用或延迟过大时,读流量回退到主库 |
| 只读端点 | read endpoint | 面向读流量的统一入口(VIP/域名/端口) |
读写分离的收益边界
读写分离扩展的是读吞吐,不是写吞吐。这个认知决定了它的适用边界:
- 收益:主库 CPU / IO 负载下降(读请求被剥离);总查询吞吐随备库数量近线性增长;主库的资源可以更专注于写入与复杂事务;
- 无收益:写入吞吐不变(写只有主库能做);单条查询的延迟不变(除非用并行查询,那是另一回事);
- 有代价:引入复制延迟与一致性问题;备库数量越多,主库的 walsender 与网络压力越大;运维复杂度上升。
判断「值不值得做」的核心问题是:读压力是否真的挤占了写能力? 如果主库的瓶颈在写入或锁竞争,读写分离帮不上忙,要考虑的是写入优化、分区或分片。
复制延迟的三种度量
一致性方案都建立在「先量化延迟」之上,三种度量口径各有用途:
| 口径 | 获取方式 | 含义 |
|---|---|---|
| 字节差 | 主库 pg_stat_replication 的 LSN 差值 | 备库还差多少 WAL |
| 时间差 | 备库 now() - pg_last_xact_replay_timestamp() | 最后重放的事务距今多久 |
| 段落差 | receive - replay 的 LSN 差 | 已收到但未重放的量(应用堆积) |
注意时间差口径在写入稀疏的时段会「虚高」(没有新事务可重放,最后事务时间停在很久以前),需要结合字节差一起解读。
hot standby:一切的前提
读写分离能成立,前提是备库可查询——这就是 hot standby(热备):备库在持续重放 WAL 的同时,对外提供只读查询。PostgreSQL 10 以后 hot_standby 默认开启。但「可查询」不等于「无限可查询」:长查询会与 WAL 重放产生冲突,可能被强制取消;备库上的序列、锁、事务控制都有专门的行为。这些细节是下一页的主题。
语法与用法
读写分离的「语法」主要是三类:路由判断用的 SQL、一致性等待用的 SQL、以及同步复制等级的配置。
判断当前节点角色
-- 应用启动或重连后判断连到的是主库还是备库
SELECT pg_is_in_recovery(); pg_is_in_recovery
-------------------
f
(1 row)f = 主库,t = 备库。这是所有「应用层直连」方案的路由依据。
量化复制延迟
-- 主库上:每个备库的字节级延迟
SELECT application_name,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication; application_name | replay_lag_bytes
------------------+------------------
standby1 | 0
standby2 | 16432
(2 rows)-- 备库上:时间级延迟
SELECT now() - pg_last_xact_replay_timestamp() AS replay_delay; replay_delay
------------------
00:00:00.318412
(1 row)写后等待备库追上(PG17+)
PostgreSQL 17 起提供了在备库上等待重放到指定 LSN 的函数,这是实现「LSN 等待」一致性方案的原生工具:
-- 主库:写入并记下提交时的 WAL 位置(写在事务内查询)
INSERT INTO orders(user_id, amount) VALUES (42, 199.00);
SELECT pg_current_wal_lsn() AS written_lsn; written_lsn
-------------
0/3000C100
(1 row)-- 备库:等待重放追上该位置(可带超时),超时返回警告
SELECT pg_wal_replay_wait('0/3000C100'::pg_lsn);更早版本没有这个函数,等价做法是轮询 pg_last_wal_replay_lsn() 直到大于目标 LSN,或短暂休眠。
同步复制等级配置
# postgresql.conf —— 用 remote_apply 消除「重放滞后」造成的一致性问题
synchronous_commit = remote_apply
synchronous_standby_names = 'FIRST 1 (standby1)'remote_apply 让主库等备库重放完成才返回提交成功,代价是每次提交多一个网络往返加重放时间,且备库故障时写入阻塞。等级的完整对比见同步复制等级。
详细说明
三种架构模式
读写分离有三种落地形态,复杂度与侵入度递增:
模式一:应用层直连(应用自己路由)
┌──────────┐ 写 ┌──────┐
│ 应用 │─────►│ 主库 │
│ (读写路由) │ 读 └──────┘
│ │─────►┌──────┐
└──────────┘ │ 备库×N │
└──────┘
模式二:代理层(中间件路由)
┌──────────┐ ┌────────────┐ 写 ┌──────┐
│ 应用 │─────►│ 代理/中间件 │────►│ 主库 │
└──────────┘ 统一 │ (Pgpool-II │ 读 └──────┘
│ /自研网关) │────►┌──────┐
└────────────┘ │ 备库×N │
└──────┘
模式三:驱动/框架层(ORM 或数据源路由)
应用 → 框架读写路由(Laravel read/write、Sharding-JDBC 等) → 主库/备库| 模式 | 代表实现 | 优点 | 缺点 |
|---|---|---|---|
| 应用层直连 | 自研路由、配置中心 | 无额外组件、逻辑可控 | 每个应用都要实现一遍 |
| 代理层 | Pgpool-II、自研网关 | 应用零改动、统一管控 | 多一层故障域与延迟、SQL 解析限制 |
| 驱动/框架层 | Laravel 读写连接、ShardingSphere | 应用侧声明式配置 | 绑定技术栈、跨语言无法复用 |
没有哪种模式绝对占优:组件数量越少越稳,但统一管控能力越弱。选型参考第三页的对比。
read-your-writes:核心一致性挑战
复制延迟带来的问题有一个精确的名字:read-your-writes(读己之写)。用户提交订单后立刻查看订单列表,如果查看请求被路由到延迟 200ms 的备库,看到的是没有新订单的旧列表——用户以为提交失败,再点一次提交,重复数据就产生了。
需要注意的是,这不是「所有读都不可信」,而是「写之后紧接着的读」不可信。读流量中真正踩中这个窗口的只是少数,因此工程上追求的不是全局强一致(那是同步复制的领域),而是把踩中窗口的读识别出来,路由到安全的地方。
识别「写后读」有两种典型模式:
- 会话级:同一个用户会话里,写过之后的后续读都走主库,直到某个条件(超时、离开页面)解除;
- 事务级:事务内一旦发生写,整个事务剩余部分全部走主库——这是最基本也最重要的规则,因为事务内的读写混用跨节点在语义上几乎总是错的(见下文常见坑)。
一致性保障手段
按实现成本从低到高,四种主流手段:
| 手段 | 原理 | 成本 | 一致性强度 |
|---|---|---|---|
| 会话粘滞 | 用户会话固定路由(写后会话暂走主库) | 低(路由层记忆) | 会话内一致 |
| 写后读主 | 写操作后给该用户/连接打标记,T 秒内读走主 | 低 | 时间窗口内一致 |
remote_apply | 备库重放完成才算提交成功 | 中(提交延迟+可用性代价) | 全局一致(读任意节点都可见) |
| LSN 等待 | 写后记下 LSN,读前在备库等待重放到该 LSN | 中(需要应用配合或 PG17+) | 单次读写一致 |
工程上的常见组合是「默认读备库 + 关键路径写后读主 / LSN 等待」:绝大多数读(列表页、报表、缓存回源)容忍秒级延迟走备库;提交后的跳转、支付结果页等关键读走主库或等待追平。
LSN 等待方案的完整闭环:
1. 应用在主库执行写事务
2. 事务提交时取得 pg_current_wal_lsn()(或用 PG17+ 的事务级信息)
3. 应用带着该 LSN 发起下一次读请求
4. 备库上执行 pg_wal_replay_wait('目标LSN') 或轮询 pg_last_wal_replay_lsn()
5. 追上后执行读查询;超时则回退主库读复制延迟的成因分解
一致性方案的设计必须建立在「延迟从哪来」的分解之上。一次提交从主库到备库可读,要经过五段:
① 提交与发送 主库事务提交 → walsender 把 WAL 发出
② 网络传输 TCP 往返、带宽、丢包重传
③ 接收与落盘 walreceiver 写入并 fsync 本地 pg_wal
④ 重放 startup 进程按序应用 WAL 到数据文件
⑤ 可见 新事务的快照包含该变更,读请求可见| 段 | 常见瓶颈 | 治理方向 |
|---|---|---|
| ① 发送 | wal_sender_timeout 误判、大事务拖延 | 监控 write_lag |
| ② 网络 | 带宽不足(跨机房常见)、丢包 | 压缩、级联、专线 |
| ③ 落盘 | 备库 pg_wal 盘慢(synchronous_commit 远端等待在此发生) | 提升备库盘性能 |
| ④ 重放 | 单进程重放、大事务、锁等待 | 监控 replay_lag;避免备库上的长锁 |
| ⑤ 可见 | 本页讨论的一致性窗口 | 路由策略与 LSN 等待 |
读写分离视角的两个关键结论:④ 重放是延迟堆积的主段(单进程按序重放,追不上持续写入时会持续落后);② ③ 决定延迟的下限(物理规律,无法靠配置消除)。write_lag / flush_lag / replay_lag 三个监控字段正对应这几段的耗时度量,含义详见流复制监控。
典型架构图
把上一节的模式组合成生产上常见的完整形态:
┌──────────────────────────────┐
│ 应用集群 / API 服务 │
│ (或框架层读写路由) │
└──────┬────────────────┬──────┘
写连接 │ │ 读连接
┌──────┴──────┐ ┌──────┴───────────┐
│ 写端点 │ │ 读端点 │
│ (主库VIP/域名) │ │ (读VIP/DNS轮询) │
└──────┬──────┘ └──────┬───────────┘
│ ┌─────┼─────┐
┌──────┴──────┐ │ │ │
│ 主库 │ ┌─┴─┐ ┌─┴─┐ ┌─┴─┐
│ 10.0.0.10 │ │备1 │ │备2 │ │备3 │
└──────┬──────┘ └───┘ └───┘ └───┘
│ 流复制 (WAL)
└──────────────────────►要点:
- 写端点与读端点是两个独立的「稳定地址」,切换与扩容都发生在端点背后;
- 主库故障时,写端点漂移到新主库(高可用层负责),读端点把故障备库摘除即可,两件事互不阻塞;
- 备库越多,读端点的负载均衡压力越大,这也是很多团队选择代理层方案的原因。
流量分配策略
决定「一条 SQL 去哪」的策略,按粒度递增:
| 策略 | 说明 | 适用 |
|---|---|---|
| 按连接 | 连接建立时指定目标(两个数据源) | 最简单,写后读由应用保证 |
| 按事务 | 事务一律走主库,自动提交的只读语句走备库 | 通用默认策略 |
| 按语句 | 解析 SQL:写语句/SELECT ... FOR UPDATE 走主库,纯 SELECT 走备库 | 代理层方案的核心能力 |
| 按业务 | 声明式指定:报表模块读备库,交易模块全走主库 | 业务可清晰切分时 |
| 按比例 | 读流量按比例分配到备库(考虑备库规格差异) | 备库规格不均时 |
按语句解析有明确的局限:只看 SQL 文本无法知道语义。例如 SELECT nextval(...) 是只读语句但不能在备库执行;带副作用的函数(pg_sleep 无所谓,但自定义的写表函数在 SELECT 里调用)会让纯文本路由出错。所以代理层方案必须保留「强制走主库」的逃生通道(注释 hint、白名单)。
备库数量与负载均衡
备库数量取决于读吞吐需求与容错要求:
- 一主一备:读写分离收益有限(只是分走一半读),但故障转移的最小配置;
- 一主两备:常见起点——一台承接读流量,另一台做热备与容错,读流量与高可用互不挤占;
- 一主 N 备:读扩展为主;要考虑主库的 walsender 上限(
max_wal_senders)、级联复制(下游备库从备库拉 WAL,减轻主库压力)与归档带宽。
多备库负载均衡的注意点:
- 备库规格不同时要加权分配(16GB 备库和 64GB 备库均分流量是常见事故);
- 备库故障要自动摘除并告警,读端点绝不能把流量发给失联节点;
- 延迟差异大的备库做分层:低延迟备库服务实时性敏感的读,高延迟备库服务报表类读;
- 级联复制的下游备库延迟天然更高一档,适合放报表与分析类流量。
与连接池的配合
连接池是读写分离架构里的常客,但连接池不等于读写分离——它解决连接复用,不解决路由。两者的正确组合方式:
应用 → 应用内连接池(写池 + 读池分开) → 主库 / 备库
应用 → PgBouncer(每池一个后端指向) → 主库 / 备库
应用 → Pgpool-II(既是池也是路由) → 主库 / 备库关键规则:
- 写池与读池必须分开,不能共用(否则「会话粘滞」「事务一致性」全部失效);
- PgBouncer 是纯连接池,不做读写路由——应用连 PgBouncer 时要开两个端口/两个池分别指向主库与备库;
- 事务内的连接复用要以事务边界为准:事务开始到提交之间,连接不能跨节点(框架层的事务粘滞就是保证这一点);
- 连接池的
server_reset_query等重置机制要在主备两端行为一致,避免会话状态(如SET过的参数)串库。
延迟监控与自动降级
读写分离体系必须回答「备库延迟多大时不能再读它」。标准做法是给读路由加延迟熔断:
延迟熔断的参考逻辑:
1. 采集:每个备库的 replay_lag_bytes 与 replay_delay
2. 阈值:设定字节阈值与时间阈值(如 1MB / 5s,按业务敏感度定)
3. 动作:超阈值 → 该备库从读端点摘除 → 读流量回主库(或剩余健康备库)
4. 恢复:延迟回落后自动(或人工确认)重新加入
5. 告警:熔断动作本身必须告警——它意味着读能力降级监控查询组合(主库视角):
SELECT application_name, state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication; application_name | state | lag_bytes | write_lag | flush_lag | replay_lag
------------------+-----------+-----------+------------+------------+------------
standby1 | streaming | 0 | 00:00:00.0004 | 00:00:00.0012 | 00:00:00.0035
standby2 | streaming | 82432 | 00:00:00.9 | 00:00:00.9 | 00:00:01.2
(2 rows)standby2 的延迟接近 1 秒、累积 80KB,如果业务阈值是 1 秒,监控应在它跨线前预警而不是等熔断发生后才看见。
常见坑
读写分离的坑大多与「SQL 语义 vs 物理复制」的错配有关,逐个点名:
| 坑 | 现象 | 根源与对策 |
|---|---|---|
| 事务内读写混用 | 事务内写了但读不到 | 事务必须整体走主库;框架层事务粘滞兜底 |
| 临时表 | 备库上会话相关行为与主库预期不一致 | 临时表操作留在主库连接里 |
序列 nextval() | 备库报错无法执行 | 序列取值是写操作,必须走主库(详见下一页) |
SELECT ... FOR UPDATE | 备库报错或行为异常 | 行锁是写意图,必须走主库 |
| DDL | 备库报错只读 | DDL 是写操作,走主库 |
| 大查询拖垮复制 | 备库重放被取消或延迟飙升 | 长查询与重放冲突,配置 hot_standby_feedback/max_standby_streaming_delay(下一页) |
| 连接串指错 | 「读流量全在主库」或「写进备库报错」 | 路由配置核对 + 启动时用 pg_is_in_recovery() 自检 |
| 读端点指向主库 | 备库空转,读压力没分走 | 监控各节点 QPS 分布 |
其中「事务内读写混用」是最高频也最隐蔽的坑:单测环境延迟为零,问题不暴露;上生产偶发「读不到刚写的数据」,排查半天发现是某个事务里的读被路由到了备库。规则要定为:只要进入写事务,连接全程钉死在主库。
什么业务适合读写分离
读写分离不是万能扩展,它适合与不适合的场景界限分明:
| 适合 | 不适合 |
|---|---|
| 读多写少(读写比 5:1 以上) | 写吞吐是瓶颈 |
| 读请求容忍秒级延迟(列表、搜索、报表) | 强一致读占比高(扣减库存、余额校验) |
| 复杂分析查询可隔离到专用备库 | 单条查询本身慢(需要的是索引/优化,不是备库) |
| 已有流复制高可用架构(备库是现成资源) | 写入需要横向扩展(需要的是分片/分区) |
一个实用的检验法:把「当前主库上最耗资源的 10 条 SQL」拉出来看——如果它们都是可容忍延迟的读,读写分离立竿见影;如果瓶颈是写、锁或单条慢查询,先做别的事。
实战示例
示例一:为应用准备写端点与读端点自检
应用启动时对两个端点各做一次角色自检,防止配置指错:
# 写端点必须是主库
psql -h primary.app.internal -c "SELECT pg_is_in_recovery();" pg_is_in_recovery
-------------------
f
(1 row)# 读端点必须是备库(若读端点负载均衡到多台,逐台检查)
psql -h replica.app.internal -c "SELECT pg_is_in_recovery();" pg_is_in_recovery
-------------------
t
(1 row)把这个检查放进部署流水线,路由配置错误在部署阶段就被拦截。
示例二:搭建延迟熔断的监控
-- 采集脚本:输出每个备库的延迟,超阈值输出告警标记
SELECT application_name,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes,
CASE
WHEN pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) > 1048576
THEN 'ALERT'
ELSE 'OK'
END AS status
FROM pg_stat_replication; application_name | lag_bytes | status
------------------+-----------+--------
standby1 | 0 | OK
standby2 | 3145728 | ALERT
(2 rows)示例三:LSN 等待实现写后读(PG17+)
-- 步骤 1:主库上提交写入并记录 LSN
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 7;
COMMIT;
SELECT pg_current_wal_lsn() AS lsn_after_commit; lsn_after_commit
------------------
0/3000D200
(1 row)-- 步骤 2:备库上等待追上后再读
SELECT pg_wal_replay_wait('0/3000D200'::pg_lsn);
SELECT balance FROM accounts WHERE id = 7; pg_wal_replay_wait
--------------------
(1 row)
balance
---------
900
(1 row)返回即为追上;等待超时则收到警告,此时应回退主库读。
示例四:Laravel 框架层读写分离配置
// config/database.php —— 框架自动路由读/写连接
'pgsql' => [
'driver' => 'pgsql',
'host' => 'primary.app.internal', // 写连接默认目标
'database' => 'appdb',
'username' => 'app_user',
'password' => 'secret',
'charset' => 'utf8',
'prefix' => '',
'read' => [ // 读连接目标(可多个,框架做负载均衡)
'host' => ['replica1.app.internal', 'replica2.app.internal'],
],
'write' => [ // 写连接目标
'host' => ['primary.app.internal'],
],
'sticky' => true, // 请求内写过之后,后续读复用写连接
],sticky = true 是框架对 read-your-writes 的最小实现:同一个请求周期内执行过写,该请求后续的读都走写连接。请求结束即失效,跨请求仍可能读到备库旧数据——关键路径仍需显式处理。
示例五:验证读流量确实被分走
-- 在主库上统计活跃连接的来源分布
SELECT client_addr,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) AS total
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY client_addr
ORDER BY total DESC; client_addr | active | total
--------------+--------+-------
10.1.0.20 | 3 | 60
10.1.0.21 | 1 | 8
(2 rows)如果原本期望「应用经读端点打到备库」,主库上却看到大量来自应用网段的连接,说明读路由没有生效(读端点配错、DNS 指错、连接池池化复用了写连接),要逐层排查。
示例六:备库不可用时的降级演练
# 演练:停掉一台备库,验证读端点摘除与流量回退
ssh replica2-host "sudo systemctl stop postgresql"
# 读端点(负载均衡器)健康检查摘除该节点后:
# 1. 读流量不再打向 replica2(查负载均衡器状态页)
# 2. 剩余备库与主库读流量上升,观察主库连接数是否逼近上限
psql -h primary.app.internal -c \
"SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';" count
-------
78
(1 row)演练要验证的临界点:全部备库同时不可用时,主库能否独自扛住全部读流量(连接数上限、CPU、内存)。如果扛不住,说明读端点需要「全量回主」之外的降级策略(限流、只读缓存兜底)。
示例七:估算 WAL 产生速率与备库追赶时间
延迟熔断阈值与容量规划都需要回答「主库每秒产生多少 WAL」。用两次采样做差:
-- 第一次采样(记录时间与 LSN)
SELECT pg_current_wal_lsn() AS lsn1, clock_timestamp() AS t1; lsn1 | t1
-----------+-------------------------------
0/3000C100 | 2026-09-30 14:00:00.000000+08
(1 row)-- 一分钟后第二次采样并求差
SELECT clock_timestamp() AS t2,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), '0/3000C100'::pg_lsn)
) AS wal_per_minute; t2 | wal_per_minute
--------------------------+----------------
2026-09-30 14:01:00+08 | 42 MB
(1 row)解读:每分钟约 42MB ≈ 每秒 0.7MB。假设某备库当前落后 100MB,在持续写入不衰减的前提下,追平大约需要 100 ÷ 0.7 ≈ 143 秒——这个估算直接回答了「熔断后多久能恢复读分发」。如果业务对读延迟的容忍上限是 5 秒,那么 delay_threshold 类阈值就应该设在 5 × 0.7 ≈ 3.5MB 附近,而不是拍一个 100MB。建议在业务高峰、平峰两个时段各采样一次,取较悲观值。
注意事项
读写分离只扩展读,不扩展写。 写瓶颈要靠写入优化、分区或分片解决,不要指望多加备库。
事务必须整体走主库。 事务内读写跨节点在语义上几乎总是错的,这是所有方案的第一铁律。
read-your-writes 是产品体验问题,不只是技术问题。 用户「提交后看不到」的投诉,往往比监控指标更早暴露一致性缺陷;关键路径的写后读要显式处理。
remote_apply 不是免费的。 它消除重放滞后的代价是提交延迟与备库故障时的写入阻塞;只在确有全局一致读需求时启用。
时间延迟指标在写入稀疏时会虚高。 now() - pg_last_xact_replay_timestamp() 在没有新事务时持续变大,熔断逻辑要结合字节差一起判断,否则深夜会误熔断。
备库不是「免费的读资源」。 每个备库消耗主库的 walsender、网络带宽,长查询还会与重放冲突;加备库前先评估长查询治理。
连接池与读写分离要协同设计。 读写池分开是底线;共用连接池时,「会话粘滞」与「事务边界」两个保证都会被池化破坏。
序列、锁、DDL 在备库上有专门行为。 看似只读的语句(nextval、FOR UPDATE)在备库不可用,路由层要按语义而非语法判断。
主库故障切换后读流量要自动跟随。 新主库提升后,原读端点对它的引用、复制拓扑的重建都要有预案,否则「切完主库发现读端点还指着旧节点」。
监控要覆盖「流量分布」而不只是「节点健康」。 备库健康但读流量没分过来(配置错误)是一种静默失效,各节点 QPS 分布是最直接的检测面。
阈值类参数要用数据推导而不是照抄。 延迟熔断的字节阈值依赖 WAL 产生速率,每个集群的答案都不同;抄来的阈值要么形同虚设,要么频繁误熔断。
备库数量与主库容量要联动评估。 每个备库消耗主库一份 WAL 发送带宽与 walsender 资源,扩备库前先确认主库与网络的余量,必要时用级联复制分层。
最佳实践
从「事务走主库 + 自动提交读走备库」这条默认策略起步。 它覆盖 80% 的场景且语义安全;更细的语句级优化等流量数据说话。
识别应用中的关键路径写后读,逐个处理。 处理手段按优先级:读主库 > LSN 等待(PG17+)>
remote_apply;不要试图用「全局同步复制」一刀切。写池与读池物理分开,启动时用
pg_is_in_recovery()自检。 配置错误在部署阶段拦截,比生产事故便宜一百倍。建立延迟熔断:字节阈值 + 时间阈值双条件。 阈值从业务敏感度反推(如「读延迟容忍上限 2 秒」),而不是拍脑袋定 1MB。
熔断与恢复都要告警留痕。 读能力降级是业务可见事件,静默熔断会让「备库挂了但没人知道」。
备库规格不同必须加权分配流量。 均分是备库异构环境里最高频的过载事故来源。
报表与分析类流量放专用下游备库。 用级联复制隔离重型查询,避免它们与实时读争抢同一台备库。
给备库数量留高可用余量。 「N 台备库全用于读」意味着任何一台故障都直接影响读容量;要么多一台热备,要么接受读降级预案。
定期演练「备库全灭」与「主库切换」的组合场景。 读写分离架构的真实可用性只有组合故障演练才能验证。
监控各节点 QPS 与连接数分布。 「流量没分走」是读写分离最常见的静默失效,流量分布是最早的信号面。
框架层方案要理解其粘滞语义的边界。 例如请求级 sticky 不覆盖跨请求一致性,关键路径需要显式增强而不是默认依赖。
把「读端点是否真的在读备库」写进例行巡检。 一条 SQL 的成本,换的是对整个架构是否按预期运转的确认。
下一节
继续学习:备库只读查询(hot_standby)
参考链接
- PostgreSQL 官方文档:Hot Standby
- PostgreSQL 官方文档:High Availability, Load Balancing, and Replication
- PostgreSQL 官方文档:Replication — Configuration Parameters
- PostgreSQL 官方文档:Monitoring Database Activity — pg_stat_replication / pg_stat_database_conflicts
- PostgreSQL 官方文档:System Administration Functions(pg_wal_replay_wait)
- PostgreSQL 官方文档:Log-Shipping Standby Servers
- Pgpool-II 官方文档
- PgBouncer 官方文档
- Laravel 官方文档:Database — Read / Write Connections