转载

MySQL死锁与Spring事务

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这个框架必备的工具。

MySQL死锁与Spring事务

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

MySQL死锁与Spring事务

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

MySQL死锁与Spring事务

可以看到一个条件分支,如果当前是新开启的事务,直接进行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 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的排他锁。

我们分析一下

死锁的索引约束
 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洞察中的记录。

事务1

MySQL死锁与Spring事务

事务2

MySQL死锁与Spring事务

可以看到在两次查询时都执行了 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成功。

MySQL死锁与Spring事务

分析

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

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

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

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

MySQL死锁与Spring事务

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

解决

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

MySQL死锁与Spring事务

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

寻根溯源

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

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

MySQL死锁与Spring事务

问题就出在划红线的这行。 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也算是躺枪,假设我们手动设置一个事务隔离级别,也会触发这个异常。

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

原文  http://www.deanwangpro.com/2020/03/08/2020-0312-spring-retry-with-tx/
正文到此结束
Loading...