PG 高可用实战:流复制 + pgBouncer 连接池——生产级部署方案
引言
"周四凌晨 2:17,主库服务器主板故障宕机,告警准时响了。值班同学打开手机看到'PG 主库不可达',翻运维手册、找从库、改应用配置、重启连接池——业务恢复时是 3:04,整整 47 分钟。好在是凌晨,只积压了十几分钟的订单,如果是大促高峰,这个数字会是几十万笔。"
复盘会上两个结论:手动切换不可接受,必须让"检测故障 → 选举新主 → 提升从库 → 流量切过去"全程自动化,把 47 分钟压到 30 秒以内;同时排查中发现主库 8000 多个连接、40GB 内存被几百个空闲会话白白吃掉——MySQL 一个连接一条线程尚可硬扛,PG 是一个连接 fork 一个进程,连接数根本开不起太高,必须上连接池。
最终落地的就是生产圈公认的"高可用三件套":流复制负责把 WAL 日志实时搬到从库,保证主库挂了有库可切;Patroni 负责故障检测、选主和 VIP 漂移,把切换 RTO 从 47 分钟压到 28 秒;pgBouncer 负责把应用的几千个连接收敛成后端几十个真正的 PG 进程,内存从 40GB 降到 6GB。这篇文章按部署一个新集群的真实顺序展开:先把流复制跑通,再权衡同步/异步丢不丢数据,然后加 Patroni 让切换自动化,最后套上 pgBouncer 治理连接——全套配置基于 PostgreSQL 15 + Patroni 3.x + pgBouncer 1.21+,可直接抄。
系列前置阅读:为什么从 MySQL 迁 PG、以及 PG 的性能底子见《为什么迁移 PG》《MySQL vs PG 性能横评》;单库层面的优化见《PG 性能调优实战》《PG 分区表实战》。本文解决的是"库挂了怎么办"和"连接数爆了怎么办"这两个单机优化救不了的问题。
一、全景:单点故障的代价与高可用三件套
1.1 两个 R 词:先把目标定清楚
讨论高可用前必须先明确两个指标,后面所有权衡都围绕它们:
| 指标 | 含义 | 本文方案的能力 |
|---|---|---|
| RPO(Recovery Point Objective) | 最多丢多少数据 | 异步复制:秒级;同步复制:接近零丢失 |
| RTO(Recovery Time Objective) | 服务中断多久 | 手动切换约 30~60 分钟;Patroni 自动切换约 20~40 秒 |
先说一个反直觉但必须写在最前面的事实:复制不是备份。主库上 DROP TABLE 会在毫秒级同步到所有从库, replication 救不了逻辑错误。所以完整的生产方案 = 流复制(高可用)+ pgBackRest/WAL-G 定期全量备份与 WAL 归档(PITR 时间点恢复)+ 可选的延迟从库(防误删缓冲)。本文聚焦高可用三件套,备份体系单独立项。
1.2 目标架构
应用集群(JDBC,前端连接可到几千)
│
▼
pgBouncer(pool_mode=transaction,前端 max_client_conn=5000)
│ 后端真实连接仅 50 × N 个
▼
VIP 10.0.0.100 —— 始终漂在"当前主库"所在节点
│
┌─────────────────┐ WAL 流复制(单向) ┌─────────────────┐
│ pg1 10.0.0.11 │ ───────────────────▶ │ pg2 10.0.0.12 │
│ 当前主库(Primary)│ pg3 10.0.0.13 │ 从库(Standby) │
│ Patroni+watchdog│ ◀──── 建议至少两从 ── │ Patroni+watchdog│
└─────────────────┘ └─────────────────┘
│ ┌──────────────┐ │
└──────────▶ │ etcd 集群×3 │ ◀──────────┘
│ Leader 租约 │
└──────────────┘
三个组件各司其职,边界要分清:etcd/Patroni 管"谁是主"(控制面),WAL 流复制管"数据到哪了"(数据面),pgBouncer 管"谁能进来多少"(入口面)。任何一层缺位,事故形态都不同:没有复制 → 主库挂了无库可切;没有 Patroni → 有从库但切换靠人;没有 pgBouncer → 应用一扩容主库进程数先爆。
二、流复制:WAL 日志的实时搬运
2.1 WAL:一切复制的基石
PostgreSQL 每次事务提交前,先把变更记录写到预写日志(WAL,Write-Ahead Log)并确保落盘——这就是"先写日志、后写数据页"。WAL 是崩溃恢复的依据,也天然是复制的载体:把主库产生的 WAL 字节流实时发给从库,从库落盘后回放,就得到了一份持续追赶主库的数据副本。 本文用的是物理流复制(字节级复制数据页),逻辑复制(表级、可跨版本、可写从库)适合订阅/迁移场景,不承担高可用主责,两者对比见《为什么迁移 PG》。
2.2 主库配置
# postgresql.conf(PG 15)
listen_addresses = '*'
wal_level = replica # 复制所需的最小级别;logical 用于逻辑订阅
max_wal_senders = 10 # 允许的 WAL 发送进程数
max_replication_slots = 10 # 复制槽数量
wal_keep_size = 2GB # 不依赖槽时,旧 WAL 至少保留多少(PG13+,旧版为 wal_keep_segments)
hot_standby = on # 从库可提供只读查询
# pg_hba.conf:放行复制账号
host replication replicator 10.0.0.0/24 scram-sha-256
-- 建专用复制账号
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '复制密码请走密钥管理';
2.3 一条命令拉起从库
PG 12 之后配置方式有重大变化:不再有 recovery.conf,改用 standby.signal 空文件标识从库角色。用 pg_basebackup -R 一条命令完成克隆+配置:
# 在 pg2 上执行:全量拷贝主库数据目录,-R 自动生成 standby.signal 和 primary_conninfo
pg_basebackup -h 10.0.0.11 -U replicator \
-D /data/pgdata -Fp -Xs -P -R
# -Fp plain 格式;-Xs 流式拉取 WAL;-P 显示进度;-R 写 recovery 配置
生成的关键文件:
# /data/pgdata/postgresql.auto.conf(-R 自动写入)
primary_conninfo = 'host=10.0.0.11 port=5432 user=replicator application_name=pg2 ...'
# /data/pgdata/standby.signal —— 空文件,存在即从库
注意 application_name=pg2:它是后续同步复制、监控 SQL 里识别每个从库的标识,务必规范命名。启动后验证:
-- 主库上执行:看到一行 walreceiver 即复制建立
SELECT application_name, state, sync_state,
write_lag, flush_lag, replay_lag,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_bytes
FROM pg_stat_replication;
-- application_name | state | sync_state | replay_lag | replay_bytes
-- pg2 | streaming | async | 00:00:00.002 | 0
2.4 复制槽:别让 WAL 断供,也别让它吃满磁盘
网络抖动导致从库短暂掉线时,主库可能已经回收了它还没收到的 WAL,从库就得重新 basebackup。**复制槽(replication slot)**让主库为每个从库保留从库尚未确认的 WAL:
-- 主库创建物理复制槽(从库 primary_slot_name 指向它)
SELECT pg_create_physical_replication_slot('slot_pg2');
# 从库 postgresql.auto.conf 追加
primary_slot_name = 'slot_pg2'
但槽是把双刃剑:从库长期宕机时,槽会阻止主库回收 WAL,把磁盘静悄悄撑爆——这是社区里最经典的翻车现场之一。必须加保险:
# 主库:单个槽允许保留的 WAL 上限,超过后强制回收并标记槽失效
max_slot_wal_keep_size = 50GB
监控 SQL:
SELECT slot_name, active, wal_status,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
-- wal_status 出现 'unreserved'/'lost' 就要立刻处理从库
2.5 从库只读查询的两个注意事项
- 从库天然只读,适合报表、备份、只读接口分流,但长查询会与 WAL 回放冲突,被强制取消(
canceling statement due to conflict with recovery)。开启hot_standby_feedback = on可以缓解(从库把查询依赖的旧版本反馈给主库,主库暂不清理),代价是主库膨胀风险上升,长查询多的报表库才开。 - 大批量导入主库时,从库回放是单进程串行的,大事务是延迟的头号来源——下文监控章节给出阈值。
三、同步 vs 异步复制:RPO 与性能的权衡
3.1 五档 synchronous_commit:每一档都在"丢数据的概率"和"每笔写入延迟"之间标价
synchronous_commit = on # 核心开关,五档取值见下表
synchronous_standby_names = 'pg2' # 同步复制时指定哪个从库参与确认
| 取值 | 提交确认点 | 最坏丢失 | 典型单笔延迟增量 | 适用 |
|---|---|---|---|---|
off | 主库写 WAL 即返回(异步刷盘) | 主库机器级故障丢秒级~分钟级 | ≈0,且可小幅提升吞吐 | 日志类、可重建数据 |
local | 主库本地 fsync 完成 | 同上,保证主库自身持久 | ≈0 | 默认推荐(本地持久已保证) |
remote_write | 从库收到 WAL 并写入 OS 缓存 | 从库机器断电才可能丢 | 低 | 折中:容忍从库宕机,不容忍其掉电 |
on | 从库 fsync 落盘完成 | 仅主从同时故障才丢 | 中(一次跨机房/跨网段 RTT) | 核心交易库推荐 |
remote_apply | 从库回放完成、查询可见 | 零丢失 + 读己之写强一致 | 高 | 少数强一致读场景 |
决策口诀:按"这笔数据丢了会怎样"定价,而不是一刀切。支付/账户库 on,普通业务库 local,日志与统计类 off。同一实例内还可以按用户/事务粒度覆盖:
-- 某个特殊用户(如对账 job)单独放宽
ALTER ROLE report_reader SET synchronous_commit = off;
-- 单个事务内临时调整
SET LOCAL synchronous_commit = off;
3.2 同步复制的最大风险:从库挂了,主库跟着卡死
on 模式下主库的每次提交都要等 synchronous_standby_names 里的从库确认,唯一的从库网络抖一下,全库写入停摆。两个防护手段:
- 至少两个从库 + 法定人数语法,任意一个存活即可确认提交:
synchronous_standby_names = 'ANY 1 (pg2, pg3)'
- 给延迟敏感的库设超时容忍:
synchronous_commit不支持超时参数,退而求其次用remote_write过渡,并配告警第一时间处理从库故障。
3.3 本节原则
- 同步复制的"不丢"是机器级故障下的不丢,防不了误删(复制不是备份)。
- 同步从库必须 ≥2 且用
ANY 1语法,否则从库成了全局单点。 - 主从尽量同机房低延迟链路(<1ms 理想);跨机房做灾备的从库用异步复制,别让每笔交易付跨城 RTT 的税。
四、故障切换:Patroni 自动切换 + VIP 漂移
4.1 为什么不能靠脚本和人
自写脚本做切换有三大死穴:误判(主库只是网络抖动,脚本却把它切了,出现双主);脑裂(网络分区时新旧主同时接受写入,数据分叉后无法合并);状态残留(旧主恢复后以主库身份启动,没人记得清它)。这些问题的正确解法是分布式选主 + 仲裁 + 隔离(fencing),Patroni 把三件事都做掉了:
- DCS 选主:所有 Patroni 节点向 etcd 写"领导租约",主库节点持续续租;TTL(默认 30 秒)内续不上,集群判定主库失联。
- 多数派仲裁:etcd 本身要求 3/5 节点多数派存活才能做决策,防止半网络分区时误提升。
- watchdog 隔离:Patroni 检测到自己"失联但 PostgreSQL 还活着"时,先触发内核 watchdog 重启自己,确保旧主死透,新主才可能提升——这是防脑裂的关键闸门。
4.2 Patroni 完整配置(三个节点同构,仅 name/address 不同)
scope: pg-prod
namespace: /db/
name: pg1 # 每台不同:pg1/pg2/pg3
restapi:
listen: 0.0.0.0:8008 # Patroni API,也是健康检查/haproxy 探测口
connect_address: 10.0.0.11:8008
etcd3:
hosts: 10.0.0.21:2379,10.0.0.22:2379,10.0.0.23:2379
bootstrap:
dcs: # 集群级参数,之后用 patronictl edit-config 修改
ttl: 30 # 领导租约秒数:越小切换越快,误判风险越高
loop_wait: 10 # Patroni 巡回间隔
retry_timeout: 10
maximum_lag_on_failover: 1048576 # 候选从库落后超过1MB不参与选举
postgresql:
use_pg_rewind: true # 旧主降级后用 pg_rewind 回追,无需重建
parameters:
wal_level: replica
max_wal_senders: 10
max_replication_slots: 10
hot_standby: "on"
postgresql:
listen: 0.0.0.0:5432
connect_address: 10.0.0.11:5432
data_dir: /data/pgdata
bin_dir: /usr/pgsql-15/bin
parameters:
max_connections: 300 # 后端连接上限,配合 pgBouncer 收敛
shared_buffers: 8GB
synchronous_commit: "on"
authentication:
superuser: { username: postgres, password: "..." }
replication: { username: replicator, password: "..." }
watchdog:
mode: required # 生产建议 required:拿不到 watchdog 就不让当主
device: /dev/watchdog # 需先 modprobe softdog
# 集群状态与日常运维
patronictl -c /etc/patroni.yml list
patronictl -c /etc/patroni.yml switchover pg-prod --leader pg1 --candidate pg2 # 计划内切换
patronictl -c /etc/patroni.yml edit-config # 改 DCS 配置
4.3 VIP 漂移:让"主库地址"变成一个可迁移的名字
应用/连接池不能写死主库 IP,业界两种标准做法:
方案 A:Patroni 回调脚本搬 VIP(简单直接)
# patroni.yml 追加
postgresql:
callbacks:
on_role_change: /etc/patroni/vip.sh
#!/bin/bash
# /etc/patroni/vip.sh —— Patroni 角色变化时以 <角色名> <scope> <member> 参数调用
VIP=10.0.0.100
IFACE=eth0
case "$1" in
master)
ip addr add ${VIP}/24 dev ${IFACE} 2>/dev/null
arping -U -c 3 -I ${IFACE} ${VIP} # 免费ARP通知交换机刷新MAC表
;;
replica|stopped|stopped-failed)
ip addr del ${VIP}/24 dev ${IFACE} 2>/dev/null
;;
esac
方案 B:vip-manager 或 haproxy 探测(更推荐,无 callbacks 失灵风险)
vip-manager 作为独立进程盯着 etcd 里的 leader key,谁当主就把 VIP 绑到谁身上;或者干脆不上 VIP,用 haproxy 按 Patroni REST 接口分流:
# haproxy:/primary 端点只有真主库返回 200
listen pg-primary
bind *:5000
option httpchk GET /primary
http-check expect status 200
server pg1 10.0.0.11:5432 check port 8008 inter 2s
server pg2 10.0.0.12:5432 check port 8008 inter 2s
server pg3 10.0.0.13:5432 check port 8008 inter 2s
pgBouncer 的 [databases] 指向 VIP 或 haproxy 端口即可,切换对应用完全透明。两种方案选一即可,不要 VIP+haproxy 叠着用,故障定位会变成噩梦。
4.4 切换时间线:47 分钟到 28 秒的账
T+0 主库进程/机器死亡
T+20s Patroni 连续 loop_wait 未完成续租,etcd 领导租约过期(ttl=30 内的最坏情况)
T+21s 集群多数派确认主库失联,从健康从库中选 lag 最小者
T+24s pg_rewind 检查/提升 standby(pg_ctl promote,秒级)
T+26s VIP 漂移 + 免费 ARP 生效
T+28s pgBouncer 后端连接重建,应用重试成功,业务恢复
对照手动流程:发现告警(5min) + 人到齐(10min) + 定位与决策(15min) + 改配置重启(10min) + 验证业务(7min) ≈ 47 分钟。Patroni 买回的不是 30 秒,是那 47 分钟里每一分钟的业务损失。
五、pgBouncer 连接池:治好 PG 的"连接贵"病
5.1 为什么 PG 开不起几千个连接
PostgreSQL 的连接模型是每个客户端连接对应一个独立的后端进程(不是线程):fork 成本、每进程私有内存(work_mem 是 per-node/per-sort 的)、进程间上下文切换,连接数上千后互相踩踏:
| 维度 | PG(每连接一进程) | MySQL(每连接一线程) |
|---|---|---|
| 单连接基线内存 | 约数 MB 起步 + work_mem 按需 | 约百 KB 级 |
| 1 万连接的表现 | 进程调度雪崩,内存数十 GB | 可勉强运行(仍不推荐) |
| 建连成本 | fork + 认证,数十毫秒 | 线程创建,毫秒级 |
| 官方建议 max_connections | 100~300 | 数百~数千 |
所以 PG 世界的铁律是:PG 只接受少量"真"连接,海量应用连接交给 pgBouncer 在前面收敛。 pgBouncer 是轻量单进程代理,自己维持到 PG 的少量长连接,把成千上万的客户端连接复用在这少量后端连接上。
5.2 三种池化模式:transaction 是默认答案
pool_mode = transaction
| 模式 | 何时归还后端连接 | 池化效率 | 兼容性 |
|---|---|---|---|
| session | 客户端断开才归还 | 差(空闲会话占着坑) | 100% 兼容 |
| transaction | 每个事务 COMMIT/ROLLBACK 后归还 | 好(推荐) | 有 session 级功能限制 |
| statement | 每条语句结束即归还 | 最好 | 不支持多语句事务 |
transaction 模式的语义:同一个人上一秒的 SET、临时表、prepared statement,下一秒可能落在另一个后端进程上。以下 session 级特性在 transaction 模式下不可用或行为变化,接入前必须过一遍清单:
✗ 会话级 SET / SET LOCAL 跨事务不保(SET LOCAL 在事务内可用,推荐改造)
✗ prepared statement(协议级命名预编译)—— 1.21+ 的 max_prepared_statements 已解决,见 FAQ
✗ LISTEN/NOTIFY
✗ session 级 advisory lock(pg_advisory_lock)→ 改用事务级 pg_advisory_xact_lock
✗ WITH HOLD 游标、跨事务的临时表
绝大多数 Java 应用(MyBatis/JPA/普通 SQL)在 transaction 模式下无感;有 session 级依赖的老系统,为它单独开一个 session 模式的库别名隔离风险。
5.3 生产配置
;; /etc/pgbouncer/pgbouncer.ini
[databases]
;; 客户端连 prod 库 → 转发到 VIP 后面的当前主库
prod = host=10.0.0.100 port=5432 dbname=appdb
;; 需要会话级兼容的老应用走独立入口
prod_legacy = host=10.0.0.100 port=5432 dbname=appdb pool_mode=session
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgbouncer_admin
pool_mode = transaction
max_client_conn = 5000 ; 前端可接纳的最大客户端连接
default_pool_size = 50 ; 每个(db, user)组合到后端的真实连接数
min_pool_size = 10 ; 保底连接,避免冷启动风暴
reserve_pool_size = 5 ; 池耗尽后的应急连接
reserve_pool_timeout = 3 ; 等待超过3秒才动用应急池
server_idle_timeout = 60 ; 后端空闲60秒可回收
server_lifetime = 3600 ; 后端连接最长寿命,防内存缓慢泄漏
max_prepared_statements = 200 ; ★ 1.21+:transaction 模式支持协议级预编译的关键
ignore_startup_parameters = extra_float_digits
# userlist.txt(SCRAM 模式存明文或 SCRAM 密文)
"app_user" "SCRAM-SHA-256$4096:...."
管理控制台开箱即用(排障利器):
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer
SHOW POOLS; -- cl_active/cl_waiting(前端排队)/ sv_active/sv_idle(后端占用)
SHOW CLIENTS; SHOW SERVERS; SHOW STATS; -- 每秒查询数、平均耗时
5.4 连接数怎么算:一条公式定生死
后端连接总数 = Σ(每个 db×user 组合的 default_pool_size)
PG 的 max_connections > 后端连接总数 + 超级用户保留 + 复制/监控连接
示例:1 个库 × 2 个用户 × 50 = 100 后端连接
max_connections = 300(留足监控、运维、应急空间)✓
反例:应用直连 PG,8 个服务 × 每个连接池 100 = 800 连接 ✗
上线后的效果对照(本案例实测):
| 指标 | 直连 PG | pgBouncer 接入后 |
|---|---|---|
| 应用侧连接数 | 8000+ | 5000(max_client_conn 内) |
| PG 后端进程数 | 8000+ 个 | 约 100 个 |
| PG 进程内存占用 | 约 40GB | 约 6GB |
| 建连 P99(服务扩容风暴) | 800ms+ | 12ms |
六、生产整合:完整上线清单与监控演练
6.1 部署顺序与检查点
① etcd 3 节点(独立于 PG 节点部署,或至少不与主库同机)→ 验证 etcdctl member list 多数派
② 主库装 PG + Patroni,patroni bootstrap 拉起初始主库 → patronictl list 看到 Member=pg1 Role=Leader
③ pg2/pg3 装 PG + Patroni 自动加入 → pg_stat_replication 出现两行 streaming
④ 配同步复制 synchronous_standby_names='ANY 1 (pg2,pg3)'(核心库)
⑤ watchdog:modprobe softdog,三节点 mode=required
⑥ VIP/haproxy 就位,psql -h VIP 验证始终连到主库
⑦ pgBouncer 部署(独立节点或应用同机),transaction 模式,压测验证池参数
⑧ 应用切换到 pgBouncer:6432,观察 SHOW POOLS 的 cl_waiting 排队是否为 0
⑨ pgBackRest 定时全备+WAL 归档(复制不是备份!)
6.2 监控告警(分级沿用团队既定 P0/P1/P2 策略)
| 级别 | 指标与阈值 | 处置 |
|---|---|---|
| P0 | Patroni leader 变更事件;主库不可达;pg_is_in_recovery()=false 节点数 >1 | 电话告警,立即介入 |
| P1 | replay_lag > 10s 或 replay_bytes > 100MB 持续 5 分钟;pgBouncer cl_waiting > 0 持续 | 15 分钟内响应 |
| P2 | 复制槽 wal_status=unreserved;磁盘 > 80%;空闲后端连接占比过高 | 早上处理 |
关键监控 SQL(可接 Prometheus postgres_exporter):
-- 复制健康:lag 三延迟 + 字节差(注意:从库空闲时 replay_lag 可能为 NULL,需用时间戳兜底)
SELECT application_name, state, sync_state, write_lag, flush_lag, replay_lag,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_bytes
FROM pg_stat_replication;
-- 从库回放时间戳兜底判断
SELECT pg_last_xact_replay_timestamp(); -- 距今超过阈值即告警
-- 槽健康
SELECT slot_name, active, wal_status FROM pg_replication_slots;
6.3 演练:没有演练过的高可用等于没有高可用
- 月度计划内切换:
patronictl switchover轮换主库,验证 VIP 漂移与应用重试,顺便让每台机器都当过主。 - 季度故障演练:
kill -9主库 postgres 进程(模拟进程崩溃)、直接关机(模拟机器故障)、iptables 断网(模拟网络分区,重点观察 watchdog 是否 fence 旧主)。 - pgBouncer 压测:用 pgbench 打出排队,验证 reserve_pool 与 max_client_conn 行为,确认 cl_waiting 有数时的用户体验。
七、常见问题
7.1 pgBouncer transaction 模式下报 "prepared statement S_1 does not exist"?
根因是 5.2 节的语义:JDBC 的命名预编译语句只存在于"某个后端进程"里,下一个事务被调度到别的进程就找不到。三种解法按优先级:① pgBouncer 升级到 1.21+ 并配置 max_prepared_statements,代理会自动跟踪并按需在新后端重新 PREPARE,对应用透明(首选);② pgJDBC 把 prepareThreshold 调大(如 1000),让绝大多数语句执行次数达不到服务端预编译阈值,不产生命名语句;③ 依赖 session 状态的存量应用临时走 pool_mode=session 的独立别名,改造后再迁回 transaction。
7.2 开了同步复制,从库网络抖动就把主库写卡死,怎么办?
这正是 3.2 节的考点:synchronous_standby_names='ANY 1 (pg2,pg3)' 让任意一个从库存活即可确认,单从库故障不再阻塞主库;单从库架构下可临时降级 remote_write(容机器断电不容数据丢失)并接受其风险边界。运维纪律:调整同步配置前先确认所有从库的 sync_state,避免出现"同步候选全是 0"的窗口;从库恢复后 pg_stat_replication 的 sync_state 回到 sync 再收工。
7.3 复制延迟突然飙到几十秒,怎么定位和治理?
先分清三类:主库写入洪峰(大促批量导入)——治理在主库侧,把超大事务拆批(单事务控制在几万行级,DELETE/UPDATE 分片提交);从库回放跟不上——物理回放是单进程串行的,max_parallel_recovery_workers(逻辑复制才有并行)帮不上物理流复制,只能靠减少大事务 + 从库磁盘升级;网络/槽异常——看 pg_replication_slots 的 wal_status 和主从网卡丢包。监控注意坑:从库空闲时 replay_lag 显示 NULL,告警逻辑要用 pg_last_xact_replay_timestamp() 兜底。审计/报表类长查询挪到专门的延迟从库(recovery_min_apply_delay = '1h'),还能顺带获得"误删表 1 小时内可救"的缓冲。
7.4 怎么确保不出现双主(脑裂)?
四道防线缺一不可:① watchdog mode=required——Patroni 与 DCS 失联但 PG 还活着时,先自我重启再谈其他,杜绝旧主继续服务;② etcd 奇数节点独立部署,多数派仲裁不与 PG 同生共死;③ 任意时刻主库只能通过 VIP/haproxy 访问,直连 IP 的脚本、监控、定时任务全部清理;④ P0 告警人肉复核——leader 变更事件发生时值班必须确认"旧主已 fence、只有一个可写节点"。另外 use_pg_rewind: true 保证旧主回归时自动以新主为基准回追,不会带着分叉数据"越狱"回主。
7.5 切换的 20~30 秒里应用会报错吗?怎么让业务无感?
会,这是物理复制 + 异步提升的固有窗口,工程上用"快速失败 + 客户端重试"消化:应用连接池(HikariCP)配置连接获取重试与 initializationFailTimeout,关键写操作包一层幂等重试(与《消息重复消费排查》的幂等设计同一套方法论——切换期间被重试的写入必须幂等);haproxy/VIP 探测间隔压到 2 秒内;pgBouncer 在后端连接断开时自动重建。目标不是"零报错",而是"报错在 200ms 内自愈、用户无感"。对绝对不能重试的链路(支付扣款),靠上游幂等 + 死信重放,而不是指望数据库层零切换。
7.6 max_connections、default_pool_size、max_client_conn 到底怎么定?
按层级倒推:PG 的 max_connections = 150~300(超过后进程调度和内存得不偿失,扩容靠加只读节点);default_pool_size =(max_connections - 保留 50)÷ (db×user 组合数),单库单用户典型 50~80;max_client_conn = 应用侧峰值连接 × 1.5,它是 pgBouncer 自身的内存消耗项(每连接约 2~10KB,5000 个不心疼)。验证方法:压测时盯 SHOW POOLS,cl_waiting 长期 > 0 说明 default_pool_size 不够或 SQL 本身慢(先查慢 SQL,见《PG 性能调优实战》,别急着加连接)。
八、总结
三件套速查卡
【流复制 · 数据面】
主:wal_level=replica + max_wal_senders + pg_hba 放行 replicator
从:pg_basebackup -R 一条命令(生成 standby.signal + primary_conninfo)
槽:pg_create_physical_replication_slot + max_slot_wal_keep_size=50GB 防磁盘撑爆
监控:pg_stat_replication(replay_lag/字节差)+ pg_replication_slots(wal_status)
【同步/异步 · RPO 定价】
off/local:可丢秒级,性能无损 → 日志/统计
remote_write:容机器故障,中低延迟 → 折中
on:主从双故障才丢,+1 次 RTT → 核心交易库
remote_apply:零丢失+读己之写,延迟最高 → 强一致读
铁律:同步从库 ≥2 用 ANY 1 (pg2,pg3);跨机房从库走异步
【Patroni · 控制面】
etcd×3 独立部署,ttl=30/loop_wait=10 起步,max_lag_on_failover=1MB
watchdog mode=required 防脑裂;use_pg_rewind 旧主自动回追
VIP:callbacks 脚本 或 vip-manager/haproxy(/primary 探测),二选一
RTO 实测 20~40 秒;月度 switchover + 季度 kill -9 演练
【pgBouncer · 入口面】
pool_mode=transaction;max_prepared_statements=200(1.21+ 解决预编译)
max_client_conn=5000 前端;default_pool_size=50×组合数=后端真实连接
session 级特性清单自查:SET/临时表/LISTEN/advisory_lock→xact_lock
观测:SHOW POOLS 的 cl_waiting 长期>0 先查慢 SQL 再加池
【底线】
复制不是备份:pgBackRest 全备+WAL 归档必须另立
延迟从库(recovery_min_apply_delay)= 误删表的人肉缓冲垫
给团队的建议
| 项 | 建议 |
|---|---|
| 架构 | 至少 1 主 2 从;etcd 独立部署;应用只碰 VIP/pgBouncer,直连 IP 全部封禁 |
| 复制 | 核心库 on + ANY 1 双从;普通库 local;max_slot_wal_keep_size 必配 |
| 切换 | watchdog required;VIP 方案二选一;切换前后都有 Patroni 事件告警 |
| 连接 | max_connections ≤300,一切海量连接走 pgBouncer transaction 模式 |
| 应用 | 幂等重试覆盖切换窗口;session 级特性清单纳入 Code Review |
| 备份 | pgBackRest/WAL-G 每日全备 + PITR 演练;延迟从库可选 |
| 演练 | 月度计划切换 + 季度故障注入,切换演练不过关不算上线完成 |
| 监控 | P0 leader 变更;P1 复制延迟 10s/100MB;P2 槽与磁盘 |
一句话
PG 的高可用三件套各自回答一个问题:流复制回答"数据到哪了"——WAL 先写日志后写数据页的天性让主从同步变成一条字节流的搬运,pg_basebackup -R 一条命令拉起从库,复制槽保证从库掉线期间 WAL 不断供、max_slot_wal_keep_size 又保证槽不把磁盘撑爆,而 synchronous_commit 的五档取值本质是给"丢数据的概率"标价,核心交易库 on 配 ANY 1 双从、日志库 off,按业务定价而不是一刀切;Patroni 回答"谁是主"——etcd 租约做选主、多数派做仲裁、watchdog 自我重启做 fencing,把手动切换的 47 分钟压到 28 秒,VIP 或 haproxy 让"主库地址"变成可迁移的名字,use_pg_rewind 让旧主回归时自动归位,防脑裂的四道防线一条都不能省;pgBouncer 回答"谁能进来"——PG 每连接 fork 一个进程的模型注定扛不住几千连接,transaction 池化模式用"事务结束即归还"把 8000 个前端连接收敛成 100 个后端进程,1.21+ 的 max_prepared_statements 补上了预编译兼容的最后一块短板,参数按"max_connections ≤300 → pool_size 反推 → max_client_conn 按 1.5 倍峰值"倒推。最后记住两条底线:复制不是备份,DROP TABLE 会瞬间同步到所有从库,pgBackRest 加延迟从库才是逻辑错误的解药;没演练过的高可用等于没有高可用,月度 switchover 加季度 kill -9,让切换成为肌肉记忆而不是赌博。
互动话题:你们 PG 集群的高可用方案是什么(Patroni / repmgr / 云 RDS / 自研)?切换 RTO 实测多少?pgBouncer 上线时踩过 prepared statement 或 SET 失效的坑吗?延迟从库有没有真救过一次误删的表?评论区聊聊你的生产架构。
标题:PG 高可用实战:流复制 + pgBouncer 连接池——生产级部署方案
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/10/18/1792327160012.html
公众号:服务端技术精选
- 引言
- 一、全景:单点故障的代价与高可用三件套
- 1.1 两个 R 词:先把目标定清楚
- 1.2 目标架构
- 二、流复制:WAL 日志的实时搬运
- 2.1 WAL:一切复制的基石
- 2.2 主库配置
- 2.3 一条命令拉起从库
- 2.4 复制槽:别让 WAL 断供,也别让它吃满磁盘
- 2.5 从库只读查询的两个注意事项
- 三、同步 vs 异步复制:RPO 与性能的权衡
- 3.1 五档 synchronous_commit:每一档都在"丢数据的概率"和"每笔写入延迟"之间标价
- 3.2 同步复制的最大风险:从库挂了,主库跟着卡死
- 3.3 本节原则
- 四、故障切换:Patroni 自动切换 + VIP 漂移
- 4.1 为什么不能靠脚本和人
- 4.2 Patroni 完整配置(三个节点同构,仅 name/address 不同)
- 4.3 VIP 漂移:让"主库地址"变成一个可迁移的名字
- 4.4 切换时间线:47 分钟到 28 秒的账
- 五、pgBouncer 连接池:治好 PG 的"连接贵"病
- 5.1 为什么 PG 开不起几千个连接
- 5.2 三种池化模式:transaction 是默认答案
- 5.3 生产配置
- 5.4 连接数怎么算:一条公式定生死
- 六、生产整合:完整上线清单与监控演练
- 6.1 部署顺序与检查点
- 6.2 监控告警(分级沿用团队既定 P0/P1/P2 策略)
- 6.3 演练:没有演练过的高可用等于没有高可用
- 七、常见问题
- 7.1 pgBouncer transaction 模式下报 "prepared statement S_1 does not exist"?
- 7.2 开了同步复制,从库网络抖动就把主库写卡死,怎么办?
- 7.3 复制延迟突然飙到几十秒,怎么定位和治理?
- 7.4 怎么确保不出现双主(脑裂)?
- 7.5 切换的 20~30 秒里应用会报错吗?怎么让业务无感?
- 7.6 max_connections、default_pool_size、max_client_conn 到底怎么定?
- 八、总结
- 三件套速查卡
- 给团队的建议
- 一句话
评论