PostgreSQL 流复制与高可用切换:主从延迟、同步提交与 Spring Boot 读写分离的边界
1. 从一个线上困惑说起:刚下单为什么查不到
假设你在做一个订单系统。用户在 App 上点了"提交订单",接口返回成功,页面跳转到订单详情,结果提示"订单不存在"。用户再点一次,又出现了两条订单。日志里写库明明是成功的,查库却查不到。这个现象背后,最常见的原因不是事务没提交,而是写请求和读请求被路由到了两台不同的数据库实例:写到了主库,读走到了还没有追上主库的备库。
这就是 PostgreSQL 流复制和高可用方案要解决的核心矛盾:我们希望用多台数据库提升吞吐和容灾能力,但多台数据库之间的数据不是瞬时一致的。理解这套体系,不是先记住 wal_level、synchronous_commit 这些参数,而是先想清楚"一次写入从产生到能被别的节点读到,中间经历了哪些环节,每个环节会带来什么代价"。
本文面向正在使用或准备使用 PostgreSQL 的 Java 后端开发者、数据库工程师和架构师。我们会先建立整体框架,再逐步展开流复制、同步提交、主从延迟、故障切换和读写分离,最后用 Spring Boot 示例把边界落到实处。
2. 一句话模型与全局框架
先记住一个最小模型:PostgreSQL 的主库先把变化写进自己的预写日志(WAL,Write-Ahead Log),再通过一条常驻连接把日志流式发给备库,备库不断重放日志来保持数据接近;主从延迟就是备库"重放到哪"和主库"写到哪"之间的时间差或字节差。
这句话里有四个关键角色:主库(primary)、备库(standby)、WAL 日志、复制连接。一次写入的完整流转顺序可以用下面的流程图表示:
text
客户端写入请求
|
v
[主库 backend 进程]
| 1. 写 WAL 到本地 wal buffer
v
[WAL writer / commit]
| 2. WAL 落盘到 pg_wal
v
[WAL sender 进程] -- 3. 通过网络流式传输 WAL --> [备库 WAL receiver 进程]
| |
| v
| 4. 写入备库 pg_wal
| |
| v
| [备库 startup 进程]
| 5. 重放 WAL 到数据文件
v
[主库返回提交成功]
看这条链路时要抓住两个"进度点":主库的 WAL 写入位置(通常看 pg_current_wal_lsn())和备库的重放位置(通常看 pg_last_wal_replay_lsn())。它们之间的差值就是延迟的量化来源。后面讨论同步提交时,我们关心的是"主库在哪个进度点才敢向客户端返回成功";讨论读写分离时,我们关心的是"读请求走到备库时,备库重放到了哪个进度点"。这两个问题的答案不同,正是很多误解的根源。
3. 流复制到底复制了什么
3.1 WAL 是复制的最小单位
PostgreSQL 不做逻辑上的"把这一行复制过去",而是把物理层面的 WAL 记录发过去。WAL 记录描述的是"在哪个数据页做了什么修改",例如在某个页面偏移处写入新元组、更新事务状态等。备库收到这些记录后,像做一次重放一样,把相同的数据页修改应用到自己的数据目录。
这个设计的好处是复制链路简单、对主库业务代码透明:无论是 INSERT、UPDATE、CREATE INDEX 还是 VACUUM,最终都会产生 WAL,都会被复制。代价是备库必须是主库的物理副本,数据目录结构一致,不能只选几张贴过去。下面的命令配置就是一个最小可用的流复制环境。
text
# 主库 postgresql.conf 关键配置
wal_level = replica
max_wal_senders = 10
wal_keep_size = 512MB
listen_addresses = '*'
# 主库 pg_hba.conf 允许备库使用复制连接
host replication repl_user 0.0.0.0/0 scram-sha-256
在主库执行创建复制用户和查看复制状态:
sql
CREATE ROLE repl_user WITH REPLICATION LOGIN PASSWORD 'repl_pass';
-- 在主库查看当前 WAL 写入位置和备库接收位置
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication;
预期结果是每个已连接的备库一行,state 通常是 streaming,replay_lag_bytes 表示备库重放落后主库的字节数。这个查询是后面所有延迟讨论的起点:没有测量,就没有延迟判断。
3.2 备库的两种启动方式
备库第一次建立时,通常用基础备份(base backup)把主库的数据目录复制过去,然后进入持续重放 WAL 的状态。备库的 standby.signal 文件告诉 PostgreSQL"我是一个备库,请持续恢复 WAL 而不是结束恢复"。备库启动后,会通过 primary_conninfo 连接主库,并请求从指定位置开始发送 WAL。
在实际部署里,我们常常还会在中间加一层 WAL 归档或复制槽(replication slot)。复制槽能保证主库不会在备库确认接收前删除备库还需要的 WAL,避免因为网络抖动导致备库追不上、需要重新做基础备份。代价是如果备库长期离线,主库的 pg_wal 会持续增长,所以必须配合磁盘监控。
3.3 级联复制与多备库
当主库连接数或网络出口有限时,可以让一个备库再作为其他备库的上游,这叫级联复制。它的数据流是主库 → 中间备库 → 下游备库。级联复制减轻了主库的发送压力,但增加了一级延迟,而且中间备库一旦故障,下游备库也会中断。选择级联还是让所有备库直连主库,本质上是在主库发送压力、延迟层级和故障域之间做取舍。
4. 同步提交:主库什么时候敢返回成功
4.1 异步提交与同步提交的差别
默认情况下,PostgreSQL 是异步提交:主库把 WAL 写到本地磁盘后,就可以向客户端返回提交成功,不需要等备库确认。如果此时主库突然宕机且无法恢复,那些还没来得及发送到备库的 WAL 就丢失了,切换后备库会丢掉最新的一小段数据。
同步提交则要求主库在返回成功前,至少等待指定数量的备库确认收到(更严格地说,确认已经写入或刷盘)WAL。配置方式是在主库设置:
text
# 主库 postgresql.conf
synchronous_commit = on # 默认级别,等待本地 WAL 刷盘
synchronous_standby_names = 'FIRST 1 (standby1, standby2)'
这样主库会等待 standby1 或 standby2 中任意一台确认,才向客户端返回提交成功。这带来了一个直接结论:同步提交不是在优化延迟,而是在用延迟换数据可靠性。
4.2 几个容易混淆的同步级别
synchronous_commit 有几个取值,它们影响的是"本地 WAL 刷盘"和"等待备库"两个维度:
| 取值 | 本地 WAL 刷盘 | 等待备库 | 典型代价 |
|---|---|---|---|
off |
不保证立即刷盘 | 不等待 | 最快,但崩溃可能丢最近若干事务 |
local |
等待本地刷盘 | 不等待 | 单机安全,主库故障可能丢未同步数据 |
on |
等待本地刷盘 | 等待同步备库确认 | 最常用,延迟受备库和网络影响 |
remote_write |
不等待本地刷盘 | 等待备库写入操作系统缓存 | 备库崩溃可能丢,主库崩溃也可能丢 |
remote_apply |
等待本地刷盘 | 等待备库完成重放 | 读备库一致性最好,延迟最大 |
最容易误解的是 remote_write 和 remote_apply。前者只要求备库把 WAL 写进它的操作系统缓存,后者要求备库已经应用到数据文件,也就是读备库已经能看到这笔数据。如果你的业务需要"写成功后立刻从备库读到",remote_apply 更接近目标,但它对延迟更敏感,通常要配合本地网络和低负载备库。
4.3 同步提交的失败模式
同步提交最大的风险不是慢,而是"同步备库全挂"时主库会阻塞。synchronous_standby_names 指定必须等待某个备库,如果那个备库失联,已提交事务会一直等待,直到超时或配置被调整。这在高可用设计里非常危险:你为了数据可靠把主库变成了必须依赖备库才能服务。
工程上的常见做法是配置多个候选备库,并用 FIRST 1 (...) 或 ANY 1 (...) 指定"任意一个确认即可"。这样单台备库故障不会阻塞主库。更激进的做法是使用 synchronous_commit = on 配合外部仲裁组件,或者接受异步提交、用备份恢复应对极小概率的数据丢失。没有绝对正确的答案,关键是明确业务能承受哪种失败。
5. 主从延迟的量、因、测
5.1 延迟有两种,不要混着看
第一种是传输延迟:主库发送了 WAL,但备库还没收到或还没写入。第二种是重放延迟:备库已经收到了 WAL,但还没把它应用到数据文件。前者影响的是"数据有没有离开主库",后者影响的是"读备库能不能看见"。pg_stat_replication 里的 write_lsn、flush_lsn、replay_lsn 正好对应这条链路上的三个进度点。
第二个重要概念是"延迟是变化的"。网络抖动、备库磁盘慢、备库上长查询与 WAL 重放抢资源、主库批量写入产生大事务,都会让延迟在几毫秒到几分钟之间波动。所以判断延迟不能只看某一瞬间,而是看持续时间、峰值和恢复速度。
5.2 如何测量延迟
最常用的方法是在主库查询 LSN 差值:
sql
-- 在主库执行:查看每个备库的字节延迟
SELECT application_name,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes,
replay_lag
FROM pg_stat_replication;
replay_lag 返回的是一个时间间隔,表示备库重放落后主库多久。很多团队会把它作为告警指标。但要注意,replay_lag 在当前没有新 WAL 时可能显示为空或零,并不代表备库一定完全一致;更稳妥的方式是结合字节延迟和应用层时间戳探针。
应用层探针的做法是:在主库写入一条带时间戳的心跳记录,在备库读取最新记录,用两者时间差衡量端到端延迟。它更贴近业务真实感受,但需要额外的表和清理逻辑。
5.3 延迟太大的常见原因
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| replay_lag 持续升高 | 备库重放速度跟不上写入速度 | 备库 CPU、磁盘 I/O、是否有长查询 |
| sent_lsn 落后 current_wal_lsn | 网络带宽不足或 WAL sender 阻塞 | 网络吞吐、主库发送进程状态 |
| 延迟突然飙升后恢复 | 主库大事务或批量写入 | 是否执行了批量 UPDATE/DDL |
| 只有某个备库延迟大 | 该备库资源不足或配置落后 | 单机监控、级联复制层级 |
这里最容易误解的是"延迟大一定是网络问题"。实际排查中,备库磁盘写入能力、max_wal_size、检查点频率、以及备库上并行执行的分析查询,都可能成为瓶颈。尤其是备库上有长事务或长查询时,PostgreSQL 需要保留旧版本数据,这会拖慢清理并间接影响重放效率。
6. 故障切换:从主库不可用到恢复服务
6.1 切换的判定与步骤
故障切换要回答三个问题:怎么判断主库真的不可用、谁来决定切换、切换后如何让应用和旧主库接受新状态。一个典型的手动切换流程如下:
text
1. 监控发现主库连接失败或心跳超时
2. 确认不是网络分区导致的误判
3. 选择一个 replay_lsn 最靠前的备库
4. 在该备库执行 pg_ctl promote,使其成为新主库
5. 将其他备库指向新主库继续复制
6. 应用侧切换写连接串到新主库
7. 旧主库恢复后作为备库重新加入,必要时先做基础备份
这里最关键的是第 4 步之前的"确认"和第 6 步的"应用切换"。如果监控误判,把还活着的旧主库切走,就可能出现双主写入,数据分叉几乎无法自动修复。所以生产上通常不会让单个监控指标直接触发切换,而是结合多数派仲裁、人工确认或专门的 HA 组件。
6.2 切换后应用连接怎么变
PostgreSQL 客户端连接一旦建立,不会因为服务端角色变化而自动重定向。也就是说,即使备库被提升为主库,应用里指向旧主库的 JDBC 连接池仍然会失效或继续报错,必须让应用感知新的写入地址。常见方式有三种:把主库地址放在负载均衡或虚拟 IP 后面;使用支持拓扑感知的连接池;或者在应用里配合配置中心做动态切换。
这三种方式都要求应用能处理"写连接短暂失败并重试"的情况。重试必须是幂等安全的,否则一次提交成功但网络超时,重试可能生成两条订单。这个问题在读写分离场景下更突出,我们下一节展开。
6.3 切换不等于零丢失
异步复制下,主库宕机时尚未发送到备库的 WAL 会丢失,切换后这部分事务就没了。同步复制可以把丢失窗口缩小到零(对已确认的事务而言),但会引入阻塞风险。还有一点常被忽略:切换后原主库可能带着"未来"的数据重新加入,如果它曾经接受过未同步的提交,就必须先处理这部分分歧数据,通常通过重新做基础备份解决。
7. Spring Boot 读写分离的边界
7.1 读分离能解决什么,不能解决什么
读写分离把写请求发到主库,把读请求发到备库,从而把报表、列表、搜索这类读负载从主库上分走。它不能解决的是"写后立刻读"的一致性问题,因为备库可能还没重放完。它也不能自动处理备库故障:如果读请求路由到已经挂掉的备库,应用需要降级到主库或返回可接受的错误。
因此,读写分离的设计重点不是"怎么配两个数据源",而是"哪些读能容忍延迟,哪些不能"。可以先把业务读分成三类:强一致读(刚写就要读,例如订单详情跳转)、单调读(同一个用户至少不会读到更旧的数据)、最终一致读(列表、统计、报表)。前两类通常应走主库或采用粘性策略,第三类才适合走备库。
7.2 三种路由策略对比
| 策略 | 实现方式 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 强制主库读 | 写后一段时间内读主库 | 强 | 低 | 订单详情、支付结果 |
| 会话粘性 | 同一会话内读写都走主库 | 较强 | 中 | 用户中心、后台管理 |
| 延迟感知路由 | 根据延迟指标决定是否走备库 | 最终一致 | 高 | 报表、搜索、列表 |
延迟感知路由听起来很美,但实现时要小心:延迟指标本身有采集延迟,而且备库可能在路由决策后瞬间变慢。更稳妥的工程做法是"默认走主库,明确标注可容忍延迟的读走备库",而不是"默认走备库,出问题再回主库"。
7.3 写后读的一个具体修复
回到开头的订单问题。一个直接修复是:订单创建成功后,把订单 ID 或用户 ID 写入一个短期的"强制主库读"标记,后续该用户的订单详情查询在标记有效期内走主库。这个标记可以放在本地缓存或 Redis 中,过期时间略大于正常复制延迟。它不能替代正确的一致性设计,但能把用户可见的异常窗口压到很小。
8. 完整示例一:用 Docker 搭一套可观察的主从复制
8.1 目标与前置环境
目标:在一台机器上用 Docker 启动一个主库和一个备库,配置流复制,并能观察到 pg_stat_replication 的延迟字段。前置环境:已安装 Docker 和 Docker Compose,主机有至少 2GB 空闲内存。
下面是最小配置。它省略了生产需要的持久化目录和监控,但足以验证复制链路。
yaml
# docker-compose.yml
services:
pg-primary:
image: postgres:16
container_name: pg-primary
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
ports:
- "5432:5432"
command:
- "postgres"
- "-c"
- "wal_level=replica"
- "-c"
- "max_wal_senders=10"
- "-c"
- "wal_keep_size=256MB"
- "-c"
- "hot_standby=on"
pg-standby:
image: postgres:16
container_name: pg-standby
depends_on:
- pg-primary
ports:
- "5433:5432"
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
command:
- "bash"
- "-c"
- |
if [ ! -f /var/lib/postgresql/data/PG_VERSION ]; then
PGPASSWORD=postgres pg_basebackup -h pg-primary -U postgres -D /var/lib/postgresql/data -Fp -Xs -R
fi
exec docker-entrypoint.sh postgres
启动并验证:
text
docker compose up -d
docker exec -it pg-primary psql -U postgres -c "SELECT client_addr, state, replay_lsn FROM pg_stat_replication;"
8.2 关键步骤与预期结果
第一步,pg_basebackup 会在备库数据目录生成一个基础备份,并写入 primary_conninfo 和 standby.signal,让备库以流复制方式启动。第二步,主库的 pg_stat_replication 应该出现一行 state = streaming。第三步,在主库插入数据后,备库应能在很短时间内查到。
容易改错的地方有三个:pg_hba.conf 没有允许复制连接时,备库会一直重试;wal_level 不是 replica 或更高时,复制无法建立;备库数据目录不为空时,pg_basebackup 会失败。这个示例不能替代生产部署,因为它没有持久化、没有 TLS、没有监控,也没有处理主库重启后备库重连的细节。
9. 完整示例二:用 Java 测量复制延迟并做读路由判断
9.1 目标与前置环境
目标:写一个可独立运行的 Java 程序,连接主库和备库,读取主库的当前 WAL 位置和备库的重放位置,计算出延迟并据此决定一次读请求是否允许走备库。前置环境:JDK 17 及以上、Maven、PostgreSQL JDBC 驱动、前面启动的主备库。
这个示例不依赖 Spring Boot,先把机制看清楚。
java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
public class ReplicationLagProbe {
private static final String PRIMARY_URL = "jdbc:postgresql://localhost:5432/postgres";
private static final String STANDBY_URL = "jdbc:postgresql://localhost:5433/postgres";
private static final String USER = "postgres";
private static final String PASSWORD = "postgres";
public static void main(String[] args) throws Exception {
long primaryLsn = currentWalLsn(PRIMARY_URL);
long standbyLsn = lastReplayLsn(STANDBY_URL);
long lagBytes = primaryLsn - standbyLsn;
System.out.println("primary lsn = " + primaryLsn);
System.out.println("standby lsn = " + standbyLsn);
System.out.println("lag bytes = " + lagBytes);
boolean allowStandbyRead = lagBytes >= 0 && lagBytes < 1024 * 1024;
System.out.println("standby read allowed = " + allowStandbyRead);
}
private static long currentWalLsn(String url) throws Exception {
try (Connection conn = DriverManager.getConnection(url, USER, PASSWORD);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT pg_current_wal_lsn()")) {
rs.next();
return parseLsn(rs.getString(1));
}
}
private static long lastReplayLsn(String url) throws Exception {
try (Connection conn = DriverManager.getConnection(url, USER, PASSWORD);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT pg_last_wal_replay_lsn()")) {
rs.next();
String value = rs.getString(1);
return value == null ? 0 : parseLsn(value);
}
}
private static long parseLsn(String lsn) {
String[] parts = lsn.split("/");
long high = Long.parseLong(parts[0], 16);
long low = Long.parseLong(parts[1], 16);
return (high << 32) | low;
}
}
9.2 关键步骤与边界
程序先连主库拿 pg_current_wal_lsn(),再连备库拿 pg_last_wal_replay_lsn(),把 LSN 字符串按十六进制解析成可比较的数值后相减。预期输出是两次 LSN 和一个字节差值。当差值小于 1MB 时,认为可以走备库读。
这里有几个容易改错的地方。第一,pg_last_wal_replay_lsn() 在备库尚未重放任何 WAL 时可能返回空,必须处理 null。第二,LSN 是 64 位值,用两个 32 位十六进制段拼接时要避免符号扩展,代码里用 long 和左移 32 位是合理做法,但在其他语言里要小心。第三,这个阈值是拍脑袋的,真实系统应该结合业务允许的延迟分布来设定,而不是照抄 1MB。
最重要的是边界:这个探测本身也会消耗连接和查询时间,不能在高频请求里每次调用。生产上应该做成后台定时采样,把结果暴露给读路由决策器,而不是在用户请求链路上同步探测。
10. 完整示例三:Spring Boot 中的读写路由与降级
10.1 目标与前置环境
目标:在 Spring Boot 应用里实现一个简单的读写路由:写操作走主库,标记为可延迟的读走备库,并且当备库延迟过大时自动降级到主库。前置环境:Spring Boot 3.x、Spring JDBC、两个数据源分别指向主库和备库。
下面的示例使用 AbstractRoutingDataSource 做动态数据源切换。它是简化实现,但足以说明边界。
java
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
private static final ThreadLocal<Boolean> READ_ONLY = ThreadLocal.withInitial(() -> false);
public static void markReadOnly() {
READ_ONLY.set(true);
}
public static void clear() {
READ_ONLY.remove();
}
@Override
protected Object determineCurrentLookupKey() {
if (READ_ONLY.get() && LagMonitor.standbyHealthy()) {
return "standby";
}
return "primary";
}
}
用一个服务类演示写后读和可延迟读的差别:
java
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final JdbcTemplate jdbcTemplate;
public OrderService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public long createOrder(String userId, String sku) {
jdbcTemplate.update(
"INSERT INTO orders(user_id, sku, status) VALUES (?, ?, ?)",
userId, sku, "CREATED");
Long id = jdbcTemplate.queryForObject(
"SELECT currval(pg_get_serial_sequence('orders', 'id'))", Long.class);
// 明确告知后续该用户的读要走主库
ReadWriteRoutingDataSource.clear();
return id == null ? -1 : id;
}
public String queryOrderStatus(long orderId) {
ReadWriteRoutingDataSource.markReadOnly();
try {
return jdbcTemplate.queryForObject(
"SELECT status FROM orders WHERE id = ?", String.class, orderId);
} finally {
ReadWriteRoutingDataSource.clear();
}
}
public long countOrders() {
ReadWriteRoutingDataSource.markReadOnly();
try {
Long count = jdbcTemplate.queryForObject("SELECT count(*) FROM orders", Long.class);
return count == null ? 0 : count;
} finally {
ReadWriteRoutingDataSource.clear();
}
}
}
10.2 关键步骤、结果与边界
关键步骤:创建订单时走主库并清除只读标记;查询订单状态时显式标记为可延迟读,走备库;统计类查询同样走备库。finally 里清理 ThreadLocal,避免线程池复用导致标记泄漏。LagMonitor.standbyHealthy() 是前面 Java 探测器的定时封装,延迟过大或备库不可用时返回 false,让路由回退到主库。
预期结果:在高延迟时,查询订单状态会透明地回到主库,用户不会看到"订单不存在"。容易改错的地方包括:忘记清理 ThreadLocal,导致后续请求被误判;在事务中切换数据源,因为事务一旦在主库开启,后续读不会自动切到备库,反而可能因为路由不一致报错;以及把 @Transactional(readOnly = true) 当成自动读分离,实际上它只是提示,不会帮你选数据源。
这个示例不能替代成熟的读写分离中间件。它没有处理多数据源事务、没有连接池隔离、没有动态权重,但它清楚地展示了读写分离的边界:路由决策必须建立在对延迟和业务容忍度的明确判断上。
11. 常见误区
第一个误区是"配了流复制就等于高可用"。流复制只解决数据副本问题,故障检测、切换决策、应用重连、旧主库回归,都是高可用的一部分。没有这些,复制只是多了一台会读旧数据的机器。
第二个误区是"同步提交一定比异步好"。同步提交提供更强的一致性保证,但它会让主库在同步备库故障时阻塞。选择同步还是异步,取决于业务能承受的是"丢一点数据"还是"停一会儿服务"。
第三个误区是"replay_lag 为零就代表数据一致"。在备库空闲、主库没有新写入时,这个值可能为零或为空,但这并不证明某条特定业务数据已经可见。要判断具体业务的一致性,需要用业务探针或直接读主库。
第四个误区是"读写分离只改配置就行"。真正的难点在应用侧的一致性策略、重试幂等和降级路径。数据库层面只提供复制,路由和一致性判断必须由应用或中间件负责。
12. 生产实践建议
第一,把延迟测量做成常态化指标,至少采集字节延迟和重放时间延迟,并对持续升高的趋势告警,而不是只对瞬时阈值告警。
第二,同步提交只用于确实不能丢的数据,并配置多个候选同步备库,避免单点阻塞主库。对日志、行为埋点这类数据,异步提交通常更合适。
第三,切换流程要演练。切换脚本、应用重连、旧主库回归这三个环节都要在预发环境完整跑通,否则真实故障时第一次执行的脚本大概率会失败。
第四,读写分离默认保守。让"可延迟读"成为显式选择,而不是默认行为,这样新代码不会无意中引入不一致。
第五,监控要覆盖复制槽积压、pg_wal 磁盘增长、备库重放冲突和长查询。这几项是流复制环境里最常见的慢性问题。
13. 排障清单
当出现"写后读不到"时,按下面顺序检查:备库是否在重放,pg_last_wal_replay_lsn() 是否推进;pg_stat_replication 里 replay_lsn 与主库当前 LSN 的差值是否很大;应用是否把读请求路由到了备库;该请求是否处于本应走主库的强一致路径;是否存在主库大事务导致延迟突然升高。
当出现"切换后写入失败"时,检查应用连接串是否更新、连接池是否重建、新主库是否可写、旧主库是否还被某个连接池持有。
当出现"同步提交阻塞"时,检查 synchronous_standby_names 指向的备库是否在线、复制连接是否正常、是否有网络分区,以及是否有多个候选备库可以接管。
当出现"备库磁盘持续增长"时,检查复制槽是否被长期离线的备库占用、wal_keep_size 是否过大、归档是否失败。
14. 面试/复盘问题
- 流复制复制的是逻辑行还是物理 WAL?这对备库的使用有什么限制?
synchronous_commit的几个取值分别保证什么,代价是什么?pg_stat_replication中write_lsn、flush_lsn、replay_lsn的区别是什么?- 同步提交下同步备库全部故障,主库会发生什么?
- 读写分离下如何解决写后立刻读的一致性问题?
- 故障切换时如何避免双主写入?旧主库恢复后如何处理?
- 主从延迟突然升高,你会按什么顺序排查?
15. 总结
回到开头的问题:用户提交订单后查不到,本质是读请求走到了尚未重放完成的备库。要解决它,需要同时理解三层:复制层负责把 WAL 从主库传到备库;提交层决定主库何时向客户端确认成功;应用层决定读请求走哪台库、在什么条件下走。
可以用一张选择清单收束全文:需要强一致读时走主库;可以容忍延迟的读走备库;不能丢数据时考虑同步提交并配置多个候选备库;主库故障时要有经过演练的切换和应用重连流程;任何读写分离策略都必须先明确业务能接受多长的延迟。把这些问题回答清楚,技术选型就不再是参数堆砌,而是可验证的工程判断。
16. 参考资料
- PostgreSQL 官方文档:High Availability, Load Balancing, and Replication
- PostgreSQL 官方文档:Write-Ahead Logging (WAL) 与
synchronous_commit - PostgreSQL 官方文档:Monitoring Database Activity,
pg_stat_replication视图 - PostgreSQL 官方文档:
pg_basebackup、复制槽与热备 - Spring Framework 官方文档:JDBC 与
AbstractRoutingDataSource - PostgreSQL JDBC Driver 官方文档