一次 ES-APM 导致的概率性大量线程阻塞问题排查

前段时间我们接入了 ELK 公司出品的 Elastic-APM 作为全链路监控平台,终结了我好几年前撸的字节码注入全链路监控平台。前段时间有一个业务在启动过程中,会概率性出现大量线程阻塞,导致可对外提供服务的 HTTP 线程非常少,流量进来以后马上出现 HTTP 线程耗尽,健康检查接口请求失败,服务被 k8s 杀死。

堆栈分析

既然是线程的问题,当然想到的是 dump 线程堆栈,人肉阅读也可以,上传到 PerfMa XSheepdog 会更加简单。在锁的这一栏的截图如下所示。

可以看到 http-nio-18880-exec-44 线程持有了一个锁,这个锁被其它的 199 个线程等待。持有锁的线程堆栈如下图所示。

上层的调用方法是 co.elastic.apm.agent.bci.ElasticApmAgent#ensureInstrumented,这段逻辑背后是在调用 bytebuddy 利用 ASM 进行 class 的转换注入。

等待锁的线程恰好 block 也在这个方法,如下图所示。

通过堆栈分析,这个问题的出现与我们业务代码用了 ParallelStream 有关。Java 中的 Stream 是一个比较好用的东西,在函数式编程、流式数据处理上写起来比较简单。除了串行化的流式处理,Java 也提供了并行流 parallelStream 可以使用多核多线程并行处理,如下图所示。

我们的业务代码是这样的:

private List<TaskRule> getTaskRules(Task task, List<TaskRule> taskRules) {
    return taskRules.parallelStream()
                   .filter(taskRule -> taskRule.getTaskId().equals(task.getId()))
                   .sorted(Comparator.comparing(TaskRule::getLower))
                   .collect(Collectors.toList());
}

parallelStream 背后的男人是 ForkJoin,采用分治的方式将任务分解为小任务,然后通过 ForkJoinPool 线程池来实现并行化。

ES-APM 为了突破跨线程链路追踪,对多线程相关的类做了注入,apm-java-concurrent-plugin 插件会注入所有 ForkJoinTask 的子类,ForkJoin 底层实现用到的很多类都继承了 ForkJoinTask,比如下面这些:

  • java.util.stream.Nodes$CollectorTask
  • java.util.stream.NodesToArrayTaskOfRef
  • java.util.stream.ReduceOps$ReduceTask
  • java.util.concurrent.CountedCompleter
  • ...

在服务启动后,大量的 HTTP 请求进来调用 getTaskRules 这个方法,HTTP 线程、ForkJoinPool 中的线程都会调用到 ES-APM 的代码,判断这些类有没有被字节码注入。ES-APM 判断类有没有被转换的代码如下:

上面的代码有一个明显的并发问题,这里的逻辑是首先查询类有没有被转换,如果没有,则进入到一个类锁,做相关类的字节码注入。

在并发量高的情况下,HTTP 线程、ForkJoinPool 中的线程调用了 getOrCreate 方法,这时因为类还没有转换,返回了一个空的 set,然后有一个幸运儿抢到了 ElasticApmAgent 类锁,其它所有的线程都 block 住了。

当幸运儿执行完类的转换,下一个抢到类锁的线程还会再对类转换一遍(可怕)。其它的没抢到的还要继续 block。不仅如此,因为 ForkJoinPool 线程池中的线程也会 block 在这个,导致 http 请求也会 block,很快整个 tomcat 线程池就被耗尽了。

这还没完,其实如果处理<typo id="typo-1785" data-origin="的" ignoretag="true">的</typo>非常快,也没有什么太大的问题,只是同一个类,每经过一次改写,就会变复杂,文件变得更大,下次类的字节码注入花的时间就更长。

使用 arthas 去注入 Bytebuddy 的 AgentBuilderDefaultExecutingTransformer.transform 方法,这个方法的签名如下。

private byte[] transform(JavaModule module,
                         ClassLoader classLoader,
                         String internalTypeName,
                         Class<?> classBeingRedefined,
                         ProtectionDomain protectionDomain,
                         byte[] binaryRepresentation) {
}

