PostgreSQL流复制与pgpool-II高可用配置及避坑指南
简介:面向数据库运维与架构人员的高可用方案文档,聚焦 PostgreSQL 主从流复制与 pgpool-II 的组合落地,适合需要搭建主备库、实现故障切换、读写分离和连接池管理的 Linux 环境实践者。文档围绕流复制同步/异步模式、WAL 日志传输机制、pgpool-II 的连接池与负载均衡策略展开,并给出环境规划、主库配置、备库初始化、pgpool 配置及启动测试等完整章节,目录还覆盖防火墙、SELinux、网络、hosts、时钟同步和系统资源调整等准备工作,便于按步骤核对与复现。读者可据此理解同步复制在一致性与写性能之间的取舍,以及 pgpool-II 故障切换与只读请求分流的配置逻辑,并借助 pg_rman 备份章节完善运维闭环。包内共 1 个 docx 文件,约 773KB,篇幅紧凑但结构完整,可作为方案选型、部署检查与排错思路的参考。已有 470 人学习,适合中级数据库从业者用于主备高可用架构的快速梳理与落地对照。
1. 主库刚提交,备库为什么查不到?先把“流复制”这件事说透
主库刚执行完一条 INSERT 并提交成功,客户端立刻去备库查,有时却查不到那一行。很多人第一次搭好 PostgreSQL 流复制都会愣一下:监控里复制状态明明是 streaming,日志也没报错,这个延迟却真实存在。要解释它,得先接受一个反直觉的结论——备库不是把数据块“同步”过去的,而是靠重放主库的 WAL 日志追出来的。围绕这条链路,pgpool-II 的高可用、读写分离、failover 才有地方挂。把这套主从流复制 + pgpool-II 高可用方案拆到底层,本质上是在回答同一个问题:日志从主库到备库,中间每一跳都可能丢、可能卡、可能慢,整套设计就是围绕这些可能性做取舍。
2. WAL 与流复制:备库凭什么能“实时”追上主库
2.1 WAL 的本质,以及 walsender / walreceiver 的接力
PostgreSQL 的写入路径里有一条硬规矩:先写日志,再写数据页。任何一次数据变更,都会先在pg_wal(老版本叫pg_xlog)里记一条 WAL 记录,等这个记录安全落盘了,事务才算提交成功。这条规矩本来是为了崩溃恢复——数据库进程突然挂了,重启时拿 WAL 把不一致的数据页重放一遍,数据就回到一致状态。流复制其实是顺手复用了这套机制:既然 WAL 已经包含了“把数据推到某个一致状态”所需的全部信息,那把它传给另一台机器重放,不就等于把那台机器也推到了同样的状态?
把 WAL 从主库弄到备库,历史上只有两条路。第一条叫 WAL 归档(base-file):主库写完一个完整的 WAL 文件,才把它拷到备库,备库通常落后主库一个文件,延迟以分钟计。第二条就是流复制:主库一产生 WAL,立刻通过 TCP 长连接推过去,延迟通常在亚秒级。文档里给出的选型理由很直接——既然要在线高可用,肯定选延迟更低的那条。
流复制实际的进程链路是这样一条接力:
# postgresql.conf 主库关键参数(12.x 示例) wal_level = replica # 至少 replica 才能支撑流复制;logical 更重但支持逻辑解码 max_wal_senders = 10 # 允许同时有多少个 walsender 进程,决定了能挂几个备库 wal_keep_segments = 64 # 主库至少保留的 WAL 段数,防止备库落后太多时旧段被回收 # 注意:13.x 起这个参数改名为 wal_keep_size(单位 MB)链路上有四个角色,各管一段:
- walwriter(主库):周期性地把共享缓冲区里的 WAL 页刷到磁盘上的
pg_wal。它只管“落盘”,不关心有没有备库。 - walsender(主库):备库连上来之后,主库为每个备库开一个 walsender 进程,从
pg_wal里读出还没发过的段,通过 TCP 推出去。 - walreceiver(备库):接收 WAL 流,写到本地磁盘上的
pg_wal,同时回一个确认给主库,告诉它“我写到哪个 LSN 了”。 - startup(备库的恢复进程):从本地
pg_wal里读出日志,逐条重放,把数据页改成和主库一致。
这里有一个很容易被忽略的细节:walreceiver写盘和startup重放是两个进程,速度并不严格一致。也就是说,WAL 已经传到备库磁盘上了,数据页却可能还没重放完。所以判断“备库能不能查到某条数据”,看的不是walreceiver收到的位置,而是startup重放到的位置。这就是第 1 章那个“主库提交了、备库查不到”现象的第一层原因——传输没延迟,重放有延迟。
pg_stat_replication这个视图能把上面几个位置一次性看清楚,主库上执行:
-- 在主库上查看每个备库的复制状态 SELECT application_name, client_addr, state, -- streaming 表示正常流复制中 sent_lsn, -- 主库已经发出去的 WAL 位置 write_lsn, -- 备库已写盘的 WAL 位置 flush_lsn, -- 备库已落盘的 WAL 位置 replay_lsn -- 备库已重放(真正可见)的 WAL 位置 FROM pg_stat_replication;排查复制延迟时,sent_lsn与replay_lsn之间差得多少,就是备库真正“落后”多远。只看state = streaming会骗人,血泪经验是至少把replay_lsn一起看一眼。
2.2 同步与异步:synchronous_standby_names 到底改变了什么
流复制默认是异步的。异步的意思是:主库提交事务时,只保证自己的 WAL 已经落盘,不等备库确认就返回。好处是写性能不受备库影响,坏处是主库突然崩溃时,已经发给备库但还没重放的那一小段 WAL 可能丢——丢的量取决于replay_lsn和主库最新提交之间的差,通常很小,但不是零。
同步复制把这段窗口关掉。打开它只要两个条件同时满足:synchronous_commit = on,并且synchronous_standby_names被设成一个非空值。这时主库提交事务前,必须等到指定的备库回复“这段 WAL 我已经 flush 到磁盘”,事务才向客户端返回成功。
# 主库 postgresql.conf 同步复制配置 synchronous_commit = on # 语法:数字 (备库名列表),数字表示至少要几个备库确认 # 例 1:指定一个备库必须确认 synchronous_standby_names = 'standby1' # 例 2:任意两个备库中至少一个确认即可 synchronous_standby_names = '1 (standby1, standby2)' # 例 3:所有列出的备库都要确认(更安全,但备库一挂主库就卡死) synchronous_standby_names = '2 (standby1, standby2)'这里的“备库名”不是主机名,而是备库primary_conninfo里配的application_name。这一点很关键:如果备库连上来时没设application_name,主库在synchronous_standby_names里根本匹配不到它,同步复制就是空转,看起来配了,实际还是异步。很多“配置都对、性能却上不去”的翻车现场,追到底就是这个匹配问题。
同步复制和异步复制之间的取舍,文档讲得很克制,但落到生产上要考虑三件事:
| 维度 | 异步复制 | 同步复制 |
|---|---|---|
| 主库写性能 | 不受备库影响 | 受最慢的那个确认备库影响 |
| 主库崩溃丢数据 | 可能丢少量 WAL | 理论上不丢已提交事务 |
| 备库故障时 | 主库继续写 | 主库可能被卡死(取决于确认配置) |
| 适用场景 | 读多写少、能容忍秒级丢失 | 强一致、不能丢一笔 |
我一般把同步复制当成一种“一致性保险”,而不是默认选项。它的代价不只是延迟,更是可用性:备库一挂,主库可能跟着阻塞,这在高频写入的库里会迅速演变成雪崩。所以真要用同步,synchronous_standby_names里至少留一个可以降级的备库,并且把主库的wal_sender_timeout、statement_timeout算清楚,别让一个慢备库拖死整个写入链路。
3. 主备配置:12.x 那条 recovery.conf 断代线
3.1 pg_hba.conf 与复制用户:认证这一关必须一次配对
流复制建立连接这件事,走的是 PostgreSQL 自己的一套认证流程,和普通客户端连接一样要过pg_hba.conf。差别在于复制连接请求的数据库字段是replication,不是某个具体库名。所以主库的pg_hba.conf里必须专门为复制留一条规则,否则备库的walreceiver会一直连不上,日志里报的通常是no pg_hba.conf entry for replication connection。
主库上创建复制用户的动作很简单,关键是权限用对:
-- 在主库上创建复制专用账号,只给 REPLICATION 权限,不给超级用户 CREATE ROLE repl WITH REPLICATION LOGIN PASSWORD 'replpass';然后主库的pg_hba.conf加一条:
# 允许备库网段用 repl 账号发起复制连接 # TYPE DATABASE USER ADDRESS METHOD host replication repl 192.168.1.0/24 scram-sha-256注意DATABASE列写的是replication,这是连接类型而不是库名。这条规则写错的概率极高——要么地址网段漏了备库 IP,要么把replication写成了具体的库名,结果备库连上来直接被拒。这是部署阶段最常见的翻车点之一,后文避坑章节还会展开。
备库初始化用pg_basebackup,它会把主库的数据目录完整拷一份,同时自动带上复制所需的配置:
# 在备库上执行,从主库拉一份基础备份 pg_basebackup -h 192.168.1.10 -p 5432 -U repl -D /data/pgsql/data \ -Fp -Xs -P -R # -Fp 普通文件格式;-Xs 流式复制 WAL;-P 显示进度;-R 自动写恢复配置-R这个参数在 12.x 前后行为不同,正好引出下一节的断代线。
3.2 12.x 前后备库标识的迁移:recovery.conf 去哪了
PostgreSQL 12 是一个分水岭。12 之前,备库靠数据目录下的recovery.conf文件进入恢复模式;12 及以后,这个文件被彻底移除,恢复参数合并进postgresql.conf,恢复模式改用数据目录下的一个信号文件recovery.signal来触发。这个改动不只是一次配置搬家,它改变了“备库到底是什么状态”的判断方式——以前看文件在不在,现在看信号文件在不在。
11.x 及以前的写法:
# 11.x 及以前:recovery.conf,放在数据目录下 standby_mode = 'on' primary_conninfo = 'host=192.168.1.10 port=5432 user=repl password=replpass application_name=standby1' recovery_target_timeline = 'latest'12.x 以后的写法:
# 12.x 及以后:这些参数直接写进 postgresql.conf primary_conninfo = 'host=192.168.1.10 port=5432 user=repl password=replpass application_name=standby1' recovery_target_timeline = 'latest' # standby_mode 参数被取消,改为在数据目录下放一个 recovery.signal 文件 # 备库启动时检测到该文件,就进入恢复模式这个变化带来的直接后果是:老版本升级到 12.x 的迁移过程,如果照搬老的recovery.conf,备库根本不会进入恢复模式,直接以普通实例启动,然后数据就分叉了。另一种情况是初始化之后忘了创建recovery.signal,备库能启动但不会接收 WAL,pg_stat_replication里根本看不到它。这两种现象本质不同,排查时要先确认版本,再判断是该找recovery.conf还是找recovery.signal。
pg_basebackup -R在 12.x 以上会自动把primary_conninfo写进postgresql.conf,并在数据目录创建recovery.signal,所以用这个参数初始化基本不会漏。但代价是:如果你之后又在postgresql.conf里手动覆盖了一次配置,两处冲突时,以最后加载的为准,排查时容易看花眼。
4. pgpool-II:中间件的四种职责与 VIP 漂移
4.1 连接池、读写分离、限流如何在同一个入口完成
pgpool-II 的位置很明确:它坐在客户端和 PostgreSQL 之间,客户端以为自己连的是一台数据库,实际上连的是 pgpool。这个“中间人”身份让它能一次性揽下四个职责,文档里列得很清楚——连接池、复制、负载均衡/读写分离、限制超过限度的连接。
其中最容易误解的是连接池。它缓存的是到后端 PostgreSQL 的真实连接,当客户端用相同参数连进来时,pgpool 直接复用一个已有连接,省掉一次 TCP 握手和认证的开销。对短连接、高并发查询的场景,这一层省下来的时间很可观。
读写分离是 pgpool 最有价值的部分。它靠 SQL 语句分类来决定往哪发:SELECT 这类只读请求按权重分给备库,INSERT/UPDATE/DELETE 发主库。main 库的权重设成 0,就表示只写不读;备库权重设成非 0,就承担读请求。代码如下:
# pgpool.conf 关键参数(4.x 示例) listen_addresses = '*' port = 9999 # 客户端连 pgpool 的端口 backend_hostname0 = '192.168.1.10' backend_port0 = 5432 backend_weight0 = 0 # 权重 0:只走写,不参与读负载均衡 backend_flag0 = 'ALLOW_TO_FAILOVER' # 允许故障时被切走 backend_hostname1 = '192.168.1.11' backend_port1 = 5432 backend_weight1 = 1 # 权重 1:参与读负载均衡 backend_flag1 = 'ALLOW_TO_FAILOVER' # 打开读请求分发 load_balance_mode = on # 健康检查:定期探后端是否活着 health_check_period = 10 health_check_timeout = 20限制连接这一项和 PostgreSQL 原生的做法不同:PostgreSQL 到了max_connections就直接拒连,pgpool 则是把超出的连接放进队列等,而不是立刻报错。这个差异在业务高峰期体验差别很大——原生拒绝会给客户端抛错,队列等待至少还能撑一阵,但代价是排队可能越积越长,需要配合num_init_children和max_pool一起调,不然队列本身会变成新的瓶颈。
状态查询用 pcp 工具,一个命令就能看到后端节点的健康状态:
# 查看 pgpool 集群中各后端节点的状态 pcp_node_info -h 127.0.0.1 -p 9898 -U pgpool -n 0 # -n 0 表示查 0 号后端节点;status 字段为 2 表示 up,为 3 表示 down4.2 watchdog 与 failover:虚拟 IP 漂移的触发链
pgpool 的高可用靠两层配合:watchdog负责 cluster 内节点之间的互相监控,failover负责在后端数据库故障时切换。两者串起来,才是完整的容灾链。
watchdog 在 pgpool 节点之间建一个集群,每个节点定期互相发心跳。主节点上配置一个虚拟 IP(delegate_IP),客户端实际连的是这个 VIP,而不是任何一台物理机的 IP。当主 pgpool 节点故障,watchdog 会把 VIP 漂移到还活着的那个 pgpool 节点上,客户端不用改连接串就能继续工作。
# pgpool.conf watchdog 相关配置 use_watchdog = on wd_hostname = '192.168.1.10' # 本机 pgpool 节点的主机名或 IP delegate_IP = '192.168.1.100' # 对外暴露的虚拟 IP,客户端连这个 wd_port = 9000 # watchdog 节点之间通信端口 # 漂移后的启动脚本:VIP 落到本机时执行,用于挂上 IP if_up_cmd = '/usr/local/bin/if_up.sh $_IP_$ eth0' if_down_cmd = '/usr/local/bin/if_down.sh $_IP_$ eth0'后端数据库故障的切换,靠的是failover_command。pgpool 探测到主库不可达,会触发这个脚本,脚本里可以写提备库为新主的逻辑,比如在备库上执行pg_ctl promote,然后让 pgpool 更新 backend 的角色。文档里 failover 测试的做法就是手动停掉主库的postgres进程或 pgpool 进程,观察 VIP 是否已经漂移、节点状态是否更新,这是验证整条容灾链是否真的通的最直接方式。
需要提醒一点:VIP 漂移和数据库提主是两件独立的事。VIP 只代表“客户端连的地址变了”,不代表“新主库已经能写”。如果 failover 脚本里只做了 VIP 切换,没做promote,客户端连上去会发现读能读、写报错。检查的先后顺序是:先看pcp_node_info里节点状态,再看pg_stat_replication(新主库上应该已经没有 stream 角色),最后才验证写入。这个顺序如果反过来,排查会绕一大圈。
5. 避坑与排查:那些文档没写的翻车现场
5.1 备库连不上,日志只报 no pg_hba.conf entry
现象:备库启动后pg_stat_replication里完全看不到它,备库日志里反复出现no pg_hba.conf entry for replication connection,walreceiver一直在重连。
原因:主库pg_hba.conf里没为复制连接单独放行。常见写法错误有两种:一是把DATABASE列写成了具体库名而不是replication;二是ADDRESS网段没覆盖备库 IP,尤其是备库 IP 配成了不在同网段的地址。
解决:在主库pg_hba.conf里补一条host replication repl <备库IP>/32 scram-sha-256,然后pg_ctl reload让主库重新加载配置。reload 不需要重启,但pg_hba.conf的改动必须 reload 才生效。
5.2 同步复制配了,主库写入却越来越慢甚至卡死
现象:打开同步复制后,主库写入延迟从毫秒级跳到秒级,备库一旦网络抖动,主库整个写入链路跟着挂起,业务开始大面积超时。
原因:synchronous_standby_names指定了某个备库必须确认,但那个备库的application_name没和主库配置对上,导致主库一直等一个“不存在的确认”;或者备库本身负载过高,flush 响应慢,主库被拖住。
解决:先在主库pg_stat_replication里核对备库的application_name,确保和synchronous_standby_names中列的一致;再把确认策略从“全部备库”降级成“至少一个”(1 (standby1, standby2)),给自己留一个降级空间。同步复制这件事,宁可退一步做异步,也不要为了强一致把可用性赔进去。
5.3 pgpool 报认证失败,客户端连不上 9999 端口
现象:后端数据库和 pgpool 本身都正常,客户端连 pgpool 的 9999 端口,报认证失败或password authentication failed。
原因:pgpool 的认证走的是自己的一套配置,pool_hba.conf负责前端认证规则,pool_passwd负责存放用户名与密码(或密码哈希)的映射。如果用户不在pool_passwd里,或者pool_hba.conf里没放行客户端网段,就会连不上——哪怕这个用户在 PostgreSQL 里是真实存在且密码正确的。
解决:用pg_md5或pool_passwd工具把对应用户写进pool_passwd;检查pool_hba.conf是否有对应条目;然后确认enable_pool_hba = on已经打开。前端认证和后端认证是两套独立配置,任何一套配错都表现为“连不上”,排查时得分开看。
5.4 备库复制断裂,日志报 requested WAL segment has already been removed
现象:备库跑了一段时间后突然停止复制,日志里报requested WAL segment ... has already been removed,pg_stat_replication里备库消失。
原因:wal_keep_segments设得太小,而备库因为网络或负载原因落后了一段时间,主库已经把备库还没收到的旧 WAL 段回收了。备库再请求时,文件已经不在了。
解决:短期靠重新用pg_basebackup拉一份基础备份恢复;长期把wal_keep_segments调大(13.x 以上是wal_keep_size),或者上 WAL 归档,让备库可以通过 restore_command 从归档里补旧段。这个坑的本质是:主库回收 WAL 的策略只认自己的磁盘空间,不认备库落后了多少,配置得站在“最坏情况备库能落后多久”的角度去算。
5.5 VIP 漂移成功,但客户端连接仍然指向旧地址
现象:failover 测试里,主库或 pgpool 进程停掉后,VIP 确实漂移到了另一台机器,但客户端应用还是连不上,连接串里写的像是“没生效”。
原因:VIP 漂移只改的是网络层地址,客户端如果用了本地 DNS 缓存或连接池缓存的旧连接,它仍然拿着旧地址去连。另一种原因是delegate_IP和if_up_cmd的网卡名不匹配,脚本执行了但没真正把 IP 挂到网卡上。
解决:先在漂移后的节点上执行ip addr确认 VIP 真的挂上了;再让客户端侧清掉 DNS 缓存和应用层连接池;if_up_cmd里的网卡名必须和实际网卡一致,这个参数错了没有任何报错,只会“看起来漂移了、实际没挂上”。检查的先后顺序是:网络层 → 客户端缓存 → 应用重连。
6. 设计取舍:同步与异步、备份与恢复之间怎么选
整套方案跑通之后,真正难的不是把组件装起来,而是回答“到底要开同步还是异步、备份做到什么粒度”。这两个问题本质上都是取舍,而取舍的依据只能来自业务对丢数据的容忍度。
同步复制解决的是“主库崩溃丢已提交事务”的问题,代价是写延迟和可用性。异步复制反过来——写性能好、可用性高,但崩溃时可能丢一两秒的 WAL。我的建议是把“同步”看成一条可降级的一致性保险:默认用异步,关键业务表或关键时间段再切同步;同步的确认策略至少要留一个备库做兜底,千万别写成“全部备库都要确认”,否则任何一台备库抖动都会把主库拖死。这个选择没有标准答案,但有一个硬指标可以对照——业务能接受的 RPO(恢复点目标)是零,还是几秒?答案不同,配置就不同。
备份是另一层保险,它和第 2 章的流复制是互补而不是替代关系。流复制保证的是“主库挂了还有一台近实时的备库顶上”,pg_rman 保证的是“误删、误更新、数据被破坏时能恢复到任意时间点”。文档里把这两件事分得很清楚:流复制是线上的、秒级的;pg_rman 是离线的、基于全备 + WAL 归档的,配合 PITR 可以回到某个具体时刻。
把备份脚本写成可重复执行的形态,比写对一次更重要:
#!/bin/bash # pg_rman 全备脚本(简化示意,参数按实际环境调整) export PGHOME=/usr/local/pgsql export PGDATA=/data/pgsql/data export BACKUP_PATH=/data/pg_rman export PATH=$PGHOME/bin:$PATH # 执行一次全量备份,保留 7 天 pg_rman backup --backup-mode=full --with-serverlog --keep-data-days=7 # 备份完必须 validate,否则坏备份和没备份没区别 pg_rman validate--with-serverlog会把服务器日志一起备进去,恢复时排查现场用得上;validate这一步千万别省,没有校验的备份在真正需要恢复时才发现损坏,等于白做。归档脚本pg_rman_archive.sh则定期把 WAL 归档到备份目录,全备 + 归档合起来才构成 PITR 的完整链条——只做全备、不做归档,最多恢复到上一次全备点,中间那几个小时的增量就丢了。
还有一个容易忽略的取舍:主备节点的操作系统和 PostgreSQL 大版本最好保持一致。流复制的二进制格式在跨大版本时可能不兼容,跨小版本虽然一般能用,但也别赌。这个约束在设计阶段就得定下来,别等上线后才发现其中一台机器还停在旧版本。
从那以后我每次规划这类方案,都会先把三件事写进设计文档再动手:同步还是异步、RPO 是多少、备份能不能覆盖到 PITR。这三件事不先定,后面装多少组件都是玄学。希望帮到你。
本文还有配套的精品资源,点击获取