MySQL中的MVCC - Sanarous的博客

MySQL中的MVCC

什么是 MVCC

MVCC,Multi-Version Concurrency Control,多版本并发控制。MVCC 是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问;在编程语言中实现事务内存。

并发控制可以简单理解为解决同时读写数据库中某条数据造成读取数据前后不一致的问题,并发控制的解决办法最容易想到的就是加锁,让读操作等待写操作完成,由于目前很多使用数据库的地方都有一定的并发量,采用这种方法在并发量较高时效率会非常低下。如果能实现一种读写分离的方式,那么可见数据库的并发度会提升的非常大,这种操作我们并不陌生,在 Java 中有一个容器叫做 CopyOnWriteArrayList,这个容器就是以读写分离的方式存储数据的,写操作时会 copy 一份原来的数组进行写,并进行加锁防止其它线程同时写入造成数据丢失,而读操作还是读取的原来的数据,在写操作结束之后再将原数组指向新的复制数组,这样就可以实现同时读写数据,但是会导致的问题也很明显,就是读数据不能读到最新的写入数据,并且 copy 数组会造成内存占用为原来的两倍。
那么数据库中能不能也实现类似的方式呢?没错,在数据库中就引入了一种多版本并发控制技术,简称 MVCC,MVCC 使得每个连接到数据库的读者,在某个瞬间看到的是数据库的一个快照,写操作造成的变化在写操作完成之前(或者数据库事务提交之前)对于其它的读操作来说是不可见的。

MVCC 的过程

可能你会问,数据库中哪里体现了 MVCC 呢?

我们知道,MySQL 的默认隔离级别是 Repeatable Read(可重复读,以下简称 RR),关于隔离级别的定义以及它们解决的问题和无法解决的问题这里就不再赘述。RR 确保同一事务的多个实例在并发读取数据时,会看到同样的数据行,我们用例子来说明,我的 MySQL 版本是 5.2.26,默认隔离级别是 RR:

我们新建一个测试数据库:

1
CREATE DATABASE testdb;

然后新建一张表,并插入一些数据:

1
2
3
4
5
6
7
8
create table `test` (
`id` int (11),
`name` varchar (50)
);
insert into `test` (`id`, `name`) values('1','xiaoming');
insert into `test` (`id`, `name`) values('2','xiaohong');
insert into `test` (`id`, `name`) values('3','xiaolv');
insert into `test` (`id`, `name`) values('4','xiaohuang');

然后在 cmd 中开两个 MySQL 窗口,可以看到目前查询是一致的结果:

然后我们同时开启事务,使用begin;命令,在左边表中进行查询 id = 2 的信息,在右边表中修改 id = 2 的 name 信息:

1
2
3
4
5
//左边窗口
SELECT * FROM TEST WHERE id = 2;

//右边窗口
UPDATE test SET name='Sanarous' where id = 2;

两边结束后,我们同时进行查询,结果如下:

由于右边窗口修改后没有提交,我们再进行提交,再次同时查询两边窗口:

提交后我们发现,左边窗口仍然读取的原来的结果,然后我们将左边窗口提交,再次查询:

在提交后,再次查询结果就是一致的。熟悉 MySQL 隔离级别的都知道,这就是 RR 的特点,在一次事务中对一行数据查询永远都是同一个结果。而这种实现方式的原理,正是利用了 MVCC 多版本并发控制实现,实际上我们在左边窗口中读取的是事务开始时的一个行数据版本快照。

当一个 MVCC 数据库需要更新一条数据记录的时候,它不会直接用新数据去覆盖旧数据,而是将旧数据标记为过时(obsolete)并在别处增加新版本的数据,这样同一行数据就有多个版本的数据共存,但是只有一个是最新的数据。这种方式就允许读者在读取他读之前已经存在的数据,即使这个数据在读的过程中被别人修改了、删除了,也对先前正在读的用户没有影响。

MVCC 实现原理

InnoDB 层面

MVCC 是在 InnoDB 存储引擎中得到支持的,InnoDB 为每行记录都实现了三个隐藏字段:

  1. 6 字节的事务 ID(DB_TRX_ID)
  2. 7 字节的回滚指针(DB_ROLL_PTR)
  3. 隐藏的 ID
此处争议较大,有的书上写的是四个隐藏字段,有的甚至是两个,但是基本思路是一致的。

其中 6 字节的事务 ID 用来标识该行所述的事务,7 字节的回滚指针需要了解下 InnoDB 的事务模型。

InnoDB 事务相关模型

为了支持事务,InnoDB 引入了下面几个概念:

  1. redo log:redo log 就是保存执行的 SQL 语句到一个指定的 log 文件,当 MySQL 执行 recovery 操作时重新执行一遍 redo log 中记录的 SQL 操作就可以。当客户端执行每条 SQL 时,redo log 会被首先写入 log buffer;当客户端执行 COMMIT 提交时,log buffer 中的内容会被情况刷新到磁盘。redo log 在磁盘上作为一个独立的文件存在,即 InnoDB 的 log 文件。
  2. undo log:undo log 与 redo log文件相反,undo log 是为回滚而用的,具体内容就是 copy 事务前的数据库内容(行)到 undo buffer,在合适的时间把 undo buffer 的内容刷新到磁盘中,与redo log不同的是,磁盘上不存在单独的 undo log 文件,所有的 undo log 均存放在主ibd数据文件中(表空间),即使客户端设置了每表一个数据文件也是如此。
  3. rollback segment:回滚段这个概念来自 Oracle 的事务模型,在 InnoDB 中,undo log 被划分为多个段,具体某行的 undo log 就保存在某个段中,称为回滚段。可以认为 undo log 和回滚段是同一意思。
  4. 锁:InnoDB 提供了基于行的锁,如果行的数量非常大,则在高并发下锁的数量也可能会比较大,据 InnoDB 文档说,InnoDB 对锁进行了空间有效优化,即使并发量高也不会导致内存耗尽。对行的锁有分两种:排他锁、共享锁。共享锁针对对,排他锁针对写,完全等同读写锁的概念。如果某个事务在更新某行(排他锁),则其他事物无论是读还是写本行都必须等待;如果某个事物读某行(共享锁),则其他读的事物无需等待,而写事物则需等待。通过共享锁,保证了多读之间的无等待性,但是锁的应用又依赖 MySQL 的事务隔离级别。
  5. 隔离级别:隔离级别用来限制事务直接的交互程度,这里不再赘述。

