MySQL 的 MVCC 机制
# 1 什么是 MVCC
MVCC 全称是: Multiversion concurrency control,多版本并发控制,提供并发访问数据库时,对事务内读取的到的内存做处理,用来避免写操作堵塞读操作的并发问题。
举个例子,程序员 A 正在读数据库中某些内容,而程序员 B 正在给这些内容做修改(假设是在一个事务内修改,大概持续 10s 左右),A 在这 10s 内 则可能看到一个不一致的数据,在 B 没有提交前,如何让 A 能够一直读到的数据都是一致的呢?
有几种处理方法,第一种: 基于锁的并发控制,程序员 B 开始修改数据时,给这些数据加上锁,程序员 A 这时再读,就发现读取不了,处于等待情况,只能等 B 操作完才能读数据,这保证 A 不会读到一个不一致的数据,但是这个会影响程序的运行效率。还有一种就是:MVCC,每个用户连接数据库时,看到的都是某一特定时刻的数据库快照,在 B 的事务没有提交之前,A 始终读到的是某一特定时刻的数据库快照,不会读到 B 事务中的数据修改情况,直到 B 事务提交,才会读取 B 的修改内容。
一个支持 MVCC 的数据库,在更新某些数据时,并非使用新数据覆盖旧数据,而是标记旧数据是过时的,同时在其他地方新增一个数据版本。因此,同一份数据有多个版本存储,但只有一个是最新的。
MVCC 提供了时间一致性的处理思路,在 MVCC 下读事务时,通常使用一个时间戳或者事务 ID 来确定访问哪个状态的数据库及哪些版本的数据。读事务跟写事务彼此是隔离开来的,彼此之间不会影响。假设同一份数据,既有读事务访问,又有写事务操作,实际上,写事务会新建一个新的数据版本,而读事务访问的是旧的数据版本,直到写事务提交,读事务才会访问到这个新的数据版本。
MVCC 有两种实现方式,第一种实现方式是将数据记录的多个版本保存在数据库中,当这些不同版本数据不再需要时,垃圾收集器回收这些记录。这个方式被 PostgreSQL 和 Firebird/Interbase 采用,SQL Server 使用的类似机制,所不同的是旧版本数据不是保存在数据库中,而保存在不同于主数据库的另外一个数据库 tempdb 中。第二种实现方式只在数据库保存最新版本的数据,但是会在使用 undo 时动态重构旧版本数据,这种方式被 Oracle 和 MySQL/InnoDB 使用。
# 当前读
像 select …… lock in share mode
(意向共享锁), select …… for update
(意向排它锁); update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
了解更多:深入理解SELECT ... LOCK IN SHARE MODE和SELECT ... FOR UPDATE-CSDN博客 (opens new window)
笔记
- LOCK IN SHARE MODE
SELECT ... LOCK IN SHARE MODE
走的是IS锁(意向共享锁),即在符合条件的rows上都加了共享锁,这样的话,其他session可以读取这些记录,也可以继续添加IS锁,但是无法修改这些记录直到你这个加锁的session执行完成(否则直接锁等待超时)。
SELECT ... LOCK IN SHARE MODE
的应用场景适合于两张表存在关系时的写操作,拿mysql官方文档的例子来说,一个表是child表,一个是parent表,假设child表的某一列child_id映射到parent表的c_child_id列,那么从业务角度讲,此时我直接insert一条child_id=100记录到child表是存在风险的,因为刚insert的时候可能在parent表里删除了这条c_child_id=100的记录,那么业务数据就存在不一致的风险。正确的方法是再插入时执行select * from parent where c_child_id=100 lock in share mode
,锁定了parent表的这条记录,然后执行insert into child(child_id) values (100)
。
但是如果是同一张表的应用场景,那么采用lock in share mode可行吗,也是不合理的,因为两个事务同时锁定该行记录时,这时两个事务再update时必然会产生死锁导致事务回滚。我们需要使用for update
的方式直接加X锁,从而短暂地阻塞事务2的select...for update
操作
- SELECT ... FOR UPDATE
SELECT ... FOR UPDATE
走的是IX锁(意向排它锁),即在符合条件的rows上都加了排它锁,其他session也就无法在这些记录上添加任何的S锁或X锁。如果不存在一致性非锁定读的话(读取快照),那么其他session是无法读取和修改这些记录的,但是innodb有非锁定读(快照读并不需要加锁)
- InnoDB默认是行级别的锁,当有明确指定的主键时候,是行级锁。否则是表级别
- for update 仅适用于InnoDB,并且必须开启事务,在begin与commit之间才生效。
- for update的加锁方式无非是比lock in share mode的方式多阻塞了select...lock in share mode的查询方式,并不会阻塞快照读(常规的select)
- for update nowait
for update nowait 锁住表或者锁住行,只允许当前事务进行操作(读写),其他事务被拒绝,事务占据的statement连接也会被断开
# 快照读(提高数据库的并发查询能力)
像不加锁的简单的 select 操作就是快照读(select * from table where id = xxx
),即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即 MVCC,可以认为 MVCC 是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本
# 当前读、快照读、MVCC 关系
MVCC 多版本并发控制指的是维持一个数据的多个版本,使得读写操作没有冲突,快照读是 MySQL 为实现 MVCC 的一个非阻塞读功能。MVCC 模块在 MySQL 中的具体实现是由三个隐式字段,undo 日志、read view 三个组件来实现的。
# 2、InnoDB 的 MVCC 实现机制
MVCC 可以认为是行级锁的一个变种,它可以在很多情况下避免加锁操作,因此开销更低。MVCC 的实现大都都实现了非阻塞的读操作,写操作也只锁定必要的行。InnoDB 的 MVCC 实现,是通过保存数据在某个时间点的快照来实现的。一个事务,不管其执行多长时间,其内部看到的数据是一致的。也就是事务在执行的过程中不会相互影响。下面我们简述一下 MVCC 在 InnoDB 中的实现。
# 隐藏字段
MVCC 使用了“三个隐藏字段”来实现版本并发控制,我查了很多资料,看到有很多博客上写的是通过 一个创建事务id字段和一个删除事务id字段 来控制实现的。但后来发现并不是很正确,我们先来看一看 MySQL 在建表的时候 innoDB 创建的真正的三个隐藏列吧。
RowID | DB_TRX_ID | DB_ROLL_PTR | id | name | password |
---|---|---|---|---|---|
自动创建的id | 事务id | 回滚指针 | id | name | password |
- DB_ROW_ID 6byte, 隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
- DB_TRX_ID 6byte, 最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
- DB_ROLL_PTR 7byte, 回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
- DELETED_BIT 1byte, 记录被更新或删除,并不代表真的删除,而是删除flag变了
而 MVCC 使用的是其中的 事务字段,回滚指针字段,是否删除字段。我们来看一下现在的表格(DELETED_BIT按照官方说法是在一行开头的content里面,这里其实位置无所谓,你只要知道有就行了)。
DELETED_BIT | DB_TRX_ID | DB_ROLL_PTR | id | name | password |
---|---|---|---|---|---|
true/false | 事务id | 回滚指针 | id | name | password |
那么如何通过这三个字段来实现 MVCC 的 可见性算法 呢?
# undo 日志
InnoDB把这些为了回滚而记录的这些东西称之为undo log。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo log。undo log主要分为3种:
- Insert undo log :插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
- Update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
- Delete undo log:删除一条记录时,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。
- 删除操作都只是设置一下老记录的DELETED_BIT,并不真正将过时的记录删除。(即使用逻辑删除)
- 为了节省磁盘空间,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中旧记录链,它的执行流程如下:
- 比如一个有个事务插入persion表插入了一条新记录,记录如下,name为Jerry, age为24岁,隐式主键是1,事务ID和回滚指针,我们假设为NULL
- 现在来了一个事务1对该记录的name做出了修改,改为Tom
- 在事务1修改该行(记录)数据时,数据库会先对该行加排他锁
- 然后把该行数据拷贝到undo log中,作为旧记录,即在undo log中有当前行的拷贝副本
- 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,即表示我的上一个版本就是它
- 事务提交后,释放锁
- 又来了个事务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,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)
# read-view
什么是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 有四个重要的字段:
- m_ids :指的是在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务。
- min_trx_id :指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。
- max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;
- creator_trx_id :指的是创建该 Read View 的事务的事务 id。
# 可见性算法
其实主要思路就是:当生成read-view的时候如何去拿获取的 DB_TRX_ID 去和 read-view 中的三个属性(上面提到的)去作比较。我来说一下三个步骤,如果不是很理解可以参考着我后面的实践结合着去理解。
- 版本链比对规则:
- 如果 trx_id < min_trx_id,表示这个版本是已提交的事务生成的,这个数据是可见的;
- 如果 trx_id > max_trx_id,表示这个版本是由将来启动的事务生成的,是肯定不可见的。
- 如果 min_trx_id <= trx_id <= max_trx_id,那就包括两种情况
- 若row的trx_id在m_ids数组中,表示这个版本是由还没提交的事务生成的,不可见,当前自己的事务是可见的。
- 若row的trx_id不在m_ids数组中,表示这个版本是已经提交了的事务生成的,可见
如果此条记录对于该事务不可见且 ROLL_PTR 不为空那么就会指向回滚指针的地址,通过undolog来查找可见的记录版本。
下面我画了一个可见性的算法的流程图
# 实现流程
- 获取事务自己的版本号,即事务ID
- 获取Read View
- 查询得到的数据,然后Read View中的事务版本号进行比较。
- 如果不符合Read View的可见性规则, 即就需要Undo log中历史快照;
- 最后返回符合规则的数据
InnoDB 实现MVCC,是通过Read View+ Undo Log 实现的,Undo Log 保存了历史快照,Read View可见性规则帮助判断当前版本的数据是否可见。
# 读已提交(RC)隔离级别
在读已提交(RC)隔离级别下,同一个事务里面,每一次查询都会产生一个新的Read View副本,这样就可能造成同一个事务里前后读取数据可能不一致的问题(不可重复读并发问题)。
# RR 隔离等级
在可重复读(RR)隔离级别下,一个事务里只会获取一次read view,都是副本共用的,从而保证每次查询的数据都是一样的。
# 幻读问题
- 针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
- 针对当前读(select ... for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
# 总结
MySQL的InnoDB实现MVCC,就是在隔离级别为读已提交和可重复读,基于乐观锁理论,通过事务ID和read-view的记录进行比较判断分析数据是否可见,从而使大部分读操作可以无需加锁,从而提高并发性能。
# 3、简单的小例子
create table yang(
id int primary key auto\_increment,
name varchar(20));
2
3
假设系统的版本号从 1 开始.
# INSERT
InnoDB 为新插入的每一行保存当前系统版本号作为版本号. 第一个事务 ID 为 1;
start transaction;
insert into yang values(NULL,'yang') ;
insert into yang values(NULL,'long');
insert into yang values(NULL,'fei');
commit;
2
3
4
5
对应在数据中的表如下(后面两列是隐藏列,我们通过查询语句并看不到)
# SELECT
InnoDB 会根据以下两个条件检查每行记录:
a.InnoDB 只会查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的.
b.行的删除版本要么未定义,要么大于当前事务版本号,这可以确保事务读取到的行,在事务开始之前未被删除.
只有 a,b 同时满足的记录,才能返回作为查询结果.
# DELETE
In
noDB 会为删除的每一行保存当前系统的版本号(事务的 ID)作为删除标识.
看下面的具体例子分析:
第二个事务,ID 为 2;
start transaction;
select \* from yang; //(1)
select \* from yang; //(2)
commit;
2
3
4
# 假设 1
假设在执行这个事务 ID 为 2 的过程中,刚执行到(1),这时,有另一个事务 ID 为 3 往这个表里插入了一条数据; 第三个事务 ID 为 3;
start transaction; insert into yang values(NULL,'tian'); commit;
这时表中的数据如下:
然后接着执行事务 2 中的(2),由于 id=4 的数据的创建时间(事务 ID 为 3),执行当前事务的 ID 为 2,而 InnoDB 只会查找事务 ID 小于等于当前事务 ID 的数据行,所以 id=4 的数据行并不会在执行事务 2 中的(2)被检索出来,在事务 2 中的两条 select 语句检索出来的数据都只会下表:
# 假设 2
假设在执行这个事务 ID 为 2 的过程中,刚执行到(1),假设事务执行完事务 3 后,接着又执行了事务 4; 第四个事务:
start transaction;
delete from yang where id=1;
commit;
2
3
此时数据库中的表如下:
接着执行事务 ID 为 2 的事务(2),根据 SELECT 检索条件可以知道,它会检索创建时间(创建事务的 ID)小于当前事务 ID 的行和删除时间(删除事务的 ID)大于当前事务的行,而 id=4 的行上面已经说过,而 id=1 的行由于删除时间(删除事务的 ID)大于当前事务的 ID,所以事务 2 的(2)select * from yang 也会把 id=1 的数据检索出来.所以,事务 2 中的两条 select 语句检索出来的数据都如下:
# UPDATE
InnoDB 执行 UPDATE,实际上是新插入了一行记录,并保存其创建时间为当前事务的 ID,同时保存当前事务 ID 到要 UPDATE 的行的删除时间。
# 假设 3
假设在执行完事务 2 的(1)后又执行,其它用户执行了事务 3,4,这时,又有一个用户对这张表执行了 UPDATE 操作:
第 5 个事务:
start transaction;
update yang set name\='Long' where id\=2;
commit;
2
3
根据 update 的更新原则:会生成新的一行,并在原来要修改的列的删除时间列上添加本事务 ID,得到表如下:
继续执行事务 2 的(2),根据 select 语句的检索条件,得到下表:
还是和事务 2 中(1)select 得到相同的结果.