MVCC

MVCC是Multi Version Concurrency Control的简称,代表多版本并发控制。

为什么需要MVCC,还要从数据库事务的ACID特性说起。

相信很多朋友都了解ACID,它们分别代表了Atomicity(原子性), Consistency(一致性), Isolation(隔离性), Durability(持久性)。

各种数据库厂商会对各个隔离级别进行实现。

和 Java 中的多线程问题相同,数据库通常使用锁来实现隔离性。

最原生的锁,锁住一个资源后会禁止其他任何线程访问同一个资源。

但是很多应用的一个特点都是读多写少的场景,很多数据的读取次数远大于修改的次数,而读取数据间互相排斥显得不是很必要。所以就使用了一种读写锁的方法,读锁和读锁之间不互斥,而写锁和写锁、读锁都互斥。这样就很大提升了系统的并发能力。

之后人们发现并发读还是不够,又提出了能不能让读写之间也不冲突的方法,就是读取数据时通过一种类似快照的方式将数据保存下来,这样读锁就和写锁不冲突了,不同的事务 session 会看到自己特定版本的数据。

当然快照是一种概念模型,不同的数据库可能用不同的方式来实现这种功能。

之后的讨论默认均以 REPEATABLE READ 作为隔离级别。

InnoDB与MVCC

MySQL中的InnoDB存储引擎的特性有,默认隔离级别REPEATABLE READ, 行级锁,实现了MVCC, Consistent nonlocking read(默认读不加锁,一致性非锁定读), Insert Buffer, Adaptive Hash Index, DoubleWrite, Cluster Index。

上面列举了这么多,表示InnoDB有很多特性、很快。

InnoDB中通过UndoLog实现了数据的多版本,而并发控制通过锁来实现。

Undo Log除了实现MVCC外,还用于事务的回滚。

Innodb 的一些概念

下面概念用于协助理解,粗略浏览即可。

MySQL Innodb中存在多种日志,除了错误日志、查询日志外,还有很多和数据持久性、一致性有关的日志。

bin log

binlog,是mysql服务层产生的日志,常用来进行数据恢复、数据库复制,常见的mysql主从架构,就是采用slave同步master的binlog实现的, 另外通过解析binlog能够实现mysql到其他数据源(如ElasticSearch)的数据复制。

redo log

redo log记录了数据操作在物理层面的修改,mysql中使用了大量缓存,缓存存在于内存中,修改操作时会直接修改内存,而不是立刻修改磁盘,当内存和磁盘的数据不一致时,称内存中的数据为脏页(dirty page)。为了保证数据的安全性,事务进行中时会不断的产生redo log,在事务提交时进行一次flush操作,保存到磁盘中, redo log是按照顺序写入的,磁盘的顺序读写的速度远大于随机读写。当数据库或主机失效重启时,会根据redo log进行数据的恢复,如果redo log中有事务提交,则进行事务提交修改数据。这样实现了事务的原子性、一致性和持久性。

Undo Log

除了记录redo log外,当进行数据修改时还会记录undo log,undo log用于数据的撤回操作,它记录了修改的反向操作,比如,插入对应删除,修改对应修改为原来的数据,通过undo log可以实现事务回滚,并且可以根据undo log回溯到某个特定的版本的数据,实现MVCC。

redo log 和binlog的一致性,为了防止写完binlog但是redo log的事务还没提交导致的不一致,innodb 使用了两阶段提交

大致执行序列为

InnoDB prepare  (持有prepare_commit_mutex);
 write/sync Binlog;
 InnoDB commit (写入COMMIT标记后释放prepare_commit_mutex)。

Undo Log 删除

undo log 在没有活动事务依赖(用于consistent read或回滚)便可以清楚,innodb 中存在后台 purge 线程进行后台轮询删除 undo log。

rollback segment

回滚段这个概念来自Oracle的事物模型,在Innodb中,undo log被划分为多个段,具体某行的undo log就保存在某个段中,称为回滚段。可以认为undo log和回滚段是同一意思。

read view

InnoDB多版本(MVCC)实现简要分析

read view 主要是用来做可见性判断的, 比较普遍的解释便是”本事务不可见的当前其他活跃事务”, 但正是该解释, 可能会造成一节理解上的误区, 所以此处提供两个参考, 供给大家避开理解误区:

read view中的高水位low_limit_id可以参考 https://github.com/zhangyachen/zhangyachen.github.io/issues/68, https://www.zhihu.com/question/66320138

其实上面第1点中加粗部分也是相关高水位的介绍( 注意进行了+1 )

可见性比较算法

这里每个比较算法后面的描述是建立在rr级别下,rc级别也是使用该比较算法,此处未做描述)

设要读取的行的最后提交事务id(即当前数据行的稳定事务id)为 trx_id_current

当前新开事务id为 new_id

当前新开事务创建的快照read view 中最早的事务id为up_limit_id, 最迟的事务id为low_limit_id(注意这个low_limit_id=未开启的事务id=当前最大事务id+1)

比较

1.trx_id_current < up_limit_id, 这种情况比较好理解, 表示, 新事务在读取该行记录时, 该行记录的稳定事务ID是小于, 系统当前所有活跃的事务, 所以当前行稳定数据对新事务可见, 跳到步骤5.

2.trx_id_current >= trx_id_last, 这种情况也比较好理解, 表示, 该行记录的稳定事务id是在本次新事务创建之后才开启的, 但是却在本次新事务执行第二个select前就commit了,所以该行记录的当前值不可见, 跳到步骤4。

