晚上八点半,所有接口一起超时
4 月 15 号晚上八点二十几分,告警群开始刷屏:订单服务全部接口超时,错误率 100%。
Pod 是活的,CPU 和内存都正常,CPU 只有 31%。但所有请求都在报同一个错:
2021-04-15 20:23:41.882 ERROR [http-nio-8080-exec-42] c.x.GlobalExceptionHandler
: 系统异常
org.springframework.dao.DataAccessResourceFailureException: Unable to acquire JDBC Connection; nested exception is
org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available,
request timed out after 30002ms (total=20, active=20, idle=0, waiting=87)
关键信息在最后一行括号里:
total=20 池子总共 20 个连接
active=20 全部在用
idle=0 空闲 0 个
waiting=87 还有 87 个线程在排队等连接
先看是什么在占着连接
HikariCP 本身不记录"哪个 SQL 占用了连接",这是它相对 Druid 的一个短板。我当时用的是最原始的办法——直接查 MySQL:
mysql> SELECT id, user, host, db, command, time, state, LEFT(info, 80) AS sql_text
-> FROM information_schema.processlist
-> WHERE db = 'order_db' AND command != 'Sleep' ORDER BY time DESC LIMIT 20;
+-------+------+-----------------+------+---------+------+--------------+------------------------------------------+
| id | user | host | db | command | time | state | sql_text |
+-------+------+-----------------+------+---------+------+--------------+------------------------------------------+
| 88231 | order| 10.0.1.31:44122 | order| Query | 42 | Sending data | SELECT * FROM t_order_item WHERE create_t |
| 88234 | order| 10.0.1.31:44128 | order| Query | 41 | Sending data | SELECT * FROM t_order_item WHERE create_t |
| ... 20 rows, 全部是同一条 SQL,time 都是 40 秒以上
20 条连接,全在执行同一条 SQL,已经跑了 40 多秒还没结束。
这条 SQL 从哪来的
把完整 SQL 拿出来看:
SELECT * FROM t_order_item
WHERE create_time >= '2021-03-01' AND create_time < '2021-04-01'
AND merchant_id = 882341;
这是"商家订单导出"功能。那天是 4 月 15 号,正好可以导 3 月的账单,商家集中在这个时间点导出。
EXPLAIN 一下:
mysql> EXPLAIN SELECT * FROM t_order_item WHERE create_time >= ... AND merchant_id = 882341\G
***************************
id: 1
select_type: SIMPLE
table: t_order_item
partitions: NULL
type: ALL <-- 全表扫描
possible_keys: idx_merchant_id, idx_create_time
key: NULL <-- 索引一个没用上
rows: 42318842
Extra: Using where
扫 4231 万行。索引 idx_merchant_id 和 idx_create_time 都在,但优化器判断两个条件各自的选择性都不好,干脆全表扫。实际上建一个联合索引就能解决:
CREATE INDEX idx_merchant_time ON t_order_item(merchant_id, create_time);
mysql> EXPLAIN SELECT ... \G
type: range
key: idx_merchant_time
rows: 12841
扫描行数从 4231 万降到 12841。
根因:慢 SQL 吃光了连接池,其他请求全被饿死
整个故障链条其实很短:
- 导出功能上线时没加联合索引,单条 SQL 要 40 秒以上
- 连接池只有 20 个连接,20 个商家同时导出就全被占满
- 其他所有需要数据库的请求排队,
connectionTimeout30 秒后超时 - Tomcat 的 200 个工作线程也跟着被占满,最后连不需要数据库的接口都不可用
一个次要功能的慢查询,拖垮了整个服务。这就是没有资源隔离的代价。
HikariCP 的参数怎么调
我们用的是 Spring Boot 2.4.3,默认连接池 HikariCP 3.4.5。
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
connection-test-query: SELECT 1 # 驱动支持 JDBC4 的话可以不配
validation-timeout: 5000
逐个说明我的理解:
maximum-pool-size 不是越大越好
HikariCP 官方文档里有一句很经典的话,大意是:如果你有 4 核的机器,连接池 10 个连接就够了,再多只会让数据库更慢。
原因是数据库连接是有代价的:每个连接对应 MySQL 的一个线程,连接越多,MySQL 的上下文切换、锁竞争、内存占用越大。几十个客户端各开 100 个连接,MySQL 要处理几千个线程,性能会断崖式下降。
官方给的参考公式:
connections = ((core_count * 2) + effective_spindle_count)
我们的情况:8 核 + SSD(有效磁盘数按 1~2 算)
= 8 * 2 + 2 = 18 ≈ 20
所以 20 这个数字是合理的。真正的问题不是池子太小,是慢 SQL 占用时间太长。把 20 调到 100 只会让故障晚 4 倍时间发生,同时让 MySQL 压力增加 5 倍。
leak-detection-threshold:连接泄漏检测
这个参数默认 0(关闭),我强烈建议打开。它检测的是"连接借出去多久没还":
leak-detection-threshold: 60000 # 60 秒
超过阈值,HikariCP 会打一条 WARN 日志,带着借出连接时的完整堆栈:
2021-04-15 20:24:02.118 WARN [HikariPool-1 housekeeper] com.zaxxer.hikari.pool.ProxyLeakTask
: Apparent connection leak detected in com.xxx.OrderExportService.export
java.lang.Exception: Apparent connection leak detected
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
at com.xxx.OrderExportService.export(OrderExportService.java:88)
at com.xxx.ExportController.export(ExportController.java:42)
...
这次事故之后我们把这个参数设成了 60 秒。注意它的单位:Spring Boot 的 leak-detection-threshold 是毫秒,原生 HikariCP 的 leakDetectionThreshold 也是毫秒。设成 60000 才是 60 秒,写 60 的话会疯狂告警(因为几乎所有超过 60ms 的查询都会触发)。
它有一点性能开销(每次借出/归还都要记录堆栈),但只在超过阈值时才真正打日志,可以接受。
connection-timeout:多久算超时
默认 30 秒。这个数字应该小于上游给你的超时时间。我们的网关超时是 10 秒,那连接池还等 30 秒就毫无意义——网关早就断开了,请求白做。
我们改成了 8 秒,让请求快速失败,Tomcat 线程尽快释放。
max-lifetime 要小于 MySQL 的 wait_timeout
mysql> SHOW VARIABLES LIKE 'wait_timeout';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| wait_timeout | 28800 | -- 8 小时
+---------------+-------+
max-lifetime 默认 1800000(30 分钟),比 MySQL 的 8 小时短很多,这是对的。它的作用是让连接定期重建,避免长时间运行的连接出问题(比如 MySQL 端重启、网络抖动导致的半开连接)。
注意别设得比 MySQL 的 wait_timeout 长,否则连接会被 MySQL 单方面断开,客户端还以为是好的。
我们的四个改动
1. 加联合索引(治本)
这条 SQL 从 40 秒降到 180 ms。
2. 导出走独立的只读连接池(隔离)
慢查询是不可避免的,重点是别让它影响主流程。我们把导出、报表、运营后台这些"重查询"全部切到只读库,用独立的连接池:
@Configuration
public class DataSourceConfig {
@Bean("masterDs")
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() { return DataSourceBuilder.create().build(); }
@Bean("replicaDs")
@ConfigurationProperties("spring.datasource.replica")
public DataSource replicaDataSource() { return DataSourceBuilder.create().build(); }
@Bean("exportDs")
@ConfigurationProperties("spring.datasource.export")
public DataSource exportDataSource() { return DataSourceBuilder.create().build(); }
}
spring:
datasource:
master:
jdbc-url: jdbc:mysql://10.0.1.10:3306/order_db
hikari:
maximum-pool-size: 20 # 主流程
connection-timeout: 8000
leak-detection-threshold: 30000
export:
jdbc-url: jdbc:mysql://10.0.1.12:3306/order_db # 只读从库
hikari:
maximum-pool-size: 5 # 故意给很少
connection-timeout: 30000
leak-detection-threshold: 120000
导出池只给 5 个连接,而且慢一点没关系。它挂了也不影响下单。
3. 导出改成异步 + 限流
同步导出一个月的订单本来就不合理。改成提交任务 → 后台生成文件 → 短信通知下载。
// 同时最多 3 个导出任务在跑
Semaphore exportPermit = new Semaphore(3);
public String submitExport(Long merchantId, DateRange range) {
if (!exportPermit.tryAcquire()) {
throw new BizException("当前导出任务较多,请稍后再试");
}
...
}
4. 把池的指标纳入监控
这是最该早做的一件事。Spring Boot Actuator 自带 HikariCP 的指标:
$ curl http://localhost:8080/actuator/metrics/hikaricp.connections.active
{"name":"hikaricp.connections.active","measurements":[{"statistic":"VALUE","value":18.0}],
"availableTags":[{"tag":"pool","values":["HikariPool-1"]}]}
$ curl http://localhost:8080/actuator/metrics/hikaricp.connections.pending
{"name":"hikaricp.connections.pending","measurements":[{"statistic":"VALUE","value":0.0}]}
告警规则:
hikaricp_connections_active / maximum_pool_size > 0.8 持续 1 分钟 → 警告
hikaricp_connections_pending > 0 持续 30 秒 → 严重告警
hikaricp_connections_usage_seconds 的 P99 > 1 秒 → 警告(说明有慢 SQL 占用)
第二条最关键。pending 是"正在排队等连接的线程数",它大于 0 就意味着连接池已经不够用了,比接口超时早得多。
下篇预告
这篇先把《一次数据库连接池耗尽导致的全线超时》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。