2. 数据库连接池耗尽:一个慢查询引发的连锁反应

我记得刚带团队那会儿,遇到过一件特诡异的事。一个接口平时响应也就 30ms,突然某天开始隔三差五超时。重启就好了,但过几个小时又不行了。查监控发现,数据库连接池被占满了。

最开始我以为是并发太高,加了一倍连接数。结果呢?崩得更快了。

直觉陷阱:连接池不够用,不一定是并发太高。很多时候,是某个慢查询把连接「占着茅坑不拉屎」。

慢查询怎么把池子弄爆的

你想想看,一个正常查询 10ms 就释放连接了。但一个慢查询跑了 5 秒。如果这个慢查询的 QPS 是 100,那在 5 秒内,它同时占着 500 个连接不还。而你的连接池总共才 200 个——直接炸穿。

这就是典型的水位堰塞效应:

  • 健康查询:借连接 → 10ms 归还 → 连接周转快
  • 慢查询:借连接 → 5秒后归还 → 连接周转慢 500 倍
  • 等待队列:新请求进来,等不到空闲连接 → 超时 → 客户端重试 → 更挤

我调过最夸张的一次,一个没加索引的联表查询跑了 12 秒。就这一个查询,干翻了一整个支付服务。

连锁反应的全过程

光连接池爆掉还不是最要命的。它会引发一串雪崩——我称之为「五连击」:

  1. 第一击:慢查询占满活跃连接 —— 业务 SQL 没走索引,全表扫描
  2. 第二击:健康请求排队饿死 —— 连心跳检测的 SQL 都拿不到连接
  3. 第三击:健康检查失败 —— 注册中心觉得服务挂了,摘掉节点
  4. 第四击:流量倾斜到剩余节点 —— 剩下一台机器扛双倍流量,也挂了
  5. 第五击:集群雪崩 —— 整个服务集体扑街

这个链条里,最隐蔽的是第三步。我见过很多团队,连接池监控报警没配,只看接口超时率。结果从慢查询到服务下线,中间隔了快 20 分钟才被用户发现。

数据库连接池耗尽连锁反应图 慢查询 连接池耗尽 健康检查失败 节点被摘除 集群雪崩 关键转折点:健康检查失败,注册中心摘掉“假死”节点。 如果连接池有水位告警,可以在前两步就阻断雪崩。

我怎么定位这个问题的

定位这种问题,核心就一句话:查等待,不查来源。什么意思?不要直接去翻代码找哪个接口慢,而是去数据库里看「连接在等什么」。

我一般三步走:

第一步:查活跃连接数

-- MySQL
SHOW PROCESSLIST;
-- 看 Time 列,超过 1 秒的都是可疑对象
-- 重点关注 State = 'Sending data' 或 'Creating sort index'

第二步:查锁等待

SELECT * FROM information_schema.INNODB_TRX;
SELECT * FROM sys.innodb_lock_waits;
-- 有没有长事务?有没有行锁没释放?

第三步:查慢查询日志

-- 开启慢查询日志(生产环境建议长期开,阈值设 200ms)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.2;

-- 然后用 pt-query-digest 分析
pt-query-digest /var/log/mysql/slow.log

有一回我查到一个连接睡了 45 秒,SQL 是个 SELECT * FROM orders WHERE status = 1。一张 2000 万行的表,status 字段没索引。就这么简单。

避坑指南:我曾经...

犯过什么错呢? 我曾经一上来就把连接池从 100 加到 500,觉得「管你慢不慢,池子大就不怕」。结果数据库连接数暴增,CPU 被上下文切换干满了,DB 直接 OOM。

不要用增加连接池来对抗慢查询。连接池是缓冲池,不是垃圾池。慢查询越多,加连接只会拖垮数据库。

正确的解决方案长什么样

我的做法分三层:

层级 工具/手段 目标
应用层 连接池参数调优 + 断连检测 快速回收「假死连接」
数据库层 慢查询日志 + 索引优化 消灭慢 SQL 源头
监控层 连接池水位告警 + 慢 SQL 实时上报 在雪崩前截断

举个具体配置的例子。HikariCP 是我的首选连接池,参数调好了真能救命:

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20           # 别贪大,够用就行
      minimum-idle: 5                 # 预留保底连接
      connection-timeout: 3000        # 3秒拿不到连接就报错
      idle-timeout: 600000            # 空闲 10 分钟回收
      max-lifetime: 1800000           # 连接最多活 30 分钟
      leak-detection-threshold: 5000  # 连接借出去 5 秒没还,报警!
特别注意 leak-detection-threshold。这个参数我习惯设 5000ms,一旦有连接被占用超过 5s 就报警。它不解决问题,但它能帮你「在慢查询还没引发雪崩的时候,就收到通知」。

我还想让连接池更安全一点

除了参数调优,我建议所有接口都做 连接超时兜底。也就是在业务代码里,给数据库操作加一层熔断:

// 伪代码:连接池熔断示例
CompletableFuture<Result> future = CompletableFuture.supplyAsync(() -> {
    return orderService.queryOrders(userId);
});

try {
    // 整个查询最多等 2 秒
    return future.get(2000, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
    // 超时了,先记录慢查询日志,再返回降级结果
    monitor.reportSlowQuery("queryOrders", userId);
    return fallbackResult;
}

这样做,就算某个查询真的变慢了,它也不会无限占着连接不还。2 秒一到,连接回收,服务继续跑。

复盘:这个案例教会我的

回头再看这个坑,其实有几个关键点被忽略了:

  • 慢查询不能只看「执行时间」,要看「占用连接的时间」。一个 200ms 的查询,在连接池里可能排队等了 5s,实际占用连接也是 5s。
  • 连接池水位告警必须比接口超时告警更敏感。接口超时是症状,连接池水位是病因。
  • 不要只堆机器。不加索引,加十台机器也顶不住。
我的检查清单:接手一个新系统,我第一件事就是查慢查询日志和连接池配置。这两样不对劲,后面十有八九要出事。