Java并行流引起的MySQL死锁

问题描述

之前(半年前)有同事在代码中使用并行流来操作数据库,基于MySQL 5.7版本默认的事务隔离级别RR(Repeatable Read - 可重复读),在测试环境中出现了死锁的问题。

解决方式

经过同事排查发现是在并发写的情况下触发MySQL的间隙锁再引发的死锁,最后把并行流改为串行解决问题,主要是数据量不大没有必要使用并行。

问题一、并行流的使用

具体分析可以看看这里☞什么时候使用并行流,翻译至Doug Lea大神的文章。

总结来说就是并行流并不一定能提升效率,是和数据量以及流中每个操作的复杂度挂钩的。而且Java底层采用ForkJoinPool的方式来实现,并行流的盲目使用反而会带来额外的线程创建的开销,拖慢程序响应速度。

问题二、MySQL事务隔离级别及读一致性问题分析

事务隔离级别

MySQL的事务隔离级别主要是为了解决并发事务(基于InnoDB)下的读一致性问题,主要有以下四种:

隔离级别 读数据一致性 脏读 不可重复读 幻读
未提交读(Read uncommitted) 最低级别,只能保证不读取物理上损坏的数据
已提交读(Read committed,RC) 语句级
可重复读(Read repeatable,RR) 事务级
可序列化(Serilizable) 最高级别,事务级
  • 脏读:事务一读取了事务二未提交的数据(MySQL buffer pool)。
  • 不可重复读:重点是数据是否被修改。事务一读取数据,事务二对数据进行了修改并提交,事务一再次以同样的条件读取,两次读取的数据不一致。
  • 幻读:主要是新增or删除操作,重点是数据是否存在。事务一针对某一条件的数据进行读取、修改,然后提交,事务二新增or删除了同样条件的数据,导致事务一提交后发现还存在没有修改到的数据or数据已经不存在。

实际上可能很多业务场景中为了保证数据库吞吐量,对于不可重复读和幻读问题有一定的容忍度,再就是RR级别下扫描的记录都要加锁,而RC级别下扫描过但不匹配的记录不会加锁,或者是先加锁再释放,这对扫描大量数据更新的场景影响很大。所以像阿里云的RDS MySQL的默认隔离级别就调整为RC,而不是RR。

MVVC、行锁、间隙锁

在MySQL中,读取实际上分为两种:快照读(官方定义:一致性非锁定读)和当前读

  • 快照读:读取的是数据在某一时刻的快照(如果数据行没有发生改变则直接读取),普通的select...where...。RC和RR级别下默认的读取方式。

  • 当前读:始终读取的是最新的数据,如select … lock in share mode、select … for update、insert、update、delete

MVVC(Multiversion Concurrency Control)多版本并发控制:是通过了乐观锁理论(类似版本号比较)的方式来避免快照读中的不可重复读和幻读问题。但InnoDB中实际的实现并不是简单的版本号控制,而是借助了undo log

  1. 修改前的数据会存放于undo log,通过一个滚动指针关联,就是保存了一份之前版本的数据,也是为了事务的回滚操作;

  2. 每行数据有一个事务标识符 - TRX_ID(插入或更新该行的最后一个事务的标识符),通过对比数据行最新的事务ID和当前事务的ID来判断当前事务是直接读取该行数据还是读取undo log中对当前事务可见的版本数据。

由于读取的数据要么是没被修改的,要么是历史版本的,所以在快照读的情况下不会出现不可重复读和幻读问题,但可能读取到过时的数据。

行锁 + 间隙锁:组合起来就叫Next-Key Lock,主要是为了解决RR级别当前读的情况下出现的不可重复读和幻读问题。

  • 行锁(记录锁,Record Lock):排他锁,直接加在索引记录(key)上,事务对数据修改时加行锁,保证可重复读。
  • 间隙锁(Gap Lock):共享锁,锁定某个事务扫描到的数据的索引记录的一个间隙(理解为一个范围),即使范围内不存在的数据也会被锁定,这样这个范围内的数据在事务未提交前肯定不会有变动,因为其他事务会被间隙锁阻塞。只针对非唯一索引,因为唯一键肯定不会有重复的插入。

场景复现

在理解了上述原理的情况下,就可以对问题进行复现:

  • 并行流开启了多个并行的事务,需要执行写操作,在MySQL 5.7默认的RR事务隔离级别下,会触发间隙锁
  • 多个事务同时执行当前读,获取了同一个间隙锁,互相等待,就产生了死锁。

Java并行流引起的MySQL死锁
https://luckycaesar.github.io/article/Java并行流引起的MySQL死锁/
作者
LuckyCaesar
发布于
2021年3月24日
许可协议