并发事务产生问题的解决方案

在事务并发执行时可能会更新相同的记录,更新相同记录的情况大致可分为以下两种:

1. 写-写

  • 写-写情况:即并发事务相继对相同的记录做出改动。

前面说过,在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,即一开始是没有锁和记录关联的。当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁,如果没有则会在内存中生成一个锁结构与该行记录进行关联。例如事务 T1 要对某条记录做改动,就需要生成一个锁结构与之关联:
image.png
其实在 锁结构 里有很多信息,这里为了简化只把两个比较重要的属性拿了出来:

  • trx 信息:代表这个锁结构是哪个事务生成的。
  • is_waiting:代表当前事务是否在等待。

当事务 T1 改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以 is_waiting 属性值为 false,我们把这个场景称为加锁成功,然后就可以继续执行操作了。

在事务 T1 提交之前,另一个事务 T2 也想对该记录做改动,那么先去看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的 is_waiting 属性值为 true,表示当前事务需要等待,我们把这个场景称为加锁失败,整个过程如下图所示:
image.png
在事务 T1 提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务 T2 还在等待获取锁,所以把事务 T2 对应的锁结构的 is_waiting 属性设为 false,然后把该事务对应的线程唤醒,让它继续执行,此时事务 T2 就算获取到锁了。
image.png

2. 读-写、写-读

  • 读-写或写-读情况:也就是一个事务进行读取操作,另一个进行改动操作。在这种情况下可能发生脏读、不可重复读、幻读的问题。

SQL 标准规定不同隔离级别下可能发生的问题不一样:

  • 在 READ UNCOMMITTED 隔离级别下,脏读、不可重复读、幻读都可能发生。
  • 在 READ COMMITTED 隔离级别下,不可重复读、幻读可能发生,脏读不可以发生。
  • 在 REPEATABLE READ 隔离级别下,幻读可能发生,脏读和不可重复读不可以发生。
  • 在 SERIALIZABLE 隔离级别下,上述问题都不可以发生。

不过各个数据库厂商对 SQL 标准的支持都可能不一样,与 SQL 标准不同的一点就是,MySQL 在 REPEATABLE READ 隔离级别实际上就已经解决了幻读问题。那 MySQL 是怎么解决脏读、不可重复读、幻读的问题呢?其实有两种可选的解决方案:

方案一:读操作利用多版本并发控制(MVCC),写操作进行加锁。

所谓的 MVCC 其实就是通过生成一个 ReadView,然后通过 ReadView 找到符合条件的记录版本(历史版本是由 undo 日志构建的),查询语句只能读到在生成 ReadView 之前已提交事务所做的更改,在生成 ReadView 之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用 MVCC 时,读-写操作并不冲突。

普通的 SELECT 语句在 READ COMMITTED 和 REPEATABLE READ 隔离级别下会使用到 MVCC 读取记录。在 READ COMMITTED 隔离级别下,一个事务在执行过程中每次执行 SELECT 操作时都会生成一个 ReadView,ReadView 的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就避免了脏读。REPEATABLE READ 隔离级别下,一个事务在执行过程中只有第一次执行 SELECT 操作才会生成一个 ReadView,之后的 SELECT 操作都复用这个 ReadView,这样也就避免了不可重复读和幻读的问题。

方案二:读、写操作都采用加锁的方式。

如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录时就需要对其进行加锁操作,这样也就意味着读操作和写操作也要像写-写操作那样排队执行。

脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录时就给这条记录加锁,那么当前事务就无法继续读取该记录了,也就不会有脏读问题的产生了。

不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动并提交后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。

幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了 新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,我们把新插入的那些记录称为幻影记录。采用加锁的方式解决幻读问题就有一点麻烦了,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候并不知道要给谁加锁,这个的解决方案我们后面说。

很明显,采用 MVCC 方式的话,读-写操作彼此并不冲突,性能更高,采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用 MVCC 来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行,那也是没有办法的事。

2.1 一致性非锁定读

