凌晨三点,数据库:我超时了,但我不想告诉你为什么
那天我正在睡觉(别惊讶,我也是有正常作息的人),手机突然响了。告警显示:数据库连接超时,业务接口P99延迟飙到8秒。
我揉着眼睛打开日志,发现一个诡异的现象:超时不是发生在数据库查询阶段,而是发生在获取连接的时候。换句话说,不是你的SQL跑得慢,而是你连不上数据库。
这就很离谱了。我们配置了连接池大小50,按理说并发再高也不至于50个连接全被占满。但日志告诉我:waiting for connection,timeout。
到底发生了什么?
第一层:连接池饥饿
首先科普一下连接池的基本原理。你跟数据库说给我个连接,连接池先看看自己手里有没有空闲的。有,直接给你;没有,就等着;等太久,就报timeout。
正常情况下,连接用完会还回来。但问题就出在这个正常情况上。
我翻出当时的监控,发现一个有意思的规律:超时总是发生在整点前后5分钟。整点?什么业务会在整点突然爆发?
答案是:定时任务。
我们有个报表生成服务,每小时跑一次。这服务有个特点——它要查很多数据,但查询是分批进行的,每批之间还有人为设计的思考时间(sleep)。结果就是:这服务一边慢慢吞吞地占用着连接,一边还不紧不慢地释放。
雪上加霜的是,这个定时任务的并发数比我们预估的高——因为它不仅在整点跑,还有个失败重试机制,导致整点时刻有3个重试任务同时在跑。
50个连接,报表服务占35个,常规业务一挤,完蛋。
第二层:连接泄漏的幽灵
但等等,这还不足以解释全部问题。因为就算报表服务占35个,剩下15个应该够用。监控显示,当时等待的线程数远超15。
于是我开始查连接泄漏。
所谓连接泄漏,就是你拿了连接但忘了还。最常见的场景:
try {
Connection conn = dataSource.getConnection();
// ... 业务代码
if (condition) {
return; // 早期return,conn还没close
}
conn.close();
} catch (Exception e) {
// 异常情况,conn也没close
}
这是教科书级别的泄漏代码,但我翻了我们代码库,类似的模式居然还真找到了。更隐蔽的是一种半泄漏:连接没完全泄漏,但归还速度极慢。
什么导致归还慢?答案可能是:长事务。
我们的订单服务有个查单接口,看起来只是简单的SELECT,但它被包在了一个显式事务里。更骚的是,这个事务的隔离级别是SERIALIZABLE——为了安全。结果呢?一个简单的查询锁了整个表,其他请求只能排队等。
查单接口的RT从2ms变成了200ms,连接持有时间翻了几十倍。
第三层:你以为配置对了,其实没有
好了,现在我们知道了两个问题:定时任务占用大量连接 + 某些慢查询拖长了持有时间。但还有一个问题——连接池配置。
我们用的HikariCP,这是目前最流行的连接池。我问了一圈,得到的答案是:连接池大小?我们设的50,够用了。
50够不够用?这问题问得好,但方向错了。
连接池大小不是设出来的,是算出来的。公式大概是:
连接数 = (核心线程数 * 等待时间占比) + 非高峰期峰值
等等,这个公式是我现场编的。实际上有个更简单的原则:连接池大小 = ((核心数 * 2) + 磁盘数)。但更重要的是,你要知道你的数据库最大连接数是多少。
MySQL默认max_connections=151。你设了200的连接池?不好意思,数据库会直接拒绝多余的连接,你设的200根本没意义。
我们当时的情况:应用层设了50,但MySQL的max_connections是100,而且数据库还有另外两个服务在连,实际可用的可能只有70不到。报表任务一跑,连接数直接逼近上限。
我是怎么修的
治标:调大连接池到80,给报表服务单独建了一个连接池(max 20),隔离运行。
治本:做了三件事。
第一,拆掉长事务。查单接口不再需要SERIALIZABLE隔离级别,换成READ COMMITTED,加上必要的索引,RT从200ms降到3ms。
第二,重写定时任务。去掉人为的sleep,用信号量控制并发数,重试机制也改成了指数回退,不再多任务重叠。
第三,加监控。在获取连接的入口加了埋点,记录等待时长和超时情况。现在只要等待时间超过100ms,就会触发告警。
学到的
连接池的问题,本质上是资源分配的问题。你以为设个数字就完了?不,你要知道:
- 你的数据库最大能承受多少连接?
- 你的连接池大小和数据库上限之间,有多少余量?
- 你的连接都在被谁占用?占多久?
- 有没有泄漏?有没有慢查询?有没有不该在事务里的查询?
这些问题,单靠设大一点是解决不了的。你得深入进去,看清楚每一行SQL、每一个return分支、每一个事务边界。
那天凌晨三点,我学到了一件事:数据库不会撒谎,它只是用超时告诉你,你不懂它。
下次再遇到这种问题,建议先问自己一句:我真的理解我的连接池吗?
而不是:50够不够?要不要改成500?
改成500的结果,可能是你的数据库先挂。