概述


最近一向在为体系的稳定性尽力着,凡是线上有一些问题,都不轻易放过。尤其是在2023年,大环境欠好的情况下,如果it团队体系稳定性都做的欠好的话,很简略提桶走人的。

事情是这样的,在2023年3月8日的晚上七点左右,调用B服务RPC接口的其他服务,都陆续开端报【接口调用超时反常】,B服务已经有一个多月没有上线过了,而出故障的时刻当天,流量也没陡增。

这种忽然出问题,但跟流量和发版又没有关系的,一般便是先重启,由于大约率是触发某个躲藏的bug导致服务慢慢不行用了。留意,当时是没让运维dump pod的运转信息的,由于线上报错的信息比较多了,也影响到了用户,只能先止损。果然重启后,过错信息立马消失了,一向到当天凌晨,都没有再报错了。

可是单单看一堆超时的过错信息,一时之间,是很难找出根因的。那天我一向看到了晚上11点,只是得到一个浅显的定论:

过错信息,集中在B服务的某些pod上,有蛮多线程block住了。

隔天早上回到办公室后,就请求让B服务开通arms(阿里的应用实时监控服务),坐等过错再次发生。阿里的arms还是很强壮的,可是是付费的且不便宜,一般平常不开的。

一向等到了3月9号的下午五点多,B服务的接口又开端超时了,这次我赶忙到arms的事情中心大盘里,看看有无反常的事情发生,猜我看到了啥?

工作十几年,第一次在线上遇到死锁问题

居然有死锁,生平第一次在线上遇到过。查看了arms打印出来的详细日志,发现是两个线程,在两个ConcurrentHashMap目标之间,相互等待了。

[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在已取得某种资源后,还想持续获取687bfd0d目标的锁,而这把锁整被线程Dubbo-thread-499拿在手上;
  • 线程Dubbo-thread-499在已取得某种资源后,还想持续获取2205946f目标的锁,而这把锁整被线程thread_14拿在手上;

可是出自Doug Lea大神的ConcurrentHashMap怎么可能呈现死锁呢? 所以就在本地简略写了一段程序验证了一下:

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上运转上面的代码,并运用idea自带的Dump Threads功能,会发现真的触发死锁了。

工作十几年,第一次在线上遇到死锁问题

工作十几年,第一次在线上遇到死锁问题

后来看了一下jdk 1.8的ConcurrentHashMap的computeIfAbsent源代码,在并发的情况下,确实有概率性会触发死锁。

工作十几年,第一次在线上遇到死锁问题

大约的履行序列是:

  • 1、生成ReservationNode预占节点;
  • 2、对该节点进行加锁(这里是要点),然后将该节点放入指定key的槽位中;
  • 3、履行咱们传入的核算逻辑,当咱们核算逻辑中包括有computeIfAbsent时,此刻代码会重复上面的1~3步骤

到这里就大约理解了,当履行一次computeIfAbsent的嵌套逻辑时,会有两个ReservationNode目标会被加锁,那在并发的情况下,是可能会发生死锁的。

那具体是哪行代码触发的呢? 其实阿里的arms是有完整打印出来的,由于有灵敏信息,这里不能贴出来。可是触发的诱因可以说一下:

线程thread_14,是想更新一个用户的手机号信息,对应的代码逻辑会操作两个ConcurrentHashMap,先操作map1,再操作map2,这个两个map是作为本地缓存运用的,都会对其进行computeIfAbsent操作。而Dubbo-thread-499也是一样,也会操作这两个map,先操作map2,再操作map1。当有并发的情况下,处理的又是同一个手机号的时候,就可能触发死锁。

至于thread_14操作完map1这个本地缓存后,为啥还要去操作map2这个本地缓存? 我看了事务逻辑实现后,发现是没有必要的,由于这两份本地缓存的数据,都有对应的事务逻辑代码去保证它的准确性。后来问了一下开发这块的老同事,得到的回复是:

顺便更新一下另外一个map,提高一些功能。

好吧,这个就真的是好意做坏事了。

解决这次的死锁的方案也很简略,便是断掉其中一条路,防止死锁就可以了。正如方才上面分析的,两份本地缓存都有各自的事务逻辑去保证它的准确性,没必要随手去更新别人家的缓存

在2023年3月16日发版后,直到今日,2023年3月20日,暂时没有死锁的告警了。

本文正在参加「金石计划」