事务利用 MVCC 进行的读取操作称之为一致性读(Consistent Reads)或者一致性非锁定读。所有普通的 SELECT 语句在 READ COMMITTED、REPEATABLE READ 隔离级别下都算是一致性读,比如:

  1. SELECT * FROM t;
  2. SELECT * FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2

一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。

2.2 一致性锁定读

共享锁和独占锁:

由于在并发事务的写-写、读-写或写-读这些情况下可能会引起一些问题,需要使用 MVCC 或者加锁的方式来解决它们。在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以 MySQL 给锁进行了个分类:

  • 共享锁(Shared Locks),简称 S 锁。在事务要读取一条记录时,需要先获取该记录的 S 锁。
  • 独占锁(Exclusive Locks),简称 X 锁。在事务要改动一条记录时,需要先获取该记录的 X 锁。

假如事务 T1 首先获取了一条记录的 S 锁之后,事务 T2 接着也要访问这条记录。如果事务 T2 想要再获取一个记录的 S 锁,那么事务 T2 也会获得该锁,也就意味着事务 T1 和 T2 在该记录上同时持有 S 锁。如果事务 T2 想要再获取一个记录的 X 锁,那么此操作会被阻塞,直到事务 T1 提交之后将 S 锁释放掉。如果事务 T1 先获取了一条记录的 X 锁,那么不管事务 T2 接着想获取该记录的 S 锁还是 X 锁都会被阻塞,直到事务 T1 提交。

锁定读的语句:

对读取的记录加 S 锁:

  1. SELECT ... LOCK IN SHARE MODE;

