线程池运用不当的一次线上事故

本文分析了一次线上广告系统扣费服务的故障,详细讲述了线程池死锁的原因及解决过程。故障由数据库慢查询引发,最终定位为父任务与子任务共用线程池导致死锁。通过隔离任务线程池,成功解决了问题。

作者 | Rockets.Luo

来源 | IT人的职场进阶(ID:BestITer)

在高并发、异步化等场景,线程池的运用可以说无处不在。线程池从本质上来讲,即通过空间换取时间,因为线程的创建和销毁都是要消耗资源和时间的,对于大量使用线程的场景,使用池化管理可以延迟线程的销毁,大大提高单个线程的复用能力,进一步提升整体性能。

今天遇到了一个比较典型的线上问题,刚好和线程池有关,另外涉及到死锁、jstack命令的使用、JDK不同线程池的适合场景等知识点,同时整个调查思路可以借鉴,特此记录和分享一下。

业务背景描述

该线上问题发生在广告系统的核心扣费服务,首先简单交代下大致的业务流程,方便理解问题。

绿框部分即扣费服务在广告召回扣费流程中所处的位置,简单理解:当用户点击一个广告后,会从C端发起一次实时扣费请求(CPC,按点击扣费模式),扣费服务则承接了该动作的核心业务逻辑:包括执行反作弊策略、创建扣费记录、click日志埋点等。

问题现象和业务影响

12月2号晚上11点左右,我们收到了一个线上告警通知:扣费服务的线程池任务队列大小远远超出了设定阈值,而且队列大小随着时间推移还在持续变大。详细告警内容如下:

相应的,我们的广告指标:点击数、收入等也出现了非常明显的下滑,几乎同时发出了业务告警通知。其中,点击数指标对应的曲线表现如下:

该线上故障发生在流量高峰期,持续了将近30分钟后才恢复正常。

问题调查和事故解决过程

下面详细说下整个事故的调查和分析过程。

第1步:收到线程池任务队列的告警后,我们第一时间查看了扣费服务各个维度的实时数据:包括服务调用量、超时量、错误日志、JVM监控,均未发现异常。

第2步:然后进一步排查了扣费服务依赖的存储资源(MySQL、Redis、MQ),外部服务,发现了事故期间存在大量的数据库慢查询。

上述慢查询来自于事故期间一个刚上线的大数据抽取任务,从扣费服务的MySQL数据库中大批量并发抽取数据到hive表。因为扣费流程也涉及到写MySQL,猜测这个时候MySQL的所有读写性能都受到了影响,果然进一步发现insert操作的耗时也远远大于正常时期。

第3步:我们猜测数据库慢查询影响了扣费流程的性能,从而造成了任务队列的积压,所以决定立马暂定大数据抽取任务。但是很奇怪:停止抽取任务后,数据库的insert性能恢复到正常水平了,但是阻塞队列大小仍然还在持续增大,告警并未消失。

第4步:考虑广告收入还在持续大幅度下跌,进一步分析代码需要比较长的时间,所以决定立即重启服务看看有没有效果。为了保留事故现场,我们保留了一台服务器未做重启,只是把这台机器从服务管理平台摘掉了,这样它不会接收到新的扣费请求。

果然重启服务的杀手锏很管用,各项业务指标都恢复正常了,告警也没有再出现。至此,整个线上故障得到解决,持续了大概30分钟。

问题根本原因的分析过程

下面再详细说下事故根本原因的分析过程。

第1步:第二天上班后,我们猜测那台保留了事故现场的服务器,队列中积压的任务应该都被线程池处理掉了,所以尝试把这台服务器再次挂载上去验证下我们的猜测,结果和预期完全相反,积压的任务仍然都在,而且随着新请求进来,系统告警立刻再次出现了,所以又马上把这台服务器摘了下来。

第2步:线程池积压的几千个任务,经过1个晚上都没被线程池处理掉,我们猜测应该存在死锁情况。所以打算通过jstack命令dump线程快照做下详细分析。

#找到扣费服务的进程号
$ jstack pid > /tmp/stack.txt

# 通过进程号dump线程快照,输出到文件中
$ jstack pid > /tmp/stack.txt

