找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

468

积分

0

好友

66

主题
发表于 昨天 06:50 | 查看: 6| 回复: 0

“下单接口要5秒才能返回,原来只要50ms,你赶紧看看!”

当我听到这个消息时,第一反应是数据库慢查询,优化一下索引应该就能解决。然而,在尝试了添加索引、引入Redis缓存甚至考虑分库分表后,接口响应时间依然顽固地停留在5秒左右。最终,使用 jstack 工具进行线程分析,真相浮出水面:200个工作线程全部处于 BLOCKED 状态,在等待同一把锁,线程池已被完全打满

一、场景:接口响应时间从50ms异常飙升至5秒

我们的订单服务,其下单接口的响应时间原本稳定在50ms左右,却在某天突然恶化至5秒。

监控告警数据如下

  • 接口平均响应时间:从 50ms 恶化至 5 秒
  • 接口超时率:从 0% 飙升至 30%
  • 线程池活跃线程数:从 20 激增至 200(已达上限)
  • 队列中堆积的任务数:从 0 增加到 1000+

初期的排查思路

  1. 数据库:检查慢查询日志,未发现明显慢SQL。
  2. 缓存:查看Redis缓存命中率,高于95%,状态正常。
  3. 服务器资源:服务器CPU使用率约30%,内存使用率约40%,负载正常。
  4. 网络: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();
        }
    }
}

问题根源

  1. 锁粒度过粗:所有商品的库存查询和扣减操作都竞争同一把全局锁。
  2. 锁使用不当checkStock() 作为只读查询操作,根本不需要加锁。加锁仅应在涉及数据修改的 decreaseStock() 方法中。
  3. 并发瓶颈:在高并发场景下(如每秒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加锁)、合理配置线程池、在高并发写场景优先考虑乐观锁或数据库事务机制。



上一篇:SSH与Telnet协议详解:华为华三设备远程登录配置与安全实践
下一篇:PaperDebugger AI论文写作助手:基于MCP架构的智能批注与Overleaf集成方案
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-10 20:17 , Processed in 0.079835 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表