一次 ES-APM 導(dǎo)致的概率性大量線程阻塞問題排查

前段時間我們接入了 ELK 公司出品的 Elastic-APM 作為全鏈路監(jiān)控平臺内列,終結(jié)了我好幾年前擼的字節(jié)碼注入全鏈路監(jiān)控平臺沟使。前段時間有一個業(yè)務(wù)在啟動過程中,會概率性出現(xiàn)大量線程阻塞,導(dǎo)致可對外提供服務(wù)的 HTTP 線程非常少栽连,流量進(jìn)來以后馬上出現(xiàn) HTTP 線程耗盡,健康檢查接口請求失敗侨舆,服務(wù)被 k8s 殺死秒紧。

堆棧分析

既然是線程的問題,當(dāng)然想到的是 dump 線程堆棧挨下,人肉閱讀也可以熔恢,上傳到 PerfMa XSheepdog 會更加簡單。在鎖的這一欄的截圖如下所示臭笆。

可以看到 http-nio-18880-exec-44 線程持有了一個鎖叙淌,這個鎖被其它的 199 個線程等待秤掌。持有鎖的線程堆棧如下圖所示。

上層的調(diào)用方法是 co.elastic.apm.agent.bci.ElasticApmAgent#ensureInstrumented鹰霍,這段邏輯背后是在調(diào)用 bytebuddy 利用 ASM 進(jìn)行 class 的轉(zhuǎn)換注入闻鉴。

等待鎖的線程恰好 block 也在這個方法,如下圖所示茂洒。

通過堆棧分析孟岛,這個問題的出現(xiàn)與我們業(yè)務(wù)代碼用了 ParallelStream 有關(guān)。Java 中的 Stream 是一個比較好用的東西督勺,在函數(shù)式編程渠羞、流式數(shù)據(jù)處理上寫起來比較簡單。除了串行化的流式處理智哀,Java 也提供了并行流 parallelStream 可以使用多核多線程并行處理次询,如下圖所示。

我們的業(yè)務(wù)代碼是這樣的:

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盏触,采用分治的方式將任務(wù)分解為小任務(wù)渗蟹,然后通過 ForkJoinPool 線程池來實現(xiàn)并行化。

ES-APM 為了突破跨線程鏈路追蹤赞辩,對多線程相關(guān)的類做了注入雌芽,apm-java-concurrent-plugin 插件會注入所有 ForkJoinTask 的子類,F(xiàn)orkJoin 底層實現(xiàn)用到的很多類都繼承了 ForkJoinTask辨嗽,比如下面這些:

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

在服務(wù)啟動后洪乍,大量的 HTTP 請求進(jìn)來調(diào)用 getTaskRules 這個方法怜瞒,HTTP 線程监婶、ForkJoinPool 中的線程都會調(diào)用到 ES-APM 的代碼览芳,判斷這些類有沒有被字節(jié)碼注入。ES-APM 判斷類有沒有被轉(zhuǎn)換的代碼如下:

上面的代碼有一個明顯的并發(fā)問題洲押,這里的邏輯是首先查詢類有沒有被轉(zhuǎn)換武花,如果沒有,則進(jìn)入到一個類鎖杈帐,做相關(guān)類的字節(jié)碼注入体箕。

在并發(fā)量高的情況下,HTTP 線程挑童、ForkJoinPool 中的線程調(diào)用了 getOrCreate 方法累铅,這時因為類還沒有轉(zhuǎn)換,返回了一個空的 set站叼,然后有一個幸運兒搶到了 ElasticApmAgent 類鎖娃兽,其它所有的線程都 block 住了。

當(dāng)幸運兒執(zhí)行完類的轉(zhuǎn)換尽楔,下一個搶到類鎖的線程還會再對類轉(zhuǎn)換一遍(可怕)投储。其它的沒搶到的還要繼續(xù) block第练。不僅如此,因為 ForkJoinPool 線程池中的線程也會 block 在這個轻要,導(dǎo)致 http 請求也會 block复旬,很快整個 tomcat 線程池就被耗盡了垦缅。

這還沒完冲泥,其實如果處理<typo id="typo-1785" data-origin="的" ignoretag="true">的</typo>非常快壁涎,也沒有什么太大的問題凡恍,只是同一個類,每經(jīng)過一次改寫怔球,就會變復(fù)雜嚼酝,文件變得更大,下次類的字節(jié)碼注入花的時間就更長竟坛。

使用 arthas 去注入 Bytebuddy 的 AgentBuilderDefaultExecutingTransformer.transform 方法闽巩,這個方法的簽名如下。

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

其中 internalTypeName 表示要轉(zhuǎn)換的類的類名担汤,binaryRepresentation 表示當(dāng)前轉(zhuǎn)換類最新的字節(jié)碼數(shù)據(jù)涎跨。

啟動 arthas,開啟 unsafe:

options unsafe true

然后使用 watch 命令觀察<typo id="typo-2397" data-origin="入?yún)? ignoretag="true">入?yún)?lt;/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 類經(jīng)過多次轉(zhuǎn)換崭歧,從幾 k 大小變?yōu)榱?100k 以上隅很,繼續(xù)轉(zhuǎn)換還變得更大,代碼變得更復(fù)雜率碾,ASM 注入字節(jié)碼的時間會變得越來越長叔营,表現(xiàn)出來的現(xiàn)象就是:

  • 持有鎖的線程花在轉(zhuǎn)換類的時間會越來越長
  • 相應(yīng)的,等待鎖的線程將要等待更久的時間