在jstack的日志文件中,立马发现了:用于扣费的业务线程池的所有线程都处于waiting状态,线程全部卡在了截图中红框部分对应的代码行上,这行代码调用了countDownLatch的await()方法,即等待计数器变为0后释放共享锁。

第3步:找到上述异常后,距离找到根本原因就很接近了,我们回到代码中继续调查,首先看了下业务代码中使用了newFixedThreadPool线程池,核心线程数设置为25。针对newFixedThreadPool,JDK文档的说明如下:

创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。如果在所有线程处于活跃状态时提交新任务,则在有可用线程之前,新任务将在队列中等待。

关于newFixedThreadPool,核心包括两点:

1、最大线程数 = 核心线程数,当所有核心线程都在处理任务时,新进来的任务会提交到任务队列中等待; 

2、使用了无界队列:提交给线程池的任务队列是不限制大小的,如果任务被阻塞或者处理变慢,那么显然队列会越来越大。

所以,进一步结论是:核心线程全部死锁,新进的任务不对涌入无界队列,导致任务队列不断增加。

第4步:到底是什么原因导致的死锁,我们再次回到jstack日志文件中提示的那行代码做进一步分析。下面是我简化过后的示例代码:

/**
 * 执行扣费任务
 */
public Result<Integer> executeDeduct(ChargeInputDTO chargeInput) {
  ChargeTask chargeTask = new ChargeTask(chargeInput);
  bizThreadPool.execute(() -> chargeTaskBll.execute(chargeTask ));
  return Result.success();
}

/*
 * 扣费任务的具体业务逻辑
 */
public class ChargeTaskBll implements Runnable {

  public void execute(ChargeTask chargeTask) {
     // 第一步:参数校验
     verifyInputParam(chargeTask);

     // 第二步:执行反作弊子任务
     executeUserSpam(SpamHelper.userConfigs);

     // 第三步:执行扣费
     handlePay(chargeTask);

     // 其他步骤:点击埋点等
     ...
  }
}

/**
 * 执行反作弊子任务
 */
public void executeUserSpam(List<SpamUserConfigDO> configs) {
  if (CollectionUtils.isEmpty(configs)) {
    return;
  }

  try {
    CountDownLatch latch = new CountDownLatch(configs.size());
    for (SpamUserConfigDO config : configs) {
      UserSpamTask task = new UserSpamTask(config,latch);
      bizThreadPool.execute(task);
    }
    latch.await();
  } catch (Exception ex) {
    logger.error("", ex);
  }
}

通过上述代码,大家能否发现死锁是怎么发生的呢?根本原因在于:一次扣费行为属于父任务,同时它又包含了多次子任务:子任务用于并行执行反作弊策略,而父任务和子任务使用的是同一个业务线程池。当线程池中全部都是执行中的父任务时,并且所有父任务都存在子任务未执行完,这样就会发生死锁。下面通过1张图再来直观地看下死锁的情况:

假设核心线程数是2,目前正在执行扣费父任务1和2。另外,反作弊子任务1和3都执行完了,反作弊子任务2和4都积压在任务队列中等待被调度。因为反作弊子任务2和4没执行完,所以扣费父任务1和2都不可能执行完成,这样就发生了死锁,核心线程永远不可能释放,从而造成任务队列不断增大,直到程序OOM crash。

死锁原因清楚后,还有个疑问:上述代码在线上运行很长时间了,为什么现在才暴露出问题呢?另外跟数据库慢查询到底有没有直接关联呢?

暂时我们还没有复现证实,但是可以推断出:上述代码一定存在死锁的概率,尤其在高并发或者任务处理变慢的情况下,概率会大大增加。数据库慢查询应该就是导致此次事故出现的导火索。

解决方案与总结

弄清楚根本原因后,最简单的解决方案就是:增加一个新的业务线程池,用来隔离父子任务,现有的线程池只用来处理扣费任务,新的线程池用来处理反作弊任务。这样就可以彻底避免死锁的情况了。

回顾事故的解决过程以及扣费的技术方案,存在以下几点待继续优化:

