在高并发、异步化等场景,线程池的使用能够说无处不在。线程池从实质上来讲,即通过空间换取工夫,因为线程的创立和销毁都是要耗费资源和工夫的,对于大量应用线程的场景,应用池化治理能够延迟线程的销毁,大大提高单个线程的复用能力,进一步晋升整体性能。
明天遇到了一个比拟典型的线上问题,刚好和线程池无关,另外波及到死锁、jstack 命令的应用、JDK 不同线程池的适宜场景等知识点,同时整个考察思路能够借鉴,特此记录和分享一下。
01 业务背景形容
该线上问题产生在广告零碎的外围扣费服务,首先简略交代下大抵的业务流程,不便了解问题。
绿框局部即扣费服务在广告召回扣费流程中所处的地位,简略了解:当用户点击一个广告后,会从 C 端发动一次实时扣费申请 (CPC,按点击扣费模式),扣费服务则承接了该动作的外围业务逻辑:包含执行反作弊策略、创立扣费记录、click 日志埋点等。
02 问题景象和业务影响
12 月 2 号早晨 11 点左右,咱们收到了一个线上告警告诉:扣费服务的线程池工作队列大小远远超出了设定阈值,而且队列大小随着时间推移还在继续变大。具体告警内容如下:
相应的,咱们的广告指标:点击数、支出等也呈现了非常明显的下滑,简直同时收回了业务告警告诉。其中,点击数指标对应的曲线体现如下:
该线上故障产生在流量高峰期,继续了将近 30 分钟后才恢复正常。
03 问题考察和事变解决过程
上面具体说下整个事变的考察和剖析过程。
第 1 步 :收到线程池工作队列的告警后,咱们第一工夫查看了扣费服务各个维度的实时数据:包含服务调用量、超时量、谬误日志、JVM 监控,均未发现异常。
第 2 步 :而后进一步排查了扣费服务依赖的存储资源(mysql、redis、mq),内部服务,发现了事变期间存在大量的数据库慢查问。
上述慢查问来自于事变期间一个刚上线的大数据抽取工作,从扣费服务的 mysql 数据库中大批量并发抽取数据到 hive 表。因为扣费流程也波及到写 mysql,猜想这个时候 mysql 的所有读写性能都受到了影响,果然进一步发现 insert 操作的耗时也远远大于失常期间。
第 3 步 :咱们猜想数据库慢查问影响了扣费流程的性能,从而造成了工作队列的积压,所以决定立马暂定大数据抽取工作。然而很奇怪:进行抽取工作后,数据库的 insert 性能复原到失常程度了,然而阻塞队列大小依然还在继续增大,告警并未隐没。
第 4 步 :思考广告支出还在继续大幅度上涨,进一步剖析代码须要比拟长的工夫,所以决定立刻重启服务看看有没有成果。为了保留事故现场,咱们保留了一台服务器未做重启,只是把这台机器从服务治理平台摘掉了,这样它不会接管到新的扣费申请。
果然重启服务的杀手锏很管用,各项业务指标都恢复正常了,告警也没有再呈现。至此,整个线上故障失去解决,继续了大略 30 分钟。
04 问题根本原因的剖析过程
上面再具体说下事变根本原因的剖析过程。
第 1 步 :第二天下班后,咱们猜想那台保留了事故现场的服务器,队列中积压的工作应该都被线程池解决掉了,所以尝试把这台服务器再次挂载下来验证下咱们的猜想,后果和预期齐全相同,积压的工作依然都在,而且随着新申请进来,零碎告警立即再次出现了,所以又马上把这台服务器摘了下来。
第 2 步 :线程池积压的几千个工作,通过 1 个早晨都没被线程池解决掉,咱们猜想应该存在死锁状况。所以打算通过 jstack 命令 dump 线程快照做下详细分析。
# 找到扣费服务的过程号
$ ps aux|grep "adclick"
# 通过过程号 dump 线程快照,输入到文件中
$ jstack pid > /tmp/stack.txth
在 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 执行完了,反作弊子工作 2 和 4 都积压在工作队列中期待被调度。因为反作弊子工作 2 和 4 没执行完,所以扣费父工作 1 和 2 都不可能执行实现,这样就产生了死锁,外围线程永远不可能开释,从而造成工作队列一直增大,直到程序 OOM crash。
死锁起因分明后,还有个疑难:上述代码在线上运行很长时间了,为什么当初才暴露出问题呢?另外跟数据库慢查问到底有没有间接关联呢?
临时咱们还没有复现证实,然而能够推断出:上述代码肯定存在死锁的概率,尤其在高并发或者工作解决变慢的状况下,概率会大大增加。数据库慢查问应该就是导致此次事变呈现的导火索。
05 解决方案
弄清楚根本原因后,最简略的解决方案就是:减少一个新的业务线程池,用来隔离父子工作,现有的线程池只用来解决扣费工作,新的线程池用来解决反作弊工作。这样就能够彻底防止死锁的状况了。
06 问题总结
回顾事变的解决过程以及扣费的技术计划,存在以下几点待持续优化:
1、应用固定线程数的线程池存在 OOM 危险,在阿里巴巴 Java 开发手册中也明确指出,而且用的词是『不容许』应用 Executors 创立线程池。而是通过 ThreadPoolExecutor 去创立,这样让写的同学能更加明确线程池的运行规定和外围参数设置,躲避资源耗尽的危险。
2、广告的扣费场景是一个异步过程,通过线程池或者 MQ 来实现异步化解决都是可选的计划。另外,极个别的点击申请失落不扣费从业务上是容许的,然而大批量的申请抛弃不解决且没有弥补计划是不容许的。后续采纳有界队列后,回绝策略能够思考发送 MQ 做重试解决。— 完结 —
作者简介:985 硕士,前亚马逊 Java 工程师,现 58 转转技术总监。
继续分享技术和治理方向的文章。如果感兴趣,可微信扫描上面的二维码关注我的公众号:『IT 人的职场进阶』