如果系统突然卡住了,我们要如何排查呢?如果没有一个逻辑清晰的排查思路,面对这类“系统阻塞”问题,往往会手忙脚乱。
其实,阻塞问题就像人体发烧,只是表面症状,背后可能是网络、后端(数据库等中间件、外部接口等等)根本病因。
本文结合实际分析案例,一步步拆解排查流程,帮助大家找到阻塞的根源。
1. 系统阻塞常见的原因
从前端到后端,梳理一下可能导致系统阻塞的常见原因:
- 前端无限制的瞬时并发请求
- 大文件上传、导出
- 线程池耗尽
- 内存泄漏/溢出
- 代码逻辑错误,比如死循环
- 数据库连接池占满
- 数据库慢查询(如索引不生效、在刷脏页等)
- 下游接口无响应,但是又没设置超时时间
- 缓存、消息队列故障
1.1 前端无限制的瞬时并发请求
如果用户短时间内发起上百上千个相同接口请求,导致后端线程池被瞬间占满的话,系统就会卡住。
因此,我们在设计系统的时候,一般要求做一下限流控制。
1.2 大文件上传、导出
大文件导出(如导出 10 万行以上的 Excel数据)的阻塞多发生在 “数据处理” 和 “文件生成” 阶段。
//反例代码:一次性查询100万条数据
List<User> userList = userMapper.selectAll(); // 假设100万条,每条占1KB,共1GB
若此时有 2 个导出请求,直接触发 OOM;即使没 OOM,大量对象创建会导致 Young GC 频繁(每秒几次),系统响应延迟从 100ms 增至 1-2 秒。
- 再比如,POI 是 Java 处理 Excel 的常用库,之前在原来的公司,就遇到个POI导致的full GC问题,最后系统响应变慢
POI 生成.xlsx文件时,会把整个文档结构保存在内存,10 万行数据可能占用 500MB-1GB 内存,且对象回收不及时,最后可能触发full GC的问题。
1.3 线程池耗尽
比如,Tomcat 默认最大线程数为 200,若接口平均处理时间从 100ms 增至 5s,每秒只能处理 40 个请求,当并发超过 40 时就会排队,超过 200 则直接拒绝。
1.4 内存泄漏/溢出
未关闭的 IO 流、静态集合无限制存储数据,会导致 JVM 内存持续增长,最终触发 GC 频繁(STW 时间变长)甚至 OOM,系统表现为 “间歇性无响应”。
1.5 代码逻辑错误,比如死循环
假设有一个接口,每次请求都会启动一个线程执行一段死循环代码:
当用户多次调用这个接口后:
- 系统会创建大量线程,每个线程都陷入死循环,疯狂占用 CPU
- 很快 Tomcat 线程池被占满,新请求无法处理
- 此时调用其他接口会发现:要么超时,要么完全无响应 —— 系统阻塞了
这就是死循环导致系统阻塞的过程:无限制消耗 CPU 和线程资源,最终让整个系统无法处理新请求。
1.6 数据库连接池占满
数据库连接池占满是导致系统阻塞的常见原因之一。当连接池中的连接被耗尽且无法及时释放时,新的数据库操作请求会排队等待空闲连接,最终导致系统响应缓慢或无响应。
比如这个简单例子:
- 假设数据库连接池的最大连接数配置为 10
- 当用户多次调用/leak-connection接口(比如调用 10 次),连接池中的 10 个连接会被全部占用且不释放
- 此时调用/normal-query接口时,会发现请求一直处于等待状态
- 因为新的请求无法从连接池中获取到空闲连接,只能排队等待,最终导致接口超时
1.7 数据库慢查询
慢查询是指执行时间过长的 SQL(如未加索引的全表扫描、复杂关联查询),会长期占用数据库连接和 CPU。
示例代码:
@GetMapping("/slow-query")
public String slowQuery() {
// 无索引的大表全表扫描(假设user表有1000万行)
List<Map<String, Object>> result = jdbcTemplate.queryForList(
"SELECT * FROM user WHERE create_time > '2023-01-01'"
);
return "查询到" + result.size() + "条数据";
}
阻塞过程:
- 这条 SQL 执行需要 30 秒(无索引导致全表扫描)
- 执行期间,该请求占用的数据库连接被 “锁住”,无法释放
- 若同时有 5 个这样的请求,连接池的 5 个连接会被占用 30 秒
- 其他需要数据库操作的请求必须排队等待,表现为 “接口超时”(即使连接池没满,也会因等待慢查询释放连接而阻塞)
1.8 下游接口无响应,但是又没设置超时时间
这个阻塞主要表现如下:
- 假设下游接口
http://downstream-service/unresponsive 已崩溃,无法响应任何请求
- 当用户调用
/call-downstream 时,RestTemplate会发起请求并一直等待(无超时设置)
- 每个/call-downstream请求会占用一个 Tomcat 线程,且永远不会释放
- 当调用次数达到 Tomcat 最大线程数(默认 200),线程池被完全占满
- 此时调用/health接口会发现:请求无响应或超时 —— 系统已阻塞
1.9 缓存、消息队列故障
缓存和消息队列等中间件故障,导致系统阻塞的原理与下游接口超时类似。
当依赖的中间件出现问题,而系统未做防护处理时,会导致线程长期占用,最终引发整体阻塞。
我们以缓存故障(以 Redis 为例),阻塞过程:
- 假设 Redis 服务崩溃,所有连接请求都会卡住
- 用户调用/get-user时,redisTemplate.get()会无限等待 Redis 响应(默认无超时)
- 每个请求占用一个 Tomcat 线程,且不会释放
- 线程池被占满后,/ping等正常接口也无法响应 —— 系统阻塞
缓存客户端(如 RedisTemplate)未设置连接超时,且未实现降级逻辑(如缓存不可用时直接返回默认值),导致线程被永久占用。
2. 一次系统阻塞的真实排查过程
上个月我们的系统出现了阻塞的生产问题。下面简单分享一下我的排查思路和过程:
当时收到系统监控告警,核心问题是好多请求都超时了,没有处理成功。
- 检查请求量:通过日志平台查看请求量,发现并不大,没有瞬时高并发的场景。排除了高并发或第三方攻击导致的可能性。
- 排查大文件操作:发现主要调用的是一个排班接口,量并不特别大,也没有处理大文件的接口。排除大文件上传、导出的原因。
- 观察JVM状态:查看JVM监控面板,没有发现内存泄露或OOM的迹象。排除内存相关问题。
- 检查数据库层面:查看慢SQL监控面板,没有发现大量慢SQL,数据库连接池状态也正常。排除数据库慢查询等可能。
- 确认中间件状态:检查Redis、ES、RocketMQ等中间件,发现都能正常响应。排除中间件自身故障的影响。
- 聚焦下游调用:通过日志分析,发现调用下游的排版接口时,一直没返回,大量请求被挂起。因此初步怀疑,可能是没有设置超时时间或超时时间设置不合理导致的。
最终跟踪代码和日志,确实发现了问题根源:居然没有设置超时时间! 下游接口出故障后,由于原系统没有对这个接口设置超时时间,导致Tomcat线程池被占满。
问题代码示例:
@Autowired
private RestTemplate restTemplate;
JSONObject result = restTemplate.postForEntity(url, data, JSONObject.class).getBody();
@Configuration
public class RestemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
这个RestTemplate作为远程调用模板,需要设置连接和读取超时时间,正确写法如下:
@Bean
public RestTemplate restTemplate() {
// 创建请求工厂并设置超时参数
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
// 连接超时时间:3秒(单位:毫秒)
requestFactory.setConnectTimeout(3000);
// 读取超时时间:5秒(单位:毫秒)这个时间,大家根据实际业务调整
requestFactory.setReadTimeout(5000);
// 使用配置好的工厂创建RestTemplate
return new RestTemplate(requestFactory);
}
总结
系统阻塞问题的排查,需要有一个清晰的、从外到内的排查路径。通常可以从监控告警入手,依次排除网络、前端并发、应用自身(线程、内存、代码)、数据库等中间件、以及下游依赖等各个环节的问题。本文列举的九种常见原因和实战排查案例,希望能为大家提供一个有效的参考框架。在实际工作中,结合完善的监控体系和日志记录,能更快地定位并解决问题。更多类似的Java实战排查经验,欢迎在云栈社区的技术论坛中交流探讨。