Administrator
发布于 2018-05-08 / 2418 阅读
18

线程池参数怎么设?一次线程池打满导致的服务不可用

上午十点,接口全部超时

5 月 8 号上午十点整,监控告警连着响了:订单服务的接口 P99 从 80ms 涨到 30 秒(超时阈值),成功率掉到 12%。登录机器看,进程还在,CPU 只有 6%,内存正常,但所有请求都在超时。

第一感觉是数据库挂了。查了 MySQL 监控,QPS 只有平时的三分之一,慢查询为零——不是数据库的问题,是请求压根没到数据库。

jstack 抓了一把,看到了这个:

"order-pool-3" #71 prio=5 os_prio=0 tid=0x00007f9c nid=0x7a31 waiting on condition
   java.lang.Thread.State: WAITING (parking)
    at sun.misc.Unsafe.park(Native Method)
    at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
    at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
    at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)

一堆线程 park 在队列上等任务,说明线程池是空的。那任务去哪了?往前翻 jstack,找到了真正的卡点——有 200 多个线程堵在同一个地方:

"http-nio-8080-exec-187" #238 daemon prio=5 tid=0x00007f9c nid=0x7b12 waiting on condition
   java.lang.Thread.State: WAITING (parking)
    at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:429)
    at java.util.concurrent.FutureTask.get(FutureTask.java:191)
    at com.xxx.order.service.OrderQueryService.batchQuery(OrderQueryService.java:88)

全都卡在 FutureTask.get() 上,等一个永远不会被执行的任务。

问题代码

@Service
public class OrderQueryService {

    // 用了 Executors 的便捷工厂方法
    private static final ExecutorService POOL = Executors.newFixedThreadPool(4);

    public List<OrderDetail> batchQuery(List<Long> orderIds) {
        List<Future<OrderDetail>> futures = new ArrayList<>();
        for (Long id : orderIds) {
            futures.add(POOL.submit(() -> orderMapper.selectDetail(id)));
        }
        List<OrderDetail> result = new ArrayList<>();
        for (Future<OrderDetail> f : futures) {
            result.add(f.get());        // 第 88 行,卡死在这里
        }
        return result;
    }
}

看起来人畜无害。4 个线程的池子,一个请求提交 N 个任务并发查,然后等结果。平时 orderIds 只有十几条,跑得很正常。

那天上午运营搞了个活动,有个商家一次性查了 3000 个订单。3000 个任务塞进池子,池子只有 4 个线程,一个任务 60ms,全部执行完需要 3000 × 60 / 4 = 45 秒。而 Tomcat 的 200 个工作线程全部堵在 f.get() 上等这 3000 个任务——Tomcat 线程池瞬间被打满,新请求进不来,整个服务不可用。

更要命的是这个请求自己也在无限等待,永远等不到那 4 个线程来服务它。典型的线程池饥饿导致的死锁

Executors 的三个坑

出事之后我把 Executors 的三个工厂方法翻出来看了一遍源码,发现它们都有隐患。

newFixedThreadPool:队列无界

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

注意 new LinkedBlockingQueue<Runnable>() 没传容量,默认容量是 Integer.MAX_VALUE(约 21 亿)。任务堆积时队列会一直涨,直到 OOM。而且因为队列永远不满,maximumPoolSize 这个参数形同虚设,永远不会创建超出 corePoolSize 的线程。

newCachedThreadPool:最大线程数无界

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

maximumPoolSize 是 Integer.MAX_VALUE,来多少任务建多少线程。我见过一个用 CachedThreadPool 处理 HTTP 回调的服务,下游抖动时一瞬间创建了 6000 多个线程,直接把机器的内存和上下文切换开销拖垮。

newSingleThreadExecutor:同上,队列无界

《阿里巴巴 Java 开发手册》里直接把这三个列为不推荐,要求用 ThreadPoolExecutor 的构造方法手动创建。我以前觉得这规定教条,现在服了。

把 ThreadPoolExecutor 的参数掰开看

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

任务进来的处理流程

这个流程我之前一直理解错,以为"先开到 corePoolSize,满了就开到 maximumPoolSize,再满了进队列"。实际顺序不是这样

  1. 当前线程数 < corePoolSize → 直接新建线程执行(哪怕有空闲线程也新建);
  2. 当前线程数 ≥ corePoolSize → 先尝试入队
  3. 队列满了 → 才新建线程,直到达到 maximumPoolSize;
  4. 线程数已达 maximumPoolSize 且队列满 → 执行拒绝策略。

关键在第 2 步:队列先于最大线程数。这意味着如果你用了无界队列,maximumPoolSize 就永远不会生效;反过来,如果你用 SynchronousQueue(容量为 0 的队列),队列永远"满",那么线程会一直建到 maximumPoolSize——这就是 CachedThreadPool 的行为。

拒绝策略

JDK 8 内置了四种:

