☰
PostgreSQL流复制与pgpool高可用:WAL同步和故障切换实战
2026/10/31 7:27:14 网站建设 项目流程

简介:围绕 PostgreSQL 主从流复制与 pgpool-II 高可用架构展开的部署方案文档,面向数据库运维、后端开发及需要搭建主备容灾环境的工程师。内容从流复制原理切入,说明 WAL 日志由主库 walsender 传至备库 walreceiver 并持续应用的过程,并比较同步复制与异步复制在数据一致性和写性能上的取舍;随后介绍 pgpool-II 的连接池、复制、负载均衡与读写分离、连接数限制等能力,给出主库参数配置、备库初始化、pgpool 策略设置及故障模拟切换的实践路径。目录还覆盖软件环境规划、postgres 用户与权限、防火墙和 SELinux 关闭、hosts 配置、时钟同步、sysctl 与 limit 资源调整、备份等部署细节,便于按章节逐项落地。整包仅含 1 个 docx 文件,约 773KB,篇幅集中、结构清晰;已有 471 人学习,可作为构建 PostgreSQL 主备集群、验证切换流程与规划备份策略时的参考手册。

1. 主库宕机只丢一个事务:这套方案把"已提交"的判定交给了谁

很多人第一次搭 Postgres 主从流复制,脑子里默认的模型是"备库有份完整拷贝,主库挂了切过去就行"。真跑一遍会发现两件对不上的事:主库掉电重启后,备库末端那几个事务可能凭空消失;主库进程还活着、只是数据盘坏了,切换脚本反而不确定该不该动。Postgres 主从流复制配 pgpool 高可用,真正解决的不是"数据存两份",而是把"什么时刻的数据算已提交"从单机自己说了算,改成由若干节点共同确认,再把确认过的 WAL 日志用一条 TCP 长连接持续推给备库。搞懂这条链路,才知道切换时哪些事务丢得起、哪些一条都不能丢。

2. WAL 的旅程:从 commit 到备库落盘,中间隔着几个进程

流复制的所有行为,都能还原成一组位点的追赶关系。把这组位点认全了,pg_stat_replication里那些sent_lsn、flush_lsn、replay_lsn就不再是天书,主备延迟到底卡在网络上还是卡在重放上,一眼能分出来。

2.1 段文件、LSN 与三个位点

WAL 在数据目录下的pg_wal(12 之前叫pg_xlog)里,按 16MB 一段切分,文件名是时间线加段号。日志本身记录的是页面级变更,不是 SQL 语句,所以重放不需要重新执行一遍逻辑,只要把数据页刷成日志描述的最终形态即可。每条记录有个全局唯一的字节偏移叫 LSN,主备之间所有的"追到哪了"都用 LSN 表达。

关键在一个事务提交时,WAL 要经过三个地方:先是写进共享内存的 WAL buffer,然后由walwriter(或提交进程自己)落到本地磁盘,最后被walsender发到备库。主库认为"这条日志已经安全",指的是它到了备库的哪个位置——这就分出了三个位点:sent_lsn是主库已发出的位置,write_lsn是备库收到并写进内核缓冲的位置,flush_lsn是备库真正 fsync 到磁盘的位置,replay_lsn是备库 startup 进程已经应用到数据页的位置。四个位点严格递减,差距就是延迟的三种成因。

2.2 主库侧:walsender 不在轮询,它在等信号

有个常见误解是 walsender 会周期性地去扫 WAL 目录。实际不是。备库的walreceiver建立连接后,主库 fork 出一个walsender进程,它先按备库请求的起始 LSN 把已有的 WAL 段读出来发过去(catchup 阶段),追平之后进入 streaming 状态,此后就阻塞等待 latch 信号——主库每次推进 WAL 写入位置都会唤醒它。这个设计让空闲期的主备之间几乎没有额外开销,也让延迟能压到毫秒级,而不是等一个 16MB 段写完才发。

