连接池:那个你以为配置正确,却让系统死得很难看的家伙

2026-07-20 13 0

有一次线上告警炸了:数据库连接数逼近 max_connections=200,应用开始大量超时。DBA 在群里@我:你们是不是泄漏连接了?

我一脸无辜:我们就用了连接池啊,HikariCP,默认配置,应该很稳的吧?

DBA 回了一句:你们连接池 max_pool_size 配的多少?

我说:50。

DBA 说:你们 pod 副本数是 8 个。

我算了一下:50 × 8 = 400。等等,PostgreSQL 的 max_connections 是 200?

DBA 发了一个微笑表情。

这就是连接池最讽刺的地方——它本该是你的守护神,结果因为一个小学数学错误,把你送进了生产事故的修罗场。

连接池不是什么"连接放一起"

很多人以为连接池就是:建立一个连接,用完了放回去,下次接着用。很朴素,很直观,也很危险——因为这种理解漏掉了整个链路上的所有细节。

当你调用 dataSource.getConnection() 时,你以为拿到的是一个数据库连接。但实际上,这中间发生了:

应用进程 → [TCP三次握手] → 负载均衡器 → [TCP三次握手] → 数据库代理/服务 → [TCP三次握手] → PostgreSQL进程

每一个箭头都是一个完整的 TCP 连接建立过程,都有 RTT 延迟,都消耗操作系统的 fd(文件描述符),都会被 net.ipv4.tcp_keepalive_* 参数所影响。

连接池做了两件事:提前建立连接(避免每次请求都握手)和复用已归还的连接。但"复用"这个词背后,藏着一堆你可能从没仔细想过的细节。

连接复用的真相:你以为的"复用"不是真正的复用

大多数连接池(HikariCP、c3p0、pgbouncer)复用的是物理TCP连接,而不是逻辑数据库会话

这句话什么意思?

PostgreSQL 的会话不是严格绑定在一个 TCP 连接上的。一个 PostgreSQL 连接(Session)包含了:

  • 后端进程(backend process)
  • 会话状态(session state)
  • 事务状态(transaction state)
  • Prepared statements
  • SET参数
  • 通知/订阅状态

当你把连接归还到连接池时,池会尝试重置这个连接:回滚未提交事务、清空会话变量、重置 search_path……然后把这个"干净"的连接给下一个请求。

但这个重置过程是有代价的,也是有风险的。

来看一个真实案例。我们系统里有一个定时任务,每小时跑一次,批量处理数据。代码结构大概是:

@Scheduled(cron = "0 0 * * * *")
public void hourlyJob() {
    List<Record> records = fetchRecords();
    for (Record r : records) {
        processWithNewTransaction(r); // 每个记录开一个新事务
    }
}

这个代码本身没什么问题。但它跑着跑着,连接池开始报警:Connection is not available, request timed out after 30000ms

查了一圈,发现问题出在 processWithNewTransaction 里有一个嵌套调用,嵌套调用里又开了新事务,而最外层事务因为某种原因一直未提交。结果就是:这一个请求持有连接的时间,从毫秒级变成了整整30秒。

30秒 × 1000条记录 = 这个定时任务把整个池都堵死了。

这不是连接泄漏,这是慢查询持有连接时间过长导致的池耗尽。区别很大,但结果一样惨烈。

HikariCP 的那些"聪明"配置,坑了多少人

HikariCP 号称"最快"的连接池,配置项也确实比 c3p0 之流复杂得多。但很多团队的配置逻辑就是:去博客抄一份。

最常见的一份"最佳配置"是这样的:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

这套配置在单机部署时问题不大。但一旦上 K8s,副本数 × maximum-pool-size = 实际最大连接数。这个公式搞不清楚的人,比你想象的多得多。

还有一个更隐蔽的问题:minimum-idle

很多人以为设了 minimum-idle=5,池里就会永远保持5个空闲连接。这在 HikariCP 里是真的——前提是连接能正常维持

如果数据库有连接限制(云数据库常见),或者网络偶尔抖动,HikariCP 维持 minimum-idle 的努力会变成一场灾难:它会不断尝试创建新连接、失败、再尝试,最终把数据库连接数打满。

HikariCP 有一个 pool-name 配置,这个名字会出现在 pg_stat_activityapplication_name 字段里。很多团队没配这个,导致出问题查连接来源时,只能看着一堆 ip 干瞪眼。

教训:pool-name 一定要设,设成有意义的字符串,比如 ${spring.application.name}-${POD_NAME:default}

连接池与负载均衡:一个经常被忽视的死亡组合

我们的系统用过 AWS RDS + RDS Proxy。RDS Proxy 的作用是把大量短连接汇聚成少量长连接,减轻 PostgreSQL 的连接压力。听起来很美,但遇到了一个诡异的问题:

