聊一次线程池使用不当导致的生产故障–图文解析
原文作者:货拉拉技术团队
原文链接:https://juejin.cn/post/7382121812434747418
1 抢救
交代了背景:交付的软件运行中出现了故障,报警机制被触发,通过飞书与报警电话通知了作者,一个标识为zone-2的异地机房中的“bfe-customer-application-query-svc”应用程序(其实就是部署后的一个应用名称的全称)出现了RT(响应时间Response Time)飙升,随后整个zone-2机房处于不可用状态。
响应时间:计算机对用户的输入或请求作出反应的时间。通常速度越快,用户体验越好。
2 诊断病因
原文与解析
原文:bfe-customer-application-query-svc 是近期上线的新应用,故障发生时每个 zone 仅有 2 个 Pod 节点;
解析:
每个机房只有两个pod节点,pod是什么?
通过上下文阅读可以了解,该系统运维采用了k8s(kubernetes)技术,k8s太过于庞大在这里不再做展开讲解,我们进需要知道以下概念即可:
图片来自:https://www.jianshu.com/p/04ef80b76b6a
1.容器:简单理解为就是一个虚拟机,我们开发好一个项目,部署到服务器中,服务器需要对该项目配置环境,如果对一个服务器部署多个项目,尤其是不同语言的项目以及使用多种不同版本中间件对其进行环境配置的时候,很容易造成环境污染,导致项目的环境配置失败,所以通过docker等技术,把开发好的项目以及项目依赖的相关的库,中间件等元素统一部署到一个容器中,容器和容器之间隔离,互不干扰,这样就可以在一个服务器中部署多个项目并且不会污染服务器环境了。
2.node: k8s中的node中文称作节点,一个node可以简单理解为就是一台物理机,一台服务器。k8s至少需要一个主节点以及多个工作节点。
3.pod:在一个Node中可以部署多个pod,在java领域中,每个pod通常包含一个微服务容器,但有时一个Pod中会包含多个容器,这些容器通常是紧密耦合的,必须一起工作。例如,一个微服务和它的辅助服务(如日志收集器)可以部署在同一个Pod中。这种方式主要用于需要共享资源或紧密协作的场景。
k8s的主要工作之一就是增加弹性,当用户请求量以及计算量特别大的时候,可以考虑通过replicas参数的设置来增加副本的数量,概念类似于网络游戏的多开。
原文中的"两个Pod"的意思就是,同一个应用有两个副本,k8s会有对应的组件负责副本间的负载均衡等操作,如果其中一个pod挂了,会自动进行重启,如果node挂了,其中的pod会被部署到其他node中去。
原文:故障发生的前一天晚上,刚刚进行了确认订单页面二次估价接口(以下称作 confirmEvaluate)的切流(50% → 100%),故障发生时,该接口的 QPS 是前一天同时段的约 2 倍,如图1;
切流:就是更换调用的接口,简单说就是,原来调用的接口A我们觉得太慢了,于是新开发了接口B,我们把原来访问A接口的流量切换到B接口,这个过程叫做切流,为了防止B接口本身刚开发出来不稳定,所以不会一下子把所有的流量都切到B接口,而是先切50%,发现没有任何问题后再一点点的增加比例,直到100%.
原文:初步判断,故障可能是 confirmEvaluate 依赖的一个下游接口(以下称作 getTagInfo)超时抖动引起的(RT 上涨到了 500ms,正常情况下 P95 在 10ms 左右,持续约 1s);综合这些信息来看,Pod 节点数过少 + 切流导致流量增加 + 依赖耗时突发抖动,且最终通过扩容得以恢复,这叠满的 buff, 将故障原因指向了 “容量不足” 。
但这只能算是一种定性的判断,就好比说一个人突然倒地不醒是因为心脏的毛病,但心脏(心血管)的疾病很多,具体是哪一种呢?在计算机科学的语境中提到 “容量” 这个词,可能泛指各种资源,例如算力(CPU)、存储(Mem) 等硬件资源,也可能是工作线程、网络连接等软件资源,那么,这次故障究竟是哪个或哪些资源不足了呢?还需要更细致的分析,才能解答。
2.1 初步定位异常指征:tomcat 线程池处理能力饱和,任务排队
经过一系列的排除,最终发可能是线程出现了问题,项目中使用tomcat作为线程池,在监控中发现线程池已经达到最大值了。
为了更好的理解曲线变化的含义,我们需要认真分析一下 tomcat 线程池的扩容逻辑。
原文用了一定篇幅介绍了tomcat的线程池,这里做一下展开讲解
池化技术:常见的诸如对象池,连接池,线程池
对象池:用户每次请求会调用对应的方法,方法中会创建对象,频繁的创建,销毁/回收对象很耗费时间,所以就创建一个对象池,把对象以单例的形式存放在对象池中,每次需要该对象的时候直接拿来就用即可。比如java中的spring,当然,我们一般叫他bean容器。
连接池:连接数据库等中间件时,每次创建,销毁连接很耗费时间,所以提前创建一些连接对象存放在集合中,称作连接池。
线程池:原理同上。
原文中提到,tomcat线程池继承自ThreadPoolExecutor,这个ThreadPoolExecutor是Java自己的JUC包下的线程池:
详细解释以及具体使用Demo引用自该链接内容:
https://blog.csdn.net/qishiheyongshi/article/details/132155705
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {}
线程池创建的七大参数:
1、corePoolSize:线程池核心线程数量,核心线程不会被回收,即使没有任务执行,也会保持空闲状态。如果线程池中的线程少于此数目,则在执行任务时创建。
2、maximumPoolSize:当线程数量达到corePoolSize,且workQueue队列塞满任务了之后,继续创建线程,但不能超过 maximumPoolSize。
3、keepAliveTime:超过corePoolSize之后的“临时线程”的存活时间。
4、unit:keepAliveTime的单位。
5、workQueue:当线程数超过corePoolSize时,新的任务会处在等待状态,并存在workQueue中
6、threadFactory:创建线程的工厂类,通常我们会自定义一个threadFactory设置线程的名称,这样我们就可以知道线程是由哪个工厂类创建的,可以快速定位。
7、handler:线程池执行拒绝策略,当线程数量达到maximumPoolSize大小,并且workQueue也已经塞满了任务的情况下,线程池会调用handler拒绝策略来处理请求。
原文:
实际上, tomcat线程池(org.apache.tomcat.util.threads.ThreadPoolExecutor
)继承了java.util.concurrent.ThreadPoolExecutor
,而且并没有重写线程池扩容的核心代码,而是复用了java.util.concurrent.ThreadPoolExecutor#execute
方法中的实现,如图4。 其中,第4行~第23行的代码注释,已经将这段扩容逻辑解释的非常清晰。即每次执行新任务(Runnable command)时:
- Line 25 - 26:(当有任务到来时)首先判断当前池中工作线程数是否小于 corePoolSize(核心线程数量),如果小于 corePoolSize (核心线程数量)则直接新增工作线程执行该任务;
- Line 30:否则,尝试将当前任务放入 workQueue(工作队列)中等待
- Line 37:如果第 30 行未能成功将任务放入 workQueue(工作队列),即
workerQueue.offer(command)
返回 false,则继续尝试新增工作线程执行该任务(第 37 行);
上面这一段原文相对比较好理解,直接读完即懂。
图 4:tomcat 线程池扩容实现:
原文:
比较 Tricky(困难) 的地方在于 tomcat 定制了第 30 行 workQueue 的实现,代码位于org.apache.tomcat.util.threads.TaskQueue
类中。TaskQueue
继承自 LinkedBlockingQueue
,并重写了offer
方法,如图5。可以看到:
- Line 4:当线程池中的工作线程数已经达到最大线程数时,则直接将任务放入队列;
- Line 6:否则,如果线程池中的工作线程数还未达到最大线程数,当提交的任务数(
parent.getSubmittedCount()
)小于池中工作线程数,即存在空闲的工作线程时,将任务放入队列。这种情况下,放入队列的任务,理论上将立刻被空闲的工作线程取出并执行; - Line 8:否则,只要当前池中工作线程数没有达到最大值,直接返回
false
。此时图 4第30行workQueue.offer(command)
就将返回false
,这会导致execute
方法执行第37行的addWorker(command, false)
,对线程池进行扩容;
图 5:tomcat TaskQueue offer() 方法实现
通过分析这两段代码,得到如图6的 tomcat 线程池扩容流程图。
图 6:tomcat 线程池扩容逻辑流程图
上面的原文读起来有点困难,我稍作翻译,注意,tomcat的ThreadPoolExecutor扩容策略和JUC的ThreadPoolExecutor略有不同,JUC的是如果核心线程池已经满了,队列也满了,但总线程数还没有到达线程最大值,就会创建新的线程,直到达到线程最大值后触发拒绝策略。这个过程就是JUC的线程池的扩容过程。
而Tomcat线程池扩容过程是:核心线程池满了,总线程数没满,则直接创建新的线程,直到总线程数满了,再把新提交进来的任务放入队列,队列如果再满了,经过二次判断(就是队列装不下了,在拒绝策略异常处理中还会再执行一次尝试存入队列)依旧放不进队列,才会执行拒绝策略。
2.2 线程池处理能力饱和的后果:任务排队导致探活失败,引发 Pod 重启
标题中的探活失败,引发 Pod 重启是什么意思?下面是k8s的配置以及对应的Pod重启条件
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
timeoutSeconds: 5
表示探针请求的超时时间为 5 秒。如果在 5 秒内没有收到响应,探针将认为检查失败。
是的,如果存活探针连续 failureThreshold
次(在这个例子中是 3 次)检测失败,k8s将认为 Pod 不健康,并自动重启该 Pod。
而/actuator/health这样的探针地址是由springboot的actuator模块提供的,用于监控和管理 Spring Boot 应用。它包含了一系列的生产就绪特性,可以帮助你监控应用的健康状态、收集应用的度量数据、审计事件、查看应用的配置信息等。我们需要再对应的服务的依赖中引入下面的依赖才可以成功使用探针:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
原文:
当前,该应用基于默认配置,使用 SpringBoot 的健康检查端口(Endpoint),即actuator/health
,作为容器的存活探针。而问题就有可能出在这个存活探针上,k8s 会请求应用的actuator/health
端口,而这个请求也需要提交给 tomcat 线程池执行。
设想如图7中的场景。Thread-1 ~ Thread-200 因处理业务请求而饱和,工作队列已经积压了一些待处理的业务请求(待处理任务 R1 ~ R4),此时 k8s 发出了探活请求(R5),但只能在队列中等待。对于任务 R5 来说,最好的情况是刚刚放入工作队列就立刻有大于等于5个工作线程从之前的任务中释放出来,R1~R5 被并行取出执行,探活请求被立即处理。但线程池因业务请求饱和时,并不存在这种理想情况。通过前文已经知道,confirmEvaluate 的耗时飙升到了 5s,排在 R5 前面的业务请求处理都很慢。因此比较坏的情况是,R5 需要等待 R1、R2、R3、R4 依次执行完成才能获得工作线程,将在任务队列中积压 20s(4×54\times54×5), 甚至更久。
图 7:在任务队列中等待的探活请求
而 bfe-customer-application-query-svc 的探活超时时间设置的是 1s,一旦发生积压的情况,则大概率会超时。此时 k8s 会接收到超时异常。果然,如图8、图9,通过查阅 Pod 事件日志,我发现 Pod-1(..186.8)在 08:53:59 记录了探活失败,随后触发了重启,Pod-2(..188.173)则是在 08:53:33 记录了探活失败,随后也触发了重启。而这两个时间正是在上文提到的 “线程池达到饱和" 的两个时间点附近(Pod-1 08:54:00 和 Pod-2 00:53:30)。
由于 zone-2 仅有 2 个 Pod,当 Pod-1 和 Pod-2 陆续重启后,整个 zone-2 便没有能够处理请求的节点了,自然就表现出完全不可用的状态。
图 8:探活失败(..186.8)
图 9:探活失败(..188.173)
但为什么只有 zone-2 会整个不可用呢?于是,我又专门对比了 zone-1 的两个 Pod,如 图10到图13。从图10、图11可以看到,zone-1 的两个 Pod 在下游依赖抖动时也发生了类似 zone-2 的 tomcat 线程池扩容,不同之处在于,zone-1 两个 Pod 的线程池都没有达到饱和。从图12、图13也可以看到,zone-1 的两个 Pod 在 八点五十分前后这段时间内,没有任何探活失败导致重启的记录。
图 10:tomcat 线程池使用情况(..92.140)
图 11:tomcat 线程池使用情况(..167.148)
图 12:探活成功(..92.140)
图 13:探活成功(..167.148)
显然, zone-1 并没有置身事外,同样受到了耗时抖动的影响,同样进行了线程池扩容。但可用线程仍有余量,因此并没有遇到 zone-2 探活失败,进而触发 Pod 重启的问题。
2.3 深度检查:寻找线程池处理能力恶化的根因
原文中作者感觉是SOA 调用下游 getTagInfo 耗时增加,导致 tomcat 线程陆续陷入等待,最终耗尽了所有可用线程。其中,调用上下游的相关服务指的是当前服务调用其他服务器或其他容器中的服务,不是自己容器内部的服务,为了提高调用效率,比如需要计算的结果需要通过来自A,B,C三个服务的数据,而依次调用这三个服务的数据太慢了,所以会采用多线程的方式并行处理。
2.3.1 与推论相矛盾的关键证据:WAITING
状态线程数飙升
这一小节作者主要工作时查看问题线程的状态究竟是WAITING还是TIMED_WAITING。
原文中作者发现监控中给出的阻塞线程状态为WAITING,而作者认为应该是TIMED_WAITING。但是作者查看了IOReactorWorker 的源码后发现,对线程状态修改的关键代码都给与了超时参数的设置,这与监控中给出的WAITING状态不符,所以排除了IOReactorWorker 。
在 Java 中,线程可以处于以下六种状态之一:
- 新建(NEW):
- 线程对象被创建,但尚未调用
start()
方法。 - 例如:
Thread t = new Thread();
- 线程对象被创建,但尚未调用
- 可运行(RUNNABLE):
- 线程已经调用了
start()
方法,等待被线程调度器选中执行。 - Java 将操作系统中的就绪(ready)和运行(running)状态统一称为“可运行”状态。
- 线程已经调用了
- 阻塞(BLOCKED):
- 线程等待获取一个监视器锁(monitor lock)。
- 例如:线程试图进入一个同步块或方法,但该锁被其他线程持有。
- 等待(WAITING):
- 线程等待其他线程执行特定操作(如通知或中断)。
- 进入方式:调用
Object.wait()
、Thread.join()
或LockSupport.park()
。 - 例如:
synchronized (obj) { obj.wait(); }
- 超时等待(TIMED_WAITING):
- 线程在指定时间内等待,可以在超时后自动返回。
- 进入方式:调用
Thread.sleep(long millis)
、Object.wait(long timeout)
、Thread.join(long millis)
、LockSupport.parkNanos(long nanos)
或LockSupport.parkUntil(long deadline)
。 - 例如:
Thread.sleep(1000);
- 终止(TERMINATED):
- 线程的
run()
或main()
方法执行结束,或者因异常退出。 - 例如:线程正常执行完毕或抛出未捕获的异常。
- 线程的
至于WAITING和TIMED_WAITING的区别,通过下图就可以了解,其实TIMED_WAITING中的方法里多了一个参数,可以理解为过期时间,简单说就是,WAITING
状态的线程本身不会主动唤醒,处于 WAITING
状态的线程不会参与锁竞争。它们在等待被唤醒时,不会尝试获取任何锁。因此,WAITING
状态的线程不会影响其他线程对锁的竞争。必须依赖其他线程调用 notify()
或 notifyAll()
方法来唤醒它。只要没有其他线程调用这些方法,处于 WAITING
状态的线程就会一直休眠下去。而TIMED_WAITING
状态的线程一旦过期时间到达,则自动被唤醒进入blocked
状态,可以参与锁竞争。
2.3.3 弄巧成拙的业务代码
作者发现既然调用的三方框架不存在导致WATING状态线程的操作,那么监控中大量的WATING状态的线程从何而来呢,最终,作者在项目中的业务代码(自己人写的)发现了问题。
问题1:调用上下游接口没有超时处理
CompletableFuture
CompletableFuture
是 Java JUC中提供的一种工具,用来简化和增强异步编程。它的主要作用是让你可以更方便地处理并行任务和异步操作。
- 异步任务:
- 你可以让某个任务在后台运行,不会阻塞主线程。比如,你可以在后台下载文件,同时继续处理其他任务。
- 组合任务:
- 你可以把多个异步任务组合起来,形成一个任务链。比如,先下载文件,然后解压,再处理文件内容。
- 异常处理:
- 如果异步任务中出现了异常,你可以很方便地捕获并处理这些异常,不会让程序崩溃。
- 手动完成:
- 你可以手动设置任务的结果,比如在某些条件下提前返回结果。
- 并行计算:同时执行多个计算任务,提高效率。
- 异步 I/O 操作:处理耗时的输入输出操作,比如网络请求,不会阻塞主线程。
- 复杂工作流:通过组合多个异步任务,完成复杂的操作流程。
DEMO:
public class CompletableFutureExample {
public static void main(String[] args) {
// 模拟调用第一个服务
CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
// 模拟服务调用延迟
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return 10; // 返回结果
});
// 模拟调用第二个服务
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
// 模拟服务调用延迟
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return 20; // 返回结果
});
try {
// 分别获取两个服务的结果
Integer result1 = future1.get();
Integer result2 = future2.get();
// 将结果相加
Integer finalResult = result1 + result2;
// 打印结果
System.out.println("结果: " + finalResult);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
问题就出在future1.get()和future2.get()这里,开大get()方法,我们会发现:
内部使用了waitingGet方法,也就是说,当前线程在等待任务执行完成之前会一直处于Waiting状态,直到任务执行完成为止,如果任务执行过程中遇到了高耗时这样的网络抖动的情况,也只能干等着,没有任何操作,直到线程池耗尽。
原文中作者并未给出解决方案,我个人理解应该是这样的, 简单说当遇到调用上下游接口遇到长耗时这样的网络抖动的情况时,不应该等到最终tomcat线程池耗尽,pod重启才报警给技术人员,而是应该在长耗时发生时立即写入日志,频繁发生时应直接报警,交给技术人员处理:
public class CompletableFutureExample {
public static void main(String[] args) {
// 模拟调用第一个服务
CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
// 模拟服务调用延迟
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return 10; // 返回结果
});
// 模拟调用第二个服务
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
// 模拟服务调用延迟
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return 20; // 返回结果
});
try {
// 分别获取两个服务的结果,设置超时时间为2秒
Integer result1 = future1.get(2, TimeUnit.SECONDS);
Integer result2 = future2.get(2, TimeUnit.SECONDS);
// 将结果相加
Integer finalResult = result1 + result2;
// 打印结果
System.out.println("结果: " + finalResult);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} catch (TimeoutException e) {
// 处理超时异常
System.err.println("获取结果超时,执行相应的处理逻辑");
// 例如:记录日志、重试、返回默认值等
Integer defaultResult = 0; // 默认值
System.out.println("使用默认值: " + defaultResult);
}
}
}
其中 future2.get(2, TimeUnit.SECONDS);的带参的get方法会调用timedGet方法,线程状态是TIMED_WAITING的,如果超时,会触发TimeoutException,我们妥善捕获并处理该异常即可。
问题2:自定义线程池没有考虑业务需求
原文介绍的项目中,他们自定义了线程池,名字叫做BizExecutorsUtils
- Line 16:可以看到线程池的最大线程数只有 20;
- Line 19:工作队列却很大,可以允许 1K+ 个任务排队;
原文也提到了,EXECUTOR
是静态初始化的,在同一个 JVM 进程中全局唯一。这里的线程池定义,很难不让我想到只有 3 个诊室,却排了 500 号病人的呼吸内科。这种现象被称作瓶口效应(也就是瓶颈)
问题3:pod数量太少
CI (持续集成Continuous Integration, CI)团队为了降低成本,多次找到应用的 Owner 并以 CPU 水位为唯一标准沟通缩容计划,最终两边达成一致,将每个 zone 的 Pod 数量缩容到了 2 个。但实际这个应用不是CPU密集,而是I/O密集的。
定义了线程池,名字叫做BizExecutorsUtils
- Line 16:可以看到线程池的最大线程数只有 20;
- Line 19:工作队列却很大,可以允许 1K+ 个任务排队;
[外链图片转存中…(img-ZMecMTWh-1721987114724)]
原文也提到了,EXECUTOR
是静态初始化的,在同一个 JVM 进程中全局唯一。这里的线程池定义,很难不让我想到只有 3 个诊室,却排了 500 号病人的呼吸内科。这种现象被称作瓶口效应(也就是瓶颈)
问题3:pod数量太少
CI (持续集成Continuous Integration, CI)团队为了降低成本,多次找到应用的 Owner 并以 CPU 水位为唯一标准沟通缩容计划,最终两边达成一致,将每个 zone 的 Pod 数量缩容到了 2 个。但实际这个应用不是CPU密集,而是I/O密集的。