我在代碼中加了一些打印所宰,time 后面的兩個數(shù)字绒尊,第一個是 ensureInstrumented 整個方法的耗時,第二個是 Bytebuddy 字節(jié)碼注入的耗時仔粥,單位都是毫秒婴谱。

[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 字節(jié)碼注入的時間也在快速增大勘究,后面甚至要幾分鐘。

驗證

為了側(cè)面印證前面的分析過程斟冕,可以驗證這樣的情況:第一次只有一個請求口糕,沒有用戶造成的并發(fā)情況,讓并發(fā)相關(guān)的類都快速注入完成磕蛇,再來做壓測景描,看看還會不會出問題十办。步驟如下:

  • 第一步,等服務(wù)啟動完超棺,使用 curl 請求向族,先請求一次接口。
  • 然后使用 ab 或者 jmeter 瘋狂來壓那個接口棠绘,看看接口的響應(yīng)情況件相。

結(jié)果如預(yù)料中的一樣,接口響應(yīng)時間正常了氧苍,jstack 查看現(xiàn)場的堆棧夜矗,也沒有任何線程阻塞等在 ElasticApmAgent 的類鎖上了。

解決辦法

最簡單的解決是業(yè)務(wù)暫時去掉 parallelStream让虐,有坑先繞過紊撕。還有一個不成熟的改法,也不知道對不對:把 synchronized 同步塊里的雞肋二次判斷 contains 前面加一行獲取最新 appliedInstrumentations 的邏輯赡突,如下所示对扶,如果已經(jīng)轉(zhuǎn)換過了,就可以跳過后面的邏輯了惭缰。

經(jīng)過重新打包 ES-APM 進(jìn)行測試浪南,確實解決了這個場景下的問題。

小結(jié)

出問題的時候从媚,不要懷疑自己逞泄,大膽的懷疑框架吧,ES-APM 這個調(diào)用超級頻繁的代碼中有一個類鎖性能好不到哪里去拜效,有很大的改善空間喷众。好了,落班紧憾。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末到千,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子赴穗,更是在濱河造成了極大的恐慌憔四,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,194評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件般眉,死亡現(xiàn)場離奇詭異了赵,居然都是意外死亡,警方通過查閱死者的電腦和手機甸赃,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,058評論 2 385
  • 文/潘曉璐 我一進(jìn)店門柿汛,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人埠对,你說我怎么就攤上這事络断〔锰妫” “怎么了?”我有些...
    開封第一講書人閱讀 156,780評論 0 346
  • 文/不壞的土叔 我叫張陵貌笨,是天一觀的道長弱判。 經(jīng)常有香客問我,道長锥惋,這世上最難降的妖魔是什么昌腰? 我笑而不...
    開封第一講書人閱讀 56,388評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮净刮,結(jié)果婚禮上剥哑,老公的妹妹穿的比我還像新娘。我一直安慰自己淹父,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 65,430評論 5 384
  • 文/花漫 我一把揭開白布怎虫。 她就那樣靜靜地躺著暑认,像睡著了一般。 火紅的嫁衣襯著肌膚如雪大审。 梳的紋絲不亂的頭發(fā)上蘸际,一...
    開封第一講書人閱讀 49,764評論 1 290
  • 那天,我揣著相機與錄音徒扶,去河邊找鬼粮彤。 笑死,一個胖子當(dāng)著我的面吹牛姜骡,可吹牛的內(nèi)容都是我干的导坟。 我是一名探鬼主播,決...
    沈念sama閱讀 38,907評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼圈澈,長吁一口氣:“原來是場噩夢啊……” “哼惫周!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起康栈,我...
    開封第一講書人閱讀 37,679評論 0 266
  • 序言:老撾萬榮一對情侶失蹤递递,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后啥么,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體登舞,經(jīng)...
    沈念sama閱讀 44,122評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,459評論 2 325
  • 正文 我和宋清朗相戀三年悬荣,在試婚紗的時候發(fā)現(xiàn)自己被綠了菠秒。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,605評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡隅熙,死狀恐怖稽煤,靈堂內(nèi)的尸體忽然破棺而出核芽,到底是詐尸還是另有隱情,我是刑警寧澤酵熙,帶...
    沈念sama閱讀 34,270評論 4 329
  • 正文 年R本政府宣布轧简,位于F島的核電站,受9級特大地震影響匾二,放射性物質(zhì)發(fā)生泄漏哮独。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,867評論 3 312
  • 文/蒙蒙 一察藐、第九天 我趴在偏房一處隱蔽的房頂上張望皮璧。 院中可真熱鬧,春花似錦分飞、人聲如沸悴务。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,734評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽讯檐。三九已至,卻和暖如春染服,著一層夾襖步出監(jiān)牢的瞬間别洪,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,961評論 1 265
  • 我被黑心中介騙來泰國打工柳刮, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留挖垛,地道東北人。 一個月前我還...
    沈念sama閱讀 46,297評論 2 360
  • 正文 我出身青樓秉颗,卻偏偏與公主長得像痢毒,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子站宗,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,472評論 2 348

推薦閱讀更多精彩內(nèi)容