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 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
笔记
在RC级别中,幻读是没有办法解决的,因为RC中快照读是每一次都会重新生成快照,并且RC中也不会有间隙锁。
在RR级别中,因为有MVCC机制,对于普通的无锁查询,这种是属于快照读的,RR的快照读在同一个事务中只会读一次,所以在事务过程中,其他事务的变更不会影响到当前事务的查询结果。所以这种幻读是可以解决的。
当时,MVCC只能对快照读起作用,而对于加锁的读请求,这种属于当前读,当前读的话是可以查询到其他事务的变更的,所以会产生幻读。
想要解决幻读,可以使用Serializable这种隔离级别,或者使用RR也能解决大部分的幻读问题。
在RR级别下,为了避免幻读的发生,要么就是使用快照读,要么就是在事务一开始就加锁。但是需要注意的是,间隙锁是导致死锁的一个重要根源~所以,用起来也需要慎重。
# 总结
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 得到相同的结果.