一次 HikariCP 连接池耗尽导致的线上雪崩排查实录——问题概览

本文记录了一次线上接口大面积超时的完整排查过程:从 MySQL Lock wait timeout 异常,到怀疑连接池被打满,再到代码层发现 V1 大事务在事务内同步调用外部顺丰 API,最终定位到应用层 HikariCP 连接池容量过小是直接诱因。希望对遇到类似问题的同学有所帮助。


一、事故背景

我们做的是一套互联网医院系统,核心模块包括处方、订单、物流、HIS 对接等。日常早高峰(8:30-10:00)是租户侧发药、HIS 处方同步最密集的时段。

2026-07-27 09:05 左右,监控告警开始狂响:大量接口响应超时,租户回调失败,患者端发药、订单查询频繁报错。故障持续到 09:50 左右才逐渐恢复。

现象速览

  • 大部分接口返回"网络繁忙,请稍候重试"或"系统异常";
  • 租户侧的 syncDeliveryInfo(发药/物流同步)接口大量失败;
  • 非核心接口(如保存系统交互日志、预问诊信息查询)也报错,说明不是单个业务挂了;
  • MySQL 日志出现大量 Lock wait timeout exceeded
  • 应用日志出现大量 org.mybatis.spring.MyBatisSystemException: null

第一直觉:这不是单纯的代码 bug,而是某个公共资源被耗尽了。


二、第一阶段:定位是数据库问题,还是连接池问题?

2.1 先看 MySQL 连接数

执行sql,查询mysql连接信息:

sql 复制代码
-- 查看当前所有连接
SHOW FULL PROCESSLIST;

-- 查看当前连接数
SHOW STATUS LIKE 'Threads_connected';

-- 查看最大连接数限制
SHOW VARIABLES LIKE 'max_connections';

-- 按用户/主机分组统计连接数
SELECT user, host, COUNT(*) AS conn_count
FROM information_schema.processlist
GROUP BY user, host;

-- 查看portal写入的表是否有锁等待
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started ASC;

-- 查看是否有慢查询
SELECT id, user, host, db, command, time, state, info
FROM information_schema.processlist
WHERE command != 'Sleep' AND time > 2
ORDER BY time DESC;

生产 MySQL 连接信息:

指标 数值
Threads_connected 378
max_connections 4190
连接使用率 9.2%

第一感觉:MySQL 没挂。 总连接数才用了不到 10%,不像是数据库扛不住了。

但奇怪的是,应用层却拿不到连接。那问题大概率出在应用层连接池

2.2 再看应用日志:MyBatisSystemException

翻开 0727系统超时问题-怀疑是HikariPool连接池被打满了.txt,大量这种异常:

typescript 复制代码
2026-07-27 09:23:51.909 [portal,...] [http-nio-8881-exec-11] - [WARN]
  [c.c.m.i.s.impl.SystemInteractionLogServiceImpl : 211] - 错误参数:
  org.mybatis.spring.MyBatisSystemException
    at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(...)
    at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:441)
    ...

注意这个异常 message 是 null,底层真正的 Connection is not available, request timed out after 300000ms 被全局异常处理吞掉了。这给我的排查增加了不少难度。最終还是找到了连接池耗尽的关键证据!!

到这里,基本可以判断:HikariCP 连接池已经耗尽,新请求拿不到连接。


三、第二阶段:为什么连接池会满?

3.1 检查 Nacos 配置,发现核心服务连接池只有 10

我们的数据源配置统一放在 Nacos 的 common-mysql.yml 里,但每个服务可以单独覆盖。逐个检查后发现:

配置文件 是否显式配置 maximum-pool-size 连接池大小
hospital.yml 50
mpc-server.yml 50
patient.yml 默认 10
portal.yml 默认 10
operate.yml 默认 10
baseservice.yml 默认 10
... 默认 10

核心高并发服务 patient/portal/operate 竟然都用的 HikariCP 默认 10 个连接!

而且 hospital.yml 里的 connection-timeout 配了 300000ms(5 分钟),这意味着一旦池子满了,线程会阻塞 5 分钟才抛异常。Tomcat 线程很快就被占光,服务对外表现为"全部接口超时"。

3.2 量化一下:10 个连接能抗多少并发?

我们以这次事故的"重灾区" syncOrderDeliveryInfo 为例。这个接口在 V1 实现里会同步调用顺丰医寄通 API 下单,整个过程平均要占住数据库连接约 20 秒**【这个物流问题之前说过,优化过一个V2版本,但为了降低风险,仅开通一家医院调试,其余80多家目前仍然用的V1】。**

按 20 秒/请求估算:

单服务连接池大小 最大并发数 每分钟可处理请求
10 10 ~30 笔
50 50 ~150 笔
100 100 ~300 笔

早高峰一个医院几笔、十几个医院同时发药,30 笔/分钟根本不够看。连接池被打满是必然的。

