做PostgreSQL生产环境运维的同学,大概率迟早会碰上一个场景:业务量上来之后,读请求把主库CPU打到90%以上,慢查询一条接一条,监控告警响个不停。这时候很多人第一反应是加内存、加SSD,但硬件堆完没几天,瓶颈又回来了。真正治本的做法是在数据库前面加一层流量路由,把读请求均匀分流到只读节点上。这篇实战指南要聊的,就是PostgreSQL 18环境下,用Pgpool-II实现负载均衡的完整方案,从原理拆解到配置落地,再到生产环境常见坑的排查方法,一次讲透。
如果你正在维护一个PostgreSQL集群,或者准备从单机往主从架构演进,这篇内容值得仔细看。Pgpool-II是目前PostgreSQL生态里最常用的中间件之一,它同时承担连接池、负载均衡、读写分离、自动故障转移等职责,相比HAProxy纯四层转发或者应用层自己实现读写分离,Pgpool-II的集成度更高,对PostgreSQL协议的理解也更深入。下面直接进入正题。
1. 为什么需要负载均衡:先搞清楚要解决什么问题
1.1 读多写少场景下的典型瓶颈
大部分业务系统的访问模型都是读多写少,比如内容管理系统、电商前台、报表查询平台,读请求占比往往在80%以上。在没有负载均衡的情况下,所有查询都打到主库,主库的CPU和IO很快成为瓶颈。
我在实际项目里见过一个很典型的案例:一个日活十万左右的业务系统,数据库单表数据量在两千万级别,高峰期每秒查询量大概3000左右,写请求只有两百。主库CPU持续跑在85%以上,大量查询排队,页面响应时间从50毫秒飙升到800毫秒。后来通过部署两个只读从库,用Pgpool-II做读写分离和负载均衡,主库CPU直接降到30%以下,整体吞吐提升了接近三倍。这就是负载均衡带来的直观收益。
那为什么不直接用物理主从复制加上应用层自己判断读写路由呢?当然可以,但问题在于业务代码侵入性太强。你需要在每个数据访问层写死数据源路由逻辑,而且还难以应对从库故障的自动摘除。一旦某个从库挂了,应用不知道,还是会往那个节点发送查询,导致大量报错。Pgpool-II的价值就在于把这些事情从应用层剥离出来,统一在数据库中间件层面解决。
1.2 负载均衡方案选型对比
PostgreSQL生态里做负载均衡的方案有好几种,很多人纠结选哪个,下面用表格直接对比一下常见的三个方向。
| 方案 | 工作层级 | 读写分离能力 | 连接池 | 自动故障切换 | 适用场景 |
|---|---|---|---|---|---|
| Pgpool-II | PostgreSQL协议层 | 原生支持,基于SQL解析 | 内置 | 内置 | 中小型集群一体化方案 |
| HAProxy | TCP四层 | 不支持,只做连接分发 | 无 | 需配合外部脚本 | 纯流量分发 |
| 应用层多数据源 | 应用代码 | 可自行实现 | 依赖连接池组件 | 需自行开发 | 高度定制化需求 |
从表格能看到,Pgpool-II最核心的优势是它工作在PostgreSQL协议层,能够识别客户端发过来的SQL是SELECT还是写操作,从而做到真正的读写分离。HAProxy只能做到连接层面的分发,要么把连接全部分到主库,要么全部分到从库,无法做到同一个连接内根据SQL类型自动路由。
不过要注意,Pgpool-II也有它的局限。它本身是一个独立进程,如果部署不当会成为新的单点,所以生产环境通常需要部署两个Pgpool-II节点配合keepalived做VIP漂移。另外,它对复杂事务和临时表场景需要额外的配置处理,这些细节后面会展开讲。
1.3 Pgpool-II的核心能力边界
在实际落地之前,我觉得有必要把Pgpool-II的能力边界讲清楚,这样你才知道哪些场景适合它,哪些场景要用别的工具配合。
先说它能做的事情。第一,连接池复用,客户端不再频繁创建和销毁数据库连接,应用侧连接数可以大幅降低;第二,负载均衡,读请求按权重分发到各个只读节点;第三,读写分离,通过解析SQL语义,把INSERT、UPDATE、DELETE路由到主库,SELECT路由到从库;第四,自动故障转移,检测到主库故障时,自动把某个从库提升为新的主库;第五,在线恢复,配合流复制可以把新节点随时纳入集群。
它不能做什么呢?最典型的是跨节点分布式事务,如果业务要求一个事务里同时更新主库和从库的数据,Pgpool-II做不到,关系型数据库的分布式事务扩展本身就是个复杂课题。另外,它不解决数据同步延迟问题,从库的数据始终存在一个微小的复制延迟,这是物理复制的硬特性,任何中间件都掩盖不了。
清楚了能力边界之后,你就能判断,Pgpool-II适合的是主从架构下的读写分离与连接管理,而不是分布式数据库替代品。接下来我们深入它内部的工作机制。
2. Pgpool-II核心机制拆解:负载均衡到底怎么工作
2.1 读写分离与负载均衡的路由规则
Pgpool-II之所以能区分读写请求,靠的是对前端发过来的SQL进行解析和判断。它在内部维护了一套规则,凡是SELECT开头的语句默认走从库,其他语句走主库。但这里有几个细节值得关注。
第一个是事务内的SELECT。如果一个事务里先执行了写操作,然后执行SELECT,那么这条SELECT必须走主库,否则会读到不一致的数据,因为从库此时可能还没同步到这个事务的更新。Pgpool-II的默认策略是,一旦事务内出现写语句,整个事务后续的所有语句都路由到主库。这个行为用参数write_through_query可以控制,默认是关闭的,表示遇到写操作后事务内的读也全部走主库,这个默认值在生产环境是合理的。
第二个是隐式事务。很多ORM框架会把单个SQL包在BEGIN和COMMIT之间,如果应用开启事务后第一条语句就是SELECT,Pgpool-II会把这个事务的路由结果保持住,后续如果出现写操作,它会在事务级别做切换,把后面的语句全部转到主库执行。
第三个是无FROM的SELECT。像SELECT 1这种不涉及表查询的语句,走哪个库都无所谓,Pgpool-II默认也会参与负载均衡。
负载均衡的权重通过backend_weight参数控制。比如你有两台从库,一台机器性能强,另一台是老机器,可以给它们设置不同的权重,例如主库权重为0不参与读,从库A为2,从库B为1,那么读请求会按2:1的比例分发。这个权重是概率加权,不是严格每三个请求里精确分配两个给A一个给B,而是长期趋近这个比例。
2.2 连接池与会话状态管理
Pgpool-II的连接池机制,说白了就是在客户端和后端数据库之间再加一层连接复用层。客户端连接到Pgpool-II,Pgpool-II从后端连接池里取一个空闲连接处理请求,处理完再把连接放回池子。这样做的好处非常明显,应用侧不需要维护大量真实数据库连接,连接的建立和释放开销被大幅降低。
在配置层面,num_init_children控制Pgpool-II预创建的连接数,可以理解为同时能处理多少个客户端会话;max_pool表示每个Pgpool-II子进程最多缓存多少个后端连接。这个地方很多人容易配错。我见过一个生产事故,num_init_children配了4,结果业务高峰期同时在线会话超过4个,后面所有请求全部排队等待,数据库CPU反而没跑满,因为流量都堵在Pgpool-II的连接池外面。所以这个值必须根据业务并发量实际压测来定,而不是套用默认值。
连接池还有一个隐藏的坑,就是会话级临时对象。如果应用使用了CREATE TEMP TABLE或者自定义GUC参数,比如SET search_path,这些状态是绑定在具体数据库连接上的。当连接被池化复用后,下一个客户端拿到这个连接时,残留的临时表或参数可能造成数据错乱。Pgpool-II针对这个问题提供了connection_life_time参数,定期重建连接,同时应用侧要尽量避免在长连接里大量使用临时表。
2.3 主备监控与自动故障转移逻辑
自动故障转移是Pgpool-II最吸引人的特性之一,但它也是配置最复杂、最容易踩坑的部分,生产环境里因为故障转移误触发导致集群抖动的事故我见过多次。
Pgpool-II通过定期发送探活请求来监控后端节点状态,health_check_period控制检查周期,默认是10秒;health_check_timeout控制单次检查超时,默认是20秒。如果连续health_check_max_retries次检查失败,节点就被标记为宕机。
主库宕机后的切换逻辑严格依赖failover_command脚本。这个脚本在Pgpool-II判定主库故障时执行,核心动作一般是调用pg_ctl promote提升一个从库为新主库,然后更新Pgpool-II的节点状态信息。这里最大的坑在于,promote操作是异步的,如果脚本写得不够健壮,可能会出现旧主库其实还没彻底宕掉的情况,双主写同一份数据,后果就是数据分裂。
我个人的建议是,Pgpool-II的自动故障切换可以作为第一道防线,但生产环境最好还是配合Patroni这类专门的集群管理工具来实现更严谨的failover决策。Pgpool-II负责流量路由和读写分离,Patroni负责仲裁和选主,两者分工是业界比较成熟的做法。不过本文聚焦的是Pgpool-II本身,所以后面还是以它独立运作的视角来展开。
3. 实操配置:在PostgreSQL 18集群上部署Pgpool-II
3.1 环境准备与安装方式
先讲一下我这次演示的集群环境。三台机器,分别对应主库、从库1、从库2,另外单独一台机器装Pgpool-II做负载均衡入口。操作系统是CentOS 7.9,PostgreSQL版本为18 Beta版,Pgpool-II使用4.5版本。实际生产可能还在用PostgreSQL 15或16,但配置逻辑完全一致,不用太纠结版本数字。
Pgpool-II的安装方式有两种主流方案,一是通过各个发行版的软件仓库直接安装,比如CentOS下可以配置Pgpool-II官方YUM源后执行yum install pgpool-II;二是从源码编译安装,适合需要定制化编译参数或者官方仓库没有对应版本的场景。
这里贴一下源码编译的常规流程。我使用的是官方提供的tar包,版本为pgpool-II 4.5。
tar -zxvf pgpool-II-4.5.tar.gz cd pgpool-II-4.5 ./configure --prefix=/usr/local/pgpool --with-pgsql=/usr/local/pgsql make -j4 make installconfigure阶段有两个参数比较关键。--with-pgsql必须指向你PostgreSQL的安装目录,因为Pgpool-II编译时需要借助PostgreSQL的头文件来解析SQL语义,路径写错直接编译失败。--prefix指定安装路径,建议单独放在一个目录,不要和PostgreSQL混装,后期升级维护会麻烦。编译完成后,可执行文件pgpool、pcp_*系列管理命令、psql扩展工具都会生成在bin目录下。
3.2 pgpool.conf核心配置逐项解析
装好之后,最重要的就是配置pgpool.conf。这个配置文件是整个Pgpool-II的灵魂,参数非常多,但生产环境常用的核心参数也就二十个左右。下面我按配置模块拆开讲,每项参数都会说明含义和推荐值。
# 连接监听配置 listen_addresses = '0.0.0.0' port = 9999 socket_dir = '/tmp' # 后端节点配置 backend_hostname0 = '192.168.1.10' backend_port0 = 5432 backend_weight0 = 0 backend_data_directory0 = '/data/pgsql' backend_flag0 = 'ALLOW_TO_FAILOVER' backend_hostname1 = '192.168.1.11' backend_port1 = 5432 backend_weight1 = 1 backend_data_directory1 = '/data/pgsql' backend_flag1 = 'ALLOW_TO_FAILOVER' backend_hostname2 = '192.168.1.12' backend_port2 = 5432 backend_weight2 = 1 backend_data_directory2 = '/data/pgsql' backend_flag2 = 'ALLOW_TO_FAILOVER'节点配置有几个细节要强调。backend_weight设为主库为0、从库为1,意味着所有读请求都均匀分布到两台从库,主库不参与读负载;backend_data_directory必须准确填写,数据目录填错会导致Pgpool-II无法判断节点角色的目录信息;backend_flag的ALLOW_TO_FAILOVER允许该节点参与failover,如果某个节点只是临时加进来测试,可以去掉这个标记。
再看连接池和进程相关参数。
num_init_children = 32 max_pool = 4 child_life_time = 300 child_max_connections = 0 connection_life_time = 300 client_idle_limit = 0num_init_children = 32表示Pgpool-II最多启动32个子进程同时处理客户端请求,每个子进程是一个独立的后端连接管理者。这个值我前面说过,必须结合业务压测来定,可以先从32起调,观察连接池排队情况再慢慢升。child_max_connections = 0表示单个子进程处理的客户端连接数不设上限,生产环境建议设为0,让生命周期控制由child_life_time负责,避免连接长期占用造成内存碎片。
然后是负载均衡和读写分离配置。
load_balance_mode = on ignore_leading_white_space = on read_only_function_list = 'pg_is_in_recovery' write_function_list = 'nextval,setval' primary_routing_query_pattern = '^SELECT\s+.*FOR\s+UPDATE'read_only_function_list这个参数很容易被忽略。它的作用是声明哪些函数是只读函数,Pgpool-II会把包含这些函数的SELECT语句都路由到从库。pg_is_in_recovery通常会配置进去,否则有些ORM启动时会调用它做连通性检查,结果被路由到从库后返回false,应用误以为当前连接的是主库,逻辑就乱了。write_function_list很重要,nextval、setval这两个序列函数本质上是写操作,必须强制走主库,否则在从库上执行nextval会直接报错,因为从库只允许只读操作。primary_routing_query_pattern则是把带FOR UPDATE锁定的SELECT识别为主库操作,这些语句读的是最新数据,还要加锁,绝不能走从库。
3.3 启动集群并验证负载均衡效果
配置完成后,先启动后端PostgreSQL集群,确保流复制状态正常。在主库上执行:
SELECT pg_is_in_recovery(); -- 返回 false 表示当前为主库在两台从库上执行同样命令,应该返回true。然后启动Pgpool-II:
pgpool -f /usr/local/pgpool/etc/pgpool.conf -n-n参数表示前台运行,日志直接打到标准输出,方便观察启动过程有没有报错。启动后可以用pcp_node_info命令查看Pgpool-II视角下的节点状态:
pcp_node_info -h 127.0.0.1 -U pgpool -p 9898 -w输出类似下面这样:
192.168.1.10:5432 PRIMARY 192.168.1.11:5432 STANDBY 192.168.1.12:5432 STANDBY节点角色识别正确,说明配置基本没问题。接下来验证负载均衡效果。用psql连上Pgpool-II的9999端口,执行几条查询:
SELECT * FROM t_user WHERE id = 1; -- 在主库的日志里观察连接来源还有一种更直观的验证方式,通过show pool_nodes命令查看当前会话的后端分配情况。Pgpool-II会在内部记录客户端连接实际使用了哪个后端节点。我在实际调优时习惯做一轮并发压测,用pgbench生成300并发持续十分钟的读流量,然后在三台数据库上分别看查询数量,正常情况下两台从库的SELECT数量基本是持平的,主库几乎只有少量写操作,这就说明负载均衡真正生效了。
4. 生产环境避坑:常见问题与排查实录
4.1 读请求全部走在主库,从库成了摆设
这是最常被问到的问题,配置看起来没问题,load_balance_mode = on也开启了,但监控显示主库的查询量还是非常高,从库基本没流量。
排查思路从三个方向入手。第一,确认后端节点角色是否被正确识别。如果Pgpool-II把从库当成了主库,或者全都当成主库,负载均衡就不会生效,执行pcp_node_info逐一确认。第二,检查是否所有业务会话都走了事务导致路由失效。前面提到过,如果应用框架自动开启事务,且事务内先发生了写操作,那么整个事务的读都会在主库执行。这种场景从Pgpool-II的日志里能看到特征,大量查询都是事务内路由。第三,检查配置里的ignore_leading_white_space参数,如果设置为off,SQL前面多一个空格就会被当成普通语句处理,甚至导致路由判断失效。建议保持为on。
除此之外,还有一个比较隐蔽的原因是应用使用了带注释的SQL,比如MyBatis生成的/* index */ SELECT ...,Pgpool-II对注释的处理策略会在某些版本中影响解析结果。遇到这种情况,可以临时开启Pgpool-II的调试日志,把解析后的SQL打印出来看路由决策结果。
4.2 健康检查误判导致频繁故障切换
故障切换是保护机制,但误触发就是灾难了。有一次生产环境集群里的从库被Pgpool-II频繁标记为宕机,然后马上又恢复,反复切换导致应用侧出现了大量连接中断报错。
排查后发现,问题出在健康检查参数配置太激进。health_check_timeout我配成了5秒,health_check_max_retries配了1,当时从库正好在做全量vacuum,资源占用较高,健康检查请求在5秒内没等来响应,就被判定为宕机,触发failover脚本,把一个健康的从库给切掉了。后来我把health_check_timeout调整到10秒,health_check_max_retries调整为3,情况就再没出现过。
这个案例想说明的是,健康检查参数要结合数据库实际负载来配置,不能为了追求快速感知故障而把超时设得太短。PostgreSQL在跑大事务、vacuum、索引重建时,短时间内对轻量查询的响应确实可能变慢,这时候误判的代价比延迟发现故障大得多。建议生产配置至少满足health_check_timeout * health_check_max_retries大于数据库业务高峰期一条慢查询可能持续的最长时间。
4.3 读从库读到旧数据,业务层出现一致性问题
读写分离最常见的矛盾就是复制延迟。主库刚插入一条记录,客户端立刻通过从库去读,如果从库还没应用完这条WAL日志,查询结果就是空的。
这个问题的解决思路有三个层次。第一层是业务容忍,很多读多写少场景对秒级延迟并不敏感,比如资讯列表、商品详情,这种无需处理。第二层是会话级读一致性,Pgpool-II提供了delay_threshold参数,如果从库的复制延迟超过这个阈值,Pgpool-II会自动把该从库的读请求临时转发到主库,保证用户读到的数据足够新。第三层是应用主动规避,对强一致要求的查询,在SQL里加注释强制走主库,Pgpool-II支持/*NO LOAD BALANCE*/这种注释指令,SQL前加上这个注释,该查询就会被强制路由到主库。
/*NO LOAD BALANCE*/ SELECT balance FROM accounts WHERE account_id = 1001;这种方式虽然需要开发配合,但效果最精准。我在实际项目中是三层组合使用,默认延迟阈值设500毫秒,业务上明确要求强一致的查询代码里加注释,两者互相兜底,基本没有出现过因延迟导致的投诉。
4.4 常见问题速查表
最后把我在多次部署和运维中遇到的高频问题整理成一张速查表,方便你遇到同类问题时快速定位。
| 故障现象 | 常见原因 | 排查动作 |
|---|---|---|
| 连接被拒绝 | 9999端口未监听或防火墙拦截 | ss -lntp确认监听,检查iptables |
| 所有请求都走主库 | 从库角色未被识别 | pcp_node_info节点状态,检查backend配置 |
| 频繁failover | 健康检查超时配置过短 | 调大health_check_timeout和重试次数 |
| 从库查询报序列不存在 | nextval被路由到从库 | 在write_function_list中加入nextval |
| 连接池耗尽,请求排队 | num_init_children偏小 | 压测后按并发量调大该值 |
| 切换后写不进去 | failover脚本未正确提升新主库 | 检查脚本执行日志和promote命令是否生效 |
| 负载比例不均衡 | 后端权重配置不合理 | 按硬件性能调整backend_weight |
这张表里的每一个问题都是我或者朋友在生产环境真实踩过的,不是从文档里抄出来的理论。尤其是健康检查误判和nextval路由问题,几乎每个刚上Pgpool-II的团队都会遇到一次。
写在最后的实战心得
从我个人的体会来看,Pgpool-II是一个功能非常强但细节也很多的组件,它的难点不在安装,而在路由规则的细致理解。很多人上来就配一通,能用就以为完事了,等到事务内读取错乱、序列报错、从库负载不均这些问题冒出来才回头翻配置文档,这时候排查成本就高了。
我习惯的做法是每次改完Pgpool-II的配置,都先用pgpool -f pgpool.conf --config-test做一次配置语法检查,然后故意停掉一个从库,观察应用侧是否能自动避开故障节点,再恢复观察自动纳入,整套验证通过之后才让配置生效到生产。这个流程虽然多花十分钟,但能规避绝大部分低级失误。
另外,如果你想在现有集群上平滑引入Pgpool-II,可以先把Pgpool-II挂到集群前面,配置成直通模式,也就是暂时不开启读写分离,只做连接池,观察一段时间,确认不引入额外问题之后再打开load_balance_mode。这样分阶段上线,风险会小很多,也方便定位问题到底出在Pgpool-II还是业务层。