12

MySQL死锁与Spring事务 | 大名Dean鼎

 4 years ago
source link: https://www.deanwangpro.com/2020/04/12/spring-retry-with-tx/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

MySQL死锁从产品之初就偶有发生,算是萦绕在心中的噩梦之一。由于死锁大都伴随着锁等待,所以一般都会拉低服务QPS,在死锁发生时肯定会出现各种意料不到的问题。前期一直采用“指标不治本”的办法,对特别容易产生死锁的方法增加retry。但当retry和事务嵌套在一起时也会出现不可预知的错误。

对于数据库死锁这个万恶之源,真可谓深恶痛绝,所以这次在解决retry和事务嵌套问题时,将这个元凶也一并解决。

一些关于事务的概念

为了更好的说明问题,我们先来解释一下基本概念

TransactionDefinition 接口中定义了五个表示隔离级别的常量:

  • TransactionDefinition.ISOLATION_DEFAULT: 使用后端数据库默认的隔离级别,Mysql 默认采用的 REPEATABLE_READ隔离级别 Oracle 默认采用的 READ_COMMITTED隔离级别.
  • TransactionDefinition.ISOLATION_READ_UNCOMMITTED: 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  • TransactionDefinition.ISOLATION_READ_COMMITTED: 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  • TransactionDefinition.ISOLATION_REPEATABLE_READ: 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • TransactionDefinition.ISOLATION_SERIALIZABLE: 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

隔离级别是数据库和应用层(这里就是指Spring)都有的概念,一般来说应用层不应该修改隔离级别,都应默认使用数据库的隔离级别,也就是使用TransactionDefinition.ISOLATION_DEFAULT

事务传播行为

当一个事务方法被另一个事务方法调用时,对于已存在的事务如何处理?通过指定事务传播机制来解决。在TransactionDefinition定义中包括了如下几个表示传播行为:

支持当前事务的情况:

  • TransactionDefinition.PROPAGATION_REQUIRED: 如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
  • TransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  • TransactionDefinition.PROPAGATION_MANDATORY: 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。

不支持当前事务的情况:

  • TransactionDefinition.PROPAGATION_REQUIRES_NEW: 创建一个新的事务,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。

其他情况:

  • TransactionDefinition.PROPAGATION_NESTED: 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。

一般常用的就是PROPAGATION_REQUIRED。特别需要注意的是事务传播行为并不是数据库的定义,而是应用层(这里就是指Spring)引入的概念。

事务状态是如何传递的

假设默认使用PROPAGATION_REQUIRED,当一个事务方法A调用事务方法B(即都用@Transactional声明的方法),事务方法B为何知道此时已经存在事务了?

其实spring的事务管理器TransactionManager内部会持有当前线程的事务状态transcationInfo来判断一个事务是否已经开启。每个线程都要有一个状态,聪明的你一定会想到使用ThreadLocal这个框架必备的工具。

tx_threadlocal.png

那么对于嵌套事务,Spring是怎么实现子事务失败,父事务也失败呢?先来看commit方法,

tx_commit.png

看标注①的代码,如果TransactionStatus里的rollbackOnly是true,那么就会processRollback。继续跟进标注②的代码,看什么时候会设置这个标志位。在processCommit这个方法内,一旦捕获RuntimeException都会进入doRollbackOnCommitException方法,继续跟进这个方法

tx_rollback.png

可以看到一个条件分支,如果当前是新开启的事务,直接进行rollback。如果当前已有父事务,那么不会直接发送rollback到数据库,而是执行标注①的doSetRollbackOnly方法,设置TransactionStatus里的rollbackOnly=true。这样父事务在commit的时候就会rollback。

由于篇幅原因,源码部分就不过多展示了, 如果感兴趣可以自行debug一下就能了解其中的机制。

当 Spring Retry 与事务相遇

上文谈到我们在处理死锁时采用重试的方式解决,但并不是说任何方法都套上@Retryable就万事大吉了。

Retry的注意点

一般来说,在spring中使用retry非常的简单,在注解中定义好重试次数和避让策略就可以了。

