首页 > 编程知识 正文

自己实现线程池(由于用火不当引起的事故)

时间:2023-05-04 17:59:05 阅读:100535 作者:529

在高并发、异步的场景中,线程池的应用可以说是无处不在。本质上,线程池是用空间换时间,因为线程的创建和销毁消耗资源和时间。对于使用大量线程的场景,使用池管理可以延缓线程的销毁,大大提高单个线程的可重用性,进一步提升整体性能。

今天遇到了一个典型的线上问题,恰巧和线程池有关,还涉及到死锁、jstack命令的使用、JDK不同线程池的适用场景等知识点。同时整个调查思路可以借鉴,所以想记录下来分享一下。

01 业务背景描述

这个线上问题发生在广告系统的核心扣费服务。首先,简要说明一般的业务流程,以便于理解问题。

绿框部分是扣费服务在广告召回过程中的位置。简单理解:用户点击广告时,会从C端发起实时扣费请求(CPC,根据点击扣费方式),扣费服务将承担这一动作的核心业务逻辑,包括实施反作弊策略、创建扣费记录、点击日志埋点等。

00-1010 12月2日晚上11: 00左右,我们收到一条线上报警通知:扣费服务的线程池任务队列大小远超设定阈值,且队列大小随时间持续增长。警报详情如下:

相应的,我们的广告指标,比如点击量、营收等。也呈现出非常明显的下滑,与商业警示通告几乎同时发布。其中,点击数指标对应的曲线如下:

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

00-1010以下详细介绍了整个事故的调查分析过程。

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

第二步:随后,我们进一步调查了扣费服务所依赖的存储资源(mysql、redis、mq)和外部服务,发现事故期间存在大量数据库查询变慢的情况。

上述慢查询来自于事故期间刚启动的一个大数据抽取任务,从扣费服务的mysql数据库中大量抽取数据并发至hive table。因为推演过程还涉及到写mysql,猜测此时mysql的所有读写性能都受到了影响,发现插入操作的耗时比正常周期要长很多。

第三步:我们猜测数据库的查询速度慢影响了扣费流程的性能,导致任务队列积压,所以决定立即做一个试探性的大数据提取任务。但奇怪的是:停止抽取任务后,数据库的插入性能恢复到正常水平,但阻塞队列大小持续增加,告警并没有消失。

第四步:考虑到广告收入持续大幅下降,进一步分析代码需要很长时间,所以我们决定立即重启服务,看看有没有效果。为了保留事故现场,我们保留了一台服务器,没有重启,只是将这台机器从服务管理平台中移除,这样就不会收到新的扣款请求。

果然,重启服务的杀手锏效果不错,各项业务指标恢复正常,警报也没有再出现。至此,整个在线故障得以解决,持续时间约30分钟。

00-1010接下来,将详细描述事故根本原因的分析过程。

第一步:第二天上班后,我们猜测保留事故现场的服务器,队列中积压的任务应该已经被线程池处理掉了,所以我们再次尝试挂载这个服务器来验证我们的猜测。结果与预期完全相反,积压的任务还在,随着新请求的到来,系统警报立即重新出现,所以我们立即关闭了这台服务器。

第二步:线程池积累的上千个任务,一晚上都没有被线程池处理掉。

分析。

#找到扣费服务的进程号 nbsp;jstack pid > /tmp/stack.txt # 通过进程号dump线程快照,输出到文件中 nbsp;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。

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

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

05 解决方案

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

06 问题总结

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

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

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

原文:https://mp.weixin.qq.com/s/oT30svYuCyc8DqLy-SZRmw

版权声明:该文观点仅代表作者本人。处理文章:请发送邮件至 三1五14八八95#扣扣.com 举报,一经查实,本站将立刻删除。