策略行为适用场景
AbortPolicy(默认)抛 RejectedExecutionException需要快速失败,通知调用方
CallerRunsPolicy让提交任务的线程自己执行异步转同步,天然限流
DiscardPolicy静默丢弃,无任何提示基本不用,丢数据了都不知道
DiscardOldestPolicy丢弃队列头那个,重试提交允许丢旧任务的场景

我最后给订单查询池配的是 CallerRunsPolicy。它的好处很实在:任务太多时,提交任务的 Tomcat 线程会自己去执行任务,这样一来它就没法继续提交新任务了,形成一个天然的反馈式限流,不会把线程池打死,也不会丢数据。

参数到底怎么设

这个问题我查了不少资料,也问了组里做过压测的同事,结论是先区分任务类型。

CPU 密集型

任务主要在算,比如大量数据的排序、加密解密、复杂报表计算。线程数太多只会带来上下文切换开销。

线程数 ≈ CPU 核数 + 1

那个 +1 是为了应对偶尔的页缺失或暂停,多了反而有害。我们 8 核的机器,一个做对账计算(纯内存运算,无 IO)的池子配了 9 个线程,压测下来比配 16 个快 12% 左右。

int cpuCount = Runtime.getRuntime().availableProcessors();

IO 密集型

任务是查数据库、调 HTTP 接口、读写文件,线程大部分时间在等 IO。这时可以让线程数远大于核数。

线程数 ≈ CPU 核数 × (1 + 等待时间 / 计算时间)

这个公式里的比例可以估算。比如我们查一次订单明细:CPU 计算约 2ms,等 MySQL 返回约 58ms,那线程数 ≈ 8 × (1 + 58/2) = 240。当然这是理论值,实际上还要受数据库连接池大小的限制——池子有 300 个线程,但连接池只有 50 个连接,那多出来的 250 个线程全堵在等连接上,纯属浪费。

所以实际做法是:先定数据库连接池大小,再倒推线程数,让线程数不超过连接池大小(如果这个池子里的任务都要查库的话)。

我改完之后的配置

@Configuration
public class ThreadPoolConfig {

    @Bean("orderQueryPool")
    public ThreadPoolExecutor orderQueryPool() {
        int core = 20;
        int max = 60;
        return new ThreadPoolExecutor(
            core,
            max,
            60L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(2000),          // 有界队列
            new ThreadFactoryBuilder()
                .setNameFormat("order-query-%d")
                .setUncaughtExceptionHandler((t, e) -> log.error("线程异常", e))
                .build(),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }
}

几个说明:

  • 用 ArrayBlockingQueue(2000) 而不是 LinkedBlockingQueue 无界。 队列满了就触发拒绝策略,而不是无限堆积到 OOM。2000 这个数是按"最多容忍堆积 3 秒的量"估的。
  • 给线程起名字。 ThreadFactoryBuilder 来自 Guava。这一点太重要了,之前 jstack 出来全是 "pool-3-thread-1" 这种名字,根本不知道是哪个业务。现在看到 "order-query-17" 就知道问题出在哪。
  • corePoolSize 和 maximumPoolSize 拉开差距。 20 到 60,平时维持 20 个线程,突发流量时扩到 60 扛一扛。

另外两个我加上的改进:

限制批量查询的大小。 接口层直接挡掉不合理的请求:

if (orderIds.size() > 200) {
    throw new BizException("单次查询订单数不能超过 200");
}

给 Future.get() 加超时。 这是防御性编程,避免任何一个下游抖动把线程池拖死:

for (Future<OrderDetail> f : futures) {
    try {
        result.add(f.get(3, TimeUnit.SECONDS));
    } catch (TimeoutException e) {
        f.cancel(true);
        log.warn("查询订单超时,已取消任务");
    }
}

加监控

光配好参数不够,还得能看见。我们加了个定时任务,每 10 秒打一次线程池状态到日志,再由 ELK 收集:

@Scheduled(fixedDelay = 10000)
public void logPoolStatus() {
    log.info("pool=orderQuery, active={}, poolSize={}, core={}, max={}, " +
             "queue={}/{}, completed={}, rejected={}",
        pool.getActiveCount(),
        pool.getPoolSize(),
        pool.getCorePoolSize(),
        pool.getMaximumPoolSize(),
        pool.getQueue().size(),
        pool.getQueue().size() + pool.getQueue().remainingCapacity(),
        pool.getCompletedTaskCount(),
        rejectCounter.get());
}

rejected 那个计数是自定义 RejectedExecutionHandler 里累加的,JDK 没提供这个统计。配了告警规则:queue 使用率 > 80% 持续 1 分钟 就发钉钉。

改完上线之后又跑了一次 3000 订单的查询,会被参数校验直接挡掉;200 个订单的查询耗时从优化前的 12 秒(实际上那次是超时失败)降到 340ms,服务整体 P99 回到 85ms。

下篇预告

这篇先把《线程池参数怎么设?一次线程池打满导致的服务不可用》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考