工作十幾年,第一次在線上遇到死鎖問題
概述
最近一直在為系統(tǒng)的穩(wěn)定性努力著,但凡線上有一些問題,都不輕易放過。尤其是在2023年,大環(huán)境不好的情況下,如果it團(tuán)隊(duì)系統(tǒng)穩(wěn)定性都做的不好的話,很容易提桶走人的。
事情是這樣的,在2023年3月8日的晚上七點(diǎn)左右,調(diào)用B服務(wù)RPC接口的其他服務(wù),都陸續(xù)開始報(bào)【接口調(diào)用超時(shí)異常】,B服務(wù)已經(jīng)有一個(gè)多月沒有上線過了,而出故障的時(shí)間當(dāng)天,流量也沒陡增。
這種突然出問題,但跟流量和發(fā)版又沒有關(guān)系的,一般就是先重啟,因?yàn)榇蟾怕适怯|發(fā)某個(gè)隱藏的bug導(dǎo)致服務(wù)慢慢不可用了。注意,當(dāng)時(shí)是沒讓運(yùn)維dump pod的運(yùn)行信息的,因?yàn)榫€上報(bào)錯(cuò)的信息比較多了,也影響到了用戶,只能先止損。果然重啟后,錯(cuò)誤信息立馬消失了,一直到當(dāng)天凌晨,都沒有再報(bào)錯(cuò)了。
但是單單看一堆超時(shí)的錯(cuò)誤信息,一時(shí)之間,是很難找出根因的。那天我一直看到了晚上11點(diǎn),只是得到一個(gè)粗淺的結(jié)論:
錯(cuò)誤信息,集中在B服務(wù)的某些pod上,有蠻多線程block住了。
隔天早上回到辦公室后,就申請(qǐng)讓B服務(wù)開通arms(阿里的應(yīng)用實(shí)時(shí)監(jiān)控服務(wù)),坐等錯(cuò)誤再次發(fā)生。阿里的arms還是很強(qiáng)大的,但是是付費(fèi)的且不便宜,一般平時(shí)不開的。
一直等到了3月9號(hào)的下午五點(diǎn)多,B服務(wù)的接口又開始超時(shí)了,這次我趕緊到arms的事件中心大盤里,看看有無異常的事件發(fā)生,猜我看到了啥?
居然有死鎖,生平第一次在線上遇到過。查看了arms打印出來的詳細(xì)日志,發(fā)現(xiàn)是兩個(gè)線程,在兩個(gè)ConcurrentHashMap對(duì)象之間,相互等待了。
js復(fù)制代碼[ARMS] Found deadlock: "thread_14" Id=xxxx BLOCKED on java.util.concurrent.ConcurrentHashMap$Node@687bfd0d owned by "Dubbo-thread-499" Id=1044 at java.util.concurrent.ConcurrentHashMap.putVal(ConcurrentHashMap.java:1027) - blocked on java.util.concurrent.ConcurrentHashMap$Node@687bfd0d at java.util.concurrent.ConcurrentHashMap.putIfAbsent(ConcurrentHashMap.java:1535) ? "Dubbo-thread-499" Id=cccc BLOCKED on java.util.concurrent.ConcurrentHashMap$ReservationNode@2205946f owned by "thread_14" Id=yyy at java.util.concurrent.ConcurrentHashMap.putVal(ConcurrentHashMap.java:1027) - blocked on java.util.concurrent.ConcurrentHashMap$ReservationNode@2205946f at java.util.concurrent.ConcurrentHashMap.putIfAbsent(ConcurrentHashMap.java:1535) ?
也就是說:
線程thread_14在已獲得某種資源后,還想繼續(xù)獲取687bfd0d對(duì)象的鎖,而這把鎖整被線程Dubbo-thread-499拿在手上;
線程Dubbo-thread-499在已獲得某種資源后,還想繼續(xù)獲取2205946f對(duì)象的鎖,而這把鎖整被線程thread_14拿在手上;
但是出自Doug Lea大神的ConcurrentHashMap怎么可能出現(xiàn)死鎖呢? 于是就在本地簡單寫了一段程序驗(yàn)證了一下:
java復(fù)制代碼import java.util.Map; import java.util.concurrent.ConcurrentHashMap; ?public class TestConcurrentMapDeadlock { ?? ?public static void main(String[] args) { ?? ? ? ?Map<String, Integer> concurrentHashMap1 = new ConcurrentHashMap<>(16); ?? ? ? ?Map<String, Integer> concurrentHashMap2 = new ConcurrentHashMap<>(16); ? ? ? ? ?new Thread(() -> concurrentHashMap1.computeIfAbsent("a", key -> { ?? ? ? ? ? ?concurrentHashMap2.computeIfAbsent("b", key2 -> 2); ?? ? ? ? ? ?return 1; ?? ? ? ?})).start(); ? ? ? ? ?new Thread(() -> concurrentHashMap2.computeIfAbsent("b", key -> { ?? ? ? ? ? ?concurrentHashMap1.computeIfAbsent("a", key2 -> 2); ?? ? ? ? ? ?return 1; ?? ? ? ?})).start(); ?? ?} }
在Intellij idea上運(yùn)行上面的代碼,并使用idea自帶的Dump Threads功能,會(huì)發(fā)現(xiàn)真的觸發(fā)死鎖了。
后來看了一下jdk 1.8的ConcurrentHashMap的computeIfAbsent源代碼,在并發(fā)的情況下,確實(shí)有概率性會(huì)觸發(fā)死鎖。
大概的執(zhí)行序列是:
1、生成ReservationNode預(yù)占節(jié)點(diǎn);
2、對(duì)該節(jié)點(diǎn)進(jìn)行加鎖(這里是重點(diǎn)),然后將該節(jié)點(diǎn)放入指定key的槽位中;
3、執(zhí)行我們傳入的計(jì)算邏輯,當(dāng)我們計(jì)算邏輯中包含有computeIfAbsent時(shí),此時(shí)代碼會(huì)重復(fù)上面的1~3步驟
到這里就大概明白了,當(dāng)執(zhí)行一次computeIfAbsent的嵌套邏輯時(shí),會(huì)有兩個(gè)ReservationNode對(duì)象會(huì)被加鎖,那在并發(fā)的情況下,是可能會(huì)產(chǎn)生死鎖的。
那具體是哪行代碼觸發(fā)的呢? 其實(shí)阿里的arms是有完整打印出來的,由于有敏感信息,這里不能貼出來。但是觸發(fā)的誘因可以說一下:
線程thread_14,是想更新一個(gè)用戶的手機(jī)號(hào)信息,對(duì)應(yīng)的代碼邏輯會(huì)操作兩個(gè)ConcurrentHashMap,先操作map1,再操作map2,這個(gè)兩個(gè)map是作為本地緩存使用的,都會(huì)對(duì)其進(jìn)行computeIfAbsent操作。而Dubbo-thread-499也是一樣,也會(huì)操作這兩個(gè)map,先操作map2,再操作map1。當(dāng)有并發(fā)的情況下,處理的又是同一個(gè)手機(jī)號(hào)的時(shí)候,就可能觸發(fā)死鎖。
至于thread_14操作完map1這個(gè)本地緩存后,為啥還要去操作map2這個(gè)本地緩存? ?我看了業(yè)務(wù)邏輯實(shí)現(xiàn)后,發(fā)現(xiàn)是沒有必要的,因?yàn)檫@兩份本地緩存的數(shù)據(jù),都有對(duì)應(yīng)的業(yè)務(wù)邏輯代碼去保證它的準(zhǔn)確性。后來問了一下開發(fā)這塊的老同事,得到的回復(fù)是:
順便更新一下另外一個(gè)map,提升一些性能。
好吧,這個(gè)就真的是好心做壞事了。
解決這次的死鎖的方案也很簡單,就是斷掉其中一條路,避免死鎖就可以了。正如剛才上面分析的,兩份本地緩存都有各自的業(yè)務(wù)邏輯去確保它的準(zhǔn)確性,沒必要順手去更新別人家的緩存。
在2023年3月16日發(fā)版后,直到今天,2023年3月20日,暫時(shí)沒有死鎖的告警了。