也正因为是按字节流发送,而不是按文件传送,流复制和 WAL 归档(base-file 方式)的差别不只是速度。归档方式必须等一个完整段写满、被归档命令搬走,备库永远落后一个段;流复制只要求 WAL 已经产生。这是选型的硬理由。

# 在主库上观察复制链路。sync_state 为 async 说明当前是异步; # 若为 sync / quorum 说明已被 synchronous_standby_names 纳入同步集合 psql -c "SELECT application_name, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag, sync_state FROM pg_stat_replication;" # state 的取值含义: # startup —— 备库刚连上,还没开始接收 # catchup —— 正在补历史 WAL,尚未追平 # streaming—— 实时接收中 # backup —— 该连接是 pg_basebackup 拉起的,不是常驻备库

上面这段查询里,write_lag大而replay_lag与它接近,说明瓶颈在网络或备库 IO;replay_lag明显大于flush_lag,说明 WAL 已经安全落地,但备库应用跟不上——那种情况通常是备库上有长查询在撑着快照,或者备库机器 CPU/IO 被打满。

2.3 备库侧:落盘和重放是两件事

备库上的walreceiver只负责接收并写盘,它把收到的字节直接追加到本地 WAL 段,做完 fsync 之后向主库回一个确认。真正修改数据文件的是 startup 进程,它读本地 WAL 逐条重放。这两个进程分离,才让同步复制的等待点可以选:主库可以只等"备库写完 WAL",也可以等"备库应用完"。

备库对外提供只读查询(hot_standby = on)时,冲突就出在这里。一个跑了 40 秒的报表查询持有旧快照,而 startup 进程要重放一条会清理死元组的记录,两者撞上,Postgres 的处理方式是先等——等到max_standby_streaming_delay(默认 30 秒)就强制取消查询,报canceling statement due to conflict with recovery。这是备库读的经典翻车点:业务以为是偶发抖动,其实是重放优先级高于查询。缓解手段是把hot_standby_feedback打开,让备库把最老快照的 xmin 反馈给主库,主库就不清理那部分死元组——代价是主库膨胀。这个取舍没有两全,只能按业务读查询的时长来定。

2.4 同步复制到底卡在 commit 的哪一步

默认是异步,主库提交时不等备库,主库崩了会丢末端若干个事务。想改成同步,两个参数缺一不可:synchronous_standby_names非空,synchronous_commit不为 off。

真正的细节在synchronous_commit的档位,它决定主库等哪个位点:

取值主库等到备库的哪一步主库崩溃备库崩溃代价
off不等,本地提交即可可能丢事务不受影响最低延迟,一致性最弱
local只等主库本地刷盘可能丢事务不受影响与单机相当
remote_write等到write_lsn不丢可能丢(未 fsync)延迟低,防主库宕机
on(默认同步档)等到flush_lsn不丢不丢延迟取决于备库 fsync
remote_apply等到replay_lsn不丢不丢且备库立即可见延迟最大

绝大多数人配同步复制就停在on,这已经能防住主库宕机;remote_apply意味着每次提交都要等备库应用完,写吞吐会降到肉眼可见的程度,只在"切过去必须读到刚写的数据"这种强要求下才用。

# postgresql.conf(主库) wal_level = replica # 至少要 replica,logical 才支持逻辑解码 max_wal_senders = 10 # 允许的 walsender 数量,要 >= 备库数 + 备份连接 wal_keep_size = 1GB # 13 之前叫 wal_keep_segments,按段数算 synchronous_standby_names = 'ANY 1 (standby_a, standby_b)' synchronous_commit = on

synchronous_standby_names的语法值得单独说。FIRST 1 (a, b)表示按列表优先级取第一个可用的,是主备结构的常规写法;ANY 1 (a, b)是 quorum 语义,任意一个确认即可。两节点环境里写FIRST 1和ANY 1效果一样,但只要写成ANY 2而实际只有两个备库,就等于要求两个都活着才能提交——一个备库挂了,主库直接拒绝所有写操作。这是同步复制最容易踩的坑:可用性和一致性在参数里硬碰硬,配错一个数字就是全站只读。

3. 12.x 的分水岭:recovery.conf 消失后,备库靠什么认出自己

同一份方案要同时覆盖 10.x 和 12.x,最麻烦的差别不在流复制本身,而在"备库怎么知道自己是谁"。这一步的判断逻辑变了,导致很多照旧文档操作的人在 12 上直接卡住:备库起不来,日志里也没有明确说缺了什么。

3.1 从 recovery.conf 到 standby.signal

12 之前,备库靠数据目录下存在recovery.conf进入恢复模式,里面写primary_conninfo、restore_command、trigger_file。12 开始这个文件被移除,改成两处:数据目录里一个空的standby.signal文件,加上postgresql.conf里的primary_conninfo。

改动的动机不复杂。recovery.conf和postgresql.conf有一批同名参数(比如restore_command相关),谁覆盖谁容易搞混,也没法对它执行ALTER SYSTEM;更要命的是它的语义靠"文件存在与否"表达,提升备库时得删或改名这个文件,文件系统操作和数据库状态之间没有原子性,断电恰好卡在这步就会进入一个说不清的状态。改用信号文件加普通参数,参数走统一的配置加载链路,信号文件只负责表达"我要做什么",职责分开。

# 12 及以后,用 -R 一步写好备库需要的两样东西 pg_basebackup -h 10.0.0.11 -p 5432 -U repl_user \ -D /data/pgdata -Fp -Xs -P -R \ -S standby_a # -Fp 普通文件格式(相对 -Ft 的 tar 包),便于直接启动 # -Xs 流式传输 WAL,备份过程中产生的 WAL 一并带走,避免备份不一致 # -P 打印进度 # -R 自动写 standby.signal 与 primary_conninfo(10 及以前写 recovery.conf) # -S 使用提前建好的物理复制槽,防止备份期间 WAL 被回收

-R这个参数在跨版本迁移脚本里要特别注意:同一行命令打到 10 上生成的是recovery.conf,打到 12 上生成的是standby.signal,脚本本身不用改,但如果后续还有手工mv recovery.done recovery.conf之类的动作,在 12 上就是纯粹的空操作,会让人误以为配置已经生效。

3.2 提升备库:那些被写进脚本却没验证过的路径

提升动作在 12 之前是往数据目录放一个trigger_file,或者pg_ctl promote;12 之后只剩pg_ctl promote和 SQL 函数pg_promote()。而 failover 脚本通常是拼字符串执行 shell,最容易出问题的地方是:脚本判定主库挂了,去备库执行提升,但命令是异步的,脚本立刻返回成功,pgpool 就把流量切过去了——此时备库还没完成从恢复模式到正常模式的转换,连接全部报错。

稳妥的做法是在脚本里轮询确认,而不是发完命令就认为成功:

# pgpool 的 failover_command 里,$H 是失效节点编号,$N 是新的主库编号 # 这里假设已通过 pcp 或映射表拿到备库地址 pg_ctl -D "$STANDBY_DATA" promote # 不要立刻退出,等到实例真的能接受写为止 for i in $(seq 1 30); do if psql -h "$STANDBY_HOST" -U postgres -tAc \ "SELECT pg_is_in_recovery();" 2>/dev/null | grep -q '^f$'; then exit 0 # 已脱离恢复模式,可以放流量 fi sleep 1 done exit 1 # 超时返回非零,让 pgpool 记录 failover 失败而不是假装成功

pg_is_in_recovery()返回 false 是"已经成为可写主库"的可靠判据,比去 grep 日志稳得多。脚本返回非零还有个额外好处:pgpool 的日志里会留下 failover 失败的记录,事后排查有据可依,而不是一片安静。

3.3 复制槽:既是保险也是定时炸弹

物理复制槽的作用是让主库承诺"这个备库还没收到的 WAL 我不删"。它解决了备库短暂断连就追不上、必须全量重做的问题,但代价是主库的pg_wal只增不减。

方案里如果给备库建了槽,就必须同时想清楚两件事:备库彻底下线后槽要不要删;主库磁盘水位谁来盯。13 之前没有max_slot_wal_keep_size,槽可以一直吃盘直到主库写满崩溃——这是把高可用方案变成故障源的最短路径。备库长时间维护前,先pg_drop_replication_slot,或者确认pg_replication_slots里active为 false 的槽有人管。

-- 主库:看槽的滞留情况和每个槽卡住了多少 WAL SELECT slot_name, active, restart_lsn, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained FROM pg_replication_slots; -- 13 及以后可以设上限,超过就自动让槽失效、断开该备库(宁可断也不能撑爆主库) -- ALTER SYSTEM SET max_slot_wal_keep_size = '10GB';

retained这一列是最实用的监控项。它持续增长而active为 false,说明有个备库已经断开但槽还在保着它的位置,磁盘迟早被吃干净。

4. pgpool-II 接管现场:连接池、读写分离与 VIP 漂移如何协同

流复制保证备库有数据,但应用连的是谁、主库挂了谁来改这个连接,是 pgpool 的活。它不是简单的 TCP 转发,而是在协议层解析 SQL、维护后端节点状态、并在多个 pgpool 实例之间做投票。理解它的关键,是分清哪些判断是"语句级"的,哪些是"进程级"的。

4.1 连接池与"排队而不是拒绝"

pgpool 的工作进程模型是预 fork:num_init_children决定能同时服务多少个客户端连接,每个子进程在max_pool范围内缓存到后端的连接。当并发客户端数超过num_init_children时,连接不是被拒绝,而是进到监听队列里排队,直到有子进程空出来。

这个行为跟 Postgres 自己的max_connections是两套限流。前端的排队发生在 pgpool 上,后端真正的连接数最多是num_init_children × max_pool,所以max_connections应该按后者的量级来配,而不是按客户端总数。把num_init_children开得很大反而有害:每个子进程都要跟所有后端保持连接,后端连接数会被放大,主库的内存和锁竞争跟着上去。

4.2 读写分离靠解析语句,不靠端口

pgpool 判断一条 SQL 该发到主库还是备库,是在解析阶段完成的:SELECT 走备库(按backend_weight加权分发),INSERT/UPDATE/DELETE 走主库。但有一批语句会被强制打回主库,理解这份例外清单比记住配置项有用得多——显式开启的读写事务、访问临时表、带写操作的函数、用了nextval的查询,都会让 pgpool 放弃分发。

流复制模式下最要紧的一条是不该开复制功能。3.x 的写法是replication_mode = off加master_slave_mode = on、master_slave_sub_mode = 'streaming';4.x 统一成backend_clustering_mode = 'streaming_replication'。如果这里配成了复制模式,pgpool 会试图自己把写语句广播到所有后端,跟流复制打架,结果是备库上出现重复数据或者直接报只读错误。方案文档给出 3.x 和 4.x 两套示例,主要差异就在这一处和故障切换相关的参数名上。

# pgpool.conf 关键片段(4.x 流复制模式) backend_clustering_mode = 'streaming_replication' backend_hostname0 = '10.0.0.11' backend_port0 = 5432 backend_weight0 = 1.0 backend_flag0 = 'ALWAYS_PRIMARY' # 主库,不参与自动降级 backend_hostname1 = '10.0.0.12' backend_port1 = 5432 backend_weight1 = 1.0 backend_flag1 = 'DISALLOW_TO_FAILOVER' # 备库,由 failover 脚本处理提升 health_check_period = 10 # 每 10 秒探活一次 health_check_timeout = 5 health_check_max_retries = 3 # 连续 3 次失败才判定节点失效,避免网络抖动误切 health_check_user = 'health_user' health_check_database = 'postgres' failover_command = '/etc/pgpool/failover.sh %d %H %m %P %N'

