文章大纲
- Undo日志
- 整体流程
- Redo日志
- 整体流程
- MVCC
- 事务隔离级别
- 什么是MVCC
- MVCC实现原理
- 存储结构
- ReadView结构
- MVCC实现RR/RC事务隔离级别
Undo 日志
关系型数据需要实现事务的 ACID 特性,其中一点就是事务的原子性,Mysql 就是通过 Undo 日志就来实现的。
数据库处理数据都是先读到内存中,然后修改内存中的数据,最后将数据写回磁盘。
在事务处理过程中,操作数据之前会先将数据缓存至 Undo 日志,然后进行数据的修改。当事务回滚时,或者数据库奔溃时,系统可以利用 Undo 日志中的备份将数据恢复到事务开始之前的状态,撤销未提交事务对数据库产生的影响。
具体流程
假设有个用户表,字段有 id
、name
。
id | name |
---|---|
1 | curry |
现需要将 id=1
的 name 修改为 kawaii
,流程如下:
1、事务开始
2、将id=1
的行数据记录到 undo log buffer
3、修改id=1
的行数据name
为kawaii
4、undo log buffer 写入磁盘为 undo log file
5、将数据写入磁盘
6、事务提交
步骤 1 - 3 都是在内存中完成,第 4 步之前包括第 4 步如果出现问题,undo log 与数据都未写入磁盘,可以直接回滚,第 4 步之后出现问题,此时 undo log 已写入磁盘,利用 undo log 进行回滚。
对于insert操作,undo 日志记录新数据的 PK(ROW_ID),回滚时直接删除
对于delete/update操作,undo 日志记录旧数据,回滚时直接恢复
*存在问题*
事务提交时需要将内存中的数据同步写入磁盘,数据写入磁盘属于随机IO,性能较差,会极大影响数据库的吞吐量。
优化方案:将修改行为先写到 Redo 日志(顺序写),再定期将数据刷到磁盘上,这样能极大提高性能。
Redo 日志
Undo 日志存储的是历史数据快照,Redo 日志则存储最新数据。
具体流程
还是以上文中的操作为例:
1、事务开始
2、将id=1
的行数据记录到 undo log buffer
3、修改id=1
的行数据name
为kawaii
4、记录修改日志到 redo log buffer
5、undo log buffer 写入磁盘为 undo log file
6、redo log buffer 写入磁盘为 redo log file
7、事务提交
都是写磁盘,写 Redo 日志文件与直接写数据库文件有什么区别?
写 Redo 日志文件是顺序IO,而写数据库文件是随机IO,性能差别大。
Redo 日志文件写入成功后,数据库会另起线程将 Redo 日志写入数据库数据文件,实现持久化。
MVCC
MVCC(Multi-Version Concurrency Control)即多版本并发控制,Mysql、Oracle、PostgreSQL等数据库都实现了MVCC,但各自实现的机制不尽相同,因为MVCC没有统一的实现标准。
事务隔离级别
事务有四个隔离级别
隔离级别 | 脏读 | 重复读 | 幻读 |
---|---|---|---|
READ UNCOMMITTED(读未提交) | 有 | 有 | 有 |
READ COMMITTED(读已提交) | 无 | 有 | 有 |
READ REPEATABLE(可重复读) | 无 | 无 | 有 |
SERIALIZABLE(串行化) | 无 | 无 | 无 |
- 脏读:事务中的未提交的修改对其它事务是可见的。
如下表所示,事务B可以读取到事务A未提交的数据。
事务A | 事务B |
---|---|
修改金额为 4000,事务未提交 | … |
… | 读取金额为 4000 |
- 重复读:事务中多次读取同一数据,结果不一致。
如下表所示,事务B两次读取之间事务A修改了数据并提交事务,事务B的第二次读取与第一次读取结果有变化。
事务A | 事务B |
---|---|
… | 读取金额为3000 |
修改金额为 4000,提交事务 | … |
… | 读取金额为 4000 |
- 幻读:一个事务在读取某个范围内的记录时,另一个事务在该范围插入的新记录,之前事务再次读取该范围记录时,会产生幻行。
如下表所示,事务B两次读取之间事务A插入了一条数据并提交事务,事务B第二次读取 id < 5
的范围数据时,数量增加了。
事务A | 事务B |
---|---|
… | id < 5,记录数据为 3 |
插入 id = 4 数据行,提交事务 | … |
… | id < 5,记录数据为 4 |
什么是MVCC
InnoDB 存储引擎默认的事务隔离级别为 RR (可重复读)
那么 InnoDB 如何实现可重复读?加行级锁是肯定可以实现的,但如果对读操作也加行级锁,将严重影响数据库的并发性能,而MVCC 就是这个问题的解决方案。
MVCC 是一种用来解决读-写冲突
的无锁并发控制,通过对行数据的多版本控制,避免了读操作时的加锁操作,因此开销更低,大大提高数据库系统的并发性能。
InnoDB 就是通过 行级锁+MVCC 共同实现事务隔离,正常读的时候不加锁,写的时候对数据行加排它锁。
MVCC 只能在
Read Committed
和Repeatable Read
两个隔离级别下工作。
MVCC实现原理
存储结构
InnoDB 支持聚簇索引,默认设置主键列为聚簇索引,如果表中无主键,则会选择一个唯一的非空索引作为聚簇索引,如果也没有,则会隐式定义一个主键作为聚簇索引。
InnoDB 的数据行存储在聚簇索引上,结构如下:
主键列 |
DB_TRX_ID(事务ID) |
DB_ROLL_PTR(回滚指针) |
非主键列 |
可以看到索引上不仅存储了索引列、行数据,还包含了两个隐藏字段:
- DB_TRX_ID(6字节):表示最近一次修改(insert | update)的事务ID。
- DB_ROLL_PTR(7字节):回滚指针,指向这条记录的上一个版本(存储于 roolback segment 里的 update undo log)
还有一个删除 flag 隐藏字段,delete 操作被认为是一个 update 操作,只是修改了该删除标记位,而不是物理删除。
以上文中的表结构为例:
第一步:事务1,插入一条记录
表数据
id(主键) | name | DB_TRX_ID(事务ID) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|
1 | curry | 1 | null |
第二步:事务2,修改 name 为 kawaii
,流程如下
1、事务开始,对数据行加排它锁
2、将该行数据记录到 undo log,事务ID 为 1 的数据行
3、修改该行数据name
为kawaii
,DB_TRX_ID 修改为 2,DB_ROLL_PTR 指向 步骤 2 中记录的 undo log
4、提交事务,释放排它锁
当前最新数据如下,其中 0x23636355
指向 undo log 中 DB_TRX_ID 为 1 的地址
id(主键) | name | DB_TRX_ID(事务ID) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|
1 | kawaii | 2 | 0x23636355 |
undo log 数据:
id(主键) | name | DB_TRX_ID(事务ID) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|
1 | curry | 1 | null |
第三步:事务3,修改 name 为 unknown
,流程如下
1、事务开始,对数据行加排它锁
2、将该行数据记录到 undo log,事务ID 为 2 的数据行
3、修改该行数据name
为unknown
,DB_TRX_ID 修改为 3,DB_ROLL_PTR 指向 步骤 2 中记录的 undo log
4、提交事务,释放排它锁
当前最新数据如下,其中 0x65461234
指向 undo log 中 DB_TRX_ID 为 2 的地址
id(主键) | name | DB_TRX_ID(事务ID) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|
1 | unknown | 3 | 0x65461234 |
undo log 数据:
id(主键) | name | DB_TRX_ID(事务ID) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|
1 | kawaii | 2 | 0x23636355 |
id(主键) | name | DB_TRX_ID(事务ID) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|
1 | curry | 1 | null |
可以看出,通过 DB_ROLL_PTR 指针,同一行数据的多个版本形成了一个单向链表,链表中除第一条记录,都存储在 undo log。
ReadView 结构
通过 undo log 实现数据的多版本存储,接下来需要处理的就是数据读取的问题,该读取哪个版本的数据?ReadView 就是用来做可见性判断的。
ReadView 结构有几个重要的变量
creator_trx_id
:当前事务的ID,每个事务都会拥有一个ID,是一个递增的编号。trx_ids
:Read View创建时其他未提交的活跃事务ID列表。low_limit_id
:目前出现过的最大的事务ID+1,即下一个将被分配的事务ID。up_limit_id
:活跃事务列表trx_ids
中最小的事务ID,如果trx_ids
为空,则up_limit_id
为low_limit_id
。
InnoDB 会在事务执行第一个 select 语句的时候创建 ReadView,读取某行记录的时候,根据该行的DB_TRX_ID 与 ReadView 进行可见性分析,判断读取哪个版本的数据。
具体的可见性比较算法如下:
- 判断
DB_TRX_ID < up_limit_id
,如果成立,表明 最新修改该行的事务 在 当前事务 创建 ReadView 之前就已经提交,所以DB_TRX_ID
版本对 当前事务 是可见的,否则进入下一个判断 - 判断
DB_TRX_ID >= low_limit_id
,如果成立,表明 最新修改该行的事务 在 当前事务 创建 ReadView 之后才修改该行,所以DB_TRX_ID
版本对 当前事务 是不可见的 - 判断
DB_TRX_ID
是否在活跃事务列表trx_ids
中- 如果在,表明在 当前事务 创建 ReadView 时,最新修改该行的事务 还在活跃中,尚未提交,所以
DB_TRX_ID
版本对 当前事务 是不可见的。 - 如果不在,表明在 当前事务 创建 ReadView 之前,最新修改该行的事务 就已经提交,所以
DB_TRX_ID
版本对 当前事务 是可见的。
- 如果在,表明在 当前事务 创建 ReadView 时,最新修改该行的事务 还在活跃中,尚未提交,所以
DB_TRX_ID
版本对 当前事务 不可见,则根据DB_ROLL_PTR
取出上一个版本数据,使用其DB_TRX_ID
重新判断一次。
看完规则后,也许脑瓜子是嗡嗡的,那么接下来做一个模拟分析
事务1 | 事务2 | 事务3 | 事务4 |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
… | … | … | 修改且提交 |
… | 快照读 | … | - |
事务2 执行快照读时,数据库为该行数据创建一个 ReadView,此时事务1、3处于活跃状态,事务4已提交,那么 ReadView 中各个变量的值如下:
trx_ids = [1,3]
low_limit_id = 4 + 1 = 5
up_limit_id = 1
事务2读取最新记录,该记录的 DB_TRX_ID = 4
,根据上文中的规则
- 判断
DB_TRX_ID < up_limit_id
,不成立,下一步 - 判断
DB_TRX_ID >= low_limit_id
,也不成立,下一步 - 判断
DB_TRX_ID
是否在trx_ids
集合中,结果为否,符合可见性条件。
所以事务4提交的最新结果对事务2是可见的。
再来一个更复杂的模拟分析,原始金额为 500,DB_TRX_ID 为 100
事务101 | 事务102 | 事务103 | 事务104 |
---|---|---|---|
事务开始 | 事务开始 | ||
第一次 select 创建 ReadView trx_ids = [102] low_limit_id = 103 up_limit_id = 101 (未开启) | … | ||
… | … | 事务开始 | |
… | 第一次 select 创建 ReadView trx_ids = [101,103] low_limit_id = 104 up_limit_id = 101 (未开启) | … | |
… | … | … | 事务开始 |
DB_TRX_ID = 100 DB_TRX_ID < up_limit_id 读取最新数据500 | … | … | … |
事务提交 | … | … | … |
- | … | 第一次 select 创建 ReadView trx_ids = [102,104] low_limit_id = 105 up_limit_id = 102 (未开启) | … |
- | … | … | 第一次 select 创建 ReadView trx_ids = [102,103] low_limit_id = 105 up_limit_id = 102 (未开启) |
- | … | … | 修改金额为400 |
- | … | … | 提交事务 |
- | DB_TRX_ID = 104 DB_TRX_ID>=low_limit_id 当前数据400不可见 DB_TRX_ID = 100 DB_TRX_ID < up_limit_id 读取undo数据500 | DB_TRX_ID = 104 trx_ids包含DB_TRX_ID 当前数据400不可见 DB_TRX_ID = 100 DB_TRX_ID < up_limit_id 读取undo数据500 |
MVCC实现RR/RC事务隔离级别
前文中给出的示例都是基于 RR 隔离级别,下文给出一个 RR 隔离级别的简单示例,方便与 RC 隔离级别进行比较。
RR 隔离级别
事务A | 事务B |
---|---|
开启事务 | 开启事务 |
快照读金额为500 | 快照读金额为500 |
更新金额为400 | … |
提交事务 | … |
… | 快照读金额为500 |
RC 隔离级别
事务A | 事务B |
---|---|
开启事务 | 开启事务 |
快照读金额为500 | 快照读金额为500 |
更新金额为400 | … |
提交事务 | … |
… | 快照读金额为400 |
都是使用 MVCC 进行实现,同样的操作需要得到不同的结果,区别就在于创建 ReadView 的时机,RR 隔离级别只在第一个 select 语句时创建,而 RC 隔离级别则是每一个 select 语句都创建。
参考文档:
【MySQL笔记】正确的理解MySQL的MVCC及实现原理
MySQL-InnoDB-MVCC多版本并发控制
MySQL中MVCC的正确打开方式(源码佐证)