在高并发、异步化等场景,线程池的使用能够说无处不在。线程池从实质上来讲,即通过空间换取工夫,因为线程的创立和销毁都是要耗费资源和工夫的,对于大量应用线程的场景,应用池化治理能够延迟线程的销毁,大大提高单个线程的复用能力,进一步晋升整体性能。
明天遇到了一个比拟典型的线上问题,刚好和线程池无关,另外波及到死锁、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人的职场进阶』