连接池泄漏的锅,谁来背?从一次诡异的线上故障说起
凌晨两点,手机震了。报警邮件上写着:数据库连接数打满了。
这不是我们第一次遇到这种问题,但却是最离谱的一次。
现象是这样的:服务重启后,前几分钟跑得好好的,然后突然开始报错——Too many connections。一看监控,连接数从50噌噌噌涨到500,直接把数据库打爆。
第一反应:有人在漏连接。
于是我开始排查代码,找连接泄漏的bug。结果你猜怎么着?代码里连接管理写得那叫一个规范——try-with-resources全用上了,每个方法都老老实实关了连接。
那问题在哪?
我顺着链路往下看,发现了不对劲的地方:这个服务用的是HikariCP,配置写的是:
spring:
datasource:
hikari:
maximum-pool-size: 50
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
看起来很正常对吧?50个连接,30秒超时,10分钟空闲回收,30分钟最大生命周期。
但我仔细看了业务代码后发现了一个致命问题:这个服务对外暴露了HTTP接口,允许调用方传入自定义的SQL查询。简单说,就是一个"通用查询接口",前端传个表名和条件,后端拼成SQL执行。
问题就出在这了。
你的连接池,可能根本不是你想象的那样
很多人配置连接池的时候,习惯性地把maximum-pool-size设成一个"够用"的数字,比如20、50、100。然后就认为高枕无忧了。
但实际上,连接池的行为远比这复杂。
让我们先搞清楚几个概念:
活跃连接(Active Connection):正在执行SQL的连接。
空闲连接(Idle Connection):已经归还池中,等待复用的连接。
等待线程(Waiting Thread):申请连接但池子没空连接的线程。
正常情况下,线程从池里拿连接,用完归还,流程如下:
Thread A -> getConnection() -> 获得连接C -> 执行SQL -> close() -> 连接C回到池里
但有一种情况会被很多人忽略:如果你的SQL执行时间很长,而同时并发量上来了,会发生什么?
答案是:连接不够用了。
更准确地说,是可用连接不够用了。
假设池子有50个连接,每条SQL平均执行时间是2秒。如果QPS是100,那么每秒需要100个连接×2秒 = 200个连接·秒的吞吐量。50个连接明显不够。
这时候,HikariCP会:
- 所有50个连接都被占用
- 第51个线程开始等待,进入了
connectionTimeout倒计时 - 如果等待超时,就会抛出异常
但这还不是我们遇到的问题。
真正的元凶:连接池饥饿
回到我们的故障场景。
我后来发现,真正的问题不是连接泄漏,而是连接池饥饿(Pool Starvation)。
那个通用查询接口,允许调用方传任何SQL。问题是,有些查询巨慢。比如有人传了这样的查询:
SELECT * FROM large_table WHERE condition = 'xxx'
AND created_at > '2020-01-01'
ORDER BY id LIMIT 10000
没有索引,扫全表,还排序。这种查询一跑就是十几秒甚至几十秒。
当这种慢查询多起来的时候,50个连接很快就被占满了。而那些正常的快查询,反而拿不到连接,只能排队等着。
更坑的是,很多连接其实早就执行完了,只是在等结果返回(网络IO),但连接并没有释放。结果就是池子看起来"满了",实际上很多连接都在"空转"。
连接池配置的本质:一场供给与需求的博弈
经过这次故障,我重新思考了连接池配置的本质。
连接池大小的计算,不是靠"经验值",而是有一套公式的。
最经典的是Oracle提出的公式:
连接数 = (核心数 × 2) + 有效磁盘数
这个公式的逻辑是:对于CPU密集型任务,连接数可以等于CPU核心数;对于IO密集型任务,可以适当增加。但增加太多也没用,因为会增加上下文切换的开销。
但我个人更常用另一个思路:
最优连接数 = (QPS × 平均执行时间) / 单连接吞吐量系数
听起来复杂,其实很简单。关键是你要知道自己系统的真实QPS和SQL平均执行时间,而不是拍脑袋设一个数。
怎么拿到这两个数据?
HikariCP自带了监控,启用很简单:
spring:
datasource:
hikari:
pool-name: ProdPool
register-mbeans: true # 开启JMX监控
开启后,你可以通过JMX或者Actuator看到这些指标:
ActiveConnections- 当前活跃连接数IdleConnections- 当前空闲连接数ThreadsAwaitingConnection- 等待连接的线程数ConnectionTimeoutRate- 连接超时率
ConnectionTimeoutRate是个特别关键的指标。如果这个值持续大于0,说明你的连接池不够用,或者SQL执行太慢。
实战调优:从50到20,反而更快了
后来我把这套逻辑用到了实际调优中。
我们的主服务QPS大概200左右,平均SQL执行时间15ms(不含网络开销)。按照刚才的公式:
并发需求 ≈ 200 × 0.015 = 3(每秒需要3个连接完成工作)
考虑到并发波动 × 3 = 9
加上余量 ≈ 15-20
而原来配置的是50个连接。50个连接意味着什么?意味着当并发高的时候,会有大量连接同时活跃,而数据库的CPU和IO是有限的,连接太多反而会互相竞争资源,导致整体吞吐下降。
我把连接数从50降到了20。
结果?
平均响应时间从45ms降到了28ms,数据库CPU从85%降到了60%。
连接少了,资源竞争少了,反而更快了。这就是连接池的"反直觉"定律。
最后说几个坑
除了连接数,还有几个配置也容易踩坑:
1. connectionTimeout 不是越长越好
很多人觉得超时短了容易报错,就设得很长,比如60秒。但实际上,等待连接的时间本身就是浪费。如果你的SQL正常应该在100ms内完成,等了10秒还没拿到连接,说明肯定出问题了,这时候应该快速失败而不是无限等待。
建议:设置为平均执行时间的3-5倍。
2. idleTimeout 别设太长
很多连接池在连接闲置太久后会主动关闭,这是好事。但如果你设了10分钟的idleTimeout,而你的数据库的wait_timeout也是10分钟,可能会出现连接被数据库端关闭但池子不知道的情况。
建议:idleTimeout 设置为数据库 wait_timeout 的一半左右。
3. 最重要的是:别让慢查询拖垮池子
连接池再大,也扛不住慢查询。解决方案有两个:一是加超时限制(MySQL的max_execution_time,PostgreSQL的statement_timeout);二是加索引、优化SQL。
慢查询不是连接池的问题,但会通过连接池放大成系统问题。
总结
这次故障给我的教训是:很多性能问题,不是"资源不够",而是"资源没用到刀刃上"。
连接池配置也是一样。不要迷信"大连接池",要根据真实的业务负载来调优。监控数据永远比经验值可靠。
下次再遇到"Too many connections",先别急着加连接数。问自己几个问题:
- QPS真的有那么高吗?
- SQL执行时间正常吗?
- 有没有慢查询在占用连接?
- 连接数是不是反而太多了?
有时候,减少连接,反而是一种增援。
毕竟,兵不在多而在精。将无能,累死三军。