1、使用固定线程数的线程池存在OOM风险,在阿里巴巴Java开发手册中也明确指出,而且用的词是『不允许』使用Executors创建线程池。 而是通过ThreadPoolExecutor去创建,这样让写的同学能更加明确线程池的运行规则和核心参数设置,规避资源耗尽的风险。

2、广告的扣费场景是一个异步过程,通过线程池或者MQ来实现异步化处理都是可选的方案。另外,极个别的点击请求丢失不扣费从业务上是允许的,但是大批量的请求丢弃不处理且没有补偿方案是不允许的。后续采用有界队列后,拒绝策略可以考虑发送MQ做重试处理。

作者简介:我是一名程序员,985硕士,前亚马逊Java工程师,参与过创业,目前是一家B轮电商公司的技术总监。在这里会持续分享我个人的成长之路,写一些关于技术和管理方向的总结,希望对你有所启发。


更多精彩推荐
☞110+优秀作品、20+热门领域曝光, 高通如何玩转“AI 的夏天”!
☞优酷爱奇艺回应被处罚;字节跳动否认微软求购TikTok全球业务;斗鱼发布开源框架Jupiter| 极客头条
☞互联网公司没有前浪
☞我还没考试,算法就说我的物理一定挂科
☞游戏行业应该如何建设数据中台?
☞Eth2 验证者快速启动器发布,还有什么惊喜是我们不知道的?
点分享点点赞点在看
<think>首先,用户的问题是基于系统指令和之前的引用。指令要求:所有行内数学表达式用$...$,独立公式用$$...$$并单独成段;使用中文回答;生成相关问题;引用的段落末尾添加引用标识如[^1]。 用户查询:"我想查找线程池线上监控的相关信息 请问线程池线上监控的方法和技术" 引用的内容来自多个来源:[^1], [^2], [^3], [^4], [^5]。这些引用提供了关于线程池监控的信息。 关键点是线程池线上监控的方法和技术: - 从引用[^3]:可以通过JMX监控线程池的各项指标,如当前活动线程数、已完成任务数、排队任务数等;引入第三方监控工具如Prometheus和Grafana;在应用内部添加日志记录。 - 引用[^4]提到:使用定时任务线程记录线程池的状态、线程数量和队列任务数量;实现方案是创建一个单例类缓存线程池对象,定时遍历并记录。 - 引用[^5]:基于Prometheus + Alertmanager的自动化监控规则;提供了性能优化checklist。 - 其他引用补充监控指标和最佳实践。 我的回答需要结构清晰,帮助用户逐步解决问题,保证真实可靠。基于指令,回答中引用的段落末尾要添加引用标识。 回答结构: 1. **介绍线程池监控的重要性**:简要说明为什么需要监控线程池(基于引用)。 2. **监控方法和技术**: - 监控指标:如活动线程数、队列大小、完成的任务数等(引用[^3], [^4], [^5])。 - 监控工具:JMX、第三方工具如Prometheus/Grafana、日志记录。 - 实现方案:例如定时任务监控(引用[^4]),自动化规则(引用[^5])。 3. **优化策略**:基于监控数据如何优化(如引用[^3]的问题解答)。 4. **生成相关问题**:在回答末尾,添加相关问题部分,使用"§§相关问题§§"格式。 使用中文回答。确保任何数学表达式(如果有)正确格式,但线程池监控可能没有数学表达式,所以可能不需要。 最后,生成相关问题:基于内容,提出2-3个相关问题。 现在,制定回答大纲: - 开头:确认用户查询,简述线程池监控的重要性。 - 主体: - 线程池监控的关键指标(解释每个指标)。 - 监控方法:JMX、日志、第三方工具。 - 具体实现示例(如代码或配置片段,如果合适)。 - 优化建议基于监控。 - 结尾:总结,并添加相关问题。 引用添加:在相关句子末尾添加引用标识,例如:"可以通过JMX监控线程池指标[^3]"。 生成相关问题例子: 1. 如何设置线程池的核心线程数和最大线程数? 2. 线程池监控中的常见问题及解决方案? 3. 如何使用Prometheus实现线程池监控? 确保回答真实可靠,基于提供的引用。 </think>### 线程池线上监控的方法与技术 线程池作为企业级应用的核心组件,其线上监控是保障系统稳定性和性能的关键。监控的主要目标是实时跟踪线程池的运行状态,及时发现瓶颈(如线程饥饿、队列积压),并基于数据驱动优化决策。下面我将逐步介绍线程池线上监控的核心方法和技术,帮助您全面掌握该主题。 #### 1. **监控的核心指标** - **活动线程数(Active Thread Count)**:当前正在执行任务的线程数量,可用于判断线程资源是否充足。如果持续接近最大线程数,可能表示系统过载[^3]。 - **队列任务数量(Queue Size)**:等待执行的任务数。队列过长会导致任务延迟,甚至触发拒绝策略[^3][^4]。 - **已完成任务数(Completed Task Count)**:历史累计完成的任务量,用于评估吞吐量。 - **拒绝任务数(Rejected Task Count)**:因队列满或线程不足而被拒绝的任务量,过高时需调整线程池参数[^5]。 - **线程池状态(Pool State)**:如运行中、关闭中等,反映线程池生命周期。 这些指标是监控的基础,通常需要定期采集(如每秒或每分钟),以识别趋势和异常[^4][^5]。 #### 2. **监控方法与工具** 线程池线上监控主要通过以下技术实现,每种方法各有优势: - **JMX(Java Management Extensions)**: 通过JMX暴露线程池的MBean,直接监控指标如活动线程数和队列大小。适用于Java应用,无需额外依赖。示例:使用JConsole或VisualVM实时查看数据[^3]。 - **日志记录(Log-based Monitoring)**: 在应用内部添加定时任务,定期输出指标到日志文件。实现方案: - 创建一个单例类,缓存所有线程池对象。 - 启动定时线程(例如ScheduledExecutorService),每隔固定时间(如5秒)遍历线程池,记录指标数据[^4]。 优点:简单易集成;缺点:日志量大时需结合ELK等工具分析。 - **第三方监控工具(如Prometheus + Grafana)**: 这是企业级主流方案: - **Prometheus**:通过自定义Metrics收集线程池数据(例如使用Micrometer库)。 - **Grafana**:可视化展示时序数据,支持实时仪表盘。 - **告警规则**:例如设置活跃线程占比超过80%或队列使用率超过60%时触发告警[^5]。 自动化规则示例(基于Prometheus): ```yaml # alert.rules groups: - name: thread_pool.rules rules: - alert: ThreadPoolOverload expr: thread_pool_active_count / thread_pool_max_size > 0.8 for: 5m labels: severity: warning annotations: summary: "线程池 {{ $labels.pool }} 负载过高" ``` 优点:支持动态扩缩容和自动化响应;缺点:部署复杂度较高[^2][^5]。 - **APM工具(如SkyWalking, Zipkin)**: 集成到分布式追踪系统中,监控任务执行链路和线程池行为,适合微服务架构[^1][^5]。 #### 3. **监控数据的优化策略** 基于监控结果,可实施针对性优化: - **队列过长时**:增加核心线程数或最大线程数;改用有界队列防止内存耗尽;优化任务逻辑减少执行时间[^3][^5]。 - **频繁拒绝任务时**:调整队列容量或引入任务超时机制;对非关键任务启用降级策略。 - **资源利用率低时**:合并线程池或调整参数(如减少核心线程数)。 优化需结合压测工具(如JMeter)验证效果,确保在高并发下平衡吞吐量和延迟[^2][^5]。 #### 4. **最佳实践与注意事项** - **配置独立线程池**:为不同任务类型(如CPU密集型和I/O密集型)设置专属线程池,避免资源争用[^5]。 - **指标持久化**:将监控数据存储到时序数据库(如InfluxDB),便于历史分析。 - **自动化熔断**:为关键线程池添加熔断保护,当指标异常时自动降级或扩容[^5]。 - **线程命名规范**:设置线程名称前缀(如`order-service-pool`),便于日志排查[^5]。 实践中,推荐组合使用JMX和Prometheus,兼顾实时性和可扩展性。定期审查监控数据,可预防80%的线程池相关问题[^3][^5]。 通过以上方法,线程池监控不仅能提升系统稳定性,还能为性能调优提供数据支撑。如果您有具体场景(如Spring Boot应用),可参考工具文档进一步实施。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值