一、前言
在分析 MVCC 的原理之前,我们先回顾一下 MySQL 的一些内容以及关于 MVCC 的一些简单介绍。(注:下面没有特别说明默认 MySQL 的引擎为 InnoDB )
1.1 数据库的并发场景
数据库并发场景有三种,分别是:
- 读-读:不存在线程安全问题,不需要并发控制。
- 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、不可重复读、幻读等问题。
- 写-写:有线程安全问题,可能会存在更新丢失的问题,比如第一类更新丢失、第二类更新丢失。
(第一类丢失更新:事务A回滚时,将已经提交的事务B的更新数据覆盖了;第二类丢失更新:事务A提交覆盖了事务B已经提交的数据,造成事务B所做的操作丢失)
1.2 什么是 MVCC
- MVCC全称 Multi-Version Concurrency Control ,即多版本并发控制,MVCC 是一种并发控制的方法,一般在数据库管理系统中实现对数据库的并发访问,在编程语言中实现事务内存。
- 多版本控制:指的是一种提高并发的技术,在最早的数据库系统中只有读-读之间可以并发,读-写、写-写之间都要阻塞。引入多版本并发控制之后,只有写-写之间相互阻塞,其他三种操作都可以并行,这样大幅的提高了 InnoDB 的并发度。在内部实现中,InnoDB 是通过 undo log 实现的,通过 undo log 可以找回数据的历史版本。找回的历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在 InnoDB 内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见行。
一句话概述,MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理读写冲突,做到即使有读写冲突时,也能做到不加锁,做到非阻塞并发读。
1.3 当前读和快照读
-
当前读
select xxx lock in share mode; # 共享锁
#排它锁
select xxx for update;
update xxx;
delete xxx;
insert xxx;像上面的这些操作就是一种当前读,因为它读取的是数据的最新版本,读取时还要保证其他事务不能修改当前记录,会对记录进行加锁。
-
快照读
不加锁的 select 就是快照读,即不加锁的非阻塞读。(快照读的前提是隔离级别不是 serializable,serializable 的隔离级别下快照读会退化成当前读) 之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于 MVCC 的,可以认为 MVCC 是行锁的一个变种,但是它在很多情况下避免了加锁操作,降低了开销。既然是基于多版本,所以快照读可能读到的不一定是数据的最新版本,而有可能是之前的历史版本。
1.4 当前读和快照读与 MVCC 的关系
准确的说,MVCC 指的是**"维护一个数据的多个版本,使得读写操作没有冲突"这么一个概念,仅仅是一个理想状态。而在 MySQL 中,实现这么一个 MVCC 理想概念,我们就需要 MySQL 提供具体的功能去实现,而快照读**就是 MySQL 为我们实现 MVCC 理想模型的其中一个具体非阻塞读功能。而相对而言,当前读就是一个悲观锁的具体功能实现,而要说的在细致一点,快照读本身也是一个抽象概念,在深入研究,MVCC 模型在 MySQL 中的具体实现则是由三个隐式字段、undo log 、Read View 等去完成的。
1.5 MVCC 能解决什么问题
MVCC 是一种解决读写冲突的无锁并发控制手段,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照,所以 MVCC 可以为数据库解决以下问题:
- 在并发读数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写性能。
- 可以解决脏读、不可重复读、幻读等事务隔离问题(不能解决更新丢失的问题)。
所以说 MVCC 就是开发人员不满意只让数据库采用悲观锁(加锁)这样性能不佳的形式去解决读-写的问题而提出的解决方案,所以在数据库中,因为有了 MVCC ,所以我们可以形成两个组合:
- MVCC + 悲观锁
MVCC解决读写冲突,悲观锁解决写-写冲突
- MVCC + 乐观锁
MVCC解决读写冲突,乐观锁解决写-写冲突。
二、MVCC实现原理
2.1 隐式字段
在一张表中,除了我们自定义的字段,实际上 MySQL 会隐式的定义一些字段。
-
DB_TRX_ID
6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
-
DB_ROLL_PTR
7byte,回滚指针,指向这条记录的上一个版本(存储于 rollback segment 里)
-
DB_ROW_ID
6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引
-
实际还有一个删除 flag 隐藏字段, 即记录被更新或删除并不代表真的删除,而是删除 flag 变了
例如下面是 person 表的某条记录,如下图,DB_ROW_ID 是数据库为改行记录生产的唯一隐式主键,DB_TRX_ID 是当前操作该记录的事务ID,而 DB_ROLL_PTR 是一个回滚指针,用于配合 undo log 日志,指向上一个版本。
2.2 undo log日志
undo log 类型
-
insert undo log
是指在 insert 操作中产生的 undo log。因为 insert 操作的记录,只对当前事务本身可见,对其他事务不可见(这是事务隔离性的要求),因此这种 undo log 可以在事务提交后直接删除。不需要进行 purge 操作
-
update undo log
是对 delete 和 update 操作产生的 undo log。该 undo log 可能需要提供给 MVCC 机制使用,因此不能在事务提交时就进行删除,提交时放入 undo log 链表,等待 purge 线程进行最后的删除。
purge 线程
为了实现 InnoDB 的 MVCC 机制,更新或者删除操作都只是设置一下老记录的 deleted_bit(即前面提到的删除 flag ),并不真正将过时的记录删除。为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个 Read View(这个 Read View 相当于系统中最老活跃事务的 Read View ),如果某个记录的 deleted_bit 为true,并且 DB_TRX_ID 相对于 purge 线程的 Read View 可见,那么这条记录一定是可以被安全清除的。
对 MVCC 有帮助的实质是 update undo log ,undo log 存在于 rollback segment 中旧记录链,它的执行流程如下:
- 一个事务插入 person 表插入了一条新记录,记录如下,name 为 Jerry, age 为24,隐式主键是1,事务ID和回滚指针我们假设为NULL。
2.现在来了一个 事务1 对记录的 name 进行了修改,改为了 Tom,执行过程如下:
- 在事务1修改该行数据时,数据会先对这行记录加排它锁。
- 然后把该行数据拷贝到 undo log 中,作为旧记录,即在 undo log 中有当前行的拷贝副本。
- 拷贝完毕后,修改该行的 name 为 tom,并且修改隐藏字段的事务ID为当前事务1的ID,我们默认从1开始,之后递增,回滚指针指向拷贝到 undo log 的副本记录,即表示我的上一个版本就是它。
- 事务提交后,释放排它锁。
3.又来了个事务2修改 person 表的同一个记录,将 age 修改为30岁,执行过程如下:
- 在事务2修改该行数据时,数据库也先为该行加锁
- 然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有 undo log 了,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面
- 修改该行 age 为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到 undo log 的副本记录
- 事务提交,释放锁
从上面我们可以看出,不同事务或者相同事务对同一记录的修改,会导致该记录的 undo log 称为一条记录版本线性表,即链表,undo log 的表头就是最新的旧记录,(当然就像之前说的该 undo log 的节点可能会被 purge 线程清除掉,像图中的第一条 insert undo log ,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)
2.3 Read View
什么是 Read View ?
Read View 是事务进行快照读操作时产生的读视图,在该事务执行快照读的那一刻,会生成数据库系统的当前的一个快照,记录并维护当前活跃事务的ID(当每个事务开启时,都会被分配一个 ID,这个 ID 是自增的,所以最新的事务,ID 值越大)
可见行判断
所以我们知道 Read View 主要是用来做可见性判断的,即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
Read View 遵循一个可见性算法,主要是将要被修改的数据的最新记录的 DB_TRX_ID(即当前事务ID),与系统当前其他活跃事务的ID去对比(由 Read View 维护),如果 DB_TRX_ID 跟 Read View 的属性做了某些对比,不符合可见性,那么就由 DB_ROLL_PTR 回滚指针去取出undo log中的 DB_TRX_ID 再比较,即遍历链表的 DB_TRX_ID(从链表头到尾,即从最近的一次修改查起),直到找到满足特定条件的 DB_TRX_ID,那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新的老版本。
判断条件是什么?
我们可以将 Read View 简单的理解为三个全局属性
-
trx_list 一个数值列表,用来维护 Read View 生成时刻此时系统正活跃的事务ID
-
up_limit_id 记录 trx_list 中的最小的事务 ID
-
low_limit_id 在 Read View 生成时刻系统尚未分配的下一个事务 ID,即目前(不一定是 Read View 中)已经出现过的事务 ID 最大值+1
比较步骤
- 首先比较 DB_TRX_ID < up_limit_id, 如果小于,则当前事务能看到 DB_TRX_ID 所在的记录,如果大于等于进入下一个判断。
- 接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断。
- 判断 DB_TRX_ID 是否在活跃事务之中,trx_list.contains( DB_TRX_ID ),如果在,则代表我 Read View 生成时刻,你这个事务还在活跃,还没有 Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你的这个事务在 Read View 生成之前就已经 Commit了,你修改的结果,我当前事务是能看见的。
2.4 实现流程
我们在了解了隐式字段、undo log 以及 Read View 的概念之后,我们模拟一下 MVCC 实现的整体流程。
-
假设当前有四个事务,当事务2对某行数据执行了快照读,数据库为该行数据生成一个 Read View 读视图,假设当前事务ID为2,此时还有事务1和事务3在活跃中,事务4在事务2快照读前一刻提交更新了,所以 Read View 记录了系统当前活跃事务1,3的ID,维护在一个列表上,假设我们称为 trx_list 。
事务1事务2事务3事务4事务开始事务开始事务开始事务开始.........修改且已提交进行中快照读进行中
......... -
Read View 不仅仅会通过一个列表 trx_list 来维护事务2执行快照读那刻系统正活跃的事务ID,还会有两个属性 up_limit_id(记录 trx_list 列表中事务ID最小的ID),low_limit_id(记录 trx_list 列表中事务ID最大的ID,也有人说快照读那刻系统尚未分配的下一个事务ID也就是目前已出现过的事务ID的最大值+1,所以在这里例子中 up_limit_id 就是1,low_limit_id 就是4 + 1 = 5,trx_list 集合的值是1,3,Read View 如下图
-
我们的例子中,只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以当前该行当前数据的 undo log 如下图所示;我们的事务2在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id , low_limit_id 和活跃事务ID列表( trx_list )进行比较,判断当前事务2能看到该记录的版本是哪个。
-
所以先拿该记录 DB_TRX_ID 字段记录的事务ID 4去跟 Read View 的的 up_limit_id 比较,看4是否小于 up_limit_id(1),所以不符合条件,继续判断 4 是否大于等于 low_limit_id(5),也不符合条件,最后判断4是否处于 trx_list 中的活跃事务, 最后发现事务ID为4的事务不在当前活跃事务列表中, 符合可见性条件,所以事务4修改后提交的最新结果对事务2快照读时是可见的,所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本。
流程图
2.5 RC/RR级别快照读有什么不同
生成 Read View 的时机不同,从而造成 RC RR 级别下快照读的结果的不同。
- 在RR级别下的某个事务对某条记录进行的第一次快照读会创建一个快照 Read View,此后在调用快照读的时候,使用的还是同一个ReadView,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见.
- 而在 RC 隔离级别下,事务中每次快照都会生成一个快照和 ReadView,这就是我们在 RC 级别下的事务中可以看到别的事务提交更新的原因。
总之在 RC 隔离级别下,每次快照读都会生成最新的 ReadView;而在 RR 级别下,则是同一个事务中的第一个快照读才会创建ReadView,之后的快照读获取的都是同一个 ReadView。所以说 RR 在 RC 的基础上通过生成 Read View 的时机不同从而解决了不可重复读的问题
总结
本文讲解了 MySQL 中的隐式字段、undo log 日志和 Read View 的原理以及 MVCC 的实现流程,对于我们在日常的开发过程中对于数据库的并发操作以及 MySQL 的各种隔离级别有了清晰的认识。