也就是在普通的 SELECT 语句后边加 LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加 S 锁,这样允许别的事务继续获取这些记录的 S 锁,但是不能获取这些记录的 X 锁(比如别的事务使用 SELECT … FOR UPDATE 语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的 X 锁,那么它们会阻塞,直到当前事务提交后将这些记录上的 S 锁释放掉。

对读取的记录加 X 锁:

  1. SELECT ... FOR UPDATE;

也就是在普通的 SELECT 语句后边加 FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加 X 锁,这样既不允许别的事务获取这些记录的 S 锁,也不允许获取这些记录的 X 锁。如果别的事务想要获取这些记录的 S 锁或者 X 锁,那么它们会阻塞,直到当前事务提交后将这些记录上的 X 锁释放掉。

在可重复读隔离级别,事务 T 启动时会创建一个一致性视图 ReadView,之后事务 T 执行期间,即使有其他事务修改了数据,事务 T 看到的仍然跟在启动时看到的一样。但一个事务要更新时,如果刚好有另外一个事务拥有这一行的行锁,那它会被锁住进入等待状态。此时既然进入了锁等待状态,那等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?我们以下图为例进行分析,假设当前值为 (1,1)。
image.png
实际上,事务 B 查到的 k 值是 3,而事务 A 查到的 k 值是 1。事务 A 查询的结果是符合预期的,但事务 B 的 update 语句,如果按照一致性读,好像结果不对。因为事务 B 的视图数组是先生成的,之后事务 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?

实际上,如果事务 B 要更新数据时,它就不能再在历史版本上更新了,否则事务 C 的更新就丢失了。因此,事务 B 的更新是在(1,2)的基础上进行的更新。所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读只能读当前的值,也被叫做一致性锁定读

2.3 写操作

平常所用到的写操作无非是 DELETE、UPDATE、INSERT 这三种:

DELETE:
对一条记录做 DELETE 操作的过程其实是先在 B+ 树中定位到这条记录的位置,然后获取一下这条记录的 X 锁,然后再执行 delete mark 操作。

UPDATE:
在对一条记录做 UPDATE 操作时分为三种情况:

  • 如果未修改该记录的主键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在 B+ 树中定位到这条记录的位置,然后再获取一下记录的 X 锁,最后在原记录的位置进行修改操作。
  • 如果未修改该记录的主键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在 B+ 树中定位到这条记录的位置,然后获取一下记录的 X 锁,将该记录彻底删除掉(就是把该记录彻底移入垃圾链表),最后再插入一条新记录。新插入的记录由 INSERT 操作提供的隐式锁进行保护。
  • 如果修改了该记录的主键值,则相当于在原记录上做 DELETE 操作之后再来一次 INSERT 操作,加锁操作就需要按照 DELETE 和 INSERT 的规则进行了。

INSERT:
一般情况下,新插入一条记录的操作并不加锁,InnoDB 通过隐式锁来保护这条新插入的记录在本事务提交前不被别的事务访问。当然,在一些特殊情况下 INSERT 操作也是会获取锁的。

意向锁

上面提到的锁都是针对记录的,也可以称其为行锁,对一条记录加锁影响的也只是这条记录而已。其实一个事务也可以在表级别进行加锁,可以称其为表锁,对一个表加锁影响整个表中的记录,给表加的锁也可以分为共享表锁(S 锁)和独占表锁(X 锁):

如果一个事务给表加了 S 锁,那么:

  • 别的事务可以继续获得该表的 S 锁
  • 别的事务可以继续获得该表中的某些记录的 S 锁
  • 别的事务不可以继续获得该表的 X 锁
  • 别的事务不可以继续获得该表中的某些记录的 X 锁

如果一个事务给表加了 X 锁,那么:

  • 别的事务不可以继续获得该表的 S 锁
  • 别的事务不可以继续获得该表中的某些记录的 S 锁
  • 别的事务不可以继续获得该表的 X 锁
  • 别的事务不可以继续获得该表中的某些记录的 X 锁

但是这里有两个问题,如果我们想对表加 S 锁,首先需要确保该表中的记录没有被其他事务获取 X 锁,如果有记录被其他事务获取了 X 锁,则需要等到其他事务释放了 X 锁才可以对表整体上 S 锁。如果我们想对表加 X 锁,首先需要确保该表中的记录没有被其他事务获取 X 锁及 S 锁,如果有则需要等到其他事务释放了 X 锁及 S 锁才可以对表整体上 X 锁。

那我们在对表上锁时,如何知道表中有没有行已经被上锁了呢?如果遍历检查表中的所有记录,则效率会很慢。于是 InnoDB 提出了 意向锁(Intention Locks)的概念:

  • 意向共享锁(Intention Shared Lock),简称 IS 锁。当事务准备在某条记录上加 S 锁时,需要先在表级别加一个 IS 锁。
  • 意向独占锁(Intention Exclusive Lock),简称 IX 锁。当事务准备在某条记录上加 X 锁时,需要先在表级别加一个 IX 锁。

这样,当对表加 S 锁时,首先要看一下有没有被别的事务对该表加了 IX 锁,如果有,则意味着该表中的某条记录被别的事务加了 X 锁,需要等到其他事务把这个 IX 锁撤掉后才可以在表上加 S 锁。同理,当对表加 X 锁时,首先要看一下有没有被别的事务对该表加了 IS 锁或 IX 锁,如果有,则需要等到其他事务把这个 IX 锁或 IS 锁撤掉后才可以在表上加 X 锁。

注意,当对表加 IS 锁时,是不关心表是否有 IX 锁的;对表加 IX 锁时,也是不关心表是否有 IS 锁或者其他 IX 锁的。因为 IS 和 IX 锁只是为了在之后加表级 S 锁和 X 锁时可以快速判断表中记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,即 IS 锁和 IX 锁是兼容的,IX 锁和 IX 锁也是兼容的。

锁阻塞

因为不同锁之间的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它所占用的资源,这就是阻塞。阻塞并不是一件坏事,其目的是为了确保事务可以并发且正常地运行。

在 InnoDB 存储引擎中,参数 innodb_lock_wait_timeout 用来控制等待的时间(默认是 50 秒),参数 innodb_rollback_on_timeout 用来设定是否在等待超时时对进行中的事务进行回滚操作(默认不回滚)
image.png

死锁

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。这里用数据库中的行锁举个例子。
image.png
这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:

  • 直接进入等待,直到超时。超时时间通过参数 innodb_lock_wait_timeout 设置

  • 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但它也有额外负担。每当一个事务被锁的时候都要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。如果所有的事务都要更新同一行的话,每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。虽然最终检测的结果是没有死锁,但这期间要消耗大量 CPU 资源。