应用层连接池配置是 maximum-pool-size=50,RDS Proxy 的 max_connections 也够用,但生产环境里还是频繁出现连接超时。

查了很久,最后发现是 负载均衡器 health check 与连接池行为的交互

我们的 K8s service 配置了 readinessProbe,会定期发 HTTP 请求到后端。后端服务有一个逻辑:每次请求都会从连接池拿一个连接来做健康检查查询(SELECT 1)。

这本身没问题。但当 pod 数量多、健康检查频率高时,大量并发的 SELECT 1 会同时从连接池借出连接,而这些连接的生命周期很短(健康检查嘛,用完就还)。这导致连接池的行为从"少量长连接复用"变成了"大量短连接轮转"。

连接池在高频短连接场景下,idle-timeout 形同虚设——连接根本到不了空闲超时就被还回去了。而真正需要连接的正常业务请求,反而要排队等。

解决方式:把健康检查的数据库查询改成了连接池外的 ping(直接 TCP),或者把健康检查频率降下来。

真正的连接池配置方法论

说了这么多坑,该给点正面的了。经过真实生产环境的踩坑,总结出一套相对可靠的连接池配置思路。

第一步:搞清楚上限。

PostgreSQL: max_connections = 1000(共享内存允许范围内)
云数据库:看实例规格,rds.mysql.small 实例可能 max_connections = 400
RDS Proxy:一般比数据库的 max_connections 低,要查文档

第二步:计算每个 pod 的配额。

单pod最大连接数 = floor(数据库max_connections × 0.7 / pod副本数)

乘 0.7 是留 30% 给:DBA 直连、监控agent、其他偶尔连进来的服务。不要贪心,留余量是美德。

第三步:设置 maximum-pool-size

这个值应该等于计算出来的单pod配额。但更重要的是:确保你的并发线程数不会超过这个值。如果你的服务配置了 200 个工作线程,但连接池只有 50 个 max pool size,那最多只有 50 个线程能同时持有连接,其余 150 个会等待。这不一定是问题(线程池本身就会排队),但你要知道这个数学关系。

第四步:minimum-idle 设不设?

如果你的流量比较平稳,可以设,有助于避免冷启动延迟。如果流量波动大,不设也行,让 HikariCP 按需创建——它在这方面其实挺智能的。

第五步:connection-timeoutidle-timeoutmax-lifetime 三兄弟。

connection-timeout: 5000(获取连接超时,5秒不给就报错的阈值)
idle-timeout: 300000(5分钟空闲后释放,不要太长)
max-lifetime: 1800000(30分钟强制销毁,不管用没用过)

max-lifetime 是必须的,因为数据库侧有时候会强制断开长时间空闲的连接(比如 RDS 有 wait_timeout),如果你不主动控制这个时间,对端断连时你的池会收到一个诡异的 IOException,然后整个池要重建。

监控:比配置更重要的事

最后说一个很多人忽略的点:连接池的监控比配置重要 100 倍

HikariCP 自带了一套指标,通过 JMX 暴露:

  • HikariPool-1 Pool.Size:当前活跃 + 空闲连接数
  • HikariPool-1 Active.Count:当前正在使用的连接数
  • HikariPool-1 Pending.Count:等待获取连接的线程数
  • HikariPool-1 ConnectionTimeoutRate:连接获取超时率

建议把这些指标全部接入 Prometheus/Grafana,设置报警:

  • Active.Count > maximum-pool-size × 80%
  • Pending.Count > 0(出现等待就该查)
  • ConnectionTimeoutRate > 0.01(1%以上的超时率)

最后,回到开头那个故事。最终我们的解决方案是:把 maximum-pool-size 从 50 改成了 20,副本数从 8 缩容到 4,数学终于对上了,事故解除。

DBA 跟我说:下次上线前,先算清楚数。

我觉得他说得对,但那个微笑表情,我到现在还耿耿于怀。

连接池这玩意儿,说简单也简单,说复杂也复杂。大多数人倒在的不是"不会配置",而是"以为会了"。真正理解连接生命周期、池行为和上游限制之间的关系,才能真正用好它。

希望这篇文章,能让你下次被DBA@的时候,少一点微笑表情包。

相关文章

你以为代码写对了,API就快了?Too young,那些偷偷吃掉你200ms的幽灵
你那console.log调出来的bug,凭什么让我背锅?——日志规范实战
写API这事儿:我是怎么从”能用”进化到”好用”的
连接池:那个你以为配置正确,却让系统死得很难看的家伙
为什么你的REST API会被吐槽?因为你可能从一开始就跑偏了
还在为部署AI工具熬夜?来找小龙虾,39块搞定一切

发布评论