Administrator
发布于 2020-10-16 / 6251 阅读
131

一次线程池参数设置不当导致的雪崩

十月十六号:一个线程池配置引发的连锁故障

那天下午三点,监控系统开始报"下单接口超时率 35%"。我打开看的时候,发现不只是下单——商品详情、购物车、用户信息,几乎所有接口都在超时。网关的活跃连接数从平时的 200 涨到了 7800。

整个服务集群像是被什么东西卡住了。

现象:线程全在 WAITING,但 CPU 很低

先抓线程栈:

$ jstack 12847 > /tmp/stack.txt
$ grep -c 'http-nio-8080-exec' /tmp/stack.txt
198
$ grep 'http-nio-8080-exec' -A 3 /tmp/stack.txt | grep 'java.lang.Thread.State' | sort | uniq -c
    186    java.lang.Thread.State: WAITING (parking)
     12    java.lang.Thread.State: RUNNABLE

198 个 Tomcat 工作线程(server.tomcat.max-threads 配的 200),186 个在 WAITING。看其中一个在等什么:

"http-nio-8080-exec-87" #231 daemon prio=5 os_prio=0 tid=0x00007f8c4c0d8000 nid=0x5a21 waiting on condition
   java.lang.Thread.State: WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x00000006c2a14c08> (a java.util.concurrent.FutureTask)
	at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:429)
	at java.util.concurrent.FutureTask.get(FutureTask.java:191)
	at com.xxx.service.OrderService.buildDetail(OrderService.java:142)
	at com.xxx.controller.OrderController.detail(OrderController.java:56)

FutureTask.get() 阻塞。对应的代码长这样:

@Service
public class OrderService {

    // 罪魁祸首
    private final ExecutorService executor = Executors.newFixedThreadPool(20);

    public OrderDetailVO buildDetail(String orderNo) {
        Future<OrderPO> orderFuture = executor.submit(() -> orderMapper.selectByNo(orderNo));
        Future<List<OrderItemPO>> itemsFuture = executor.submit(() -> itemMapper.listByOrderNo(orderNo));
        Future<UserVO> userFuture = executor.submit(() -> userClient.getUser(orderNo));
        Future<AddressVO> addrFuture = executor.submit(() -> addressClient.getAddress(orderNo));

        OrderDetailVO vo = new OrderDetailVO();
        try {
            vo.setOrder(orderFuture.get());        // 逐个 get,阻塞等待
            vo.setItems(itemsFuture.get());
            vo.setUser(userFuture.get());
            vo.setAddress(addrFuture.get());
        } catch (Exception e) {
            throw new BizException("查询订单详情失败", e);
        }
        return vo;
    }
}

问题一眼看上去不明显,但有个致命配置:Executors.newFixedThreadPool(20) 用的是无界队列 LinkedBlockingQueue

根因一:无界队列让线程池失去了拒绝能力

看 JDK 8 的源码,newFixedThreadPool 的实现:

// java.util.concurrent.Executors
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

LinkedBlockingQueue 无参构造的容量是 Integer.MAX_VALUE(21 亿)。这带来的后果:

  • maximumPoolSize 参数完全失效。因为队列永远填不满,永远不会触发"创建超出 corePoolSize 的线程"这个逻辑,线程数永远是 20。
  • 拒绝策略永远不触发RejectedExecutionHandler 只有在队列满且线程数达上限时才生效,队列无界意味着它永远不会执行。
  • 任务无限堆积。上游来得比处理得快,队列就一直涨,每个 Runnable 对象占内存,直到 OOM。

故障当时的队列长度(用 Arthas 看的):

$ ognl '#sp=@com.xxx.service.OrderService@executor, #sp.getQueue().size()'
@Integer[48213]

队列里堆了 48213 个任务。20 个线程在跑,每个任务平均 8ms,消化完这 4.8 万个要 19 秒。而每个进来的请求都要 submit 4 个任务并等它们全部完成——排在队尾的请求要等 19 秒才能轮到。

更要命的是请求不会超时退出Future.get() 没有设超时,它会一直等到任务跑完或者被中断。Tomcat 的 200 个线程全部耗在 get() 上,新请求进来直接被拒绝连接。

根因二:上游拖垮下游,雪崩传遍整个链路

但队列为什么会堆到 4.8 万?线程池只有 20 个线程,平时 QPS 800 完全够用。触发点是用户服务变慢

从监控上看,15:02 开始 userClient.getUser() 的耗时从 8ms 涨到 3.2 秒:

时间userClient P99队列长度下单 TP99Tomcat 活跃线程
15:008ms1295ms34
15:023200ms18404100ms198
15:053400ms23400超时198(满)
15:083300ms48213超时198(满)

