Spring 事务的致命陷阱:一个缓慢的 HTTP 请求,是如何耗尽数据库连接池的?
案发时间:周五晚 18:30,打车/外卖/电商晚高峰。
案发现场:核心交易微服务。
灾难表现:
前端疯狂反馈页面一直转圈,最后提示“网络超时”。
你赶紧打开监控大盘,却看到了极其诡异的一幕:
- CPU 利用率:5%(低得离谱)。
- 内存使用率:40%(完全没有 OOM 的迹象)。
- 业务错误日志:干净得像一张白纸,连个
Exception都搜不到。 - 但 Tomcat 并发数:已经顶到了最大的 200,且全部处于阻塞状态!
整个系统就像一个植物人,除了微弱的呼吸,对外界没有任何反应。这叫作**“系统假死”**。
🛡️ 一、破案第一步:突破思维盲区
遇到假死,很多初级开发的第一个念头是:是不是代码里写了死循环?
错!如果是死循环,CPU 早就飙到 100% 了(就像上期的正则回溯一样)。
CPU 这么低,说明线程根本没有在干活,它们全都在“等”!
等什么?要么等锁,要么等网络 I/O,要么等极其稀缺的系统资源。
🔬 二、法医取证:一发jstack击穿迷雾
不要犹豫,立刻登录服务器,导出线程快照。
# 1. 找到 Java 进程号 (假设是 9527)jps-l# 2. 导出全部线程堆栈到文件中jstack9527>jstack_dump.txt打开jstack_dump.txt,搜索 Tomcat 的工作线程名http-nio-8080-exec。
你会发现,近乎 100% 的业务线程,状态全部是TIMED_WAITING(限期等待),并且它们全都卡在了同一个极其眼熟的类上:
"http-nio-8080-exec-50" #108 daemon prio=5 os_prio=0 tid=0x... nid=0x... waiting on condition [0x...] java.lang.Thread.State: TIMED_WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078) at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:188) at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128) at org.springframework.jdbc.datasource.DataSourceUtils.doGetConnection(DataSourceUtils.java:115) ... at com.example.mall.service.OrderService.createOrder(OrderService.java:68)案情瞬间大白!
- 凶器:
HikariPool.getConnection()。所有的 Tomcat 线程,都在向数据库连接池(HikariCP)乞讨数据库连接。 - 死因:连接池里的连接已经被前面的人借光了,一滴都不剩了。后面的请求只能排队死等。
- 为什么不报错?:因为 HikariCP 默认的
connectionTimeout是 30000 毫秒(30 秒)。在等待的这 30 秒内,线程不会抛出异常,只会默默地挂起。Tomcat 的 200 个核心线程瞬间被这 30 秒的漫长等待彻底榨干,无法再接收任何新请求!
💻 三、深入巢穴:揪出连接“只借不还”的内鬼
到底是谁借走了连接却不还?
HikariCP 的默认最大连接数是maximum-pool-size: 10(官方强烈推荐保持小连接数以压榨底层性能)。
也就是说,只要有 10 个极其缓慢的请求,就能彻底瘫痪一整个微服务实例!
顺着堆栈往下看,找到了我们的业务代码:OrderService.java的第 68 行。
@ServicepublicclassOrderService{@AutowiredprivateOrderMapperorderMapper;@AutowiredprivateThirdPartySmsClientsmsClient;// 致命的 @Transactional 加上长耗时操作@Transactional(rollbackFor=Exception.class)publicvoidcreateOrder(OrderDTOdto){// 1. 业务校验(毫秒级)validate(dto);// 2. 插入数据库,生成订单!!!// 【核心转折点】:从这里开始,Spring 从 HikariCP 借出了 1 个数据库连接orderMapper.insert(dto.toEntity());// 3. 调用外部第三方 RPC/HTTP 接口发短信// 【夺命真凶】:这个外部接口网络抖动,响应极慢,卡了 15 秒!smsClient.sendSms(dto.getPhone(),"您的订单已创建");}// 只有当方法结束时,Spring 事务才会提交,数据库连接才会被归还给 HikariCP!}死亡回放 (长事务的反噬):
- 用户发来请求,进入
@Transactional方法。 - 执行到
insert语句时,Spring 帮我们向 HikariCP 申请了 1 个数据库连接。 - 接着往下走,代码发起了一个外部 HTTP 请求去发短信。
- 注意!此时数据库操作已经结束了,但事务还没提交,所以这个数据库连接依然被当前线程死死捏在手里!
- 外部发短信的接口极其拉胯,卡了 15 秒才返回。
- 这也就意味着,这 1 个极其宝贵的数据库连接,被白白浪费(空闲挂起)了 15 秒!
- 如果同时有 10 个用户下单,HikariCP 的 10 个连接会瞬间被占满。第 11 个请求连
insert语句都走不到,直接卡死在获取连接的那一步。
这就是微服务开发中最臭名昭著的连环杀手:长事务 (Long Transaction) 导致的连接池泄漏。
🛠️ 四、紧急抢救与终极防身术
凌晨的紧急抢救:
立刻修改代码,将所有耗时的外部 RPC/HTTP 调用,全部剥离出数据库事务!
publicvoidcreateOrder(OrderDTOdto){// 将数据库操作单独抽出一个独立的小事务方法orderServiceDb.saveOrderToDb(dto);// 事务已经提交,连接已归还!现在放心大胆地去调慢速 HTTP 接口smsClient.sendSms(dto.getPhone(),"您的订单已创建");}架构师的终极底牌(HikariCP 泄漏检测探针):
很多时候,代码太庞大,你根本不知道是哪段长事务耗尽了连接。
其实,HikariCP 早就为你准备了一个神器,只不过 99% 的人都不知道开启它。
在你的application.yml里加上这极其金贵的一行配置:
spring:datasource:hikari:# 连接泄漏检测阈值(毫秒)。建议设置为 2000 (2秒) 或更长。leak-detection-threshold:2000它的魔法在于:
只要有哪个线程借走连接超过 2 秒钟还没有归还,HikariCP 就会在后台大吼一声,直接在日志里打出一个警告(WARN),并且把你当时借走连接的那行代码的堆栈全部打印出来!
WARN - Connection leak detection triggered... at com.example.mall.service.OrderService.createOrder(OrderService.java:68)这就相当于给每个连接安了一个 GPS 定位器,谁敢霸占不还,立刻全球通报,抓内鬼简直易如反掌。
