从更新丢失说InnoDB多版本并发控制(MVCC)

吴琦隆

本文从一个Mysql丢失更新的案例入手,介绍InnoDB存储引擎的非锁定一致性读取,多版本并发控制MVCC,事务隔离级别,以及InnoDB中的锁策略。

例子

有一个银行账户,里面有余额1000元,A,B两个用户同时使用两个ATM进行余额查询,他们都看到余额为1000元,于是A用户转出账户中的900元,银行将余额更新为100元,B用户转出账户中的1元,银行将余额更新为999元。由于同时操作的原因,最终该账户的余额有可能被更新为999元,但是账户却转出去两笔钱,出现了逻辑意义上的更新丢失,模拟如下: 创建一个测试表,user字段为自增主键,cash字段表示余额:

-- ----------------------------
-- Table structure for account
-- ----------------------------
DROP TABLE IF EXISTS `account`;  
CREATE TABLE `account` (  
  `user` int(11) NOT NULL AUTO_INCREMENT,
  `cash` int(11) NOT NULL DEFAULT 0,
  PRIMARY KEY (`user`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

插入一条数据

-- ----------------------------
-- Records of account
-- ----------------------------
INSERT INTO `account` VALUES ('1', '1000');  

以时间顺序展示sql以及执行效果如下: Fs6eyt.md.png

上面这个例子可以联想到多线程中对共享数据的处理,如果多个线程同时修改共享数据,那么数据将会产生错乱,可以使用加锁的方式对访问和操作共享数据的代码段(称做临界区)进行加锁,使得同一时间只能有一个线程持有锁,达到保护共享数据的目的。

容易想到的是,InnoDB会对多个事务同时进行更改的数据进行加锁(具体锁的类别和不同语句的设置的锁下文中进行说明),以避免并发问题保证同步,事实上在上面例子中user=1这行数据确实被事物A所在的线程加锁,使用 SELECT * FROM `performance_schema`.data_locks;

可以看到如下结果: FssqgI.md.png

那么为什么在事物A加锁期间,事务B对数据的读取依旧没有阻塞呢?以及在第2步中事务A更新了该行数据,第3步中事务B读取到的数据却依旧是最初的数据cash=1000呢?

一致性非锁定读(Consistent Nonlocking Reads)

如上所述,事务A在进行更新行数据,但是其他事物的查询操作并没有阻塞,这是因为InnoDB的普通查询采用了一致性非锁定读的特性,InnoDB在某个时间点对数据库查询得到的是一个快照。查询将查看在该时间点之前提交的事务所做的更改,而不查看后续事务或未提交事务所做的更改。

InnoDB采用这种设计极大地提高了数据库的并发性,使得对数据实时性并不是很高的查询(可以接受一个不那么新鲜的数据)不被阻塞,得到一个该行之前版本的数据,因为不需要等待锁的释放,所以称其为非锁定读。上面例子事物B之所以没有被阻塞,是因为他获得的是一个快照数据。

InnoDB是一个多版本的存储引擎,多个事务可能会看到多个数据版本,这种技术就是多版本技术(Multi-Versioning),由此带来的并发控制称为多版本并发控制(Multiversion concurrency control)。容易看出,这种设计解决了以下问题:

  1. 事务的回滚:如果事务失败需要回滚,那么事务可以根据快照信息构建行的早期版本,从而保证事务要么成功,要么失败的原子性
  2. 读的性能:当事务或者更新语句锁住行记录时,其他事务对行的普通读不需要等待锁的释放,读的性能得到提高
  3. 读者过多引起的写者饥饿问题:如果不采用MVCC,读者对所读的数据添加读锁,防止数据在读的过程中被其他线程修改,写者在锁释放之前无法进行更新操作,如果存在大量的读者必定会使等待的写者处于饥饿状态。

那么InnoBD引擎是如何实现多版本的特性呢,我们接着往下看

undo log

由于随机访问硬盘的速度远远低于内存,即ms和ns的差距,因此操作系统会将最近使用的数据加载到内存中,这样做的考虑是出于数据一旦被访问,在短期内有可能被再次访问;程序写操作时也会把数据先写到内存中,硬盘不会立即更新,而是把内存中这些数据标记为脏页,由回写进程将脏页回写进磁盘。

InnoDB作为高效的数据库引擎,也是采用类似的策略,它在内存中分配缓冲池缓存表和索引等数据。经常使用的数据直接从缓冲池中处理,数据更新时首先在内存中更新,然后异步由线程刷新到硬盘中。

在上面例子的第2步中,事务A确实已经将内存中的cash的值更新为100,它自己随后进行查询也会返回更新后的值。那么事务B的快照信息是怎么回事呢?原来,InnoDB引擎在事务更新数据时会记录一种叫做undo log的重做日志,这些信息存储在名为回滚段( rollback segment)的数据结构中。InnoDB使用回滚段中的信息来执行事务回滚所需的撤消操作。例如,如果事务进行回滚,结合undo log,对于每个插入操作,InnoDB引擎会执行一个相反的删除操作,对于每个更新操作,InnoDB引擎会执行一个相反的更新操作,对于每个删除操作,InnoDB引擎会执行一个相反的插入操作,以此完成事务的回滚。

undo log的另一个作用就是实现MVCC,事务可以根据undo log“推理”出之前的行版本信息,从而实现非锁定读取,这就是例子中第3步,事务B进行查询,得到的结果却依旧是数据的最初版本,而不是内存中事务A更新后值。

行记录是如何找多回滚段的位置呢?在内部,InnoDB向数据库中存储的每一行添加三个字段。一个6字节的DBTRXID字段指示插入或更新行的最后一个事务的事务标识符。一个7字节的DBROLLPTR字段,称为滚动指针。滚动指针指向回滚段(rollback segment)的撤销日志(undo log record)记录。如果更新了行,则撤消日志记录包含在更新行之前重建行的内容所需的信息。一个6字节的DBROWID字段包含一个行ID,随着插入新的行,这个行ID会单调地增加。如果InnoDB自动生成主键,则索引包含行ID值。否则,DBROWID列不会出现在任何索引中。

事务隔离级别

说到这里,我们就能清晰的理解事务的四个隔离级别之间的差异了。事务隔离是数据库处理的基础之一,隔离级别是在多个事务同时进行更改和执行查询时,对性能和可靠性、一致性和结果可重复性之间的平衡进行微调的设置。

InnoDB提供SQL:1992标准描述的所有四个事务隔离级别:未提交读、提交读、可重复读和可序列化( READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, and SERIALIZABLE)。InnoDB的默认隔离级别是可重复读。

REPEATABLE READ

这是InnoDB的默认隔离级别。在同一事务内的读取为第一次读取建立的快照snapshot。这表示,如果在同一个事务中发出几个普通(非锁定)SELECT语句,那么这些SELECT语句彼此之间读取到的数据是一致的。即在上面的例子中,无论事务A提交或是未提交,事务B提交前读到的数据始终都是它在开始事物的时间点所看到的数据版本,所以称为可重复读。

READ COMMITTED

每个一致的读取,甚至在同一个的事务中,都读取最新快照。即在上面的例子中,事务A提交之前,事务B执行普通的select看到的余额是1000元,而在事务A提交之后,事务B执行普通的select就可以看到事务A更新后的数据,即余额变为100,由于同一个事物中同一个查询语句每次查询结果可能不同,所以称为提交读。

READ UNCOMMITTED

这种隔离级别下,select语句可以读取其他事务未提交的数据,即脏读。在上面的例子中,事务A更新余额后即使没有提交事务,事务B的查询也可以看到余额被更新了,而此时事务A未必能最终执行成功,当其他事务回滚时,数据会产生不一致。

SERIALIZABLE

这个级别类似于可重复读,但是比可重复读更严格,InnoDB隐式地将所有普通SELECT语句转换为 SELECT ... LOCK IN SHARE MODE,即对普通的读取操作也进行加锁。在上面的例子中,事务A执行select后,事务B使用select查询将被阻塞,直到事务A提交事务释放锁。

如何解决问题

到这里,我们明白了更新丢失现象的原因:一致性的非锁定读读取到了快照数据。是时候解决问题了,在这之前,首先了解一下InnoDB提供的几种锁。

共享锁和独占锁

InnoDB实现了标准行级别锁定,其中有两种类型的锁、共享锁(shared locks,简称s锁)和独占锁(exclusive locks,简称x锁)。
- 共享锁允许持有锁的事务读取行。 - 独占(X)锁允许持有锁的事务更新或删除一行。

例如事务T1持有行r上的共享(S)锁,那么从某些不同的事务T2中请求对行r的锁的处理如下: - T2对S锁的请求可以立即授予。因此,T1和T2在r上都有S锁。 - T2对X锁的请求不能立即授予。

如果事务T1在行r上持有独占(X)锁,则不能立即授予来自某个不同事务T2的请求,以获取r上任意类型的锁。相反,事务T2必须等待事务T1释放在r行上的锁。

Intention Locks意向锁

InnoDB支持多粒度锁,允许行锁和表锁共存。为了使多粒度级别的锁更实用,InnoDB使用意图锁。意图锁是表级锁,它指示在一个表中,一个事务需要稍后进行哪些类型的锁(共享或独占)。意图锁有两种类型:
- 一个意图共享锁(IS)表示一个事务打算在一个表中为单独的行设置一个共享锁。 - 意图独占锁(IX)指示事务打算在表中的单个行上设置独占锁。

意图锁定协议如下 - 事务在获取表中一行上的共享锁之前,必须首先获取表上的IS锁或更强的锁。 - 事务在获取表中的行上的独占锁之前,必须首先获取表上的IX锁。 意图锁除了全表请求外不会阻塞任何东西(例如, LOCK TABLES ... WRITE).)。意图锁定的主要目的是显示某人正在锁定一个行,或者在表中锁定一行。

表级锁类型兼容性总结如下矩阵,如果请求事务与现有锁兼容,则授予锁,但与现有锁冲突则不授予锁。事务等待直到冲突的现有锁被释放。 || X| IX| S| IS| |--|-|--|--|--|--| |X| Conflict| Conflict| Conflict| Conflict| |IX| Conflict| Compatible |Conflict| Compatible| |S| Conflict| Conflict| Compatible| Compatible| |IS| Conflict| Compatible| Compatible| Compatible|

不同SQL语句设置的锁

在最开始的示例中我们查看了事务A执行更新后数据库实例中存在的锁,其中在表上有一个IX锁,行记录上有一个X锁,易于想到一种避免丢失更新的解决方案:在查询的时候对数据行也加上X锁,不使用非锁定读取。普通的SELECT ... FROM使用多版本不加锁读取数据库快照,除非事务隔离级别设置为 SERIALIZABLE可序列化。

MYSQL提供了加锁的读取方式: SELECT ... FOR UPDATE 会为符合条件的行添加排他锁,SELECT ... LOCK IN SHARE MODE会为符合条件的行添加共享锁。因此,我们使用SELECT ... FOR UPDATE语句优化示例执行如下:

Fs6Jln.md.png

可以看到当事务A使用 SELECT ... FOR UPDATE语句后,为符合条件的记录添加X锁,B事务不采用非锁定读取获得快照数据,同样使用SELECT ... FOR UPDATE语句进行查询,在事务A提交事务释放锁后,事务B获取锁得到余额,并对余额进行计算更新成正确的值,问题解决。

小结

本文通过一个更新丢失的例子介绍了InnoDB中事务回滚的原理,涉及一致性读取,多版本并发控制,事务隔离级别以及锁等内容。由于InnoDB中多版本并发控制及背后原理是比较难以理解的地方,因此主要对这部分内容做了介绍。限于篇幅,对redo log的深入解析以及对InnoDB中其他方面的内容并未提及,例如InnoDB插入缓冲,双写,二进制日志等特性,InnoDB中的索引,InnoDB间隙锁与下一个键锁等其他锁等。如对其他内容有兴趣或想深入了解InnoDB实现,推荐阅读Mysql官方文档以及《MySQL技术内幕 InnoDB存储引擎 》(第2版)