“下单接口要5秒才能返回,原来只要50ms,你赶紧看看!”
当我听到这个消息时,第一反应是数据库慢查询,优化一下索引应该就能解决。然而,在尝试了添加索引、引入Redis缓存甚至考虑分库分表后,接口响应时间依然顽固地停留在5秒左右。最终,使用 jstack 工具进行线程分析,真相浮出水面:200个工作线程全部处于 BLOCKED 状态,在等待同一把锁,线程池已被完全打满。
一、场景:接口响应时间从50ms异常飙升至5秒
我们的订单服务,其下单接口的响应时间原本稳定在50ms左右,却在某天突然恶化至5秒。
监控告警数据如下:
- 接口平均响应时间:从 50ms 恶化至 5 秒
- 接口超时率:从 0% 飙升至 30%
- 线程池活跃线程数:从 20 激增至 200(已达上限)
- 队列中堆积的任务数:从 0 增加到 1000+
初期的排查思路:
- 数据库:检查慢查询日志,未发现明显慢SQL。
- 缓存:查看Redis缓存命中率,高于95%,状态正常。
- 服务器资源:服务器CPU使用率约30%,内存使用率约40%,负载正常。
- 网络:Ping数据库延迟仅为1ms,网络通畅。
基于以上信息,我当时的判断是:问题可能出在数据库的锁竞争上,尽管慢查询日志没有记录。
二、踩坑:针对数据库的优化尝试均告失败
第一次尝试:优化索引
通过分析SQL执行计划,发现查询走了全表扫描。
-- 查看SQL执行计划
EXPLAIN SELECT * FROM order WHERE user_id = 12345;
-- 添加索引
ALTER TABLE order ADD INDEX idx_user_id (user_id);
结果:响应时间从5秒略微降至4.8秒,收效甚微。
第二次尝试:引入Redis缓存
在服务层添加缓存逻辑,希望减少数据库访问。
@Service
public class OrderService {
@Autowired
private RedisTemplate<String, Order> redisTemplate;
public Order createOrder(Order order) {
// 1. 先尝试从缓存获取
Order cached = redisTemplate.opsForValue().get("order:" + order.getId());
if (cached != null) {
return cached;
}
// 2. 执行创建订单的数据库操作
orderMapper.insert(order);
// 3. 将结果写入缓存
redisTemplate.opsForValue().set("order:" + order.getId(), order, 10, TimeUnit.MINUTES);
return order;
}
}
结果:响应时间从4.8秒降至4.5秒,依然很慢。
三、关键排查:使用jstack发现线程池瓶颈
第一步:使用jstack导出线程栈信息
# 查找Java应用进程ID
jps | grep order-service
# 输出示例:12345 order-service.jar
# 导出线程栈到文件
jstack 12345 > thread.dump
# 统计处于BLOCKED状态的线程数量
grep "BLOCKED" thread.dump | wc -l
# 输出:200
第二步:分析线程栈,定位阻塞点
分析 thread.dump 文件,发现大量线程阻塞在同一个方法上。
"http-nio-8080-exec-150" #250 daemon prio=5 os_prio=0 tid=0x00007f8d4c123000 nid=0x7f8d waiting for monitor entry [0x00007f8d3c123000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.service.StockService.checkStock(StockService.java:45)
- waiting to lock <0x00000000f7a3d7ac2> (a java.util.concurrent.locks.ReentrantLock)
at com.example.service.OrderService.createOrder(OrderService.java:123)
at com.example.controller.OrderController.createOrder(OrderController.java:45)
第三步:追踪锁的持有者
根据地址查找持有该锁的线程。
$ jstack 12345 | grep -B 5 "0x00000000f7a3d7ac2"
"http-nio-8080-exec-123" #223 daemon prio=5 os_prio=0 tid=0x00007f8d4c0f8000 nid=0x7f8d runnable [0x00007f8d3c0f8000]
java.lang.Thread.State: RUNNABLE
at com.example.service.StockService.checkStock(StockService.java:45)
- locked <0x00000000f7a3d7ac2> (a java.util.concurrent.locks.ReentrantLock)
真相大白:
- 线程
exec-123 持有锁,正在执行 checkStock 方法。
- 其余199个线程都在等待这把锁的释放。
- 线程池的200个线程全部被阻塞占用,新的请求无法得到处理,导致任务队列堆积超过1000个。
四、根因分析:粗粒度的锁设计成为性能瓶颈
有问题的原始代码:
@Service
public class StockService {
// 整个服务共用一把全局锁
private final Lock lock = new ReentrantLock();
public boolean checkStock(Long productId, Integer quantity) {
lock.lock(); // 所有查询请求都在此排队
try {
Stock stock = stockMapper.selectByProductId(productId);
return stock.getQuantity() >= quantity;
} finally {
lock.unlock();
}
}
public void decreaseStock(Long productId, Integer quantity) {
lock.lock(); // 扣减库存同样竞争这把锁
try {
Stock stock = stockMapper.selectByProductId(productId);
if (stock.getQuantity() >= quantity) {
stock.setQuantity(stock.getQuantity() - quantity);
stockMapper.updateById(stock);
}
} finally {
lock.unlock();
}
}
}
问题根源:
- 锁粒度过粗:所有商品的库存查询和扣减操作都竞争同一把全局锁。
- 锁使用不当:
checkStock() 作为只读查询操作,根本不需要加锁。加锁仅应在涉及数据修改的 decreaseStock() 方法中。
- 并发瓶颈:在高并发场景下(如每秒100个请求),每个请求都必须串行等待,平均等待时间长达3秒,最长甚至达到30秒,直接打满线程池。
五、解决方案:细化锁粒度与优化锁策略
方案1:按商品ID细化锁粒度(推荐)
利用 ConcurrentHashMap 为每个商品ID分配独立的锁。
@Service
public class StockService {
// 使用ConcurrentHashMap存储商品ID到锁的映射
private final ConcurrentHashMap<Long, Lock> locks = new ConcurrentHashMap<>();
public boolean checkStock(Long productId, Integer quantity) {
// 只读操作,无需加锁
Stock stock = stockMapper.selectByProductId(productId);
return stock.getQuantity() >= quantity;
}
public void decreaseStock(Long productId, Integer quantity) {
// 根据商品ID获取对应的锁
Lock lock = locks.computeIfAbsent(productId, k -> new ReentrantLock());
lock.lock();
try {
Stock stock = stockMapper.selectByProductId(productId);
if (stock.getQuantity() >= quantity) {
stock.setQuantity(stock.getQuantity() - quantity);
stockMapper.updateById(stock);
}
} finally {
lock.unlock();
}
}
}
优点:锁的竞争范围从服务级别缩小到商品级别,不同商品的操作可以完全并行,性能提升显著。
方案2:利用数据库乐观锁(高并发场景终极方案)
在数据库层面通过版本号控制并发更新,避免应用层加锁。
@Service
public class StockService {
public boolean decreaseStock(Long productId, Integer quantity) {
Stock stock = stockMapper.selectByProductId(productId);
if (stock.getQuantity() >= quantity) {
// 使用乐观锁更新,通过版本号避免更新冲突
int updated = stockMapper.updateWithVersion(
productId,
stock.getQuantity() - quantity,
stock.getVersion()
);
return updated > 0; // 返回更新是否成功
}
return false;
}
}
对应的Mapper SQL:
<update id="updateWithVersion">
UPDATE stock
SET quantity = #{quantity}, version = version + 1
WHERE product_id = #{productId} AND version = #{version}
</update>
优点:完全去除了应用层锁,性能最佳,依靠数据库的原子操作保证一致性,非常适合秒杀等高并发场景。想要深入了解如何在SpringBoot项目中实现此类并发控制,可以参阅 SpringBoot最佳实践。
六、线程池配置优化建议
线程池配置不合理也会加剧问题。以下是针对SpringBoot内置容器的优化示例。
优化前默认配置可能的问题:
server:
tomcat:
threads:
max: 200
min-spare: 10
accept-count: 100 # 等待队列可能过长
优化后配置:
server:
tomcat:
threads:
max: 200
min-spare: 20 # 适当增加常备线程,加快响应
accept-count: 50 # 控制队列长度,避免堆积过多
connection-timeout: 5000 # 设置连接超时
自定义线程池(更灵活的控制):
@Configuration
public class ThreadPoolConfig {
@Bean
public ThreadPoolExecutor orderExecutor() {
return new ThreadPoolExecutor(
20, // 核心线程数
100, // 最大线程数
60L, // 空闲线程存活时间
TimeUnit.SECONDS, // 时间单位
new ArrayBlockingQueue<>(50), // 有界工作队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用者运行
);
}
}
拒绝策略选择:
CallerRunsPolicy:当队列满时,让提交任务的线程自己执行该任务,可以保证任务不丢失,但可能拖慢调用方。
AbortPolicy:直接抛出 RejectedExecutionException 异常。
DiscardPolicy:默默丢弃无法处理的任务。
DiscardOldestPolicy:丢弃队列中最旧的任务,然后尝试提交新任务。
七、建立监控与告警机制
使用Spring Boot Actuator暴露指标:
management:
endpoints:
web:
exposure:
include: metrics
endpoint:
metrics:
enabled: true
通过 /actuator/metrics 端点可以查看 tomcat.threads.busy(繁忙线程数)等关键指标。
简单的线程池监控组件:
@Component
public class ThreadPoolMetrics {
@Autowired
private ThreadPoolExecutor orderExecutor;
@Scheduled(fixedRate = 60000) // 每分钟上报一次
public void reportMetrics() {
System.out.println("活跃线程数:" + orderExecutor.getActiveCount());
System.out.println("队列大小:" + orderExecutor.getQueue().size());
System.out.println("已完成任务数:" + orderExecutor.getCompletedTaskCount());
}
}
Prometheus告警规则示例(监控线程池使用率):
groups:
- name: thread-pool
rules:
- alert: ThreadPoolExhausted
expr: tomcat_threads_busy_threads / tomcat_threads_config_max_threads > 0.9
for: 5m
labels:
severity: warning
annotations:
summary: "Tomcat线程池使用率超过90%"
总结:
- 排查三板斧:面对接口性能骤降,优先使用
jstack 分析线程状态,聚焦锁竞争,优化锁粒度。
- 预防三板斧:设计时采用细粒度锁(如按Key加锁)、合理配置线程池、在高并发写场景优先考虑乐观锁或数据库事务机制。