聊一次线程池使用不当导致的生产故障

1 抢救

2023 年 10月 27 日,是一个风和日丽的周五,我正在开车上班的路上。难得不怎么堵车,原本心情还是很不错的。可时间来到 08:50 左右,飞书突然猛烈的弹出消息、告警电话响起,轻松的氛围瞬间被打破。我们的一个核心应用 bfe-customer-application-query-svc 的 RT 飙升,没过一会儿,整个 zone-2 [1][1]陷入不可用的状态。随后便是紧张的应急处置,限流、回退配置、扩容...宛如抢救突然倒地不醒的病患,CPR、AED 除颤、肾上腺素静推... 最终经过十几分钟的努力,应用重新上线,zone-2 完全恢复。

2 诊断病因

“病人” 是暂时救回来了,但病因还未找到,随时都有可能再次陷入危急。到公司后,我便马不停蹄的开始定位本次故障的根因。

好消息是,在前面应急的过程中,已经掌握了不少有用的信息:

整理了一份面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题

需要全套面试笔记的【点击此处即可】即可免费获取

  1. bfe-customer-application-query-svc 是近期上线的新应用,故障发生时每个 zone 仅有 2 个 Pod 节点;
  2. 故障发生的前一天晚上,刚刚进行了确认订单页面二次估价接口(以下称作 confirmEvaluate)的切流(50% → 100%),故障发生时,该接口的 QPS 是前一天同时段的约 2 倍,如图1
  3. 初步判断,故障可能是 confirmEvaluate 依赖的一个下游接口(以下称作 getTagInfo)超时抖动引起的(RT 上涨到了 500ms,正常情况下 P95 在 10ms 左右,持续约 1s);

图 1:confirmEvaluate 故障当日 QPS(zone-1 和 zone-2 流量之和)

综合这些信息来看,Pod 节点数过少 + 切流导致流量增加 + 依赖耗时突发抖动,且最终通过扩容得以恢复,这叠满的 buff, 将故障原因指向了  “容量不足” 。

但这只能算是一种定性的判断,就好比说一个人突然倒地不醒是因为心脏的毛病,但心脏(心血管)的疾病很多,具体是哪一种呢?在计算机科学的语境中提到 “容量” 这个词,可能泛指各种资源,例如算力(CPU)、存储(Mem) 等硬件资源,也可能是工作线程、网络连接等软件资源,那么,这次故障究竟是哪个或哪些资源不足了呢?还需要更细致的分析,才能解答。

2.1 初步定位异常指征:tomcat 线程池处理能力饱和,任务排队

通过 Pod 监控,轻易就能排除算力、存储等硬件资源耗尽的可能性[2][2]。该应用不依赖数据库,并未使用连接池来处理同步 I/O,也不太可能是连接池耗尽[3][3]。因此,最有可能还是工作线程出了问题。

我们使用 tomcat 线程池来处理请求,工作线程的使用情况可以通过 “tomcat 线程池监控” 获得。如 图2图3,可以看到,Pod-1(..186.8)在 08:54:00 线程池的可用线程数(Available Threads)已经到了最大值(Max Threads)[4][4],Pod-2(..188.173)则是在更早的 08:53:30 达到最大值。

图 2:tomcat 线程池使用情况(*.*.186.8)

图 3:tomcat 线程池使用情况(*.*.188.173)

为了更好的理解曲线变化的含义,我们需要认真分析一下 tomcat 线程池的扩容逻辑。

不过在这之前,先明确一下两个监控指标和下文将要讨论的代码方法名的映射关系:

监控指标名 对应在代码中的定义
可用线程数(Available Threads) getPoolSize()
最大线程数(Max Threads getMaximumPoolSize()

实际上, tomcat线程池(org.apache.tomcat.util.threads.ThreadPoolExecutor)继承了java.util.concurrent.ThreadPoolExecutor,而且并没有重写线程池扩容的核心代码,而是复用了java.util.concurrent.ThreadPoolExecutor#execute方法中的实现,如图4。 其中,第4行~第23行的代码注释,已经将这段扩容逻辑解释的非常清晰。即每次执行新任务(Runnable command)时:

  1. Line 25 - 26:首先判断当前池中工作线程数是否小于 corePoolSize,如果小于 corePoolSize 则直接新增工作线程执行该任务;
  2. Line 30:否则,尝试将当前任务放入 workQueue[5][5];
  3. Line 37:如果第 30 行未能成功将任务放入 workQueue[6][6],即 workerQueue.offer(command) 返回 false,则继续尝试新增工作线程执行该任务(第 37 行);

图 4:tomcat 线程池扩容实现

比较 Tricky 的地方在于 tomcat 定制了第 30 行 workQueue 的实现,代码位于org.apache.tomcat.util.threads.TaskQueue类中。TaskQueue 继承自 LinkedBlockingQueue,并重写了offer方法,如图5。可以看到:

  1. Line 4:当线程池中的工作线程数已经达到最大线程数时,则直接将任务放入队列;
  2. Line 6:否则,如果线程池中的工作线程数还未达到最大线程数,当提交的任务数(parent.getSubmittedCount())小于池中工作线程数,即存在空闲的工作线程时,将任务放入队列。这种情况下,放入队列的任务,理论上将立刻被空闲的工作线程取出并执行;
  3. Line 8:否则,只要当前池中工作线程数没有达到最大值,直接返回false。此时图 4第30行workQueue.offer(command) 就将返回false,这会导致execute方法执行第37行的addWorker(command, false),对线程池进行扩容;

图 5:tomcat  TaskQueue offer() 方法实现

通过分析这两段代码,得到如图6的 tomcat 线程池扩容流程图。

图 6:tomcat 线程池扩容逻辑流程图

归纳一下,可以将 tomcat 线程池的扩容过程拆分为两个阶段。

所处阶段 触发阈值 线程池处理能力
阶段 1 当前工作线程数 < 最大线程数 当 ”工作线程数<最大线程数“ 时,只要 ”提交的任务量>池中已有的工作线程数“,就会通过创建新的线程来处理新的任务。这是一种牺牲线程复用效率换取更大、更高效吞吐能力的 “野蛮生长” 模式,在这个过程中,不会有任务积压
阶段 2 当前工作线程数 = 最大线程数 当 “工作线程数” 达到线程池设定的 “最大线程数”时,将不能再通过创建新的工作线程的方式,来处理新提交的任务。在这种情况下,如果继续提交新任务,那该任务只能先放入队列,等待线程池中有线程空闲了,再来处理

2.2 线程池处理能力饱和的后果:任务排队导致探活失败,引发 Pod 重启

当前,该应用基于默认配置,使用 SpringBoot 的健康检查端口(Endpoint),即actuator/health,作为容器的存活探针。而问题就有可能出在这个存活探针上,k8s 会请求应用的actuator/health端口,而这个请求也需要提交给 tomcat 线程池执行。

设想如

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值