MySQL死锁从产品之初就偶有发生,算是萦绕在心中的噩梦之一。由于死锁大都伴随着锁等待,所以一般都会拉低服务QPS,在死锁发生时肯定会出现各种意料不到的问题。前期一直采用“指标不治本”的办法,对特别容易产生死锁的方法增加retry。但当retry和事务嵌套在一起时也会出现不可预知的错误。
对于数据库死锁这个万恶之源,真可谓深恶痛绝,所以这次在解决retry和事务嵌套问题时,将这个元凶也一并解决。
为了更好的说明问题,我们先来解释一下基本概念
TransactionDefinition 接口中定义了五个表示隔离级别的常量:
隔离级别是数据库和应用层(这里就是指Spring)都有的概念,一般来说应用层不应该修改隔离级别,都应默认使用数据库的隔离级别,也就是使用TransactionDefinition.ISOLATION_DEFAULT
当一个事务方法被另一个事务方法调用时,对于已存在的事务如何处理?通过指定事务传播机制来解决。在TransactionDefinition定义中包括了如下几个表示传播行为:
一般常用的就是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一下就能了解其中的机制。
上文谈到我们在处理死锁时采用重试的方式解决,但并不是说任何方法都套上 @Retryable
就万事大吉了。
一般来说,在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
事务2
可以看到在两次查询时都执行了 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成功。
既然知道了造成了死锁的原因,但是究竟是什么代码会设置隔离级别为串行化呢?
我们排查了所有代码依然没有任何发现,直觉告诉我,应该是某些三方库的设置导致的。目前应用中涉及到数据库的三方库也并不是很多,决定逐个摸排。
这里还得提一个阿里出品的线上调试神器 arthas
,拿来直接watch mysql驱动内 connectionImpl
的 setTransactionIsolation
方法,只要有触发了改变隔离级别的操作,那么立刻就可以看到输出。当然这一切都是在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也应该被重置回了READ_COMMITTED,那么业务中获得connection时应当还是READ_COMMITTED,不应该出现问题。正好我们有另一个服务也使用spring batch,测试下来发现过程就如上面预想的,一切正常。看来还有更深的秘密等待着我…
根据 arthas
给出的调用栈信息,定位到spring-orm的 EclipseLinkJpaDialect
这个方言适配类(我们使用的是EclipseLink而不是Hibernate)。
问题就出在划红线的这行。 uow.getLogin()
获得是一个可复用的对象(简单地认为是个单例就好),这个值在设置成SERIALIZABLE后,后续的操作都不能被设置回来。为什么后面的执行代码都不能满足 definition.getIsolationLevel() != TransactionDefinition.ISOLATION_DEFAULT
这个条件判断呢?我们来脑补一下步骤:
TransactionDefinition
的 IsolationLevel
是默认值 ISOLATION_DEFAULT
( TransactionDefinition
其实就是方法上 @Transactional
注解的信息,一般都不会设置 IsolationLevel
的)。所以无法满足条件判断, uow.getLogin()
的隔离级别还是第1步设置的SERIALIZABLE,这也是为什么READ_COMMITTED (2)又会被设置成SERIALIZABLE (8) 的原因。 总算水落石出了,是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就像侦破案件一般,有时候黑夜中唯一的一束光就能带来真相,但往往需要巨大的耐心等待它的出现。