想来想去,怎么才能把事务的原理彻底的捋清楚呢?最后决定从版本链入手。
1. 版本链
先解释一下版本链的含义:对于一条记录来说,每一次变更都是一个新的版本,将每一个版本按照变更的先后顺序连起来,就形成了一个版本链,链表的头部是最新的旧记录,链表尾部是最早的旧记录
先有这样一个感念,现在插入一个 mysql 的小知识点------记录的 隐藏 字段
InnoDB 会自动给记录添加三个隐藏字段DB_TRX_ID
、DB_ROLL_PTR
、DB_ROW_ID
隐藏字段 | 含义 |
---|---|
DB_TRX_ID | 将记录修改为新版本的事务 ID(事务的唯一标识),换句话说,该字段标记了哪个事务修改了记录 |
DB_ROLL_PTR | 回滚指针,这就像链表中的 next 指针,用来指向上一个版本的。 |
DB_ROW_ID | 隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段(在本文中,这个字段不重要)。 |
OK,现在有了上边的基础,接下来上栗子🌰。现在有这样一条初始记录
然后,有四个并发事务同时在访问这张表。
事务 2 | 事务 3 | 事务 4 | 事务 5 |
---|---|---|---|
开始事务 | 开始事务 | 开始事务 | 开始事务 |
修改 id 为 30 的记录,age 改为 3 | 查询 id 为 30 的记录 | ||
提交事务 | |||
修改 id 为 30 的记录,name 改为 A3 | |||
查询 id 为 30 的记录 | |||
提交事务 | |||
修改 id 为 30 的记录,age 改为 10 | |||
查询 id 为 30 的记录 | |||
查询 id 为 30 的记录 | |||
提交事务 |
当事务 2 执行第一条修改语句时,就会形成一个新的版本,新的版本会指向老的版本
我相信到现在你已经理解了版本的迭代过程,但是,请继续向下看。
事务 3 又对表进行了修改
事务 2 | 事务 3 | 事务 4 | 事务 5 |
---|---|---|---|
开始事务 | 开始事务 | 开始事务 | 开始事务 |
修改 id 为 30 的记录,age 改为 3 | 查询 id 为 30 的记录 | ||
提交事务 | |||
修改 id 为 30 的记录,name 改为 A3 | |||
查询 id 为 30 的记录 | |||
提交事务 | |||
修改 id 为 30 的记录,age 改为 10 | |||
查询 id 为 30 的记录 | |||
查询 id 为 30 的记录 | |||
提交事务 |
事务 4 继续修改
事务 2 | 事务 3 | 事务 4 | 事务 5 |
---|---|---|---|
开始事务 | 开始事务 | 开始事务 | 开始事务 |
修改 id 为 30 的记录,age 改为 3 | 查询 id 为 30 的记录 | ||
提交事务 | |||
修改 id 为 30 的记录,name 改为 A3 | |||
查询 id 为 30 的记录 | |||
提交事务 | |||
修改 id 为 30 的记录,age 改为 10 | |||
查询 id 为 30 的记录 | |||
查询 id 为 30 的记录 | |||
提交事务 |
请仔细熟悉上边这个看似简单的例子,记住他的名字------版本链,接下来要步入今天的正题
2. 事务原理
2.1. 事务基础
事务 是一组操作的集合,它是一个不可分割的工作单位,事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作要么同时成功,要么同时失败。
老生常谈,事务具有四大特性:
- 原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败。
- 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
- 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
- 持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。
那实际上,我们研究事务的原理,就是研究 MySQL 的 InnoDB 引擎是如何保证事务的这四大特性的。
而对于这四大特性,实际上分为两个部分。 其中的原子性、一致性、持久性,实际上是由 InnoDB 中的两份日志来保证的,一份是 redo log 日志,一份是 undo log 日志。 而持久性是通过数据库的锁,加上 MVCC 来保证的。
MVCC 是啥?你暂且就把它理解成版本链的例子就好
接下来我想直接介绍 Redo log,怕不好理解,所以,还是介绍一下前置知识------Innodb 的存储结构
2.2. Innodb 的存储结构
MySQL5.5 版本开始,默认使用 InnoDB 存储引擎,它擅长事务处理,具有崩溃恢复特性,在日常开发中使用非常广泛。下面是 InnoDB 架构图,左侧为内存结构,右侧为磁盘结构。
InnoDB 存储引擎基于磁盘文件存储,访问物理硬盘和访问内存,速度相差很大,为了尽可能弥补这两者之间的 I/O 效率的差值,就需要把经常使用的数据加载到缓冲池中,避免每次访问都进行磁盘 I/O。
Buffer Pool
存储的就是从磁盘读到内存的数据。在执行增删改查操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),然后再以一定频率刷新到磁盘,从而减少磁盘 IO,加快处理速度。
在专用服务器上,通常将多达 80%的物理内存分配给缓冲池。
参数设置: show variables like 'innodb_buffer_pool_size';
okok,前置知识先说到这里,继续我们今天的主题之前,你再看一眼左下角的 Log Buffer
。
2.3. Redo log
重做日志,记录的是事务提交时数据页的物理修改,是用来实现事务的持久性。
该日志文件由两 部分组成:重做日志缓冲(redo log buffer)
以及重做日志文件(redo log file)
,前者是在内存中,后者在磁盘中。当事务提交之后会把所有修改信息都存到该日志文件中,注意,所有修改信息都在这里边记录了。
那它究竟是干啥的?
我们知道,在 InnoDB 引擎中的内存结构中,主要的内存区域就是缓冲池,在缓冲池中缓存了很多的数据页。 当我们在一个事务中,执行多个增删改的操作时,InnoDB 引擎会先操作缓冲池中的数据,如果缓冲区没有对应的数据,会通过后台线程将磁盘中的数据加载出来,存放在缓冲区中,然后将缓冲池中的数据修改,修改后的数据页我们称为脏页。 而脏页则会在一定的时机,通过后台线程刷新到磁盘中,从而保证缓冲区与磁盘的数据一致。 而缓冲区的脏页数据并不是实时刷新的,而是一段时间之后将缓冲区的数据刷新到磁盘中,假如刷新到磁盘的过程出错了,而提示给用户事务提交成功,而数据却没有持久化下来,这就出现问题了,没有保证事务的持久性。
那么,如何解决上述的问题呢? 这时 redo log 就派上用场了,接下来我们再来分析一下,通过 redolog 如何解决这个问题。
有了 redolog 之后,当对缓冲区的数据进行增删改之后,会首先将操作的数据页的变化,记录在 redo log buffer 中。在事务提交时,会将 redo log buffer 中的数据刷新到 redo log 磁盘文件中。过一段时间之后,如果刷新缓冲区的脏页到磁盘时,发生错误,此时就可以借助于 redo log 进行数据恢复,这样就保证了事务的持久性。 而如果脏页成功刷新到磁盘 或 或者涉及到的数据已经落盘,此时 redolog 就没有作用了,就可以删除了,所以存在的两个 redolog 文件是循环写的。
那为什么每一次提交事务,要刷新 redo log 到磁盘中呢,而不是直接将 buffer pool 中的脏页刷新到磁盘呢 ?
因为在业务操作中,我们操作数据一般都是随机读写磁盘的,而不是顺序读写磁盘。 而 redo log 在往磁盘文件中写入数据,由于是日志文件,所以都是顺序写的。顺序写的效率,要远大于随机写。 这种先写日志的方式,称之为 WAL(Write-Ahead Logging)。
Redo log 介绍到这里,那么问题来了
Redo log 保证的是事务的哪个特性?
持久性呗。
持久性是指事务一旦提交,它对数据库中数据的改变就应该是永久性的,即使系统出现故障,也能保证数据不会丢失。即使刷数据到磁盘失败了,也能通过 Redo log 将操作重做一次。
2.4. Undo log
Undo log 重做日志,诶嘿嘿,你猜它是啥?答案:版本链。啥?它是版本链?
版本链依赖 undo log 构建:当一个事务对数据库中的某一行数据进行修改时,InnoDB 会将修改前的该行数据的旧版本写入到 undo log 中,同时会在数据行的隐藏列中记录一个指向该 undo log 记录的指针。所以,下边这个图的橙色区域就是 undo log。
undo log 和 redo log 记录物理日志不一样,它是逻辑日志。可以认为当 delete 一条记录时,undolog 中会记录一条对应的 insert 记录,反之亦然,当 update 一条记录时,它记录一条对应相反的 update 记录。当执行 rollback 时,就可以从 undo log 中的逻辑记录读取到相应的内容并进行回滚。
这里提出一个问题,redo log 是当脏数据落盘后就可以销毁了,那 undo log 什么时候可以销毁?
这个问题将在后边解答。
Undo log 介绍到这里,那么问题来了
Undo log 保证的是事务的哪个特性?
原子性呗。
原子性是指事务包含的所有操作要么全部成功,要么全部失败回滚。如果全部成功,那还说啥,没啥说的。如果有一部分失败了,那就需要全部回滚到历史版本,历史的版本就是通过这个版本链找到的。
如果你对事务一点不了解,有没有可能会觉得版本链的作用到此就结束了。重点还没到🙄,接下来就介绍一下多版本并发控制(MVCC)。
3. MVCC 多版本并发控制
来理解一下,什么叫做 多版本并发控制?
并发,那就是多个事务同时存在呗;多版本,那就是版本链上有好多个版本呗。那哪个事务读哪个版本呢?
这就有说道了,不得不引出数据库的隔离级别:读未提交
、读已提交
、可重复读
、串行化
- 读未提交:每次读取的都是最新版本。
- 串行化:事务都是串行执行的,因此只会有一个版本。
上边两个比较好理解,读已提交
、可重复读
这两个就涉及到读取链上的版本了。那也别担心,可以用一招破解他们两个的运作方式。别急,先来了解一下当前读
和快照读
3.1. 当前读
读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如:select ... lock in share mode
(共享锁),select ...for update
、update
、insert
、delete
(排它锁)都是一种当前读。
在测试中我们可以看到,即使是在默认的 RR 隔离级别下,事务 A 中依然可以读取到事务 B 最新更改的内容,因为在查询语句后面加上了 lock in share mode 共享锁,此时是当前读操作。当然,当我们加排他锁的时候,也是当前读操作。
3.2. 快照读
简单的 select(不加锁)就是快照读,快照读读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读。
- 读已提交:每次 select,都生成一个快照读。
- 可重复读:开启事务后第一个 select 语句才是快照读的地方。
在测试中,我们看到即使事务 B 提交了数据,事务 A 中也查询不到。 原因就是因为普通的 select 是快照读,而在当前默认的 RR 隔离级别下,开启事务后第一个 select 语句才是快照读的地方,后面执行相同的 select 语句都是从快照中获取数据,可能不是当前的最新数据,这样也就保证了可重复读。
3.3. ReadView 读视图
字段 | 含义 |
---|---|
m_ids | 当前活跃的事务 ID 集合 |
min_trx_id | 最小活跃事务 ID |
max_trx_id | 预分配事务 ID,当前最大事务 ID+1(因为事务 ID 是自增的) |
creator_trx_id | ReadView 创建者的事务 ID |
在 readview 中就规定了版本链数据的访问规则:trx_id 代表当前 undolog 版本链对应事务 ID。
不同的隔离级别,生成 ReadView 的时机不同,所以能读到的版本就是不同的:
- RC:在事务中每一次执行快照读时生成 ReadView。
- RR:仅在事务中第一次执行快照读时生成 ReadView,后续复用该 ReadView。
3.4. 实操
3.4.1. RC 隔离级别
RC 隔离级别下,在事务中每一次执行快照读时生成 ReadView。
我们就来分析事务 5 中,两次快照读读取数据,是如何获取数据的?
在事务 5 中,查询了两次 id 为 30 的记录, 由于隔离级别为 Read Committed,所以每一次进行快照读都会生成一个 ReadView,那么两次生成的 ReadView 如下。
这两次快照读在获取数据时,就需要根据所生成的 ReadView 以及 ReadView 的版本链访问规则,到 undolog 版本链中匹配数据,最终决定此次快照读返回的数据。
先来看第一次快照读具体的读取过程:
-
第三个版本,即事务3的版本。对照着规则一条一条比较
db_trx_id = 3
,creator_trx_id = 5
,不满足规则要求db_trx_id = creator_trx_id
db_trx_id = 3
,min_trx_id = 3
, 不满足规则要求db_trx_id < min_trx_id
- 最终比较到最后一条,满足
min_trx_id <= db_trx_id <= max_trx_id
且 <math xmlns="http://www.w3.org/1998/Math/MathML"> db_trx_id ∈ m_ids \text{db\_trx\_id} \in \text{m\_ids} </math>db_trx_id∈m_ids,该条规则的结论是不可访问。
-
那就继续比较下一个版本,即事务2的版本。对照着规则一条一条比较
db_trx_id = 2
,creator_trx_id = 5
,不满足规则要求db_trx_id = creator_trx_id
db_trx_id = 2
,min_trx_id = 3
, 满足规则要求db_trx_id < min_trx_id
,该条规则的结论是可以访问。
再来看第二次快照读具体的读取过程
3.4.2. RR 隔离级别
还是来分析事务 5 中,两次快照读读取数据,是如何获取数据的?
在事务 5 中,查询了两次 id 为 30 的记录, 由于隔离级别为 Read Repeated,所以只在第一次快照读时生成读视图
第一次快照读和RC隔离级别的结论是一样的
重点看下,第二次快照读。使用的读视图,仍然是第一次生成的。
按照我们直观的理解,可以读取的版本应该是和第一次读的是同一个。那就按照规则比较验证一下
-
事务4的版本
- 满足
min_trx_id <= db_trx_id <= max_trx_id
且 <math xmlns="http://www.w3.org/1998/Math/MathML"> db_trx_id ∈ m_ids \text{db\_trx\_id} \in \text{m\_ids} </math>db_trx_id∈m_ids,该条规则的结论是不可访问。
- 满足
-
事务3的版本
- 满足
min_trx_id <= db_trx_id <= max_trx_id
且 <math xmlns="http://www.w3.org/1998/Math/MathML"> db_trx_id ∈ m_ids \text{db\_trx\_id} \in \text{m\_ids} </math>db_trx_id∈m_ids,该条规则的结论是不可访问。
- 满足
-
事务4的版本
db_trx_id = 2
,min_trx_id = 3
, 满足db_trx_id < min_trx_id
,该条规则的结论是可以访问。
3.5. 锁的作用
通过以上的介绍,你会不会有种错觉,版本链+读视图 就已经让事务之间彼此隔离了呀。
非也,那只是读的时候。对同一条记录并发写会有问题的,所以这个时候锁的作用就体现了,在写操作(排他锁)未提交时,其他当前读都会被阻塞。下边动图是一个例子。
加锁的规则也是比较复杂的,还涉及到间隙锁、临键锁、记录锁等等,这不是本文的重点。
4. 总结
现在这张图就比较好理解了吧
-
原子性:undo log 保证的,未执行完的事务可以在版本链上回滚。
-
持久性:redo log 保证的,刷磁盘失败的,可以通过redo log 进行重做。
-
隔离性:MVCC 保证事务读之间的隔离性、锁保证事务写之间的隔离性。
-
一致性:通过 原子性、持久性、隔离性 来保证一致性,这是最终目的。
前边留下了一个问题,undo log 什么时候可以销毁?是事务提交后,不再需要回滚到该版本时就可以销毁么?
不是的。事务提交后,版本还会用于MVCC。
Purge 线程的清理机制
-
后台 Purge 线程:InnoDB 有一个后台线程(Purge Thread),负责定期清理不再需要的 Undo Log。
-
清理条件:当某个 Undo Log 不再被任何活跃事务或 MVCC 读视图(Read View)依赖时,会被标记为可删除,随后由 Purge 线程物理清理。
-
清理优先级 :Undo Log 分为
INSERT UNDO
和UPDATE UNDO
:- INSERT UNDO:事务提交后即可立即清理(因为 INSERT 操作的回滚只需要删除新插入的行)。
- UPDATE UNDO(DELETE/UPDATE):需等待所有依赖它的 MVCC 读视图释放后才能清理。