解决死锁之路 - 学习事务与隔离级别 阅读笔记

为了调和事务的安全性和性能之间的冲突,适当的降低隔离级别,可以有效的提高数据库的并发性能。于是便有了四种不同的隔离级别:

  • 读未提交(Read Uncommitted):可以读取未提交的记录,会出现脏读,幻读,不可重复读,所有并发问题都可能遇到;
  • 读已提交(Read Committed):事务中只能看到已提交的修改,不会出现脏读现象,但是会出现幻读,不可重复读;(大多数数据库的默认隔离级别都是 RC,但是 MySQL InnoDb 默认是 RR)
  • 可重复读(Repeatable Read):解决了不可重复读问题,但是任然存在幻读问题;
  • 序列化(Serializable):最高隔离级别,啥并发问题都没有。

Innodb 的实现和上面的定义会有些差别。

InnoDb 的RR 隔离级别下不存在幻读现象,如果是快照读,InnoDb 通过版本号来保证同一个事务里每次查询得到的结果集都是一致的;如果是当前读,MySQL 通过 Next-key locks 保证其他事务无法插入新的数据,从而避免幻读问题。

隔离级别和锁本身是两个不一样的概念,SQL 规范中定义的四种隔离级别,分别是为了解决事务并发时可能遇到的四种问题,至于如何解决,实现方式是什么,规范中并没有严格定义。锁作为最简单最显而易见的实现方式,可能被广为人知,所以大家在讨论某个隔离级别的时候,往往会说这个隔离级别的加锁方式是什么样的。其实,锁只是实现隔离级别的几种方式之一,除了锁,实现并发问题的方式还有时间戳,多版本控制等等,这些也可以称为无锁的并发控制。

虽然数据库的四种隔离级别通过LBCC(Lock-Based Concurrent Control)技术都可以实现,但是它最大的问题是它只实现了并发的读读,对于并发的读写还是冲突的,写时不能读,读时不能写,当读写操作都很频繁时,数据库的并发性将大大降低,针对这种场景,MVCC 技术应运而生。

InnoDb 通过 MVCC 实现了读写并行,但是在不同的隔离级别下,读的方式也是有所区别的。首先要特别指出的是,在 read uncommit 隔离级别下,每次都是读取最新版本的数据行,所以不能用 MVCC 的多版本,而 serializable 隔离级别每次读取操作都会为记录加上读锁,也和 MVCC 不兼容,所以只有 RC 和 RR 这两个隔离级别才有 MVCC。下面我们通过两个例子,来看看这两个隔离级别下 MVCC 有什么区别。

尽管 RR 和 RC 隔离级别都实现了 MVCC 来满足读写并行,但是读的实现方式是不一样的:RC 总是读取记录的最新版本,如果该记录被锁住,则读取该记录最新的一次快照,而 RR 是读取该记录事务开始时的那个版本。虽然这两种读取方式不一样,但是它们读取的都是快照数据,并不会被写操作阻塞,所以这种读操作称为快照读(Snapshot Read),有时候也叫做非阻塞读(Nonlocking Read),RR 隔离级别下的叫做一致性非阻塞读(Consistent Nonlocking Read)

除了 快照读 ,MySQL 还提供了另一种读取方式:当前读(Current Read),有时候又叫做 加锁读(Locking Read) 或者 阻塞读(Blocking Read),这种读操作读的不再是数据的快照版本,而是数据的最新版本,并会对数据加锁,根据加锁的不同,又分成两类:

  • SELECT ... LOCK IN SHARE MODE:加 S 锁
  • SELECT ... FOR UPDATE:加 X 锁
  • INSERT / UPDATE / DELETE:加 X 锁

当前读在 RR 和 RC 两种隔离级别下的实现也是不一样的:RC 只加记录锁,RR 除了加记录锁,还会加间隙锁,用于解决幻读问题。

假设存在表account有三个字段,主键 id、姓名 name 和余额 balance,其中 name 为二级索引。

在表中插入4条数据

Insert into account values (1, 'A', 1000),(2, 'B', 1000),(3, 'C', 1000),(4, 'D', 1000);

然后在 RC 隔离级别下,开启一个事务,执行下面的 SQL 查询所有 id > 3 的记录,使用当前读而不是快照读:



mysql> begin;
Query OK, 0 rows affected (0.00 sec)
 
mysql> select * from account where id > 3 lock in share mode;
+----+----------+---------+
| id |     name | balance |
+----+----------+---------+
|  4 |        D |  1000   |
+----+----------+---------+
1 row in set (0.00 sec)



同时,开启另一个事务,向 account 表中新增一条记录,然后修改 id = 4 的记录:



mysql> begin;
Query OK, 0 rows affected (0.00 sec)
 
mysql> insert into account(name, balance) value('E', 1000);
Query OK, 1 row affected (6.24 sec)
 
mysql> update account set balance = 2000 where id = 4;



可以看到事务 2 在事务 1 当前读之后,仍然可以新增记录,但是在执行 update 操作的时候被阻塞,这说明了事务 1 在执行当前读的时候在 id = 4 这条记录上加了锁,但是并没有对 id > 3 这个范围加锁。然后我们切换到事务 1 中,再次查询:



mysql> select * from account where id > 3 lock in share mode;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction



这个时候事务 1 直接死锁了,原因其实很简单,事务 2 在 insert 新纪录的时候(id = 5)会在新纪录上加锁,所以事务 1 再次执行当前读,想获取 id > 3 的记录,就需要在 id = 4 和 id = 5 这两条记录上加锁,但是 id = 5 这条记录已经被事务 2 锁住了,于是事务 1 被事务 2 阻塞,同时事务 2 还在等待 事务 1 释放 id = 4 上的锁,于是便产生了死锁。

如果隔离级别使RR呢?重复事务1刚才的操作



mysql> begin;
Query OK, 0 rows affected (0.00 sec)
 
mysql> select * from account where id > 3 lock in share mode;
+----+----------+---------+
| id |     name | balance |
+----+----------+---------+
|  4 |        D |  1000   |
+----+----------+---------+
1 row in set (0.00 sec)



同时,事务 2 向 account 表中新增一条记录:



mysql> begin;
Query OK, 0 rows affected (0.00 sec)
 
mysql> insert into account(name, balance) value('E', 1000);



我们发现,这个时候事务 2 就被阻塞了,很显然事务 1 在执行 select ... lock in share mode 的时候,不仅在 id = 4 这条记录上加了锁,而且在 id > 3 这个范围上也加了锁。

关于 MySQL 不同的隔离级别,读操作的差异总结起来如下图所示(其中,读未提交和可序列化都和 MVCC 不兼容,可以暂且认为它们都属于当前读):




MySQL修改事务隔离级别 mysql事务隔离级别 默认_隔离级别


补充:

幻读

对于幻读,最简单的解释就是:同样的条件,第一次和第二次读出来的记录数不一样。幻读和不可重复读的区别在于,后者是两次读取同一条记录,得到不一样的结果;而前者是两次读取同一个范围内的记录,得到不一样的记录数(这种说法其实只是便于理解,但并不准确,因为可能存在另一个事务先插入一条记录然后再删除一条记录的情况,这个时候两次查询得到的记录数也是一样的,但这也是幻读,所以严格点的说法应该是:两次读取得到的结果集不一样)。很显然,不可重复读是因为其他事务进行了 UPDATE 操作,幻读是因为其他事务进行了 INSERT 或者 DELETE 操作。