1
2
3
4
5
6
@Override
@Retryable(maxAttempts = MAX_RETIES, value = {MySQLIntegrityConstraintViolationException.class, MySQLTransactionRollbackException.class}, backoff = @Backoff(delay = 100, multiplier = 2))
public Foo save(@Nullable Foo foo) {
// ...
// foo.setXXX(...)
}

值得注意是,如果方法内部会对参数对象进行修改,那么实际上每次retry的逻辑就都不是幂等的了,因为这一次的retry会受上一次的影响。假设这个save方法会根据有没有id来判断处理逻辑是创建还是更新,那么第二次的retry很有可能就会变成更新逻辑,而不是预期的创建逻辑了。如果参数是不可变的(immutable)那么可以很大程序上降低这种复杂度。这也告诉我们,immutable思想非常重要,可以在设计和编译阶段就解决很多“头疼”的问题。

回到正题,试想一下,如果这个save是个子事务方法会发生什么?按照上一节说的,第一次执行就会设置rollbackOnly=true,那么即使第二次重试成功也并不会将rollbackOnly设置为false,所以父事务依然会失败。换句话说,retry在子事务方法上是没有意义的。这点在编码时尤其需要注意。

数据库死锁

事务的问题说完,回到万恶之源的数据库死锁问题。

其实死锁的困扰已久,一般一周次数在几次到十几次不等。而且死锁的对象都是索引或者主键,查阅了一些资料觉得可能是并发写入比较严重造成,但线下模拟了很久也没有复现。次数倒也不多,也就先用重试策略抵挡一下。当然也就遇到了上文提及的问题。

近期由于业务增长,死锁发生的频繁愈发频繁,所以“治本”的任务也紧急地提上了日程。

死锁第一步分析肯定先从MySQL的死锁log入手

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
------------------------
LATEST DETECTED DEADLOCK
------------------------
2020-03-27 10:35:51 0x7f5124f91700
*** (1) TRANSACTION:
TRANSACTION 860653361, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 15 lock struct(s), heap size 1136, 7 row lock(s), undo log entries 2
MySQL thread id 11732538, OS thread handle 139984633485056, query id 5318215849 10.0.20.22 linkflow update
INSERT INTO contact_identity (contact_id, external_id, last_updated, tenant_id) VALUES (34868134, 'oPawF5kfWh7BjsgMesjfsndYc548', '2020-03-27 10:35:51.579', 1)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 433 page no 911218 n bits 600 index idx_t_contact of table `linkflow`.`contact_identity` trx id 860653361 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 412 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 8; hex 800000000000016e; asc n;;
1: len 8; hex 800000000107ef32; asc 2;;
2: len 8; hex 80000000020c7f31; asc 1;;

*** (2) TRANSACTION:
TRANSACTION 860653362, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
15 lock struct(s), heap size 1136, 7 row lock(s), undo log entries 2
MySQL thread id 11736226, OS thread handle 139986489382656, query id 5318215869 10.0.20.22 linkflow update
INSERT INTO contact_identity (contact_id, external_id, last_updated, tenant_id) VALUES (34868135, 'oPawF5t1K1_a1i96ZDrJQRx22T8w','2020-03-27 10:35:51.588', 1)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 433 page no 911218 n bits 600 index idx_t_contact of table `linkflow`.`contact_identity` trx id 860653362 lock mode S locks gap before rec
Record lock, heap no 412 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 8; hex 800000000000016e; asc n;;
1: len 8; hex 800000000107ef32; asc 2;;
2: len 8; hex 80000000020c7f31; asc 1;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 433 page no 911218 n bits 600 index idx_t_contact of table `linkflow`.`contact_identity` trx id 860653362 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 412 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 8; hex 800000000000016e; asc n;;
1: len 8; hex 800000000107ef32; asc 2;;
2: len 8; hex 80000000020c7f31; asc 1;;

*** WE ROLL BACK TRANSACTION (2)

这是两个insert操作,事务2失败,是由于等待索引idx_t_contact的排他锁。

