写点什么

MySQL 死锁与 Spring 事务

用户头像
Dean
关注
发布于: 2020 年 04 月 29 日
MySQL死锁与Spring事务

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



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

一些关于事务的概念



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

隔离级别



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



  • TransactionDefinition.ISOLATIONDEFAULT: 使用后端数据库默认的隔离级别,Mysql 默认采用的 REPEATABLEREAD隔离级别 Oracle 默认采用的 READ_COMMITTED隔离级别.

  • TransactionDefinition.ISOLATIONREADUNCOMMITTED: 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读

  • TransactionDefinition.ISOLATIONREADCOMMITTED: 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生

  • TransactionDefinition.ISOLATIONREPEATABLEREAD: 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。

  • TransactionDefinition.ISOLATION_SERIALIZABLE: 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。



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



事务传播行为



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



支持当前事务的情况:



  • TransactionDefinition.PROPAGATION_REQUIRED: 如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。

  • TransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。

  • TransactionDefinition.PROPAGATION_MANDATORY: 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。



不支持当前事务的情况:



  • TransactionDefinition.PROPAGATIONREQUIRESNEW: 创建一个新的事务,如果当前存在事务,则把当前事务挂起。

  • TransactionDefinition.PROPAGATIONNOTSUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。

  • TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。



其他情况:



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



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



事务状态是如何传递的



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



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





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





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





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



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



当 Spring Retry 与事务相遇



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



Retry的注意点



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



@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入手



------------------------
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 contactidentity (contactid, externalid, lastupdated, tenantid) 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 idxtcontact of table linkflow.contactidentity trx id 860653361 lockmode X locks gap before rec insert intention waiting
Record lock, heap no 412 PHYSICAL RECORD: nfields 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 contactidentity (contactid, externalid, lastupdated, tenantid) VALUES (34868135, 'oPawF5t1K1a1i96ZDrJQRx22T8w','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 idxtcontact of table linkflow.contactidentity trx id 860653362 lock mode S locks gap before rec
Record lock, heap no 412 PHYSICAL RECORD: nfields 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 idxtcontact of table linkflow.contactidentity trx id 860653362 lockmode 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失败,是由于等待索引idxtcontact的排他锁。



我们分析一下



死锁的索引约束
KEY idxtcontact (tenantid,contactid),
产生死锁的原因是:
TRANSACTION 860653361,插入的记录是:INSERT INTO contactidentity (contactid, externalid, lastupdated, tenantid) 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 contactidentity (contactid, externalid, lastupdated, tenantid) 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洞察中的记录。



事务1





事务2





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



我们来模拟下死锁过程





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





分析



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



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



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



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





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



解决



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





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



寻根溯源



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



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





问题就出在划红线的这行。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是默认值ISOLATIONDEFAULTTransactionDefinition其实就是方法上@Transactional注解的信息,一般都不会设置IsolationLevel的)。所以无法满足条件判断,uow.getLogin()的隔离级别还是第1步设置的SERIALIZABLE,这也是为什么READCOMMITTED (2)又会被设置成SERIALIZABLE (8) 的原因。

  4. 这个业务执行完成后,将connection放回pool,HikariCP恢复connection的隔离级别为READ_COMMITTED (2)。



总算水落石出了,是spring-orm中一个“全局变量”的状态设置出现了问题。我也给spring-orm提了[issue](https://github.com/spring-projects/spring-framework/issues/24802),并提供了[最简Project](https://github.com/deanwong/spring-orm-sample)来方便复现这个bug。感兴趣的可以看一下这个project。



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



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就像侦破案件一般,有时候黑夜中唯一的一束光就能带来真相,但往往需要巨大的耐心等待它的出现。


转载自本人博客

原文地址:https://www.deanwangpro.com/2...



发布于: 2020 年 04 月 29 日阅读数: 1457
用户头像

Dean

关注

还未添加个人签名 2014.04.20 加入

还未添加个人简介

评论

发布
暂无评论
MySQL死锁与Spring事务