更扎心的是,MySQL max_connections 有 4190,全集群按 12 个服务 × 2 实例、多数 10 连接估算,理论峰值才约 560 个连接,只占 MySQL 容量的 13%。数据库能扛,但应用层自己把并发能力锁死了。


四、第三阶段:深挖代码,V1 大事务是放大器

连接池小是直接原因,但为什么连接会被占这么久?得看代码。

4.1 V1 入口:TenantCallbackServiceImpl

租户回调进来后,会根据医院配置决定走 V1 还是 V2:

java 复制代码
// TenantCallbackServiceImpl.java
@Override
public SyncDeliveryResultVO syncDeliveryInfo(SyncDeliveryDTO dto) {
    String hosCode = ...;
    Integer syncDeliveryInfoVersion = hospitalConfigApi.getSyncDeliveryInfoVersion(hosCode).getData();
    if (Integer.valueOf(2).equals(syncDeliveryInfoVersion)) {
        return orderApi.syncOrderDeliveryInfoV2(dto).getData();  // V2
    }
    return orderApi.syncOrderDeliveryInfo(dto).getData();        // V1
}

当时只有一个医院开了 V2,其他全部走 V1。

4.2 V1 实现:syncOrderDeliveryInfoByDelivery

V1 实现方法上直接加了 @Transactional

java 复制代码
// OrderServiceImpl.java
@Transactional(rollbackFor = Throwable.class)
public SyncDeliveryResultVO syncOrderDeliveryInfoByDelivery(LogisticsCompanyEnum logisticsCompanyEnum, SyncDeliveryDTO dto) {
    // ... 查询医院、租户配置、处方信息 ...

    // 事务内同步调用顺丰医寄通 API,约 20s
    deliveredInfo = rpDeliveryService.mode(rpDeliveryMode)
        .handleDelivered(logisticsCompanyEnum, rpDeliveryData);

    // 事务内更新处方状态
    orderPrescriptionService.lambdaUpdate()...;

    // 事务内更新订单状态
    this.lambdaUpdate()...;

    // 事务内更新 HIS 订单状态
    hisOrderService.lambdaUpdate()...;
}

问题很明显:顺丰 API 调用和后面的 DB 更新都在同一个事务里。 顺丰 API 和部分业务处理,耗时约 20 秒,这 20 秒内数据库连接一直被占用,同时 ihm_order_prescription 等表的行锁也被持有。

4.3 为什么是 V1 而不是 V2 的锅?

有同事一开始怀疑是新上的 V2 接口导致的超时。我们核对后发现:

  • V2 仅对一家医院开放,早晨只有几笔订单;
  • V2 虽然也要同步调用顺丰 API,但已经把 API 调用放在事务和 Redisson 锁之外了;
  • V2 只用 TransactionTemplate 包裹处方/订单/HIS 的 DB 更新,锁也只覆盖 DB 写入段。

所以 V2 本身不是元凶,反而是正确的优化方向。真正的风险是:大多数医院还在走 V1 大事务入口。

维度 V1 V2
入口流量 绝大多数医院 单个试点医院
顺丰 API 调用位置 @Transactional 事务内 事务与锁之外
事务范围 大事务,API + DB 全包 短事务,仅 DB 写入
连接占用时长 数十秒 数秒以内
本次事故角色 放大因子 非主因

五、根因定责

我们把根因拆成两层:

  1. 直接原因(配置层) :核心服务 HikariCP maximum-pool-size 仅 10,单服务并发承载力极低,早高峰迅速耗尽连接池,是全服务雪崩的第一块多米诺骨牌。
  2. 深层诱因/放大因子(事务层) :V1 syncOrderDeliveryInfoByDelivery 在事务内同步调用顺丰 API和部分业务处理 约 20 秒,使单连接长时间被占用并持有 ihm_order_prescription 行锁,让小连接池更快耗尽。

也就是说,即便 V1 大事务存在,只要连接池配得足够大,系统也能扛过当前流量;而小连接池让这个问题在早高峰被指数级放大。


六、解决方案与落地

6.1 紧急止血:先扩连接池

事发时最优先的操作:把 patient、portal、operate 等核心服务的 maximum-pool-size 临时调到 50~100,并把 connection-timeout 降到 3000~5000ms。

这是见效最快的手段。按 20 秒/请求估算,扩到 50 后单服务并发能力从 30 req/min 提升到 150 req/min,能立刻利用 MySQL 剩余的连接容量。

6.2 短期:统一 Nacos 配置

common-mysql.yml 里统一配置,避免个别服务再用默认 10:

yaml 复制代码
spring:
  datasource:
    hikari:
      connection-init-sql: set names utf8mb4
      minimum-idle: 10
      maximum-pool-size: 50          # 核心服务统一 50,必要时单独覆盖 100
      idle-timeout: 600000
      max-lifetime: 1800000
      connection-timeout: 5000       # 获取连接最多等待 5s,快速失败
      auto-commit: true
      leak-detection-threshold: 60000 # 连接泄漏检测 60s