health_check_max_retries这个参数值得单独强调。默认值偏激进,在虚拟化环境里一次 GC 停顿就可能触发切换,而切换本身是有成本的(提升备库、重建复制关系、清空连接池)。放宽重试次数换来的几秒延迟,比误切造成的抖动划算得多。探测用的账号要有连接权限但不需要业务权限,避免因为业务库被删导致探活全挂、误判后端全死。

4.3 watchdog 与 VIP:两个 pgpool 怎么避免互相抢主

单台 pgpool 是新的单点。要做真高可用,就得部署两台 pgpool,让它们通过 watchdog 互相心跳,共享一个虚拟 IP,谁拿到 VIP 谁对外服务。心跳走wd_port(默认 9694),节点间还带一个 quorum 概念:只有获得多数派认可的节点才能对外提供服务。

这里有个容易忽略的实现细节:watchdog 集群里,每台 pgpool 要把另一台 pgpool 也注册成"后端",类型标成 PGPOOL,并设置DISALLOW_TO_FAILOVER,这样它既能被探活,又不会在失效时被当作数据库去提升。VIP 的上下线由if_up_cmd、if_down_cmd、arping_cmd这几个命令完成,本质是调系统命令给网卡加/减地址并广播 ARP。

脑裂的防线就在 quorum 上:两台 pgpool 之间网络断了,各自都认为对方死了,但只有一边能达到多数派(两节点场景通常配成enable_consensus_with_half_votes = off,即不允许半票通过),另一边主动放弃 VIP。不做这个配置,两个节点会同时绑上 VIP,交换机层面的 ARP 冲突会让流量随机落到两边,其中一边连的可能是已经被降级的备库——表现出来就是"时好时坏、报只读错误",这种问题查起来非常费劲。

5. 排查清单:配置看起来全对,为什么复制和切换还是不工作

下面这几条是按出现频率排的,每一条都能对应到一个具体的机制,而不是"重启试试"。

5.1 报 no pg_hba.conf entry for host

现象:pgpool 启动正常,客户端连上来立刻报错,日志里是no pg_hba.conf entry for host "10.0.0.10",地址正是 pgpool 自己的 IP。

原因:pgpool 到后端的连接同样要过 Postgres 的pg_hba.conf校验,而很多人只配了应用网段,忘了放行 pgpool 所在主机;用复制槽或pg_basebackup时还需要replication类型的条目,这一条更常被漏掉。

解决:在每个后端节点的pg_hba.conf里补上 pgpool 主机和复制用户的条目,然后pg_ctl reload。注意pg_hba.conf的匹配是从上往下第一条命中即生效,新加的条目如果放在一条宽泛的host all all 0.0.0.0/0 reject后面,等于没加。

# 放在文件靠前的位置,避免被后面的宽泛规则拦截 host all health_user 10.0.0.10/32 scram-sha-256 host all app_user 10.0.0.0/24 scram-sha-256 host replication repl_user 10.0.0.12/32 scram-sha-256

5.2 报 all backend nodes are down

现象:数据库本身好好的,能用 psql 直连,但通过 pgpool 一律报这个错。重启 pgpool 也没用。

原因:pgpool 把节点状态持久化在logdir下的pgpool_status文件里,重启时会读回上次的状态。也就是说,一次真实的短暂故障被判定为 down 之后,这个"坏印象"会跨越重启一直保留。这是最容易让人误判成"配置错了"的坑。

解决:先确认后端真的活着,然后用pcp_attach_node把节点挂回去;或者在停掉 pgpool 后删掉pgpool_status再启动(会丢掉所有历史状态,需要重新评估)。同时回头检查health_check_user是否能连、health_check_database是否存在——探活失败也会走到同一个报错。

5.3 备库报 requested WAL segment has already been removed

现象:备库日志出现这个错误,然后复制永久停止,重启备库也追不上。

原因:备库断连期间,主库已经把这部分 WAL 回收了(wal_keep_size太小,或者根本没建复制槽)。流复制的续传依赖主库还留着那段日志,日志没了就只能重做。