3.trx_id_current <= trx_id_current <= trx_id_last, 表示: 该行记录所在事务在本次新事务创建的时候处于活动状态,从up_limit_id到low_limit_id进行遍历,如果trx_id_current等于他们之中的某个事务id的话,那么不可见, 调到步骤4,否则表示可见。

4.从该行记录的 DB_ROLL_PTR 指针所指向的回滚段中取出最新的undo-log的版本号, 将它赋值该 trx_id_current,然后跳到步骤1重新开始判断。

5.将该可见行的值返回。

实现过程

官方介绍

InnoDB 是一个多版本的存储引擎:它

保存关于已更改行的旧版本的信息,以支持并发性和回滚等事务特性。

这些信息存储在表空间中称为回滚段的数据结构(在Oracle中类似的数据结构之后)。InnoDB使用回滚段中的信息来执行事务回滚所需的撤销操作。它还使用这些信息构建一行的早期版本,以实现一致的读取。

添加的字段

在内部,InnoDB为存储在数据库中的每一行添加三个字段。

  • DB_TRX_ID

6字节的事务ID(DB_TRX_ID)字段: 用来标识最近一次对本行记录做修改(insert|update)的事务的标识符, 即最后一次修改(insert|update)本行记录的事务id。 至于delete操作,在innodb看来也不过是一次update操作,更新行中的一个特殊位将行表示为deleted, 并非真正删除。

  • DB_ROLL_PTR

7字节的回滚指针(DB_ROLL_PTR)字段: 指写入回滚段(rollback segment)的 undo log record (撤销日志记录记录)。

如果一行记录被更新, 则 undo log record 包含 ‘重建该行记录被更新之前内容’ 所必须的信息。

  • DB_ROW_ID

6字节的 DB_ROW_ID 字段: 包含一个随着新行插入而单调递增的行ID, 当由innodb自动产生聚集索引时,聚集索引会包括这个行ID的值,否则这个行ID不会出现在任何索引中。 结合聚簇索引的相关知识点, 我的理解是, 如果我们的表中没有主键或合适的唯一索引, 也就是无法生成聚簇索引的时候, InnoDB会帮我们自动生成聚集索引, 但聚簇索引会使用DB_ROW_ID的值来作为主键; 如果我们有自己的主键或者合适的唯一索引, 那么聚簇索引中也就不会包含 DB_ROW_ID 了 。

流程演示

下面是一个非常简版的演示事务对某行记录的更新过程, 当然, InnoDB引擎在内部要做的工作非常多:

20180831-sql-mvcc-process.png

比较算法的应用过程

下面是一套比较算法的应用过程

20180831-sql-mvcc-compare.png

当前读和快照读

部分防止幻读

MySQL的InnoDB存储引擎默认事务隔离级别是RR(可重复读), 是通过 “行排他锁+MVCC” 一起实现的, 不仅可以保证可重复读, 还可以部分防止幻读, 而非完全防止;

  • 为什么是部分防止幻读, 而不是完全防止?

效果: 在如果事务B在事务A执行中, insert了一条数据并提交, 事务A再次查询, 虽然读取的是undo中的旧版本数据(防止了部分幻读), 但是事务A中执行update或者delete都是可以成功的!!

因为在innodb中的操作可以分为当前读(current read)和快照读(snapshot read):

快照读(snapshot read)

简单的 select 操作(当然不包括 select … lock in share mode, select … for update)

select * from user where id < 2;

当前读(current read)

innodb-locking-reads

select … lock in share mode

select … for update

insert

update

delete

在RR级别下,快照读是通过MVVC(多版本控制)和undo log来实现的,当前读是通过加record lock(记录锁)和gap lock(间隙锁)来实现的。

innodb在快照读的情况下并没有真正的避免幻读, 但是在当前读的情况下避免了不可重复读和幻读!!!

小结

MVCC特点:

  • 每行数据都存在一个版本,每次数据更新时都更新该版本

  • 修改时Copy出当前版本, 然后随意修改,各个事务之间无干扰

  • 保存时比较版本号,如果成功(commit),则覆盖原记录, 失败则放弃copy(rollback)

  • 就是每行都有版本号,保存时根据版本号决定是否成功,听起来含有乐观锁的味道, 因为这看起来正是,在提交的时候才能知道到底能否提交成功

InnoDB实现MVCC的方式是:

事务以排他锁的形式修改原始数据

把修改前的数据存放于undo log,通过回滚指针与主数据关联

修改成功(commit)啥都不做,失败则恢复undo log中的数据(rollback)

二者最本质的区别是

当修改数据时是否要排他锁,如果锁定了还算不算是MVCC?

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

比如, 如果事务A执行理想的MVCC, 修改Row1成功, 而修改Row2失败, 此时需要回滚Row1, 但因为Row1没有被锁定, 其数据可能又被事务B所修改, 如果此时回滚Row1的内容,则会破坏事务B的修改结果,导致事务B违反ACID。 这也正是所谓的 第一类更新丢失 的情况。

也正是因为InnoDB使用的MVCC中结合了排他锁, 不是纯的MVCC, 所以第一类更新丢失是不会出现了, 一般说更新丢失都是指第二类丢失更新。

应用场景

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

参考资料

  • mysql

https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html

  • mvcc

http://ningg.top/inside-mysql-transaction-and-mvcc/

https://liuzhengyang.github.io/2017/04/18/innodb-mvcc/

https://segmentfault.com/a/1190000012650596

http://hedengcheng.com/?p=148