MySQL MVVC

什么是MySQL MVCC

MVCC 就是多版本并发控制。MVCC 是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问。

MVVC作用

为什么需要 MVCC 呢?数据库通常使用锁来实现隔离性。最原生的锁,锁住一个资源后会禁止其他任何线程访问同一个资源。但是很多应用的一个特点都是读多写少的场景,很多数据的读取次数远大于修改的次数,而读取数据间互相排斥显得不是很必要。

所以就使用了一种读写锁的方法,读锁和读锁之间不互斥,而写锁和写锁、读锁都互斥。这样就很大提升了系统的并发能力。之后人们发现并发读还是不够,又提出了能不能让读写之间也不冲突的方法,就是读取数据时通过一种类似快照的方式将数据保存下来,这样读锁就和写锁不冲突了,不同的事务 session 会看到自己特定版本的数据。当然快照是一种概念模型,不同的数据库可能用不同的方式来实现这种功能。

InnoDB与MVCC

MVCC 只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作。其他两个隔离级别够和 MVCC 不兼容, 因为 READ UNCOMMITTED 总是读取最新的数据行, 而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的行都加锁。

Redo log与bin log和Undo log

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

undo log 除了实现 MVCC 外,还用于事务的回滚。MySQL Innodb 中存在多种日志,除了错误日志、查询日志外,还有很多和数据持久性、一致性有关的日志。

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

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。

版本链与Undo log

innodb 中通过 B+ 树作为索引的数据结构,并且主键所在的索引为 ClusterIndex(聚簇索引), ClusterIndex 中的叶子节点中保存了对应的数据内容。一个表只能有一个主键,所以只能有一个聚簇索引,如果表没有定义主键,则选择第一个非 NULL 唯一索引作为聚簇索引,如果还没有则生成一个隐藏 id 列作为聚簇索引。

除了 Cluster Index 外的索引是 Secondary Index(辅助索引)。辅助索引中的叶子节点保存的是聚簇索引的叶子节点的值。

InnoDB 行记录中除了刚才提到的 rowid 外,还有 trx_id 和 db_roll_ptr, trx_id 表示最近修改的事务的 id,db_roll_ptr 指向 undo segment 中的 undo log。

新增一个事务时事务 id 会增加,trx_id 能够表示事务开始的先后顺序。Undo log 分为 Insert 和 Update 两种,delete 可以看做是一种特殊的 update,即在记录上修改删除标记。

update undo log 记录了数据之前的数据信息,通过这些信息可以还原到之前版本的状态。当进行插入操作时,生成的 Insert undo log 在事务提交后即可删除,因为其他事务不需要这个 undo log。

进行删除修改操作时,会生成对应的 undo log,并将当前数据记录中的 db_roll_ptr 指向新的 undo log。

51_MySQL MVVC.png

ReadView

说完了 undo log 我们再来看看 ReadView。已提交读和可重复读的区别就在于它们生成 ReadView 的策略不同。

ReadView 中主要就是有个列表来存储我们系统中当前活跃着的读写事务,也就是 begin 了还未提交的事务。通过这个列表来判断记录的某个版本是否对当前事务可见。其中最主要的与可见性相关的属性如下:

up_limit_id:当前已经提交的事务号 + 1,事务号 < up_limit_id ,对于当前 Read View 都是可见的。理解起来就是创建 Read View 视图的时候,之前已经提交的事务对于该事务肯定是可见的。

low_limit_id:当前最大的事务号 + 1,事务号 >= low_limit_id,对于当前 Read View 都是不可见的。理解起来就是在创建 Read View 视图之后创建的事务对于该事务肯定是不可见的。

trx_ids:为活跃事务 id 列表,即 Read View 初始化时当前未提交的事务列表。所以当进行RR读的时候,trx_ids 中的事务对于本事务是不可见的(除了自身事务,自身事务对于表的修改对于自己当然是可见的)。理解起来就是创建 RV 时,将当前活跃事务 ID 记录下来,后续即使他们提交对于本事务也是不可见的。

用一张图更好的理解一下:

52_MySQL MVVC.png

最后我们来举个例子让我们更好理解上面的内容,比如我们有如下表:

53_MySQL MVVC.png

现在有一个事务 id 是 60 的执行如下语句并提交:

update user set name = '嗨客网1' where id = 1;

此时 undo log 存在版本链如下:

54_MySQL MVVC.png

提交事务 id 是 60 的记录后,接着有一个事务 id 为 100 的事务,修改 name=嗨客网2,但是事务还没提交。则此时的版本链是:

55_MySQL MVVC.png

此时另一个事务发起 select 语句查询 id=1 的记录,因为 trx_ids 当前只有事务 id 为 100 的,所以该条记录不可见,继续查询下一条,发现 trx_id=60 的事务号小于 up_limit_id,则可见,直接返回结果 “嗨客网1”。

那这时候我们把事务 id 为 100 的事务提交了,并且新建了一个事务 id 为 110 也修改 id 为 1 的记录 name=嗨客网3,并且不提交事务。这时候版本链就是:

56_MySQL MVVC.png

这时候之前那个 select 事务又执行了一次查询,要查询 id 为 1 的记录。

如果你是已提交读隔离级别 READ_COMMITED,这时候你会重新一个 ReadView,那你的活动事务列表中的值就变了,变成了 [110]。按照上的说法,你去版本链通过 trx_id 对比查找到合适的结果就是 “嗨客网2”。

如果你是可重复读隔离级别 REPEATABLE_READ,这时候你的 ReadView 还是第一次 select 时候生成的 ReadView,也就是列表的值还是 [100]。所以 select 的结果是强哥 1。所以第二次 select 结果和第一次一样,所以叫可重复读!

也就是说已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的 ReadView,而可重复读隔离级别则在第一次读的时候生成一个 ReadView,之后的读都复用之前的 ReadView。

这就是 Mysql 的 MVCC,通过版本链,实现多版本,可并发读-写,写-读。通过 ReadView 生成策略的不同实现不同的隔离级别。

总结

对于使用 READ UNCOMMITTED 隔离级别的事务来说,直接读取记录的最新版本即可。对于使用 SERIALIZABLE 隔离级别的事务来说,通过使用锁来实现事务的串行化执行。而对于使用 READ COMMITTED 隔离级别的事务来说,会在每次执行普通查询时都生成一个 ReadView,如果其他事务在它生成 ReadView 之前提交了事务,那么这些事务的修改就对它可见;否则就不可见,这样也就实现了只读取提交过的事务。而对于使用 REPEATABLE READ 隔离级别的事务来说,只会在第一次执行普通查询时生成一个 ReadView,后续该事务中所有的查询都会使用之前生成的 ReadView,这样就可以避免出现不可重复读的情况。

其实在 InnoDB 中,读操作可以分为两种:快照读(Snapshot Read/Consistent Read)和当前读(Current Read/Locking Read)。快照读就是简单的 SELECT 操作,不需要加锁,读取的是当前记录的快照。而当前读则是特殊的读操作,读取的是记录的最新数据,包括隐含当前读逻辑的插入、更新和删除,以及一些手工添加锁的读,比如 SELECT … FOR UPDATE、SELECT … LOCK IN SHARE MODE,它们都属于当前读,需要加锁。对于快照读来说,不存在幻读的问题;而对于当前读来说,会出现幻读的问题,这个可以通过 Next-Key Locking 来解决。