其中 internalTypeName 表示要转换的类的类名,binaryRepresentation 表示当前转换类最新的字节码数据。

启动 arthas,开启 unsafe:

options unsafe true

然后使用 watch 命令观察<typo id="typo-2397" data-origin="入参" ignoretag="true">入参</typo>

watch co.elastic.apm.agent.shaded.bytebuddy.agent.builder.AgentBuilder$Default$ExecutingTransformer transform   "{params}" -x 2

ts=2020-10-22 03:41:52; [cost=0.358887ms] result=@ArrayList[
    @Object[][
        null,
        null,
        @String[java/util/concurrent/CountedCompleter],
        @Class[class java.util.concurrent.CountedCompleter],
        null,
        @byte[][isEmpty=false;size=4379],
    ],
]
...
ts=2020-10-22 03:47:26; [cost=1021.689119ms] result=@ArrayList[
    @Object[][
        null,
        @String[java/util/concurrent/CountedCompleter],
        @Class[class java.util.concurrent.CountedCompleter],
        null,
        @byte[][isEmpty=false;size=88422],
    ],
]
ts=2020-10-22 03:50:27; [cost=1419.025166ms] result=@ArrayList[
    @Object[][
        null,
        @String[java/util/concurrent/CountedCompleter],
        @Class[class java.util.concurrent.CountedCompleter],
        null,
        @byte[][isEmpty=false;size=102910],
    ],
]

可以看到 CountedCompleter 类经过多次转换,从几 k 大小变为了 100k 以上,继续转换还变得更大,代码变得更复杂,ASM 注入字节码的时间会变得越来越长,表现出来的现象就是:

  • 持有锁的线程花在转换类的时间会越来越长
  • 相应的,等待锁的线程将要等待更久的时间

我在代码中加了一些打印,time 后面的两个数字,第一个是 ensureInstrumented 整个方法的耗时,第二个是 Bytebuddy 字节码注入的耗时,单位都是毫秒。

[06:15:32][http-nio-18880-exec-49]done:class java.util.stream.Nodes$CollectorTask time:313  306
[06:15:33][http-nio-18880-exec-45]done:class java.util.stream.Nodes$ToArrayTask$OfRef time:496  233
...
[06:15:50][http-nio-18880-exec-17]done:class java.util.stream.Nodes$ToArrayTask$OfRef time:18317    1075
...
[06:16:54][http-nio-18880-exec-27]done:class java.util.stream.Nodes$ToArrayTask$OfRef time:81713    5481
...
[06:22:51][http-nio-18880-exec-59]done:class java.util.stream.Nodes$CollectorTask time:438733   24975
...

可以看到等锁的线程,有的等了 400 多秒,bytebuddy 字节码注入的时间也在快速增大,后面甚至要几分钟。

验证

为了侧面印证前面的分析过程,可以验证这样的情况:第一次只有一个请求,没有用户造成的并发情况,让并发相关的类都快速注入完成,再来做压测,看看还会不会出问题。步骤如下:

  • 第一步,等服务启动完,使用 curl 请求,先请求一次接口。
  • 然后使用 ab 或者 jmeter 疯狂来压那个接口,看看接口的响应情况。

结果如预料中的一样,接口响应时间正常了,jstack 查看现场的堆栈,也没有任何线程阻塞等在 ElasticApmAgent 的类锁上了。

解决办法

最简单的解决是业务暂时去掉 parallelStream,有坑先绕过。还有一个不成熟的改法,也不知道对不对:把 synchronized 同步块里的鸡肋二次判断 contains 前面加一行获取最新 appliedInstrumentations 的逻辑,如下所示,如果已经转换过了,就可以跳过后面的逻辑了。

经过重新打包 ES-APM 进行测试,确实解决了这个场景下的问题。

小结

出问题的时候,不要怀疑自己,大胆的怀疑框架吧,ES-APM 这个调用超级频繁的代码中有一个类锁性能好不到哪里去,有很大的改善空间。好了,落班。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,427评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,551评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,747评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,939评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,955评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,737评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,448评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,352评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,834评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,992评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,133评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,815评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,477评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,022评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,147评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,398评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,077评论 2 355

推荐阅读更多精彩内容