问题背景
我们有一个基于 Spring Boot 的订单服务,后端接的是一套单实例 MySQL 8.0,应用层用 HikariCP 做连接池,maximumPoolSize 沿用了脚手架里的默认值 10。这个服务平时 QPS 不高,几百上下,跑了一年多都相安无事。
变化发生在一次营销活动上线之后:产品在下单成功页新增了一个「实时积分计算」的功能,这个功能会在下单事务里同步调用一个外部积分中心的 HTTP 接口。功能测试、压测都过了,谁也没多想。活动当天晚高峰,订单服务开始大面积超时,网关侧 5xx 陡增,监控群瞬间炸了。这篇记录一下这次从「接口超时」一路挖到「连接池被长事务攥死」的完整排查过程。
故障现象
现象非常典型,但一开始很容易把人带偏:
- 网关侧订单相关接口 P99 从平时的 80ms 飙到 30s 直接超时,错误率一度超过 40%。
- 应用日志里刷满同一条异常:
|
|
- 关键点:
total=10, active=10, idle=0, waiting=27——连接池 10 个连接全被占着(active),没有一个空闲(idle),还有 27 个线程在排队等连接。 - 登录 MySQL 看,
show processlist里的连接数并不多,CPU、内存、磁盘 IO 都不高,数据库本身「看起来很闲」。 - 重启应用后能好几分钟,流量一上来又迅速复现,治标不治本。
最迷惑人的地方就在这里:数据库明明很闲,应用却一直喊「拿不到连接」。第一反应是不是连接池配小了,或者数据库连接被打满了,但 MySQL 侧的连接数据并不支持这个猜测。
排查过程
第一步:确认瓶颈在连接池,不在数据库
先把方向定死。既然异常是 HikariCP 抛的 Connection is not available,说明问题出在「应用向连接池借连接」这一步,而不是「连接池向 MySQL 建连接」。
对照 MySQL 侧:
|
|
数据库连接数很低、也没有堆积的慢 SQL,基本可以排除「数据库被打满」和「慢查询拖垮数据库」这两个方向。瓶颈就在应用这端的连接池:连接被借走后长时间不归还。
第二步:开启 HikariCP 泄漏检测
HikariCP 有个专门定位连接不归还的利器 leakDetectionThreshold。临时把它调小到 5 秒,重启观察:
|
|
流量一上来,日志立刻出现大量告警,并且直接打印出「借了连接却迟迟不还」的那根线程的完整堆栈:
|
|
堆栈非常清楚:连接是在 createOrder 里借出去的,但线程当前卡在 PointClient.calcPoint 的一个 HTTP 调用上。也就是说——数据库连接被借出后,线程跑去做一个耗时的外部 HTTP 调用,在整个 HTTP 往返期间,这个数据库连接一直被攥在手里空转,谁也用不了。
第三步:定位到长事务里嵌外部调用
回到代码,createOrder 方法上有 @Transactional 注解。方法体的逻辑简化后是这样:
|
|
问题一目了然:第 3 步的外部 HTTP 调用被放进了 @Transactional 的事务边界里。只要事务没提交,这个连接就不会归还给连接池。 而积分中心那个接口平时响应 50ms,活动当天自己也扛不住,响应劣化到 3~8 秒,极端时触发到调用方的 10 秒读超时。
于是一笔下单事务从原来的几毫秒,被这个外部调用拖长到好几秒。晚高峰订单并发一上来:每笔下单都要占着一个连接等好几秒,10 个连接的池子瞬间被占满,后面的请求全部在 waiting 里排队,直到 30 秒借连接超时,抛出 Connection is not available。数据库侧因为这些连接大多在「等 HTTP 返回」而不是在跑 SQL,所以看起来一片安静——这正是它迷惑人的原因。
第四步:量化验证
用一个简单的估算印证判断。连接池能支撑的下单吞吐 ≈ 池大小 / 单笔事务持有连接的时长。正常时单笔事务约 5ms,10 个连接理论上能撑 10 / 0.005 = 2000 笔/秒;而事务被 HTTP 拖到 5s 后,变成 10 / 5 = 2 笔/秒——吞吐直接塌了三个数量级。晚高峰几百的下单并发,2 笔/秒的处理能力当然全线排队超时。数字对得上,根因坐实。
解决方案
分止血和根治两步走。
止血(立刻恢复): 把外部积分调用的超时从 10s 收紧到 800ms 并加熔断,让连接不至于被攥住太久;同时把 maximumPoolSize 临时从 10 调到 30 争取缓冲。改完发布,超时率立刻从 40% 降到个位数,先把火压住。注意扩连接池只是缓冲,不解决根本问题——连接持有时长不降下来,池子再大也有被占满的一天。
根治(把外部调用移出事务): 核心原则是事务里只做数据库操作,绝不嵌入网络 IO / 外部调用等不可控的耗时操作。把积分计算拆出事务边界:
|
|
积分改为下单成功后通过异步事件(消息队列 / @TransactionalEventListener after-commit)去算,即便积分中心慢或挂,也只是积分延迟到账,下单主链路不再被它拖累。改造后事务持有连接时长回落到毫秒级,连接池又恢复了平时几百 QPS 都用不满 10 个连接的从容状态。
根因分析
根因是在数据库事务边界内嵌入了不可控的外部 HTTP 调用,导致数据库连接被长时间占用而非归还,再叠加连接池偏小(10)缺乏缓冲,共同把「一个外部依赖变慢」放大成了「订单服务整体不可用」。
更深一层看,这是一个「持有时长」而非「资源总量」的问题:连接池耗尽的表面是「连接不够用」,本质却是「每个连接被占用的时间太长」。凡是把网络 IO、外部 RPC、甚至 Thread.sleep 放进事务里的写法,都会以类似方式放大故障——平时无感,一旦被嵌的依赖抖动,就会顺着连接池瞬间击穿整个服务。
预防措施
- 事务边界最小化:
@Transactional方法里只允许数据库操作,严禁嵌入 HTTP/RPC 调用、消息发送、大文件读写等耗时/不可控操作。代码评审把这一条列为红线。 - 连接池配套监控与泄漏检测:生产常态开启
leak-detection-threshold(如 30s 只告警不影响功能),并把 HikariCP 的active/idle/pending、连接获取耗时接入监控与告警,连接排队时第一时间可见。 - 连接池容量按公式估算:参考
池大小 ≈ 峰值 QPS × 单事务平均持有时长,结合数据库max_connections反推,不要沿用脚手架默认值拍脑袋。 - 外部依赖强制超时 + 熔断:所有对外调用必须设置合理的连接/读超时并配熔断降级,杜绝「一个下游慢,拖垮全站」。
- 压测覆盖依赖劣化场景:压测不能只测正常路径,要专门注入「外部接口变慢/超时」的故障演练,提前暴露这类被放大的隐患。
总结
这次故障的表象是「数据库连接池耗尽、接口大面积超时」,但数据库本身全程很闲,真正的元凶是一次被塞进事务里的同步外部 HTTP 调用,把每笔下单事务从毫秒拖成数秒,让连接被长期占用、池子被瞬间占满。
排查的关键转折点有三个:一是通过异常里的 active=10, idle=0, waiting=27 判定瓶颈在连接池而非数据库;二是用 leakDetectionThreshold 直接打印出「攥着连接不放」的线程堆栈;三是顺着堆栈定位到事务里的外部调用。记住一条朴素的经验:数据库事务是很贵的资源,连接必须快借快还;任何把网络 IO 关进事务的写法,都是在给自己埋雷。