
上次我们聊了 PG 性能优化(文章 #26),从配置调优到索引优化,把单机性能榨了个遍。但单机再强也有天花板——挂了怎么办?读压力大了怎么办?
这一篇,我们解决这两个问题。主从复制负责高可用(挂了有人顶),读写分离负责扛读压力(别让报表查询拖垮主库)。
很多同学一上来就跟着教程配,配完发现跟自己想要的不一样。我们先拉一下需求:
| 需求 | 用什么技术 | 是否必须 |
|---|---|---|
| 主库挂了能自动切换 | 流复制 + 监控工具(Patroni / repmgr) | ✅ 高可用必备 |
| 主库挂了,手动切过去也接受 | 流复制 + pg_ctl promote |
⚠️ 可接受停机 |
| 把查询请求分流到从库 | 读写分离中间件或应用层路由 | ✅ |
| 从库能查,但数据不能写 | Hot Standby(默认开启) | ✅ |
| 永远不丢数据 | 同步复制(synchronous_commit = on) | ⚠️ 有性能代价 |
| 允许丢几秒数据,但性能优先 | 异步复制(默认) | ✅ 大多数场景 |
老张点评: 99% 的生产环境用 异步流复制 + Hot Standby + 应用层读写分离 就够了。同步复制不加缓存的话,性能代价挺大,后面细说。
┌─────────────────────────────────────────────────┐
│ 客户端应用 │
└────────────────────┬────────────────────────────┘
│
▼
┌─────────────────┐
│ 读写分离路由器 │ ← PgBouncer / ProxySQL / 应用层
│ (Read/Write │
│ Split Router) │
└────────┬────────┘
│
┌─────────┴─────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Primary (主) │ │ Standby (从) │ ← 可多个
│ Read + Write │ │ Read Only │
│ wal_level=replica│ │ hot_standby=on │
└────────┬─────────┘ └─────────────────┘
│ WAL 流(TCP 连接)
└─────────────────────────────────►
streaming replication (pgoutput)
核心概念:
假设两台服务器:
| 角色 | IP | 数据目录 | PG 版本 |
|---|---|---|---|
| Primary | 192.168.1.10 | /var/lib/postgresql/16/main |
16 / 17 / 18 |
| Standby | 192.168.1.20 | /var/lib/postgresql/16/main |
同版本 ⚠️ |
⚠️ 重要:主从必须同大版本(16→16 或 17→17)。跨版本复制不支持。也建议保持小版本一致,非要混的话,从库先升级。
-- 在主库执行
CREATE ROLE repuser WITH REPLICATION LOGIN PASSWORD 'your_strong_password';
💡
REPLICATION权限不授权修改数据,只允许读取 WAL 流。比 SUPERUSER 安全得多。
# ── 复制相关 ──
wal_level = replica # 必须!replica 或以上
max_wal_senders = 10 # 最多同时连接几个从库(含备份工具)
wal_keep_size = 1024 # 保留 1GB WAL,防止从库追不上时被回收
max_replication_slots = 10 # 复制槽数量
# ── 网络 ──
listen_addresses = '192.168.1.10' # 监听内网 IP
# ── 归档(推荐) ──
archive_mode = on
archive_command = 'cp %p /archive/%f' # 归档到共享目录(可选的,但推荐)
wal_level 参数说明(PG 15+ 之后简化了):
minimal— 不产生足够复制信息,不能做流复制replica— 支持流复制和归档(PG 15+ 推荐,旧版叫hot_standby/archive)logical— 支持逻辑复制(更细粒度,可以复制指定表)
# 允许从库连接复制伪数据库
# TYPE DATABASE USER ADDRESS METHOD
host replication repuser 192.168.1.20/32 scram-sha-256
sudo systemctl restart postgresql
# 或
pg_ctl reload # 只是重载配置的话 reload 就够了
方法一:pg_basebackup(推荐)
在从库服务器上执行:
# 先清空从库数据目录
sudo -u postgres rm -rf /var/lib/postgresql/16/main/*
# 从主库拉取完整备份
sudo -u postgres pg_basebackup -h 192.168.1.10 -D /var/lib/postgresql/16/main \
-U repuser -P -v --wal-method=stream
关键参数:
| 参数 | 作用 |
|:----|:----|
| -h | 主库 IP |
| -D | 从库数据目录 |
| -U repuser | 复制用户 |
| -P -v | 显示进度 |
| --wal-method=stream | 备份过程中同步 WAL,避免漏数据 |
方法二:rsync(大库更稳)
如果数据库很大(TB 级),pg_basebackup 可能超时,可以用 rsync:
# 主库上
psql -c "SELECT pg_start_backup('full_backup', true);"
rsync -acv ${PGDATA}/ standby:/srv/pgsql/standby/ --exclude postmaster.pid
psql -c "SELECT pg_stop_backup();"
PG 12+ 不再用 recovery.conf,而是通过信号文件标记:
sudo -u postgres touch /var/lib/postgresql/16/main/standby.signal
# ── 复制连接 ──
primary_conninfo = 'host=192.168.1.10 port=5432 user=repuser password=your_strong_password'
primary_slot_name = 'standby1_slot' # 推荐使用复制槽
# ── 热备模式(允许只读查询) ──
hot_standby = on
# ── 防止从库查询冲突导致复制中断 ──
hot_standby_feedback = on
# ── WAL 接收 ──
wal_receiver_create_temp_slot = on # 如果没有预创建复制槽,自动创建
wal_receiver_status_interval = 5 # 每 5 秒向主库报告状态
在主库执行:
SELECT pg_create_physical_replication_slot('standby1_slot');
复制槽的用处:即使从库断了,主库也不会删掉从库还没收到的 WAL。不加复制槽的话,从库断联太久就可能需要重新做 base backup。
sudo systemctl start postgresql
在主库查看:
\x -- 扩展显示,方便阅读
SELECT * FROM pg_stat_replication;
输出示例:
-[ RECORD 1 ]----+------------------------------
pid | 12345
application_name | walreceiver
state | streaming ← ✅ 在流复制了
sync_state | async ← 异步模式
sent_lsn | 0/3002E120 ← 已发送的位置
write_lsn | 0/3002E120 ← 从库已写入
flush_lsn | 0/3002E120 ← 从库已刷盘
replay_lsn | 0/3002E120 ← 从库已回放
write_lag | 00:00:00.002145 ← 写入延迟 ~2ms
flush_lag | 00:00:00.003012 ← 刷盘延迟 ~3ms
replay_lag | 00:00:00.003521 ← 回放延迟 ~3ms
在从库查看:
-- 当前从库接收和回放位置
SELECT pg_is_in_recovery(); -- true = 是从库
SELECT pg_last_wal_receive_lsn(); -- 最后接收的 LSN
SELECT pg_last_wal_replay_lsn(); -- 最后回放的 LSN
SELECT pg_last_wal_replay_lsn() - pg_last_wal_receive_lsn() AS replay_lag_bytes;
-- 用进程查看也有信息
\x
SELECT * FROM pg_stat_wal_receiver;
延迟速查命令:
-- 主库执行:最后写入位置
SELECT pg_current_wal_lsn();
-- 从库执行:最后回放位置
SELECT pg_last_wal_replay_lsn();
-- 二者相减 ≈ 延迟字节数(可以用 pg_wal_lsn_diff())
-- 在主库执行:
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) AS send_pending,
pg_wal_lsn_diff(sent_lsn, write_lsn) AS write_pending,
pg_wal_lsn_diff(sent_lsn, flush_lsn) AS flush_pending,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_pending
FROM pg_stat_replication;
客户端 ──COMMIT──► 主库 ──WAL──► 从库
← 立即返回
客户端 ──COMMIT──► 主库 ──WAL──► 从库
← 等从库确认后才返回
配置方式:
# 主库 postgresql.conf
synchronous_standby_names = 'FIRST 1 (standby1, standby2)'
synchronous_commit = on # 默认就是 on
| synchronous_commit 值 | 从库确认到什么程度 | 安全性 |
|---|---|---|
on(默认) |
WAL 写入并刷盘 | ⭐⭐⭐ |
remote_write |
WAL 写入 OS 缓存,未刷盘 | ⭐⭐ |
remote_apply |
事务在从库已回放,查询可见 | ⭐⭐⭐⭐ |
local |
只等本地刷盘,不等从库 | ⭐(等同于异步) |
老张点评: 同步复制不是银弹。网络延迟有多大,写入变慢就有多少。如果主从在同一机房(<1ms 延迟),影响不大。跨机房的话,每个 INSERT 都等几十毫秒往返,高并发场景直接崩。
实用建议: 核心业务数据用同步(通过 session 级设置),普通数据用异步:
-- 在应用层,只对关键事务用同步
SET synchronous_commit TO remote_write;
INSERT INTO payments (...) VALUES (...);
SET synchronous_commit TO on; -- 恢复默认
Primary ──► Standby A ──► Standby B ──► Standby C
↑ 上游 ↑ 下游
当你有很多从库时,可以让从库挂从库,减轻主库连接压力:
# Standby A(既是接收者也是发送者)
max_wal_senders = 10 # 需要配
hot_standby = on # 需要是 hot standby
# Standby B(指向 Standby A)
primary_conninfo = 'host=192.168.1.21 port=5432 user=repuser ...'
| 方案 | 适用场景 | 复杂程度 | 维护成本 |
|---|---|---|---|
| 应用层硬编码 | 小项目,就 1~2 个从库 | ⭐ | 低 |
| PgBouncer | 连接池 + 简单路由 | ⭐⭐ | 低 |
| ProxySQL + PG | 复杂路由、多规则 | ⭐⭐⭐ | 中 |
| Pgpool-II | 全功能(含连接池、负载均衡) | ⭐⭐⭐⭐ | 高 |
| 应用框架(ORM) | Rails、Django 等内置支持 | ⭐ | 低 |
老张推荐: 对大多数项目,应用层判断 + PgBouncer 连接池是最优解。ORM 可以配置多个数据源,代码里区分读写就行。不需要引入额外的单点故障。
以 Python / SQLAlchemy 为例:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
# 配置主库和从库
engines = {
'write': create_engine('postgresql://user:[email protected]:5432/dbname'),
'read': create_engine('postgresql://user:[email protected]:5432/dbname'),
}
class RoutingSession(Session):
def get_bind(self, mapper=None, clause=None, **kwargs):
if self._flushing or self.info.get('write'):
return engines['write']
return engines['read']
# 使用
db = Session(bind=engines['write']) # 写操作
db.info['write'] = True
# 读操作自动走从库
result = Session(bind=engines['read']).execute('SELECT ...')
PgBouncer 本身不做读写分离,但可以配置两个端口/数据库名来区分:
[databases]
# 主库(读写)
mydb_write = host=192.168.1.10 port=5432 dbname=mydb
# 从库(只读,可以配多个做负载均衡)
mydb_read = host=192.168.1.20 port=5432 dbname=mydb
host=192.168.1.21 port=5432 dbname=mydb
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 500
default_pool_size = 50
# 事务级连接池(用完就还,非常适合读写分离)
应用层连接:
写操作 → pgbouncer://user:pass@host:6432/mydb_write
读操作 → pgbouncer://user:pass@host:6432/mydb_read ← 自动负载均衡
ProxySQL 支持基于正则的路由规则:
-- 添加 PG 后端
INSERT INTO mysql_servers (hostgroup_id, hostname, port) VALUES (0, '192.168.1.10', 5432); -- 写组
INSERT INTO mysql_servers (hostgroup_id, hostname, port) VALUES (1, '192.168.1.20', 5432); -- 读组
-- 设置路由规则:SELECT 走读组,其他走写组
INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup)
VALUES (1, 1, '^SELECT', 1);
INSERT INTO mysql_query_rules (rule_id, active, match_pattern, destination_hostgroup)
VALUES (2, 1, '^.*', 0);
LOAD MYSQL QUERY RULES TO RUN;
问题 1:主从延迟导致读到旧数据
用户写完立马刷新页面→读到的是从库数据,但从库还没回放完→用户以为自己操作失败了。
解决方案:
问题 2:从库查询把从库打死
假设你有个日报表查询,一次跑 5 分钟。如果这种查询跑到从库上,从库复制延迟会飙到几分钟。
解决方案:
statement_timeout 防止慢查询拖死复制# 从库的 postgresql.conf 额外加
statement_timeout = '30s' # 从库上超时短的查询直接掐掉,不让它拖慢复制
idle_in_transaction_session_timeout = '60s'
问题 3:从库因为查询冲突卡住复制
当从库上有个长事务在跑 SELECT,而此时主库在 VACUUM,VACUUM 删掉的元组在从库上还没被读取,PG 就会卡住复制。
解决方案:
# 从库
hot_standby_feedback = on -- 告诉主库:别急着 vacuum 我还在读的元组
-- 主库:查看所有从库的延迟情况
SELECT application_name,
state,
sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_lag_human,
EXTRACT(EPOCH FROM write_lag) AS write_lag_seconds,
EXTRACT(EPOCH FROM replay_lag) AS replay_lag_seconds,
backend_start
FROM pg_stat_replication;
-- 从库执行
SELECT pg_is_in_recovery(), -- 是否处于恢复模式
pg_is_wal_replay_paused(), -- 复制是否暂停
pg_last_wal_receive_lsn(), -- 最后接收位置
pg_last_wal_replay_lsn(), -- 最后回放位置
pg_size_pretty(pg_wal_lsn_diff(pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn())) AS unsolved_bytes;
| 指标 | 警告阈值 | 严重阈值 | 说明 |
|---|---|---|---|
replay_lag |
> 10 秒 | > 60 秒 | 从库落后太多 |
| 从库连接断开 | 断开 > 30 秒 | 断开 > 5 分钟 | 可能需重建复制 |
pg_wal 目录大小 |
> 10 GB | > 50 GB | WAL 堆积,复制可能有问题 |
| 复制槽堆积 | 任意槽 inactive > 1h | inactive > 24h | 可能撑爆磁盘 |
#!/bin/bash
# pg_replication_check.sh - 快速查看复制健康状态
PRIMARY_HOST="192.168.1.10"
STANDBY_HOST="192.168.1.20"
echo "=== 主库复制状态 ==="
psql -h $PRIMARY_HOST -c "
SELECT application_name, state, sync_state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS lag
FROM pg_stat_replication;
"
echo ""
echo "=== 从库状态 ==="
psql -h $STANDBY_HOST -c "
SELECT pg_is_in_recovery(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn();
"
echo ""
echo "=== 复制槽 ==="
psql -h $PRIMARY_HOST -c "
SELECT slot_name, slot_type, active,
pg_size_pretty(pg_wal_lsn_diff(COALESCE(restart_lsn,'0/0'), '0/0')) AS retained_wal
FROM pg_replication_slots;
"
步骤:
# 在从库上执行
sudo -u postgres pg_ctl promote -D /var/lib/postgresql/16/main/
# 或使用触发文件(PG 12 之前的方式)
touch /tmp/promote_trigger # 如果有 trigger_file 配置
从库被 promote 后会变成新的主库,开始接受写入。
原主库恢复后不能直接加回去(数据已经不一致了),需要重建:
# 在新主库上创建复制槽
psql -h 192.168.1.20 -c "SELECT pg_create_physical_replication_slot('old_primary');"
# 在旧主库上重新做 base backup
sudo -u postgres rm -rf /var/lib/postgresql/16/main/*
sudo -u postgres pg_basebackup -h 192.168.1.20 -D /var/lib/postgresql/16/main/ \
-U repuser -P -v --wal-method=stream
# 创建 standby.signal
sudo -u postgres touch /var/lib/postgresql/16/main/standby.signal
# 更新 primary_conninfo 指向新主库
# ...
如果启用了复制槽,从库连回来会自动从断点开始继续复制,无需重建。如果没启用复制槽,且 wal_keep_size 不够,从库落后太多 → WAL 已被回收 → 需要重新 base backup。
所以:一定要用复制槽!
-- 主库:创建复制槽(第一次配的时候做)
SELECT pg_create_physical_replication_slot('standby1_slot');
-- 从库 postgresql.conf(必须配置)
primary_slot_name = 'standby1_slot'
手动切换在大半夜出故障时是噩梦。生产环境建议上 Patroni:
| 工具 | 特点 |
|---|---|
| Patroni | 自动故障检测 + 自动切换,基于 DCS(etcd/consul) |
| repmgr | 轻量,事件驱动,手动/半自动切换 |
| pg_auto_failover | Citus 出品,简单易用 |
Patroni 的配置方式是另一个话题了,后面可以另写一篇专门讲。
自查步骤:
1. 从库 CPU 是不是跑满了?→ 从库有慢查询占资源
2. 有没有长时间运行的只读事务?→ 加 hot_standby_feedback
3. 网络带宽够不够?→ 检查主库网卡流量
4. 从库磁盘 IO 能跟上吗?→ iostat 看一下
5. 是不是有索引重建/大批量写入?→ 考虑批量写时暂时不查从库
大库备份时 WAL 发送器内存不够。主库调大 wal_sender_buffer 相关参数,或改用 rsync 方式。
排查:
1. ping 通吗?→ 防火墙/网络
2. pg_hba.conf 有 replication 条目吗?→ 检查
3. 密码对不对?→ 检查 .pgpass
4. listen_addresses 配置了吗?→ 主库只监听了 127.0.0.1?
检查 pg_replication_slots 中是否有不再活动的槽:
-- 查看所有槽
SELECT slot_name, slot_type, active,
pg_size_pretty(pg_wal_lsn_diff(COALESCE(restart_lsn,'0/0'), '0/0')) AS retained
FROM pg_replication_slots;
-- 删除已废弃的槽
SELECT pg_drop_replication_slot('dead_slot_name');
PG 17 引入了
max_slot_wal_keep_size限制复制槽最大保留的 WAL 量,超过后自动失效槽——建议设置:
max_slot_wal_keep_size = '10GB' -- 每个复制槽最多保留 10GB WAL
不能。Hot Standby 模式下从库严格只读,连临时表都不能写:
postgres=# CREATE TABLE test (id int);
ERROR: cannot execute CREATE TABLE in a read-only transaction
| 物理复制 | 逻辑复制 | |
|---|---|---|
| 复制粒度 | 整个实例 | 指定表 |
| 版本要求 | 必须同版本 | 可跨大版本 |
| 数据一致性 | 完全一致 | 行级,可过滤 |
| 从库可写 | ❌ | ✅(subscriber 可写) |
| 典型场景 | 高可用、读写分离 | 数据迁移、实时数仓 |
一句话:高可用用物理复制,数据同步用逻辑复制。
主从复制 + 读写分离是 PG 生产架构的基石,但并不复杂。记住这几个要点:
最后,一切配置都在这了,你可以在测试环境走一遍,从 pg_basebackup 到 promote 来回切两轮,确保团队每个人都清楚故障切换的手动步骤——别等真的挂了才开始学。
下一篇预告:PG 主从复制进阶——Patroni + etcd 搭建自动故障切换集群,敬请期待!
本文由老张整理编写。如果你实操中遇到问题,欢迎留言讨论。