用户服务变慢的原因是它的 MySQL 有个大查询(运营跑了个全表统计)。而这个慢查询通过调用链一路传导:

  1. 用户服务慢 → userClient.getUser() 从 8ms 变 3.2 秒。
  2. 线程池只有 20 个线程,每个请求要占一个线程 3.2 秒 → 线程池的处理能力从 2500 QPS 掉到 6 QPS
  3. 请求进不来就堆队列,队列无界,一直堆到 4.8 万。
  4. Tomcat 线程全部阻塞在 Future.get(),200 个全占满。
  5. Tomcat 没有可用线程 → 所有接口(包括不依赖用户服务的商品详情)全部超时。
  6. 网关连接数暴涨,重试又放大了流量。

第 5 步是这次事故最典型的教训:订单详情这个接口的线程池配置问题,最终让整个服务的全部接口不可用。因为 Tomcat 的线程池是所有接口共享的,一个接口把线程占光,其他接口陪葬。

解决方案一:线程池参数必须显式指定

先说结论:生产代码里禁止用 Executors 创建线程池。这是《阿里巴巴 Java 开发手册》里的强制条款,我以前觉得是教条,这次是真的被教育了。

改成显式构造 ThreadPoolExecutor

@Configuration
public class ThreadPoolConfig {

    @Bean("orderQueryPool")
    public ThreadPoolExecutor orderQueryPool() {
        // 8 核容器,这个池是 IO 密集型(查库 + 调远程)
        int core = Runtime.getRuntime().availableProcessors() * 2;   // 16
        return new ThreadPoolExecutor(
                core,
                core * 2,                                  // 32
                60L, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(2000),            // 有界队列,2000
                new ThreadFactoryBuilder()
                        .setNameFormat("order-query-%d")
                        .setUncaughtExceptionHandler((t, e) -> log.error("线程异常", e))
                        .build(),
                new ThreadPoolExecutor.CallerRunsPolicy()   // 拒绝策略
        );
    }
}

四个参数怎么定,我现在的判断标准:

  • corePoolSize:IO 密集型(要调数据库、调远程接口)设 CPU核数 × 2;CPU 密集型(纯计算)设 CPU核数 + 1。这个池要查库调接口,8 核设 16。
  • maximumPoolSize:给 core 的 1.5~2 倍作为弹性空间。注意只有在队列满了之后才会扩容到这个值
  • 队列容量:这是关键。有界队列的长度决定了"能容忍多长时间的突发"。我们的算法是 核心线程数 × 单任务耗时 × 可容忍的排队秒数。16 线程 × 8ms = 每秒能处理 2000 个,队列设 2000 意味着最多容忍 1 秒的排队。超过就拒绝。
  • 拒绝策略:四种里面我选 CallerRunsPolicy
策略行为适用
AbortPolicy(默认)抛 RejectedExecutionException要快速失败并告警
CallerRunsPolicy让提交任务的线程自己执行需要反压,推荐
DiscardPolicy静默丢弃几乎不用
DiscardOldestPolicy丢弃队首任务允许丢旧的,比如日志

CallerRunsPolicy 的妙处在于反压:线程池满了之后,让 Tomcat 的工作线程自己去跑这个任务。Tomcat 线程被占用 → 它无法接收新请求 → 上游流量自然降下来。这比"默默堆队列直到 OOM"健康得多。缺点是如果提交方是主线程或者单线程,会阻塞流程,要评估。

解决方案二:Future.get() 必须设超时

原来的 get() 无参调用会无限等待。改成带超时的版本:

public OrderDetailVO buildDetail(String orderNo) {
    Future<OrderPO> orderFuture = pool.submit(() -> orderMapper.selectByNo(orderNo));
    Future<List<OrderItemPO>> itemsFuture = pool.submit(() -> itemMapper.listByOrderNo(orderNo));
    Future<UserVO> userFuture = pool.submit(() -> userClient.getUser(orderNo));
    Future<AddressVO> addrFuture = pool.submit(() -> addressClient.getAddress(orderNo));

    OrderDetailVO vo = new OrderDetailVO();
    try {
        // 每个 get 单独设超时,总耗时可控在 1 秒内
        vo.setOrder(orderFuture.get(500, TimeUnit.MILLISECONDS));
        vo.setItems(itemsFuture.get(500, TimeUnit.MILLISECONDS));
        vo.setUser(getOrDefault(userFuture));      // 非核心信息,超时用降级值
        vo.setAddress(getOrDefault(addrFuture));
    } catch (TimeoutException e) {
        orderFuture.cancel(true);
        itemsFuture.cancel(true);
        throw new BizException("订单详情查询超时", e);
    } catch (Exception e) {
        throw new BizException("订单详情查询失败", e);
    }
    return vo;
}

/** 非核心信息:拿不到就降级,不能拖累主流程 */
private <T> T getOrDefault(Future<T> future) {
    try {
        return future.get(300, TimeUnit.MILLISECONDS);
    } catch (Exception e) {
        future.cancel(true);
        log.warn("非核心信息获取失败,降级处理");
        return null;
    }
}

这里做的一个判断是区分核心和非核心依赖:订单主信息和商品明细是核心,拿不到就得报错;用户昵称、收货地址是非核心,拿不到就展示个"暂无",页面不至于整个挂掉。

