ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

pgpool-II 踩坑实录:负载均衡与连接池引发的 PostgreSQL 故障

pgpool-II 踩坑实录:负载均衡与连接池引发的 PostgreSQL 故障 如果你也在一套跑着 pgpool-II 的 PostgreSQL 集群后面做维护应该对这种感觉不陌生白天一切正常晚上日志突然刷屏某个节点被标记 down应用连接开始排队而你明明刚 psql 连过那个库一切健康。去年我负责的在线业务刚从三节点 PostgreSQL 13 集群迁到 pgpool-II 4.3.2 接管读写分离、连接池、自动 failover 配置得整整齐齐。上线前我还做过一轮验证感觉稳了结果短短两周就连续踩了两个坑回头看都是对 pgpool-II 参数和工作模型理解得不够深。这篇文章就把这两段排查经历原原本本写下来给同样在用 pgpool-II 的同行当个参考。1. 先交代架构一套三节点 PG 集群和 pgpool-II 的部署形态1.1 我的集群拓扑和 pgpool 配置这套环境不算复杂三个 PostgreSQL 13 节点pg1 是主库pg2 和 pg3 是异步流复制备库。业务是典型的读多写少所以需要读写分离来分摊读压力与此同时应用端没有自己再架一层 PgBouncer连接管理直接交给 pgpool-II。pgpool-II 跑在两台独立机器上做成 watchdog 双机模式业务通过 VIP 访问 pgpool 的 9999 端口。关键配置大概是这样的listen_addresses * port 9999 backend_hostname0 pg1 backend_port0 5432 backend_weight0 1 backend_hostname1 pg2 backend_port1 5432 backend_weight1 2 backend_hostname2 pg3 backend_port2 5432 backend_weight2 2 num_init_children 32 max_pool 4 pool_mode transaction load_balance_mode on这套配置在测试环境跑了两周读写分离正常故障切换也能把 VIP 漂移过去看起来没什么问题。真正上生产后才发现问题往往藏在“看起来没问题”的地方。1.2 理解 pgpool-II 的会话模型是排坑的前提pgpool-II 和 PostgreSQL 之间的连接关系比大多数人以为的要复杂。它不是简单地把一个客户端连接对应到一个数据库连接而是启动一批子进程每个子进程可以同时维护多个到 PostgreSQL 后端的连接。这里的两个关键参数是num_init_children和max_poolnum_init_children是子进程数量可以理解为 pgpool-II 能同时服务的前端连接数max_pool是每个子进程最多能为每个后端节点建立多少个缓存连接。所以一个后端节点最多可能承受的前端连接数是num_init_children * max_pool。如果集群有三个节点每个节点理论上都会各自承受这么多连接。这个数字一旦估算错误后面第二个坑就来了。而pool_mode transaction意味着事务内部的语句会被固定到同一个后端节点但事务外的独立语句并不保证始终走同一个节点。再加上load_balance_mode onpgpool-II 会根据权重把读请求分发到不同备库。这套机制在纯查询场景下没有问题可一旦涉及 PostgreSQL 的会话级对象比如临时表和预备语句就会产生一个微妙的断层——这是第一则踩坑日记的主角。2. 第一则日记临时表被负载均衡“分发”到别的节点之后2.1 事故现场任务间歇性报错单库复现死活不出现那天是周三业务反馈有一个积分批处理任务连续三天晚上报错报错信息大致是ERROR: relation tmp_score_batch does not exist CONTEXT: SQL statement INSERT INTO tmp_score_batch ...任务本身逻辑不复杂先创建一张临时表然后从业务明细表里捞数据灌进去再做聚合更新。这段逻辑在测试环境怎么跑都没问题即使偶发报错重跑一次就成功。因为概率低最初没人当回事直到第三天才甩到我这里。我第一反应是应用代码有 bug于是直接连到主库 pg1手动执行任务的核心 SQL 语句。结果一连跑了十几次全部正常。这就很奇怪了代码看起来没问题单库执行也没问题为什么应用一到晚上就跑不出来当时我忽略了一个关键差异我连的是 PostgreSQL 的 5432 端口而应用连的是 pgpool-II 的 9999 端口。这两条链路的行为完全不一样。发现问题出在链路上已经是半天后的事了。2.2 缩小范围绕开 pgpool、查日志、构造最小复现排查这种事最忌讳一上来就看代码猜原因。我按三步走第一步让业务方把其中一个批处理节点改成直连主库 5432绕开 pgpool-II任务当晚恢复正常。这个结果把问题范围从“应用逻辑”缩小到了“pgpool 分发逻辑”。第二步打开 pgpool-II 的日志。在pgpool.conf里设置log_statement on log_connections on log_client_messages on同时把三个 PG 节点的log_statement也打开。重新触发任务后我对了几份日志发现复现出的执行序列大致是这样的在 pg2 上执行了CREATE TEMP TABLE tmp_score_batch (...);在 pg3 上执行了INSERT INTO tmp_score_batch SELECT ...;在 pg1 上执行了SELECT count(*) FROM tmp_score_batch;三条语句被分发到了三个不同节点。临时表建在 pg2后面 INSERT 和 SELECT 却跑到了 pg3 和 pg1那当然找不到表。这就解释了一切。第三步为了确认我直接用 psql 连上 pgpool 的 9999 端口手工循环执行CREATE TEMP TABLE t_hello(id int); INSERT INTO t_hello VALUES (1); SELECT count(*) FROM t_hello;连跑二十多次大多数时候正常但确实会出现relation t_hello does not exist。偶发性在这里有个很具迷惑性的体现只要三条语句碰巧被分到同一个节点就一切正常只要有一条被分到别的节点就报错。这个概率问题会让人误以为系统“基本没问题”是最容易踩的陷阱。2.3 根因拆解会话被 pgpool-II 切碎了PostgreSQL 的临时表是会话级对象一个连接会话里创建的临时表其他连接永远看不到。正常情况下应用建立一条连接执行 N 条 SQL这 N 条 SQL 都在同一条连接上跑临时表自然没有问题。但 pgpool-II 在load_balance_mode on且pool_mode transaction时会对事务外的独立语句重新做负载均衡。简单来说应用发来一条 SQLpgpool-II 可能把它分到 pg1下一条又分到 pg2再下一条分到 pg3。对普通查询来说这没毛病但对临时表这种“有状态”的对象来说等于把一个会话劈开分给了三个互不相通的数据库后端。这个问题的本质不是 pgpool-II 的 bug而是架构设计上的冲突负载均衡追求的是请求分散临时表追求的是状态固定两者天然矛盾。官方文档其实早就提示过这个场景但多数人包括我在内最初都不会把“负载均衡”和“临时表”这两个词联系到一起。另一个细节是这个问题在显式事务内通常不会犯。因为pool_mode transaction会在事务的第一条语句执行时选定节点事务内后续语句都走同一个节点。麻烦就麻烦在应用代码把临时表相关操作放在了多个短事务里每条 SQL 都是独立的 autocommitpgpool-II 自然有权重新选择节点。2.4 修复方案把批处理逻辑收进存储过程我的修复思路经历了三次取舍。第一个想法是让业务把所有临时表操作包进一个显式事务用事务把节点“钉死”。技术上可行但业务代码里临时表的创建和使用分散在多个函数里要保证它们全部落在同一个事务内改造量非常大而且一不小心就会出现事务外第一条语句重新选节点的情况。放弃。第二个想法是给关键 SQL 加 pgpool-II 的注释提示/*NO LOAD BALANCE*/ CREATE TEMP TABLE tmp_score_batch (...);这个方案对零散的几条 SQL 很有效可以把指定的语句强制发到主库执行。缺点是业务里凡是涉及临时表的地方都得改一遍 SQL而且后期如果有新人接手不一定知道这个注释的存在。只能作为过渡手段。最终采用的是把整段批处理逻辑封装成一个存储过程CREATE OR REPLACE FUNCTION batch_calc_score(IN p_batch_id integer) RETURNS bigint LANGUAGE plpgsql AS $$ DECLARE v_total bigint; BEGIN CREATE TEMP TABLE tmp_score_batch AS SELECT id, amount FROM score_detail WHERE batch_id p_batch_id; -- 后续聚合逻辑都在函数内部走 SELECT count(*) INTO v_total FROM tmp_score_batch; RETURN v_total; END; $$;为什么这个方案最稳因为 plpgsql 函数体的执行是发生在 PostgreSQL 后端内的函数内部的语句不会再被 pgpool-II 逐条拿出来做负载均衡。只要函数被分发到某个节点函数从头到尾都在那个节点上执行临时表自然始终保持可见。改完以后这个批处理任务连续观察了两周一次报错都没再出现。2.5 这里容易衍生出的两个“坑中坑”第一不要以为把临时表操作挪到某个具体函数里就万事大吉。如果函数内部又调用了其他存储过程或者通过 dblink 之类的扩展发起了远程查询那这个“节点亲和性”的保护边界需要额外确认。我在线上就见过有人在存储过程里调 dblink 去连“主库”结果 dblink 的远端连接本身又绕了一层 pgpool问题原地复现。第二/*NO LOAD BALANCE*/提示虽然好用但最好在测试环境验证一下你们用的 pgpool-II 版本是否支持。不同小版本的注释解析行为有差异别等上线了才发现提示没生效。3. 第二则日记连接池参数理解错位高峰时段健康检查误杀节点3.1 事故现场备库明明活着pgpool-II 却宣布它 down 了第一则日记解决完之后消停了一周然后就撞上了第二起事故。这回是营销活动上线业务流量比平时高了一倍多。当天下午应用侧开始集中报错错误消息跟雪片一样FATAL: sorry, too many clients already同时 pgpool-II 日志里不断滚动WARNING: health check failed. pg3 is down LOG: failover: no valid backend. pg3 is down我立刻连上 pgpool-II 执行SHOW POOL_NODES;结果是 pg3 的 status 显示为 down权重直接变成了 0。但诡异的是我用 psql 直连 pg3 的 5432 端口一切正常检查流复制状态也正常再看看系统负载CPU 和磁盘都谈不上紧张。一个“身体很健康”的节点为什么会被 pgpool-II 判死我当时的第一个反应是健康检查参数太激进把故障切换调了太敏感。但把参数翻出来一看又觉得不至于。真正的问题藏在另一个角落。3.2 排查链路从健康检查日志一路查到连接数公式第一步看 pgpool-II 的健康检查日志。我把日志级别提到debug1翻出来的记录大致是DEBUG: health check: connect to host pg3 port 5432 DEBUG: health check: connected DEBUG: health check: select 1 DEBUG: health check: select 1 failed WARNING: health check failed. 3 is down健康检查进程能够建立 TCP 连接但发送SELECT 1后却被 PostgreSQL 拒绝执行。这说明问题不在网络而在 PostgreSQL 端已经“接不下”新的查询了。第二步看 pgpool-II 连接池状态。执行SHOW POOL_STATUS;关键参数如下num_init_children 32 max_pool 4 connection_life_time 0 connection_max_age 0到这里我意识到问题可能在连接数上。pgpool-II 到 pg3 的最大连接数理论上等于num_init_children * max_pool也就是 128。pg3 的max_connections配的是 200看起来还有富余。但这是算少了因为 pg3 上并不是只有 pgpool-II 在连。监控系统、备份任务、数据分析任务、应用直连查询这些连接平时看着不起眼高峰时会一起涌上来。第三步查 pg3 当时的连接数。登录 pg3 执行SELECT count(*) FROM pg_stat_activity;数字显示当前活跃连接已经接近 200新连接只能排队或者直接被拒。pgpool-II 健康检查发过去的SELECT 1就是这支“挤不上公交”的队伍里的一员。再看全部参数connection_life_time 0意味着缓存的连接不会被主动回收连接一旦建立就赖在池子里不走了等于火上浇油。3.3 根因拆解max_pool 被当成“总连接数”是典型误读这个事故的根子是我对两个参数语义的错误理解。当时我把max_pool理解为“pgpool-II 能创建的总后端连接数”把num_init_children理解为“初始化的进程数”认为调大max_pool就能提升吞吐。事实是num_init_children决定 pgpool-II 能同时处理多少个前端客户端的连接请求max_pool决定每一个子进程可以为每个后端节点缓存多少个 PostgreSQL 连接单个后端节点的最大连接数 num_init_children * max_pool有几个后端节点pgpool-II 就可能往每个节点分别建立这么多连接所以总连接数还要再乘以节点数。我原来的配置是 32 个子进程每个子进程 4 个池连接单节点 128 个连接三个节点理论上最多 384 个后端连接。虽然单看 pg3 的 128 小于 200但 pg3 还有其他业务连接高峰时叠加起来就把max_connections打满了。另一个帮凶是connection_life_time 0。这个参数控制空闲的后端连接多久之后被关闭默认 0 表示永久保留。平时这可以节省重复建连的开销但在连接数紧张的环境里它会让缓存连接一直占着坑位不释放高峰一来就是灭顶之灾。健康检查的工作原理也放大了这个问题pgpool-II 的判活是周期性地往每个后端发一条非常轻量的查询如果后端在health_check_timeout时间内没有返回就累积重试超过health_check_max_retries后直接判定节点 down。我当时的配置把health_check_timeout设成了 5 秒在连接池被打满、新查询被拒的情况下5 秒根本不够最终 pg3 被误杀。3.4 修复方案把连接池参数和健康检查参数一起调对事故后我做了几件事。第一重算连接池参数num_init_children 64 max_pool 3 connection_life_time 300 connection_max_age 3600num_init_children从 32 升到 64是为了让更多前端连接能同时拿到子进程减少应用侧排队max_pool从 4 降到 3是因为这个业务大多数是短查询transaction模式下连接用完后会还回池里3 个缓存连接已经够用关键是把对后端节点的连接压力降下来。这样单节点最大连接数是 64 * 3 192虽然还是接近 200但因为加了connection_life_time和connection_max_age空闲连接和超龄连接会被定期回收实际占用会小很多。第二把 PG 侧的max_connections从 200 提到 300给健康检查、监控和备份预留足量的连接配额。这是治本的一部分光调 pgpool 不调 PG等于只给水池加了个更大的进水口但水池容量没变。第三放宽健康检查参数health_check_period 10 health_check_timeout 10 health_check_max_retries 3 health_check_retry_delay 2把单次超时从 5 秒放宽到 10 秒同时允许最多重试 3 次避免因为一次查询被排队就立刻触发 failover。这个调整的本质不是让故障发现变迟钝而是让判活更不容易被瞬时高峰干扰。第四把误杀的节点重新挂回来pcp_attach_node -h 127.0.0.1 -U pcp_user -p 9898 -n 2-n 2对应 pg3节点 id 从 0 开始排。执行后再看SHOW POOL_NODESstatus 恢复为 up权重重新生效。这套配置上线后又经历了一轮活动流量压测峰值比上次事故时还高但 pgpool-II 日志里再没出现健康检查误报应用侧报错也随之消失。3.5 一个必须单独拎出来说的风险这次被误杀的是备库如果换成主库被误杀后果会严重得多。pgpool-II 一旦判定主库 down会触发 failover 流程把某个备库提升为新主库。假如旧主库其实还活着而prevent_split_brain相关机制没有配置到位那就可能同时存在两个可写节点产生脑裂。健康检查参数只是为了“更快发现故障”但它带来的副作用是“可能误判故障”。生产环境里我建议宁可在判活上多留一点容忍度也不要在高峰期用一个很短的超时把主库搞挂。4. 两轮踩坑之后我给自己列的参数与巡检清单4.1 重新理解 pgpool-II 的核心参数我整理了一张速查表每次调参前都会看一眼参数表面含义实际影响num_init_children子进程数量决定 pgpool-II 能同时服务的前端连接数不是总连接上限max_pool每个子进程的后端缓存连接数放大对 PostgreSQL 后端节点的连接压力pool_mode连接池复用模式transaction 模式下事务内固定节点事务外可重新选节点load_balance_mode是否启用负载均衡与临时表、预备语句等会话级对象存在天然冲突connection_life_time空闲连接回收时间默认 0 表示不回收连接数紧张时是隐患connection_max_age连接最大寿命避免连接长期不重建带来的状态问题health_check_timeout健康检查单次超时设太短容易误判节点 down触发 failoverhealth_check_max_retries健康检查失败重试次数重试太少会让瞬时抖动变成致命误判4.2 上线前一定要做的三类验证第一连接数压测。把应用连接池调到峰值然后观察SHOW POOL_POOLS;重点确认每个后端节点的连接数有没有逼近max_connections。不要只在测试环境用低并发跑一遍就算完一定要把健康检查连接、备份连接、监控连接一起算进配额里。第二临时表场景测试。把业务里所有用到临时表、预备语句、会话级变量的点全部列出来然后通过 pgpool-II 的 9999 端口回归一遍。不要用直连数据库的方式验证业务逻辑两者行为不同。第三故障演练。人为 kill 掉一个节点确认 pgpool-II 能正确判断、failover 脚本能正确执行、VIP 能正确漂移。更重要的是故意制造一次“健康检查轻微超时”看看系统会不会因为单次抖动就误杀节点。这个演练能暴露出超时参数设置是否合理。4.3 日常巡检命令速查我现在每周一会花几分钟在 pgpool-II 上跑这几条命令psql -p 9999 -U pgpool_user -d postgres -c show pool_nodes; psql -p 9999 -U pgpool_user -d postgres -c show pool_processes; psql -p 9999 -U pgpool_user -d postgres -c show pool_pools; psql -p 9999 -U pgpool_user -d postgres -c show pool_status;show pool_nodes看节点状态和权重show pool_processes看各个子进程的连接情况show pool_pools看后端连接缓存情况show pool_status看关键参数是否被意外改动。自从那两轮事故之后我对 pgpool-II 的态度从“配置完就不管了”变成了“定期看、定期压、定期演练”。它确实解决了连接管理和读写分离的问题但它的复杂程度决定了任何参数在没有完全理解语义之前都不要在生产环境里随意调整。这两篇日记如果能让你少走点弯路那这篇文章就没白写。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表