Spring Boot 接口间歇性变慢与数据库连接池耗尽 Spring Boot 接口间歇性变慢与数据库连接池耗尽一、业务场景某订单详情接口使用 Spring Boot MyBatis MySQL 实现。线上运行一段时间后接口出现间歇性变慢正常情况下接口耗时约 100ms。故障期间部分请求耗时达到 23 秒部分请求仍然很快。请求量没有明显增长。MySQL CPU 使用率正常慢 SQL 数量没有明显增加。重启应用后接口暂时恢复正常但运行一段时间后再次出现问题。面对这种现象不能直接认定是 SQL 性能问题也不能因为重启后恢复就认为是 JVM 故障。本次排查最终发现JVM 堆内存正常没有明显 GC 停顿。Tomcat 工作线程没有达到上限。HikariCP 活动连接数持续达到最大值等待连接数不断增加。SQL 执行时间正常。部分请求线程在等待外部 HTTP 服务响应。核心问题是请求持有数据库连接的时间过长导致连接池耗尽后续请求无法及时获取连接只能排队等待。二、理解 HikariCP数据库连接池1. HikariCP 是什么HikariCP 是一个高性能 JDBC 数据库连接池也是 Spring Boot 项目中常见的默认连接池实现。应用访问数据库时不需要每次都重新建立数据库连接而是从连接池中借用已有连接使用结束后再归还。以 MyBatis 为例正常情况下开发人员通常不需要手动获取和释放底层 JDBC 连接Spring 事务管理和 MyBatis 会按照相应的资源管理规则处理连接。但需要注意框架负责管理资源不代表连接一定会被及时归还。如果连接被事务持有或者业务流程长时间没有结束即使期间没有执行 SQL连接仍然可能处于占用状态。2. 连接池的重要指标指标含义异常表现Active当前被借出的连接数长时间接近最大值Idle当前空闲连接数持续为 0Max连接池允许的最大连接数配置过小可能限制并发Pending / 等待获取连接的线程数因暂时没有可用连接而等待的请求数量具体指标取决于监控实现持续增加获取连接耗时请求等待连接可用所花费的时间明显增加当 Active 达到 Max、Idle 变成 0同时等待获取连接的线程不断增加时说明连接池正在承受较大的连接获取压力。但连接池耗尽只是故障表现还需要继续查明连接为什么没有及时归还。3. 为什么 SQL 很快接口仍然很慢一次数据库访问的耗时并不只有 SQL 执行时间。可以将接口耗时粗略拆解为接口总耗时 业务处理时间 等待数据库连接时间 SQL 执行时间 外部服务调用时间 其他开销这是一个便于排查的概念模型不代表各部分在所有系统中都严格独立。例如等待数据库连接2 秒。SQL 执行50ms。其他处理50ms。即使 SQL 只执行了 50ms接口仍然可能耗时超过 2 秒。因此SQL 执行快不代表数据库访问链路整体快。三、如何判断是不是连接池耗尽第一步排除 JVM 问题检查堆内存使用趋势。GC 频率和停顿时间。是否存在频繁 Full GC。是否存在内存泄漏或 OOM 迹象。本次场景中堆内存正常没有明显 GC 停顿因此 JVM 暂时不是首要怀疑对象。注意没有 OOM并不能证明 JVM 一定没有问题这里是结合 GC 和内存监控缩小排查范围。第二步检查 Tomcat 工作线程检查当前工作线程数量。最大工作线程配置。是否存在大量线程阻塞。请求队列和线程使用情况。本次场景中Tomcat 工作线程没有达到上限因此不能简单归因于 Tomcat 线程数不足。不过线程没有达到上限不代表请求没有阻塞。线程可能正在等待数据库连接或外部 HTTP 响应。第三步检查 MySQL检查MySQL CPU、内存和磁盘 I/O。SQL 执行耗时。慢 SQL 数量。数据库连接数。锁等待和长事务。数据库网络及连接异常。如果 SQL 执行耗时正常、数据库负载也正常就需要进一步检查连接获取和归还过程而不是直接优化 SQL。第四步检查 HikariCP重点观察 Active、Idle、Max、等待线程数和连接获取耗时。如果出现Active Max Idle 0 Pending 持续增加并且接口的连接获取耗时同步增加就可以高度怀疑连接池已经成为请求链路中的瓶颈。但不能仅凭这些指标认定根因是事务过长还要确认连接持有时间以及持有连接的线程正在做什么。四、为什么事务过长会耗尽连接池1. Spring 事务的基本行为在常见的 Spring 本地事务配置下事务通常会绑定相关数据库连接并在事务完成后释放或归还连接。例如TransactionalpublicvoidupdateOrder(LongorderId){OrderorderorderMapper.selectById(orderId);externalService.notify(order);orderMapper.updateStatus(orderId,DONE);}假设外部 HTTP 调用耗时 2 秒。如果事务已经获取了数据库连接那么这条连接可能在整个事务执行期间持续被占用即使 HTTP 调用期间没有执行任何 SQL。这会导致开始事务 | 查询订单 | 调用外部 HTTP 服务等待 2 秒 | 更新订单 | 事务提交归还连接真正的问题不一定是 SQL 慢而是数据库连接被无数据库操作的等待过程占用了。需要注意连接通常是在事务执行期间的数据库访问阶段获取具体获取和绑定时机取决于事务管理器、连接代理及相关配置并非所有事务一进入方法就必然立即借出连接。2. 为什么请求量没增长故障仍然会发生连接池的处理能力不仅取决于连接数还取决于连接被占用的时间。可以用一个简化模型理解连接池理论吞吐能力 ≈ 可用连接数 / 平均连接持有时间例如假设连接池最大连接数为 20平均连接持有时间理想化理论吞吐能力100ms约 200 次/秒500ms约 40 次/秒1 秒约 20 次/秒这些数字只是简化估算不是生产环境的实际性能承诺真实能力还受请求分布、SQL 执行、连接等待和其他资源限制影响。如果外部服务突然变慢连接持有时间从 100ms 增长到 1 秒那么即使请求量没有增长连接池也可能从正常状态转变为持续排队。3. 为什么重启应用后暂时恢复重启可能会终止原有请求线程。释放旧连接和事务资源。清空应用进程中的部分等待状态。重新建立连接池。如果故障根因仍然存在重启后随着请求再次积累问题就可能复现。因此重启通常是临时恢复手段不是根因修复方案。五、如何区分事务持有时间过长与连接池容量不足不能只看 Active 是否达到 Max还要结合连接持有时间和线程状态。情况一事务或连接持有时间过长典型表现连接长期处于 Active 状态。SQL 执行耗时正常但连接持有时间很长。事务耗时明显大于 SQL 总耗时。线程栈显示请求线程阻塞在外部 HTTP 调用、锁等待或其他耗时操作。连接池等待线程持续增加。排查重点检查事务开始、提交和回滚时间。检查连接借出与归还时间。检查事务内部是否调用外部服务。检查是否存在长时间业务计算、线程等待或锁竞争。情况二正常连接使用时间下请求确实超过连接池承载能力典型表现单次连接持有时间基本正常。没有明显连接泄漏或长事务。连接需求长期高于连接池的可处理能力。连接池等待时间增加。处理方向评估真实并发量和连接持有时间。优化数据库访问次数。根据数据库承载能力合理调整连接池大小。必要时对请求进行限流或排队。需要注意连接池容量不足与连接持有时间过长并非互斥。增加连接数可能暂时缓解等待但如果根因是事务内等待外部 HTTP 服务单纯增加连接数可能只是延后故障。六、如何修复本次故障方案一缩短事务和连接占用时间优先检查业务代码避免在持有数据库连接或事务期间调用耗时的外部 HTTP 服务。例如将业务逻辑调整为publicvoidupdateOrder(LongorderId){OrderorderorderService.getOrder(orderId);externalService.notify(order);orderService.updateStatus(orderId,DONE);}这里假设查询和更新通过独立的事务边界执行并且外层没有其他事务包住整个方法。这种方式可以缩短数据库连接的占用时间但必须先确认业务一致性要求。例如HTTP 调用成功数据库更新失败怎么办数据库更新成功HTTP 调用失败怎么办请求超时但第三方实际已经处理成功怎么办不能为了释放连接而随意调整执行顺序。方案二合理拆分事务如果业务流程确实很长可以考虑拆成多个本地事务。例如事务 A将订单状态更新为处理中。在事务外调用第三方服务。事务 B将订单状态更新为完成。这样可以避免在等待 HTTP 响应时持续持有数据库事务。但是拆分事务意味着原本的整体原子性不再存在。例如第三方调用成功后事务 B 更新失败订单就可能一直处于处理中。因此通常需要配合明确的业务状态机。幂等机制。失败重试。补偿处理。异常告警与对账任务。方案三异步化外部服务调用如果业务允许可以将外部 HTTP 调用改为异步任务。例如接收订单请求 | 本地事务 更新订单 写入待发送通知记录 | 事务提交 | 后台任务读取通知记录 | 调用第三方 HTTP 服务 | 成功则标记完成失败则重试这样可以把数据库事务和外部网络等待解耦减少连接长期占用的风险。对于需要保证数据库更新与通知任务记录不分离的场景可以采用 Transactional Outbox 模式订单更新和待发送事件在同一个本地事务中提交再由后台任务发送事件。方案四检查是否存在连接泄漏如果连接长期无法归还还需要排查手动获取 JDBC 连接后是否正确关闭。ResultSet、Statement等资源是否正确关闭。流式查询、游标读取是否及时结束。是否存在异常路径没有完成资源清理。是否存在长事务、连接持有超时或连接池使用不当。普通 MyBatis Mapper 查询通常不需要手动关闭底层连接。不要把手动close()当作所有连接池耗尽问题的通用修复方法。方案五合理调整连接池参数在完成根因排查后可以评估最大连接数。连接获取超时时间。连接最大生命周期。空闲连接管理。数据库最大连接数及整体并发能力。连接池大小必须结合数据库容量、应用实例数和真实并发情况设计。如果每个应用实例都配置了较大的最大连接数多个实例的连接总量可能反而压垮数据库。七、第三方 HTTP 超时后的可靠性设计当第三方接口超时客户端无法确定对方到底有没有处理成功。例如本地发送通知请求。第三方处理成功。返回响应时发生网络故障。本地收到超时。本地重试可能造成重复通知。这时单纯增加重试次数并不能保证正确性。1. 使用唯一业务请求 ID为每条通知生成稳定的业务 ID重试时复用同一个 ID。如果第三方支持幂等处理就可以识别重复请求避免重复执行业务。2. 持久化通知任务将通知状态存储在数据库中例如PENDING待发送。PROCESSING处理中。SUCCESS已成功。RETRY等待重试。FAILED超过重试条件需要进一步处理。状态设计应结合实际业务并考虑进程崩溃后如何恢复处理中任务。3. 重试与对账对临时网络错误进行有限次数的重试。使用退避策略避免故障期间持续冲击第三方。对长时间未完成的任务进行告警。如果第三方支持结果查询超时后先查询处理结果再决定是否重试。对无法自动确认的情况提供人工处理或对账机制。4. 分布式事务协议的适用边界TCC、Saga、AT 等方案并非都能直接解决任意 HTTP 通知问题。TCC要求参与方支持相应的预留、确认或取消操作。Saga通过多个本地事务和补偿操作协调业务流程但补偿不代表可以撤回已经送达的外部通知。AT通常要求参与的数据库操作满足相应框架的约束不能直接把任意第三方 HTTP 服务纳入数据库事务。对于不支持分布式事务协议的第三方通常更适合采用本地事务、持久化消息或 Outbox、幂等、重试和对账机制。必须明确如果第三方既不支持幂等也不支持查询处理结果在网络超时的情况下本地系统无法单方面严格保证既不重复执行又绝不漏执行。八、线上排障流程总结遇到接口间歇性变慢可以按照以下顺序排查确认故障范围所有接口都慢还是只有特定业务接口慢。检查 JVM、GC 和内存情况。检查 Tomcat 工作线程和请求排队情况。检查 MySQL 负载、SQL 耗时、锁等待和长事务。检查 HikariCP Active、Idle、Max、Pending 和获取连接耗时。对比 SQL 执行时间与连接持有时间。结合事务日志、链路追踪和线程栈定位连接被占用的具体位置。修复长事务、外部调用阻塞或资源泄漏等根因。验证连接池等待时间和接口延迟是否恢复。增加监控、告警和必要的自动恢复机制。九、面试高频问题1. 为什么 SQL 很快接口仍然可能很慢因为接口耗时不仅包括 SQL 执行时间还可能包括获取连接的等待时间、外部服务调用、业务处理和其他阻塞。2. Spring Boot 使用 MyBatis为什么还会出现连接池耗尽框架通常负责连接资源的管理但长事务、外部调用阻塞、连接泄漏、流式查询未及时结束或并发需求过高都可能导致连接无法及时归还。3.Transactional方法中调用外部 HTTP 服务有什么风险外部服务可能耗时较长而事务期间已经获取的数据库连接可能持续被占用导致连接池可用连接减少甚至耗尽。4. 为什么重启应用只能暂时解决问题重启可以释放原有进程中的连接和线程资源但不会自动修复导致连接长期占用的业务逻辑或配置问题。5. 连接池耗尽后应该直接增大最大连接数吗不应该直接增加。先确定连接持有时间、真实并发需求及数据库承载能力。如果根因是长事务或外部调用阻塞增加连接数可能只是延后故障甚至增加数据库压力。6. 如何区分连接池容量不足和连接泄漏结合连接持有时间、连接获取耗时、Active/Idle/Pending 指标、线程栈和连接泄漏日志进行分析。连接池持续满只能说明连接供需出现问题不能单独证明存在泄漏。7. 如何保证数据库更新与第三方通知最终协调一致如果第三方不支持分布式事务协议可以考虑 Transactional Outbox、异步发送、幂等、重试、状态跟踪和对账机制。具体设计需要根据业务是否允许重复通知、是否支持结果查询等条件确定。十、本篇核心结论本次故障的根因不是 SQL 执行慢而是数据库连接被长时间占用导致连接池耗尽和请求排队。真正有效的排查方式是区分SQL 执行时间。获取连接等待时间。连接持有时间。外部 HTTP 调用时间。事务整体耗时。解决问题时优先缩短事务和连接占用时间再根据业务一致性需求选择事务拆分、异步化、Outbox、幂等与补偿机制。不要把重启应用、盲目增加连接池大小或简单增加重试次数当作根因修复方案。