我们分析一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
死锁的索引约束
KEY `idx_t_contact` (`tenant_id`,`contact_id`),
产生死锁的原因是:
TRANSACTION 860653361,插入的记录是:INSERT INTO contact_identity (contact_id, external_id, last_updated, tenant_id) VALUES (34868134, 'oPawF5kfWh7BjsgMesjfsndYc548', '2020-03-27 10:35:51.579', 1)

他被阻塞,需要等待这个记录的锁提供:
0: len 8; hex 800000000000016e; asc n;;
1: len 8; hex 800000000107ef32; asc 2;;
2: len 8; hex 80000000020c7f31; asc 1;;

而这个记录的锁,被事务TRANSACTION 860653362持有了,没释放,没释放的情况下,接着在事务860653361后,又执行了一个插入(在860653362的事务中):
INSERT INTO contact_identity (contact_id, external_id, last_updated, tenant_id) VALUES (34868135, 'oPawF5t1K1_a1i96ZDrJQRx22T8w','2020-03-27 10:35:51.588', 1)

它也需要这个记录的锁(其实它已经持有了这个记录的锁,但是他需要在队列里等待):
0: len 8; hex 800000000000016e; asc n;;
1: len 8; hex 800000000107ef32; asc 2;;
2: len 8; hex 80000000020c7f31; asc 1;;

这样就造成了死循环,产生死锁

这个就挺奇怪的,两个insert没有涉及到同一个主键或者同一个唯一索引,居然会持有S锁,并造成相同等待。咨询了阿里云的售后DBA,表示很有可能是在插入前有一个产生S锁的操作导致了这个问题,比如锁定行记录,或者是更新操作。但在我们的业务中,在写入之前只有查询操作,查询记录为空后执行插入,按理说是不会产生S锁的。之后我们又逐个的排查了应用日志中输出的SQL,也没有发现会加S锁的操作。

这一下线索又断了,在与阿里云的DBA沟通了一天后也没什么进展,期间各种查binlog也没有任何发现。好在最后DBA建议我们打开RDS的SQL洞察功能,看看会不会有什么蛛丝马迹。虽然这个功能是收费的,不过也正是由于它我们才找到了真正的元凶。

SQL洞察购买以后才会开始收集,所以这时我们只需要静静地等待死锁的再次发生。由于是回顾,我们还是以刚刚死锁日志中的两个事务来看下SQL洞察中的记录。

tx1.png
tx2

可以看到在两次查询时都执行了SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;,这个就非常奇怪了,而且正是由于这个操作导致了死锁,因为设置隔离级别为串行化以后,所以查询都会加S锁。

我们来模拟下死锁过程

事务1 事务2
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
select * from contact_identity where external_id=”abc123” and tenant_id = 1; select * from contact_identity where external_id=”123abc” and tenant_id = 1;
INSERT INTO contact_identity (contact_id, external_id, TENANT_ID) VALUES (9999, ‘123abc’, 1);
INSERT INTO contact_identity (contact_id, external_id, TENANT_ID) VALUES (9999, ‘abc123’, 1);
死锁 成功

事务1和事务2都开启了串行化,那么在查询时会将满足条件的索引都加上S锁,这里锁的就是tenant_id=1的记录,这个时候其实在事务2的第三步插入时就会产生锁等待,因为它在等待事务1释放S锁。这时事务1也提交一个插入操作,并且contact_id和tenant_id都一样(命中idx_t_contact索引),就会产生死锁,事务1失败回滚, 事务2成功。

deadlock.png

既然知道了造成了死锁的原因,但是究竟是什么代码会设置隔离级别为串行化呢?

我们排查了所有代码依然没有任何发现,直觉告诉我,应该是某些三方库的设置导致的。目前应用中涉及到数据库的三方库也并不是很多,决定逐个摸排。

这里还得提一个阿里出品的线上调试神器arthas,拿来直接watch mysql驱动内connectionImplsetTransactionIsolation方法,只要有触发了改变隔离级别的操作,那么立刻就可以看到输出。当然这一切都是在staging环境进行的。

