小死锁

文章目录
  1. 1. 背景
  2. 2. 问题原因
  3. 3. 解决办法
  4. 4. 总结
  5. 5. 遗留问题

最近线上偶尔就会报个死锁问题,上周终于解决了,周末整理下。虽然问题解决了,但是trace file里的死锁图还是不太理解。要是有人能给我讲讲那真是极好的,要是没人的话我就。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。再翻翻文档。

背景

近一个月线上偶尔就会报死锁的问题,错误如下图:

“ORA-00060: deadlock detected while waiting for resource ”这个错误说明Oracle 数据库监测到了死锁的存在。这时 Oracle 会回滚造成死锁的其中一个事务,另一个事务正常执行(不会察觉到发生了死锁),并向执行了回滚的事务抛出上面的错误信息。

在 DBA 的帮助下定位到了造成死锁的两块代码。由于项目有很多的悲观锁,即利用“SELECT…FOR UPDATE”对资源加排他行级锁,所以第一感觉就是看看这两段代码有没有按照相反的顺序对两个或多个资源进行加锁。

不过分析过代码之后却没有立刻找到可能造成死锁的原因,两块代码对数据库资源的操作如下表。

从表面上看Session 1貌似中只锁了 actor1 并更新,Session 2中依次锁了 actor2 和 actor1,不满足互相等待对方加锁的资源,就算是Session1持有actor1锁时间过长,导致 Session2 一直拿不到 actor1 的锁,也应该报“lock wait timeout”,而不是死锁。

为了验证确实是这两段代码造成的死锁,写了测试代码,开了两个线程,模仿死锁的这两段代码,去掉了与数据库无关的业务逻辑,看看能否重现。毕竟心里还有点小怀疑,会不会是 DBA 搞错了,不是这两段代码的问题。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@SpringApplicationConfiguration(classes = DeadLockTest.class)
@ImportAutoConfiguration({CommonConfig.class})
public class DeadLockTest extends BaseUnitDbTest {
Long lenderId = 16642L;
Long borrowerId = 16643L;
@Autowired
private ActorService actorService;
@Autowired
@Qualifier(CommonConfig.ORACLE_TRANSACTION_MANAGER_NAME)
private PlatformTransactionManager platformTransactionManager;
private ExecutorService es = Executors.newFixedThreadPool(5, new ThreadFactoryBuilder().setNameFormat("Test-Thread-%d").build());
@Test
public void testRefreshAndLockActor() throws Exception {
es.invokeAll(Lists.newArrayList(this::lock1, this::lock2));
}
public Void lock1() {
TransactionTemplate t = new TransactionTemplate(platformTransactionManager);
t.execute((s) -> {
System.out.println("Before Lock " + Thread.currentThread().getName());
Actor lender = actorService.refreshAndLockActor(lenderId);
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
lender.setLockedForInv(BigDecimal.ONE);
actorService.update(lender);
System.out.println("After Lock " + Thread.currentThread().getName());
return null;
});
return null;
}
public Void lock2() {
TransactionTemplate t = new TransactionTemplate(platformTransactionManager);
t.execute((s) -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Before Lock " + Thread.currentThread().getName());
Actor borrower = actorService.refreshAndLockActor(borrowerId);
Actor lender = actorService.refreshAndLockActor(lenderId);
System.out.println("After Lock " + Thread.currentThread().getName());
return null;
});
return null;
}
}

结果。。。真的报了死锁。。。。

控制台的报错如下图

问题原因

已经确定了造成死锁的两段代码,接下来就差找出原因了。
SELECT ... FOR UPDATE比较直观,就是对资源加行级排他锁,应该没什么猫腻。那就肯定是在UPDATE Actor1的时候有什么不为人知的操作,导致 Session1 需要获取 Actor2 的锁,导致死锁。

第一怀疑的是触发器,虽然目前公司已经禁止使用触发器了,但由于历史原因主库还是遗留着一些触发器。和明佳排查了所有相关的触发器之后,基本排除了是由触发器引起的。

不过虽然这个问题不是触发器的锅,但还是提会到了,在涉及到触发器时,如果不是对系统特别熟,排查错误真的很困难。。。

排除了触发器之后,DBA 提出那就只能是外键导致的了。在 DBA 把 dev环境数据库的外键去掉后,再次执行测试代码,果然就不再报死锁了。

原来 Actor 表上的 refer_id 是一个关联 Actor 表主键的外键(Self-Referential Integrity Constraints),而 actor1 的 refer_id 正好是 actor2 的 id,所以在更新 actor1 的全字段的时候,也更新了 refer_id(其实值没变),由于外键的约束,在将 actor1 的 refer_id 更新为 actor2的 id 时,需要确保 actor2 是存在的,并且在更新过程中,不能被删除,所以 Session1 会申请 actor2 的锁(个人理解不一定准确)。而这时 actor2 的锁已经被 Session2 持有了,并且 Session2 正在等待 actor1 的锁,就发生了死锁。

用图来描述下:
deadlock

解决办法

费了一天多才把问题找出来,用了几分钟就 fix 了。其实我只是需要更新 Actor 上的两个字段,根本不需要更新全部字段,只是当时在写的时候已经有更新全字段的方法了,就偷了个懒。。。。。。

所以解决办法就是不再调用更新全字段的方法,加了个只更新部分字段的方法,这样就不会在更新 actor1 的外键字段了,也就不会造成在更新 actor1 的时候去请求 actor2 的锁了。

总结

  • 虽然这个问题不是触发器引起的,但禁用触发器还是很有道理滴,不然出问题查到吐血
  • 外键这个东东,也能不用就不用吧,由程序控制。其实现在公司已经不让用外键和触发器了,不过由于历史原因,一些老系统只能慢慢重构了。查了下,由于外键引起的死锁还是蛮多的,比较常见的是外键列不加索引,导致更新主表字段时锁住了子表,下篇blog可以学习下外键和死锁不得不说的那些事。算了,还是不立 flag 了,基本上说了下篇要写啥的,都没有下篇了。。。。
  • 不要更新全字段。抛开这个死锁问题,更新全字段也是很影响效率的。还是只更新有改动的字段吧。
  • 不能偷懒,当时省了5分钟,找 bug 花了一天多。。。都是泪

遗留问题

问题虽然解决了,但是还有点小疑问的。这个死锁在 trace file 中的死锁图如下:
trace file

那么问题来了,两个 session 持有两个资源的 X 锁还是好理解的,但他们等待的为什么是 S 锁呢???至少 Session2 是在等待 actor1 的排他行级锁的,不应该是也是等待 X 么。求好心人的解答。

分享到 评论