你的数据库连接池,正在悄悄杀死你的应用
上周帮人排查一个奇怪的问题:明明服务器配置不差,代码也没改,但接口响应时间从50ms飙升到800ms。排查了一圈,最后发现是连接池漏了。
这不是什么新鲜事。但让我震惊的是,这位仁兄连接池配置是:maxPoolSize=2。两个连接支撑他那个日活几十万的业务。
今天我们来聊聊连接池——这个几乎每个后端工程师都配过,但大多数人配得像在掷骰子的东西。
连接池是什么
想象你去餐厅吃饭。没有连接池的时候,你每次想吃饭都得:先给农场打电话叫人家现杀猪、然后自己烹饪、吃完还得自己洗碗。有连接池的,相当于餐厅后厨一直温着半成品,你随时去随时能吃。
数据库连接池就是这个“预热的后厨”。它预先建立好一批连接,你需要用的时候从池子里拿,用完了归还而不是销毁。这样就避免了每次请求都建立连接的开销。
听起来很简单对吧?但魔鬼在细节里。
配置项的真相
主流连接池(HikariCP、Druid、C3P0)配置项看起来都差不多。让我逐个拆解那些你可能配错了的:
maxPoolSize:不是你想要多大就多大
很多人配连接池数量的逻辑是:CPU核心数*2,或者干脆设个100心里才踏实。
但数据库连接是有成本的。每个连接都是数据库端的一个进程或线程,都会消耗内存和CPU。设太大会把数据库打爆,设太小又会让请求排队。
正确的公式是:
最佳连接数 ≈ ((核心数 * 2) + 磁盘数)
但这也只是起点。真正的最佳值需要你在压测环境里,找到QPS刚开始下降的那个点,往回退一点。
minIdle:别设成0
有人觉得“我平时流量不大,连接闲着也是闲着”,把minIdle设成0,意思是空闲时一个连接都不保持。
这样做的问题是:流量来了,得现去建立连接。数据库建立连接可不像打开文件那么简单——要认证、要握手、要分配资源。一次握手延迟可能是几十毫秒,在高并发场景下这就是灾难。
建议把minIdle设成maxPoolSize的30%-50%。平时保持一定热度,流量来的时候也不慌。
connectionTimeout:别舍不得
这个值的意思是:从池里获取连接,超过多久就抛异常。默认值通常是30000ms(30秒)。
很多人嫌30秒太长,改成5000ms甚至1000ms。但如果你数据库慢查询多、或者偶发拥堵,5秒钟根本拿不到一个连接。
正确的做法是:把这个值设得宽裕一些,然后在应用层做超时控制。不要把数据库连接超时当成业务超时。
idleTimeout:要看场景
这个参数控制空闲连接多久后被关闭。常见值是10分钟。
但如果你的应用有定时任务,比如每天凌晨跑一个大数据量同步,而这个任务每个月才跑一次?那连接可能会被idleTimeout先干掉。
另外,有些云数据库有连接空闲超时限制(比如阿里云RDS是20分钟),你设的idleTimeout如果大于云厂商的限制,那连接会在你预期之外被断开。
连接泄漏:最贵的bug
连接泄漏是指:申请了连接但没有正常归还。少量泄漏短期内看不出问题,但连接会慢慢被耗光。
常见泄漏场景:
// 场景1:异常分支没释放
Connection conn = dataSource.getConnection();
try {
doSomething();
if (someCondition) {
return; // 这里直接return了,conn没close
}
conn.close();
} catch (Exception e) {
throw e; // 异常也是直接抛,conn没close
}
// 场景2:ResultSet和Statement忘了关闭
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery();
// 用完了只关了conn,但Statement和ResultSet的锅你背
conn.close();
// 场景3:try-with-resources用错了
try (Connection conn = ds.getConnection()) { // 这是对的
doSomething(conn);
} // 自动关闭
// 但如果doSomething里面又起了新连接,且没关闭...
void doSomething(Connection conn) {
Connection conn2 = ds.getConnection(); // 这个谁管?
// ...
}
怎么检测泄漏?HikariCP提供了一个方法:HikariPool.getLeakDetectionThreshold()。设一个值(比如60秒),如果一个连接被检出但超过这个时间没归还,就打一条warn日志告诉你“疑似泄漏”。
连接池的"阿克琉斯之踵"
即使你配置完美了,连接池还有一个致命弱点:它不知道你拿连接去干什么。
常见的坑:
长事务。你拿到一个连接,begin transaction,然后开始业务逻辑——调用外部API、发送MQ消息、甚至Thread.sleep做重试。这些操作本身不占连接,但事务一直挂着,这个连接就堵在那里。10个并发请求,如果5个都在长事务里,剩下的请求只能排队。
大查询。一个SELECT * FROM huge_table把数据库打满,或者一个没有分页的接口加载了十万条数据。连接本身很快释放了,但数据库处理这些数据可能需要几十秒,这期间数据库资源被你占着,别人只能等。
连接池不是线程安全的。我知道有人的代码是这样的:
Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, "value");
ResultSet rs = ps.executeQuery();
// 然后把这个rs扔给另一个线程去遍历
new Thread(() -> process(rs)).start();
conn.close(); // 另一个线程还在用rs呢!
连接、Statement、ResultSet都不能跨线程共享。跨线程传递连接,轻则数据不一致,重则数据库直接报连接错误。
监控:你看不见的战场
配完连接池就撒手不管?那和没配差不多。
这些指标你得盯着:
- Active Connections:当前正在使用的连接数。如果这个值长期等于maxPoolSize,说明你得扩容了。
- Idle Connections:空闲连接数。如果长期为0且有新请求在等待,说明minIdle太小。
- Wait Threads:等待连接的线程数。如果大于0,说明连接不够用。
- Connection Timeout Count:获取连接超时的次数。这个值如果非0,说明你的maxPoolSize或者connectionTimeout配置有问题。
HikariCP自带MBean,可以接进JMX。生产环境我建议接进Prometheus/Grafana,搭个dashboard。出了问题的第一时间你就得知道,而不是等用户来报。
写在最后
连接池这东西,上古时代就存在,配置项来来去去也就那些。但真正理解它怎么工作、什么情况下会出问题,需要的不是看几篇配置攻略,而是亲手踩过坑、亲自排查过连接泄漏、亲眼见过连接池耗尽时应用是怎么死的。
建议:去压测环境,故意把maxPoolSize设成2,然后跑一下你的核心接口。看看到底是50ms返回,还是憋了半天给你抛超时。
有些坑,不亲自踩一脚,永远不知道疼。
下次有人跟你说“我的应用莫名其妙慢了”,你可以先问问他连接池配了多少。大概率有惊喜。