解决方案三:隔离,别让一个接口拖垮全部

即使线程池参数对了,Tomcat 线程共享的问题依然存在。我们的做法有两层。

第一层:不同重要度的接口用不同的业务线程池

核心思路是舱壁隔离——每个依赖或者每组接口一个独立的池,互不影响。

@Configuration
public class ThreadPoolConfig {

    /** 订单查询:高频,可降级 */
    @Bean("orderQueryPool")
    public ThreadPoolExecutor orderQueryPool() { ... 队列 2000 ... }

    /** 订单创建:低频,不可降级,独立池 */
    @Bean("orderCreatePool")
    public ThreadPoolExecutor orderCreatePool() { ... 队列 200 ... }

    /** 调外部服务:慢,独立池,不占用内部资源 */
    @Bean("externalCallPool")
    public ThreadPoolExecutor externalCallPool() { ... 队列 500 ... }
}

这样即使"查订单详情"的池被打满,"创建订单"依然有自己独立的 8 个线程可用。代价是线程总数变多(上下文切换开销),但相比整个服务挂掉,这笔账划算。

第二层:接入 Sentinel 做熔断降级

线程池隔离只能限制"自己别被拖死",但没法阻止上游持续打流量过来。Sentinel 1.8 的熔断规则补上这一环:

@PostConstruct
public void initRules() {
    List<DegradeRule> rules = new ArrayList<>();

    DegradeRule rule = new DegradeRule("userClient.getUser")
            .setGrade(RuleConstant.DEGRADE_GRADE_RT)      // 按响应时间熔断
            .setCount(200)                                 // 阈值 200ms
            .setTimeWindow(10)                             // 熔断 10 秒
            .setRtSlowRequestAmount(5)                     // 连续 5 个慢请求
            .setMinRequestAmount(20);                      // 最少 20 个请求才统计
    rules.add(rule);

    DegradeRuleManager.loadRules(rules);
}

配好之后的效果:用户服务 P99 涨到 3.2 秒 → 5 个慢请求触发熔断 → 接下来 10 秒内所有对 userClient.getUser 的调用直接返回降级值,根本不发请求 → 线程池不被拖住 → 服务存活。

同时给核心接口加了限流:

FlowRule flowRule = new FlowRule("GET:/api/order/detail")
        .setCount(2000)                                    // QPS 上限
        .setGrade(RuleConstant.FLOW_GRADE_QPS)
        .setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP)
        .setWarmUpPeriodSec(10);                           // 预热 10 秒
FlowRuleManager.loadRules(Collections.singletonList(flowRule));

改造后的压测验证

用故障注入复现当时的场景:把用户服务的响应人为延迟到 3 秒,跑 10 分钟。

指标改造前改造后
订单详情 TP99超时(>30s)3200ms → 熔断后 45ms
商品详情 TP99(无关接口)超时52ms
创建订单成功率0%99.8%
队列最大长度482132000(触发拒绝)
Tomcat 线程占用198/20068/200
堆内存5.8GB(接近 OOM)2.1GB

最关键的一行是"商品详情 TP99"——无关接口不再受牵连。这就是隔离的意义。

顺带把其他几个 Executors 也清了

事故之后我全项目搜了一遍:

$ grep -rn "Executors\." --include=*.java src/main/ | grep -v "newScheduledThreadPool"
OrderService.java:47:    private final ExecutorService executor = Executors.newFixedThreadPool(20);
ExportService.java:33:   private final ExecutorService executor = Executors.newCachedThreadPool();
NotifyService.java:28:   private final ExecutorService executor = Executors.newSingleThreadExecutor();

三个都有问题:

  • newFixedThreadPool:无界队列,就是这次的主角。
  • newCachedThreadPoolmaximumPoolSize 是 Integer.MAX_VALUE,高并发下会疯狂创建线程。我们测试环境就因为这个创建过 3000 多个线程,直接 OOM(每个线程默认 1MB 栈)。
  • newSingleThreadExecutor:单线程 + 无界队列,一旦某个任务卡住,后面全堵死。

全部替换成显式构造,并且接入 Micrometer 做监控:

@Bean
public MeterBinder threadPoolMetrics(@Qualifier("orderQueryPool") ThreadPoolExecutor pool) {
    return registry -> {
        Gauge.builder("thread.pool.active", pool, ThreadPoolExecutor::getActiveCount)
             .tag("name", "orderQuery").register(registry);
        Gauge.builder("thread.pool.queue", pool, e -> e.getQueue().size())
             .tag("name", "orderQuery").register(registry);
        Gauge.builder("thread.pool.reject", pool, ThreadPoolExecutor::getRejectedExecutionHandler == null ? 0 : ...)
             .tag("name", "orderQuery").register(registry);
    };
}

队列长度超过 50% 就告警。这样下次再有堆积,能在队列打满之前发现,而不是等接口全挂。

写在后面

现在回头看,《一次线程池参数设置不当导致的雪崩》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考