接口变慢时,很多人第一反应是去翻慢 SQL。这没错,但有一个前提:用户等待的时间真的花在 SQL 执行上。
这次 service-j 的 api-order-x 就很典型。入口 P99 从 220ms 升到 5.2s,Tomcat busyThreads 从 90 涨到 260,错误日志开始出现:
SQLTransientConnectionException:
Connection is not available, request timed out after 30000ms
如果只盯慢日志,很容易得出"数据库慢了"的结论。但真正的问题不是 SQL 普遍变慢,而是应用侧拿不到数据库连接。
第一眼像慢 SQL,但证据不支持
故障窗口里,最先拉的是入口、JVM 和数据库资源指标。结果有点反直觉:
| 指标 | 故障前 | 故障中 |
|---|---|---|
| api-order-x P99 | 220ms | 5200ms |
| Tomcat busyThreads | 90 | 260 |
| 应用 CPU | 平稳 | 约 45% |
| GC | 平稳 | 无明显异常 |
| 数据库 CPU/IO | 平稳 | 未打满 |
这组证据说明两件事。
第一,应用不是 CPU 打满,也不是 GC 把请求停住了。第二,数据库也没有表现出整体资源耗尽。如果是典型慢 SQL 扩散,通常会看到慢日志增加、数据库 CPU 或 IO 同步上升,或者执行计划明显退化。
但这里异常日志指向了另一个方向:请求不是"执行 SQL 慢",而是在 getConnection() 阶段等连接。
真正的异常在连接池等待
继续看 Hikari 指标,问题就清楚了:
HikariPool-1 maximumPoolSize=80
active=80
idle=0
pending=360
connectionTimeout=30000ms
getConnection timeout count 持续增长
active=80 说明连接池已经打满,idle=0 说明没有空闲连接可借,pending=360 说明大量请求正在排队等连接。这个状态下,业务线程会被阻塞在获取连接上,最终拖高 Tomcat busyThreads。
所以入口耗时的主要组成不是 SQL 执行耗时,而是连接池等待时间。慢 SQL 只是候选原因之一,它可能导致连接长时间不归还,但不能直接等同于"SQL 本身慢"。
这个阶段最关键的判断是:连接为什么不归还?
常见方向有四个:
| 方向 | 证据入口 | 本次结论 |
|---|---|---|
| 连接泄漏 | active 是否长期不回落、异常路径是否关闭连接 | 不像典型泄漏 |
| 慢 SQL | 慢日志、执行计划、单次 SQL 耗时 | 普通 select 未明显变慢 |
| 锁等待 | processlist、data_lock_waits | 命中 |
| 事务过长 | innodb_trx、事务持续时间 | 命中 |
连接泄漏的典型表现是故障解除后 active 仍长期不回落,或者连接数随时间单向增长。本次故障恢复后 active 能回落,所以它不是主线。
连接被谁占住了
数据库侧证据把问题收敛到了锁等待和长事务:
information_schema.innodb_trx 显示 1 个事务持续 420s
performance_schema.data_lock_waits 中 table_order_x 存在锁等待
https://zhida.zhihu.com/search?content_id=278345624&content_type=Article&match_order=1&q=SHOW+PROCESSLIST&zhida_source=entity 中多条 update table_order_x 处于 Waiting for row lock
慢日志中普通 select 耗时未明显升高
这组数据很有决定性。
innodb_trx 里有一个持续 420s 的事务,说明某个连接长时间持有事务上下文。data_lock_waits 和 SHOW PROCESSLIST 又显示 table_order_x 上存在行锁等待,多条 update 卡在 Waiting for row lock。
这就形成了闭环:
- 长事务持有行锁;
- 同表更新请求进入锁等待;
- 等待中的 SQL 持续占用数据库连接;
- 应用连接池 active 被占满;
- 新请求在
getConnection()等待,直到 30s 超时; - Tomcat 业务线程被拖住,接口 P99 和超时率上升。
所以这不是"SQL 语句突然慢了",而是"事务边界和锁等待把连接池耗尽了"。SQL 只是被堵在锁上,连接池才是用户侧延迟放大的关键资源。
止血和修复不能只调大连接池
这类问题最容易做错的动作,是把 maximumPoolSize 从 80 调到更大。短时间看,pending 可能下降;但如果锁等待还在,更多连接只会把更多请求送进数据库等待队列,扩大数据库端压力。
更合理的处理顺序是:
| 动作 | 目的 |
|---|---|
| 终止异常长事务或暂停相关入口 | 先释放被持有的行锁 |
| 对 api-order-x 做临时限流 | 防止等待连接的请求继续堆积 |
| 检查事务内逻辑 | 排除事务内 RPC、复杂计算、批量循环更新 |
| 收敛更新范围和顺序 | 降低行锁冲突概率 |
| 为连接池等待建立告警 | 让问题在接口超时前暴露 |
修复后验证不能只看接口恢复,还要同时看连接池和数据库锁状态:
Hikari active 从 80 回落到稳定水位
pending 从 360 回落到 0
getConnection timeout 不再增长
SHOW PROCESSLIST 中 Waiting for row lock 消失
api-order-x P99 回到 220ms 附近
这些指标一起恢复,才说明"连接池耗尽"这个故障链路被切断。
可复用排查命令
排查 MySQL 连接池耗尽时,我一般按"应用连接池 -> 线程阻塞点 -> 数据库连接和锁 -> 事务边界"的顺序看。
curl -s http://service-j/actuator/metrics/hikaricp.connections.active
看 active 是否接近 maximumPoolSize,以及是否长期不回落。
curl -s http://service-j/actuator/metrics/hikaricp.connections.pending
看是否有大量线程等待连接。pending 上升通常比接口超时更早暴露问题。
jstack <pid> > /tmp/thread.dump
grep -n "getConnection\\|HikariPool" /tmp/thread.dump
确认业务线程是否阻塞在获取数据库连接,而不是阻塞在本地锁、RPC 或 Redis。
SHOW FULL PROCESSLIST;
看数据库端连接正在执行什么,是否大量处于 Waiting for row lock、Sending data 或长时间 Sleep。
SELECT * FROM information_schema.innodb_trx\G
看是否存在持续时间异常的事务,重点关注事务开始时间、持锁状态和对应线程。
SELECT * FROM performance_schema.data_lock_waits\G
SELECT * FROM performance_schema.data_locks\G
定位阻塞事务和被阻塞事务,确认锁等待发生在哪张表、哪个索引或哪类记录上。
SHOW ENGINE INNODB STATUS\G
用于补充锁等待、死锁和事务状态细节,适合在锁等待视图信息不足时交叉验证。
复盘要点
- 直接原因:
table_order_x上存在锁等待,部分更新语句长时间占用连接,导致 Hikari 连接池 active 打满。 - 根因分析:异常长事务扩大了锁持有时间,连接池等待时间被入口请求感知,最终表现为接口 P99 和超时率上升。
- 容易误判:接口慢不等于 SQL 慢,慢日志不明显时要检查连接池等待、锁等待和事务持续时间。
- 预防措施:连接池 active、pending、timeout 必须纳入核心告警,并和接口 P99、数据库锁等待放在同一张排障看板。
- 长期治理:控制事务边界,避免事务内调用外部依赖或做大批量循环更新;稳定性建设不能依赖无限扩资源,而要让关键资源可观测、可定位、可恢复。