解决:没有捷径,只能重建备库。pg_basebackup重新拉一份,同时把复制槽建上,并把wal_keep_size调到能覆盖"备库最长允许离线时间 × 写入速率"的量级。算出这个量级比拍一个 1GB 更靠谱。

5.4 VIP 漂移成功,应用却连不上或者连到旧库

现象:failover 日志显示一切正常,VIP 已经在备节点上,但应用侧持续报错,或者写操作报"cannot execute INSERT in a read-only transaction"。

原因:VIP 换了位置,但客户端持有的 TCP 长连接还指向原来的 pgpool 进程,连接池里缓存的到后端的连接也还活着。pgpool 切换后需要把旧连接全部作废,而应用侧的连接池往往自己还有一层缓存。

解决:failover 脚本末尾显式让 pgpool 重建连接状态,应用侧把连接池的最大存活时间设成比 health check 周期更短的值,让连接定期自然轮换。只靠 VIP 漂移而不处理连接层,切换看起来成功、业务实际中断十几分钟,是很典型的"监控绿了、用户炸了"。

5.5 主备时间不一致,日志对不上

现象:排查延迟时发现主库和备库的日志时间戳差了几十秒,根本没法把两边的记录对齐;watchdog 心跳偶发误判。

原因:环境准备阶段漏了时钟同步,或者配了但服务没设成开机自启。

解决:所有节点统一做时钟同步并纳入监控,偏移超过阈值直接告警。这条看着像环境琐事,实际上决定了出故障时能不能在几分钟内定位——两边时间轴对不齐,日志基本等于黑匣子。

6. 取舍的边界:同步等级、中间件单点与恢复点

这套方案里没有"最优配置",只有一组互相拉扯的选择,落到具体业务上才有对错。

第一组取舍在同步等级。异步复制写延迟最低,主库崩了丢末端若干事务;synchronous_commit = on能保证不丢,但每次提交都要等备库 fsync;remote_apply更严格,代价是写吞吐显著下降。而synchronous_standby_names的写法直接决定可用性:两节点环境写ANY 2意味着任何一个备库挂掉主库就拒绝写入,把高可用方案做成了低可用。我的建议是同步集合里永远留出冗余,宁可写ANY 1也不要让确认条件在节点数上卡死。

第二组取舍在 pgpool 的角色。它提供了连接池、读写分离、故障切换三件事,但三件事的可靠性不在一个量级:连接池和读写分离是稳定的,故障切换依赖探活准确性和脚本正确性,是需要反复演练的部分。另一个常被忽略的点是,既然流复制已经在做数据同步,pgpool 自身的复制模式就必须关掉,职责重叠的结果不是双保险而是互相破坏。

方案数据丢失风险切换自动化适用场景
单库 + 定时备份可能丢整个备份周期无非核心业务、可接受停机恢复
流复制(异步)+ 手工切换丢末端若干事务人工读多写少、能接受分钟级中断
流复制(同步)+ pgpool + watchdog不丢已提交事务自动核心交易类,需承担写延迟上升
流复制 + pg_rman 全量归档备份不丢已提交事务,且可回到任意时刻自动 + 事后恢复有误操作回滚需求的场景

最后一行值得多说一句。流复制防的是硬件和进程故障,防不了DELETE忘了写WHERE——那种误操作会瞬间同步到备库。真正能兜住的是全量备份加归档日志,也就是 PITR:先恢复到某个基础备份,再重放 WAL 到误操作前的一秒。这就需要在主库上开归档,用archive_command或备份工具定期做基础备份,并保证归档日志的完整链条不断。备份目录和 WAL 归档放在同一个存储上,等于没备份,这一点怎么强调都不为过。

从那以后,我每次交付这类方案,都会强制走一遍"停主库进程、停主库 pgpool 进程、确认 VIP 漂移、验证写入落到新主库、再用备份做一次时间点恢复"的完整演练,而不是只看pg_stat_replication是不是 streaming。配置正确和故障时真的能切,中间隔着一次真刀真枪的演练。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询