同时把 MySQL innodb_lock_wait_timeout 从 50 秒降到 10~20 秒,让锁竞争快速失败,避免线程长期占用连接。

6.3 中期:推进 V2 覆盖或改造 V1 事务边界

顺丰 API 不能异步(HIS 要实时拿到物流单号),但可以把调用移出事务:

  • 推进 V2 :逐步把更多医院的 sync_delivery_info_version 设为 2,从入口上消除 V1 大事务。
  • 过渡改造 V1 :如果部分医院短期无法切 V2,可以把 rpDeliveryService.handleDelivered(...) 提前到事务开启前执行,或者用 TransactionTemplate 只包裹 DB 写入段。

6.4 长期:监控与规范

  • 接入 HikariCP Micrometer metrics,监控 activeConnectionspendingThreads
  • 全局异常处理必须打印完整 cause,别再让 MyBatisSystemException: null 这种日志出现;
  • 制定规范:核心服务禁止使用默认连接池配置,外部 API 禁止放在事务内。

七、复盘与踩坑经验

7.1 不要被异常 message 骗了

这次日志里 MyBatisSystemException: null 让我们绕了不少弯路。如果全局异常处理能把底层 Connection is not available, request timed out after 300000ms 打出来,问题定位能快很多。异常处理里千万别吞 cause。

7.2 MySQL 连接数充足 ≠ 应用没问题

DBA 一看 Threads_connected=387max_connections=4190,很容易说"数据库没问题"。但应用层每个服务只配了 10 个连接,照样能把自己卡死。排查时要把应用连接池和数据库总连接数分开看。

7.3 小连接池 + 长事务 = 雪崩加速器

typescript 复制代码
连接池只有 20 个,实际需要 50 个
        │
        ▼
  连接全被占满,新请求排队等连接
        │
        ▼
  等连接的请求越来越多(越积越多)
        │
        ▼
  等太久 → 超时 → 上游调用方也超时
        │
        ▼
  上游不断重试 → 压力更大 → 更多排队
        │
        ▼
      *雪崩*

这次事故如果把连接池扩到 50,或者把 V1 大事务拆成短事务,任意一个改动都能避免雪崩。但两个坑同时踩,就爆了。所以我们在做容量规划时,不能只算 QPS,还要算"每个请求平均占连接多久"

typescript 复制代码
同时需要的连接数 = QPS × 平均每个请求占用连接的时间

7.4 上线优化版本时要同步推进覆盖

V2 已经是个正确实现,但只开了一家医院。大多数流量还在 V1,优化效果就出不来。这种"代码已优化、入口未切换"的情况,在线上排查时很容易误判。


八、结语

这次事故最终的结论是:应用层 HikariCP 连接池容量过小 + V1 长事务放大连接占用,共同导致的级联雪崩。 连接池扩容是最快、最有效的止血手段;而推进 V2、把外部 API 移出事务,则是根治方向。

希望对大家有参考价值。如果你们也遇到类似"数据库连接数不多但应用拿不到连接"的情况,不妨先检查下 HikariCP 连接池配置,很可能就是应用层自己把自己锁死了。


相关文件

相关推荐
格兰芬多呼神护卫20 小时前
# Agent 企业评估方案深度分析:从“看答案”到持续质量闭环_码士教育
java·开发语言·jvm
万亿少女的梦16820 小时前
基于Spring Boot、Vue.js与MySQL的流浪猫狗救助系统设计与实现
vue.js·spring boot·mysql·javaweb·权限管理
豆角焖肉20 小时前
冒泡、快排、堆排的实现逻辑与选择思路
java·算法·排序算法·快排·冒泡·堆排
番茄炒鸡蛋加糖20 小时前
Spring 事务传播机制 & 事务失效场景
java·后端·spring
这就是佬们吗20 小时前
回溯算法三板斧---掌握「回溯三问」思考模板快速入门回溯
java·数据库·算法
qq_1715388520 小时前
深入浅出 MyBatis XML:从入门到精通
xml·java·mybatis
qq_1715388520 小时前
Spring TransactionSynchronizationManager:事务同步的幕后指挥官
java·后端·spring
要开心吖ZSH20 小时前
一次 HikariCP 连接池耗尽导致的线上雪崩排查实录——完整排查报告
java·spring boot·mysql·连接池·hikaricp
万亿少女的梦16821 小时前
基于SSM、JSP与MySQL的秦皇岛旅游服务管理系统设计与实现
mysql·mybatis·ssm·jsp·spring mvc
SimonKing21 小时前
Spring Boot 集成 OnlyOffice,5 分钟搞定 Word/Excel 在线编辑
java·后端·程序员