行更新过程

下面演示下事务对某行记录的更新过程。

初始数据行

F1~F6 是某行列的名字,1~6 是其对应的数据。后面三个隐含字段分别对应该行的事务号和回滚指针,假如这条数据是刚 INSERT 的,可以认为 ID 为 1,其他两个字段为空。

事务1更改该行各字段的值

当事务 1 更改该行的值时,会进行如下操作:

  1. 用排他锁锁定该行
  2. 记录 redo log
  3. 把该行修改前的值 Copy 到 undo log,即上图中下面的行
  4. 修改当前行的值,填写事务编号,使回滚指针指向 undo log 中的修改前的行

事务2修改该行的值

与事务 1 相同,此时 undo log,中有有两行记录,并且通过回滚指针连在一起。
因此,如果 undo log 一直不删除,则会通过当前记录的回滚指针回溯到该行创建时的初始内容,所幸的是在 InnoDB 中存在 purge 线程,它会查询那些比现在最老的活动事务还早的 undo log,并删除它们,从而保证 undo log 文件不至于无限增长。

事务提交

当事务正常提交时 InnoDB 只需要更改事务状态为 COMMIT 即可,不需做其他额外的工作,而 Rollback 则稍微复杂点,需要根据当前回滚指针从 undo log 中找出事务修改前的版本,并恢复。如果事务影响的行非常多,回滚则可能会变的效率不高,根据经验值没事务行数在 1000~10000 之间,InnoDB 效率还是非常高的。很显然,InnoDB 是一个 COMMIT 效率比 Rollback 高的存储引擎。据说,Postgress 的实现恰好与此相反。

Insert undo log

上述过程确切地说是描述了 UPDATE 的事务过程,其实 undo log 分 insert 和 update undo log,因为 insert 时,原始的数据并不存在,所以回滚时把 insert undo log 丢弃即可,而 update undo log 则必须遵守上述过程。

总结

上述更新前建立 undo log,根据各种策略读取时非阻塞就是 MVCC,undo log 中的行就是 MVCC 中的多版本,这个可能与我们所理解的 MVCC 有较大的出入,一般我们认为 MVCC 有下面几个特点:

  1. 每行数据都存在一个版本,每次数据更新时都更新该版本
  2. 修改时 Copy 出当前版本随意修改,每个事务之间无干扰
  3. 保存时比较版本号,如果成功(commit),则覆盖原记录;失败则放弃 copy(rollback)

就是每行都有版本号,保存时根据版本号决定是否成功,听起来含有乐观锁的味道…而 InnoDB 的实现方式是:

  1. 事务以排他锁的形式修改原始数据
  2. 把修改前的数据存放于 undo log,通过回滚指针与主数据关联
  3. 修改成功(commit)啥都不做,失败则恢复 undo log 中的数据(rollback)

二者最本质的区别是,当修改数据时是否要排他锁定,如果锁定了还算不算是 MVCC?

InnoDB 的实现真算不上 MVCC,因为并没有实现核心的多版本共存,undo log 中的内容只是串行化的结果,记录了多个事务的过程,不属于多版本共存。但理想的 MVCC 是难以实现的,当事务仅修改一行记录使用理想的 MVCC 模式是没有问题的,可以通过比较版本号进行回滚;但当事务影响到多行数据时,理想的 MVCC 据无能为力了。

比如,如果 Transaciton1 执行理想的 MVCC,修改 Row1 成功,而修改 Row2 失败,此时需要回滚 Row1,但因为 Row1 没有被锁定,其数据可能又被 Transaction2 所修改,如果此时回滚 Row1 的内容,则会破坏 Transaction2 的修改结果,导致 Transaction2 违反 ACID。

理想 MVCC 难以实现的根本原因在于企图通过乐观锁代替二段提交。修改两行数据,但为了保证其一致性,与修改两个分布式系统中的数据并无区别,而二提交是目前这种场景保证一致性的唯一手段。二段提交的本质是锁定,乐观锁的本质是消除锁定,二者矛盾,故理想的 MVCC 难以真正在实际中被应用,InnoDB 只是借了 MVCC 这个名字,提供了读的非阻塞而已。

也不是说 MVCC 就无处可用,对一些一致性要求不高的场景和对单一数据的操作的场景还是可以发挥作用的,比如多个事务同时更改用户在线数,如果某个事务更新失败则重新计算后重试,直至成功。这样使用 MVCC 会极大地提高并发数,并消除线程锁。

参考文章

  1. 姜承尧 著《MySQL 技术内幕:InnoDB 存储引擎》
  2. Mysql中的MVCC
如果这篇文章对您很有帮助,不妨
-------------    本文结束  感谢您的阅读    -------------
0%