一起准备就绪后,我就开始通过UI操作可疑的功能。果然在进行数据导出的时候,发现arthas的控制台有输出了,从图上可以看到隔离级别在 2 (READ_COMMITTED) 和 8 (SERIALIZABLE) 之间来回切换。

arthas_deadlock.png

其实这样的切换和SQL洞察的输出也对的上,在事务结束后会将隔离级别恢复到数据库默认级别。阅读源码得知,设置回READ_COMMITTED是HikariCP的逻辑,在connection还回pool的时候会reset connection到默认级别。

原因弄清楚了,接下来着手解决。导出业务使用的是spring batch做异步任务。分析其源码,发现默认隔离级别是ISOLATION_SERIALIZABLE

tx_spring_batch.png

所以确实是该三方库导致的。解决很简单,既然都已经有public的setter了,那么初始化时直接设置其隔离级别,setIsolationLevelForCreate("ISOLATION_DEFAULT"),避免使用默认值。修改以后再进行测试,确实没有修改隔离级别的情况出现了。

可还是有疑问,我们的查询业务跟spring batch并没有关系。另一方面,即使在使用spring batch后,SERIALIZABLE的connection也应该被重置回了READ_COMMITTED,那么业务中获得connection时应当还是READ_COMMITTED,不应该出现问题。正好我们有另一个服务也使用spring batch,测试下来发现过程就如上面预想的,一切正常。看来还有更深的秘密等待着我…

根据arthas给出的调用栈信息,定位到spring-orm的EclipseLinkJpaDialect这个方言适配类(我们使用的是EclipseLink而不是Hibernate)。

tx_spring_orm_eclipselink.png

问题就出在划红线的这行。uow.getLogin()获得是一个可复用的对象(简单地认为是个单例就好),这个值在设置成SERIALIZABLE后,后续的操作都不能被设置回来。为什么后面的执行代码都不能满足definition.getIsolationLevel() != TransactionDefinition.ISOLATION_DEFAULT这个条件判断呢?我们来脑补一下步骤:

  1. 执行导出,这时由于使用了spring batch,在获取connection时将其隔离级别设置为SERIALIZABLE (8)。
  2. 执行完成后,将connection放回pool,HikariCP恢复connection的隔离级别为READ_COMMITTED (2)。
  3. 一个非导出业务,需要执行数据库操作,从HikariCP的pool中借出一个connection,此时TransactionDefinitionIsolationLevel是默认值ISOLATION_DEFAULTTransactionDefinition其实就是方法上@Transactional注解的信息,一般都不会设置IsolationLevel的)。所以无法满足条件判断,uow.getLogin()的隔离级别还是第1步设置的SERIALIZABLE,这也是为什么READ_COMMITTED (2)又会被设置成SERIALIZABLE (8) 的原因。
  4. 这个业务执行完成后,将connection放回pool,HikariCP恢复connection的隔离级别为READ_COMMITTED (2)。

总算水落石出了,是spring-orm中一个“全局变量”的状态设置出现了问题。我也给spring-orm提了issue,并提供了最简Project来方便复现这个bug。感兴趣的可以看一下这个project。

其实spring batch也算是躺枪,假设我们手动设置一个事务隔离级别,也会触发这个异常。

1
2
3
4
5
public interface UserRepository extends JpaRepository<User, Long> {
@Transactional(isolation = Isolation.SERIALIZABLE)
@Override
List<User> findAll();
}

所以 root casue 并不是spring batch设置了SERIALIZABLE的隔离级别,而是uow.getLogin()的状态处理有bug。

整个问题解决差不多用了3天,特别感谢阿里云RDS的SQL洞察服务和阿里出品的arthas(真不是软广)。

全局state设置一直是个难题,无论是客户端代码还是服务端代码都会面临相同的问题,当我们希望用尽量少的开销保存更多的状态时,复杂度就难以避免。即便Pivotal团队的大神也不能保证bug free。Trouble shooting就像侦破案件一般,有时候黑夜中唯一的一束光就能带来真相,但往往需要巨大的耐心等待它的出现。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK