崩溃后的恢复
恢复机制
- 在服务器不挂的情况下,redo 日志简直就是个大累赘,不仅没用,反而让 性能变得更差。但是万一数据库挂了,就可以在重启时根据 redo 日志中的记录 就可以将页面恢复到系统崩溃前的状态。
- MySQL 可以根据 redo 日志中的各种 LSN 值,来确定恢复的起点和终点。然 后将 redo 日志中的数据,以哈希表的形式,将一个页面下的放到哈希表的一个 槽中。之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在 了一个槽里,所以可以==一次性将一个页面修复好(避免了很多读取页面的随机 IO==)。 并且通过各种机制,避免无谓的页面修复,比如已经刷新的页面,进而提升崩溃 恢复的速度。
崩溃后的恢复为什么不用 binlog?
- 1、这两者使用方式不一样
- binlog 会记录表所有更改操作,包括更新删除数据,更改表结构等等,主要 用于人工恢复数据,而 redo log 对于我们是不可见的,它是 InnoDB 用于保证 crash-safe 能力的,也就是在事务提交后 MySQL 崩溃的话,可以保证事务的持久 性,即事务提交后其更改是永久性的。
- 一句话概括:binlog 是用作人工恢复数据,redo log 是 MySQL 自己使用, 用于保证在数据库崩溃时的事务持久性。
- 2、redo log 是 InnoDB 引擎特有的,binlog 是 MySQL 的 Server 层实现的, 所有引擎都可以使用。
- 3、redo log 是物理日志,记录的是“在某个数据页上做了什么修改”,恢复 的速度更快;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这的 c 字段加 1 ” ;
- 4、redo log 是“循环写”的日志文件,redo log 只会记录未刷盘的日志,已 经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是 追加日志,保存的是全量的日志。
- 5、最重要的是,当数据库 crash 后,想要恢复未刷盘但已经写入 redo log 和 binlog 的数据到内存时,binlog 是无法恢复的。虽然 binlog 拥有全量的日志, 但没有一个标志让 innoDB 判断哪些数据已经入表(写入磁盘),哪些数据还没有。
- 比如,binlog 记录了两条日志:
- 给 ID=2 这一行的 c 字段加 1
- 给 ID=2 这一行的 c 字段加 1
- 在记录 1 入表后,记录 2 未入表时,数据库 crash。重启后,只通过 binlog 数据库无法判断这两条记录哪条已经写入磁盘,哪条没有写入磁盘,不管是两条 都恢复至内存,还是都不恢复,对 ID=2 这行数据来说,都不对。
- 但 redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,数据 库重启后,直接把 redo log 中的数据都恢复至内存就可以了。
mysql同步数据源
订阅bin log
- 不需要自己写,阿里已经开源了canal
往mq也写一份
- 然后再通过mq去处理
undo 日志
事务回滚的需求
- 我们说过事务需要保证原子性,也就是事务中的操作要么全部完成,要么什 么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:
- 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作 系统错误,甚至是突然断电导致的错误。
- 情况二:程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前的 事务的执行。
- 这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经 修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这 个过程就称之为回滚(英文名:rollback),这样就可以造成这个事务看起来什 么都没做,所以符合原子性要求。
- 每当我们要对一条记录做改动时(这里的改动可以指 INSERT、DELETE、 UPDATE)(增删改),都需要把回滚时所需的东西都给记下来。比方说:
- 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只 需要把这个主键值对应的记录删掉。
- 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时 再把由这些内容组成的记录插入到表中。
- 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后 回滚时再把这条记录更新为旧值。
- 这些为了回滚而记录的这些东西称之为撤销日志,英文名为 undo log/undo 日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记 录,所以在查询操作执行时,并不需要记录相应的 undo 日志。
- 当然,在真实的 InnoDB 中,undo 日志其实并不像我们上边所说的那么简单, 不同类型的操作产生的 undo 日志的格式也是不同的。
事务 id
给事务分配 id 的时机
- 一个事务可以是一个只读事务,或者是一个读写事务:
- 我们可以通过 START TRANSACTION READ ONLY 语句开启一个只读事务。
- 在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、 改操作,但可以对用户临时表做增、删、改操作。
- 我们可以通过 START TRANSACTION READ WRITE 语句开启一个读写事务,或 者使用 BEGIN、START TRANSACTION 语句开启的事务默认也算是读写事务。
- 在读写事务中可以对表执行增删改查操作 。
- 如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存 储引擎就会给它分配一个独一无二的事务 id,分配方式如下:
- 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、 改操作时才会为这个事务分配一个事务 id,否则的话是不分配事务 id 的。
- 我们前边说过对某个查询语句执行 EXPLAIN 分析它的查询计划时,有时候在 Extra 列会看到 Using temporary 的提示,这个表明在执行该查询语句时会用到内 部临时表。这个所谓的内部临时表和我们手动用 CREATE TEMPORARY TABLE 创建 的用户临时表并不一样,在事务回滚时并不需要把执行 SELECT 语句过程中用到 的内部临时表也回滚,在执行 SELECT 语句用到内部临时表时并不会为它分配事 务 id。
- 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执 行增、删、改操作时才会为这个事务分配一个事务 id,否则的话也是不分配事务 id 的。
- 有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句, 并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务 id。
- 上边描述的事务 id 分配策略是针对 MySQL 5.7 来说的,前边的版本的分配方 式可能不同。
事务 id 生成机制
- 这个事务 id 本质上就是一个数字,它的分配策略和我们前边提到的对隐藏 列 row_id(当用户没有为表创建主键和 UNIQUE 键时 InnoDB 自动创建的列)的 分配策略大抵相同,具体策略如下:
- 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id 时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。
- 每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的 页号为 5 的页面中一个称之为 Max Trx ID 的属性处,这个属性占用 8 个字节的存 储空间。
- 当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中, 将该值加上 256 之后赋值给我们前边提到的全局变量(因为在上次关机时该全局 变量的值可能大于 Max Trx ID 属性值)。
- 这样就可以保证整个系统中分配的事务 id 值是一个递增的数字。先被分配 id 的事务得到的是较小的事务 id,后被分配 id 的事务得到的是较大的事务 id。
trx_id 隐藏列
- 我们在学习 InnoDB 记录行格式的时候重点强调过:聚簇索引的记录除了会 保存完整的用户数据以外,而且还会自动添加名为 trx_id、roll_pointer 的隐藏列, 如果用户没有在表中定义主键以及 UNIQUE 键,还会自动添加一个名为 row_id 的隐藏列。
- 其中的 trx_id 列就是某个对这个聚簇索引记录做改动的语句所在的事务对 应的事务 id 而已(此处的改动可以是 INSERT、DELETE、UPDATE 操作)。至于 roll_pointer 隐藏列我们后边分析。
- 只登记在聚簇索引中,不登记在二级索引中
undo 日志的格式
- 为了实现事务的原子性,InnoDB 存储引擎在实际进行增、删、改一条记录 时,都需要先把对应的 undo 日志记下来。一般每对一条记录做一次改动,就对 应着一条 undo 日志,但在某些更新记录的操作中,也可能会对应着 2 条 undo 日志。
- 一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记 录很多条对应的 undo日志,这些 undo 日志会被从 0 开始编号,也就是说根据 生成的顺序分别被称为第 0 号 undo 日志、第 1 号 undo 日志、…、第 n 号 undo 日志等,这个编号也被称之为 undo no。
- 这些 undo 日志是被记录到类型为 FIL_PAGE_UNDO_LOG 的页面中。这些页 面可以从系统表空间中分配,也可以从一种专门存放 undo 日志的表空间,也就 是所谓的 undo tablespace 中分配。先来看看不同操作都会产生什么样子的 undo 日志。
INSERT 操作对应的 undo 日志
- 当我们向表中插入一条记录时最终导致的结果就是这条记录被放到了一个 数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 undo 日志时,主要是把这条记录的主键信息记上。InnoDB 的设计了 一个类型为 TRX_UNDO_INSERT_REC 的 undo 日志。
- 如果记录中的主键只包含一个列,那么在类型为 TRX_UNDO_INSERT_REC 的 undo 日志中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中 的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录 下来。
- 当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索 引都插入一条记录。不过记录 undo 日志时,我们只需要考虑向聚簇索引插入记 录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们 在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应 的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。 后边说到的 DELETE 操作和 UPDATE 操作对应的 undo 日志也都是针对聚簇索引记 录而言的。
roll_pointer 的作用
- roll_pointer 本质上就是一个指向记录对应的 undo 日志的一个指针。比方说 我们向表里插入了 2 条记录,每条记录都有与其对应的一条 undo 日志。记录被 存储到了类型为 FIL_PAGE_INDEX 的页面中(就是我们前边一直所说的数据页), undo 日志被存放到了类型为 FIL_PAGE_UNDO_LOG 的页面中。roll_pointer 本质就 是一个指针,指向记录对应的 undo 日志。
DELETE 操作对应的 undo 日志
- 我们知道插入到页面中的记录会根据记录头信息中的 next_record 属性组成 一个单向链表,我们把这个链表称之为正常记录链表;被删除的记录其实也会根 据记录头信息中的 next_record 属性组成一个链表,只不过这个链表中的记录占 用的存储空间可以被重新利用,所以也称这个链表为垃圾链表。Page Header 部 分有一个称之为 PAGE_FREE 的属性,它指向由被删除记录组成的垃圾链表中的 头节点。
- 假设此刻某个页面中的记录分布情况是这样的
- 我们只把记录的 delete_mask 标志位展示了出来。从图中可以看出,正常记 录链表中包含了 3 条正常记录,垃圾链表里包含了 2 条已删除记录。页面的 Page Header 部分的 PAGE_FREE 属性的值代表指向垃圾链表头节点的指针。
- delete_mask等于0代表没删除,delete_mask等于1代表被删除
- 假设现在我们准备使用 DELETE 语句把正常记录链表中的最后一条记录给删 除掉,其实这个删除的过程需要经历两个阶段:
- 阶段一:将记录的delete_mask标识位设置为1,这个阶段称之为delete mark。此时还没有被添加到垃圾链表
- 可以看到,正常记录链表中的最后一条记录的 delete_mask 值被设置为 1, 但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态。在删除语句 所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态。
- 为啥会有这种奇怪的中间状态呢?其实主要是为了实现一个称之为 MVCC 的功能,稍后再介绍。
- 阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把 记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到 垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量 PAGE_N_RECS、上次插入记录的位置 PAGE_LAST_INSERT、垃圾链表头节点的指 针 PAGE_FREE、页面中可重用的字节数量 PAGE_GARBAGE、还有页目录的一些信 息等等。这个阶段称之为 purge。
- 把阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占 用的存储空间也可以被重新利用了。
- 从上边的描述中我们也可以看出来,在删除语句所在的事务提交之前,只会 经历阶段一,也就是 delete mark 阶段(提交之后我们就不用回滚了,所以只需 考虑对删除操作的阶段一做的影响进行回滚)。InnoDB 中就会产生一种称之为 TRX_UNDO_DEL_MARK_REC 类型的 undo 日志。 版本链
- 同时,在对一条记录进行 delete mark 操作前,需要把该记录的旧的 trx_id 和 roll_pointer 隐藏列的值都给记到对应的 undo 日志中来,就是我们图中显示的 old trx_id 和 old roll_pointer 属性。这样有一个好处,那就是可以通过 undo 日志 的 old roll_pointer 找到记录在修改之前对应的 undo 日志。比方说在一个事务中, 我们先插入了一条记录,然后又执行对该记录的删除操作,这个过程的示意图就 是这样,此时会形成一个版本链
- 从图中可以看出来,执行完delete mark操作后,它对应的undo日志和INSERT 操作对应的 undo 日志就串成了一个链表。这个链表就称之为版本链。
UPDATE 操作对应的 undo 日志
- 在执行 UPDATE 语句时,InnoDB 对更新主键和不更新主键这两种情况有截然 不同的处理方案。
不更新主键的情况
- 在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变 化和发生变化的情况。
就地更新(in-place update)
- 更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用 的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上 修改对应列的值。再次强调一边,是每个列在更新前后占用的存储空间一样大, 有任何一个被更新的列更新前比更新后占用的存储空间大,或者更新前比更新后 占用的存储空间小都不能进行就地更新。
先删除掉旧记录,再插入新记录
- 在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的 存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉, 然后再根据更新后列的值创建一条新的记录插入到页面中。
- 请注意一下,我们这里所说的删除并不是 delete mark 操作,而是真正的删 除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,跳过了打标记位的操作,并且修改 页面中相应的统计信息(比如 PAGE_FREE、PAGE_GARBAGE 等这些信息)。由用 户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的 值创建的新记录插入。
- 这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么 可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在 页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话, 那就需要进行页面分裂操作,然后再插入新记录。
- 针对 UPDATE 不更新主键的情况(包括上边所说的就地更新和先删除旧记录 再插入新记录),InnoDB 设计了一种类型为 TRX_UNDO_UPD_EXIST_REC 的 undo 日志。
更新主键的情况
- 在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们 更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变, 比如你将记录的主键值从 1 更新为 10000,如果还有非常多的记录的主键值分布 在 1 ~ 10000 之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚 至中间隔了好多个页面。针对 UPDATE 语句中更新了记录主键值的这种情况, InnoDB 在聚簇索引中分了两步处理:
将旧记录进行 delete mark 操作
- 也就是说在 UPDATE 语句所在的事务提交前,对旧记录只做一个 delete mark 操作,在事务提交后才由专门的线程做 purge 操作,把它加入到垃圾链表中。这 里一定要和我们上边所说的在不更新记录主键值时,先真正删除旧记录,再插入 新记录的方式区分开!
- 之所以只对旧记录做 delete mark 操作,是因为别的事务同时也可能访问这 条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个 功能就是所谓的 MVCC。
创建一条新记录
- 根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定 位插入的位置)。
- 由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条 记录所在的位置,然后把它插进去。
- 针对 UPDATE 语句更新记录主键值的这种情况,在对该记录进行 delete mark 操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC 的 undo 日志;之后插入 新记录时,会记录一条类型为 TRX_UNDO_INSERT_REC 的 undo 日志,也就是说 每对一条记录的主键值做改动时,会记录 2 条 undo 日志。
FIL_PAGE_UNDO_LOG 页面
- 我们前边说明表空间的时候说过,表空间其实是由许许多多的页面构成的, 页面默认大小为 16KB。这些页面有不同的类型,比如类型为 FIL_PAGE_INDEX 的 页面用于存储聚簇索引以及二级索引,类型为 FIL_PAGE_TYPE_FSP_HDR 的页面用 于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为 FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo 日志的。
MVCC
- 全称 Multi-Version Concurrency Control,即多版本并发控制,主要是为了提 高数据库的并发性能。
- 同一行数据平时发生读写请求时,会上锁阻塞住。但 MVCC 用更好的方式去 处理读—写请求,做到在发生读—写请求冲突时不用加锁。
- 这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。
- 那它到底是怎么做到读—写不用加锁的,快照读和当前读是指什么?我们后 面都会学到。
MVCC 原理
复习事务隔离级别
事务并发执行遇到的问题
- **脏读(**Dirty Read)
- 如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏 读。
- **不可重复读(**Non-Repeatable Read)
- 如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务 每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发 生了不可重复读。
- **幻读(**Phantom)
- 如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插 入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务 插入的记录也读出来,那就意味着发生了幻读。
- 幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了 之前没有读到的记录。
- 那对于先前已经读到的记录,之后又读取不到这种情况,其实这相当于对每 一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到了之前读取没 有获取到的记录。
- 前面已经说过,不同的数据库厂商对 SQL 标准中规定的四种隔离级别支持不 一样,比方说Oracle就只支持READ COMMITTED和SERIALIZABLE隔离级别。MySQL 虽然支持 4 种隔离级别,但与 SQL 标准中所规定的各级隔离级别允许发生的问题 却有些出入,具体的情况如下表:
隔离级别
- 未提交读
- 已提交读
- 可重复读
- MySQL 在 REPEATABLE READ 隔离级别下,是可以很大程度避免幻读问题的 发生的,MySQL 是怎么做到的?
- 可串行化
版本链
-
我们知道,对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包 含两个必要的隐藏列(row_id 并不是必要的,我们创建的表中有主键或者非 NULL 的 UNIQUE 键时都不会包含 row_id 列):
-
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事 务 id 赋值给 trx_id 隐藏列。
-
roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修 改前的信息。
-
为了说明这个问题,我们创建一个演示表
-
CREATE TABLE teacher (
-
number INT,
-
name VARCHAR(100),
-
domain varchar(100),
-
PRIMARY KEY (number)
-
) Engine=InnoDB CHARSET=utf8;
-
然后向这个表里插入一条数据:
-
INSERT INTO teacher VALUES(1, ‘Jack’, ‘源码系列’);
-
现在表里的数据就是这样的:
-
假设插入该记录的事务 id 为 60,那么此刻该条记录的示意图如下所示:
-
假设之后两个事务 id 分别为 80、120 的事务对这条记录进行 UPDATE 操作,操 作流程如下:
-
每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一 个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没 有更早的版本),可以将这些 undo 日志都连起来,串成一个链表,所以现在的 情况就像下图一样:
-
Trx 80 Trx 120 BEGIN BEGIN UPDATE teacher SET name = ‘Mark’ WHERE number = 1; UPDATE teacher SET name = ‘James’ WHERE number = 1; COMMIT UPDATE teacher SET name = ‘King’ WHERE number = 1; UPDATE teacher SET name = '大飞 WHERE number = 1; COMMIT
-
-
对该记录每次更新后,都会将旧值放到一条 undo 日志中,就算是该记录的 一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成 一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的 值。另外,每个版本中还包含生成该版本时对应的事务 id。于是可以利用这个记 录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版 本并发控制(Mulit-Version Concurrency Control MVCC)。
- 版本链
- 当前页面记录:
- 1 | ‘大飞’ | ‘源码系列’ | 120 | 回滚指针
- undo 日志
- 1 | ‘King’ | ‘源码系列’ | 120 | 回滚指针
- 1 | ‘James’ | ‘源码系列’ | 120 | 回滚指针
- 1 | ‘Mark’ | ‘源码系列’ | 120 | 回滚指针
- 1 | ‘Jack’ | ‘源码系列’ | 120 | 回滚指针
- 当前页面记录:
- 版本链
ReadView
- 对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交 事务修改过的记录,所以直接读取记录的最新版本就好了。
- 对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 使用加锁的方式来访 问记录。
- 对于使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务来说,都 必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修 改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是: READ COMMITTED 和 REPEATABLE READ 隔离级别在不可重复读和幻读上的区别 是从哪里来的,其实结合前面的知识,这两种隔离级别关键是需要判断一下版本 链中的哪个版本是当前事务可见的。
- 为此,InnoDB 提出了一个 ReadView 的概念,这个 ReadView 中主要包含 4 个比较重要的内容:
- m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。
- min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事 务 id,也就是m_ids 中的最小值。
- max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。 注意 max_trx_id 并不是 m_ids 中的最大值,事务 id 是递增分配的。比方说现在 有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务 在生成 ReadView 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4。
- creator_trx_id:表示生成该 ReadView 的事务的事务 id。
- 有了这个 ReadView,这样在访问某条记录时,只需要按照下边的步骤判断 记录的某个版本是否可见:
- 1、如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同, 意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 2、如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明 生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当 前事务访问。
- 3、如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不 可以被当前事务访问。
- 4、如果被访问版本的trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经 被提交,该版本可以被访问。
- 5、如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一 个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最 后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务 完全不可见,查询结果就不包含该记录。
- 在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非 常大的区别就是它们生成 ReadView 的时机不同。
- 我们还是以表 teacher 为例,假设现在表 teacher 中只有一条由事务 id 为 60 的事务插入的一条记录,接下来看一下 READ COMMITTED 和 REPEATABLE READ 所谓的生成 ReadView 的时机不同到底不同在哪里。 READ COMMITTED —— 每次读取数据前都生成一个 ReadView
- 比方说现在系统里有两个事务 id 分别为 80、120 的事务在执行:
- # Transaction 80
- UPDATE teacher SET name = ‘Mark’ WHERE number = 1;
- UPDATE teacher SET name = ‘James’ WHERE number = 1;
- …
- 此刻,表 teacher 中 number 为 1 的记录得到的版本链表如下所示:
- 版本链
- 当前页面记录:
- 1 | ‘James’ | ‘源码系列’ | 80 | 回滚指针
- undo 日志
- 1 | ‘Mark’ | ‘源码系列’ | 80 | 回滚指针
- 1 | ‘Jack’ | ‘源码系列’ | 60 | 回滚指针
- 当前页面记录:
- 版本链
- 假设现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:
- #使用 READ COMMITTED 隔离级别的事务
- BEGIN;
- # SELECE1:Transaction 80、120 未提交
- SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’Jack’
- 这个 SELECE1 的执行过程如下:
- 在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内 容就是[80, 120],min_trx_id 为 80,max_trx_id 为 121,creator_trx_id 为 0。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的 内容是’James’,该版本的 trx_id 值为 80,在 m_ids 列表内,所以不符合可见性要 求(4、如果被访问版本的 trx_id 属性值在 ReadView 的min_trx_id 和 max_trx_id 之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, *该版本不可以被访问;如果不在,说明创建* ReadView 时生成该版本的事务已经 被提交,该版本可以被访问),根据 roll_pointer 跳到下一个版本。
- 下一个版本的列name的内容是’Mark’,该版本的trx_id值也为80,也在m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列 name 的内容是’‘Jack’',该版本的 trx_id 值为 60,小于 ReadView 中的 min_trx_id 值,所以这个版本是符合要求的(2、如果被访问版本 的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问),最后返 回给用户的版本就是这条列 name 为’‘Jack’'的记录。
- 之后,我们把事务 id 为 80 的事务提交一下,
- 然后再到事务id为120的事务中更新一下表teacher 中 number 为1 的记录:
- Transaction120
- BEGIN;
- 更新了一些别的表的记录
- UPDATE teacher SET name = ‘King’ WHERE number = 1;
- UPDATE teacher SET name = ‘大飞’ WHERE number = 1;
- 此刻,表 teacher 中 number 为 1 的记录的版本链就长这样:
- 版本链
- 当前页面记录:
- 1 | ‘大飞’ | ‘源码系列’ | 120 | 回滚指针
- undo 日志
- 1 | ‘King’ | ‘源码系列’ | 120 | 回滚指针
- 1 | ‘James’ | ‘源码系列’ | 80 | 回滚指针
- 1 | ‘Mark’ | ‘源码系列’ | 80 | 回滚指针
- 1 | ‘Jack’ | ‘源码系列’ | 60 | 回滚指针
- 当前页面记录:
- 版本链
- 然后再到刚才使用 READ COMMITTED 隔离级别的事务中继续查找这个 number 为 1 的记录,如下:
- # 使用 READ COMMITTED 隔离级别的事务
- BEGIN;
- # SELECE1:Transaction 80、120 均未提交
- SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’‘Jack’’
- # SELECE2:Transaction 80 提交,Transaction 120 未提交
- SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’‘James’
- 这个 SELECE2 的执行过程如下:
- 在执行 SELECT 语句时会又会单独生成一个 ReadView,该 ReadView 的 m_ids 列表的内容就是[120](事务 id 为 80 的那个事务已经提交了,所以再次生成快照 时就没有它了),min_trx_id 为 120,max_trx_id 为 121,creator_trx_id 为 0。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的 内容是’大飞’,该版本的 trx_id 值为 120,在 m_ids 列表内,所以不符合可见性要 求,根据 roll_pointer 跳到下一个版本。
- 下一个版本的列 name 的内容是’King’,该版本的 trx_id 值为 120,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列 name 的内容是’James’,该版本的 trx_id 值为 80,小于 ReadView 中的 min_trx_id 值 120,所以这个版本是符合要求的,最后返回给用户 的版本就是这条列 name 为’‘James’'的记录。
- 以此类推,如果之后事务 id 为 120 的记录也提交了,再次在使用 READ COMMITTED 隔离级别的事务中查询表 teacher 中 number 值为 1 的记录时,得到 的结果就是’大飞’了,具体流程我们就不分析了。总结一下就是:使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView。 REPEATABLE READ —— 在第一次读取数据时生成一个 ReadView
- 对于使用 REPEATABLE READ 隔离级别的事务来说,只会在第一次执行查询语 句时生成一个 ReadView,之后的查询就不会重复生成了。我们还是用例子看一 下是什么效果。
- 比方说现在系统里有两个事务 id 分别为 80、120 的事务在执行:
- # Transaction 80
- UPDATE teacher SET name = ‘Mark’ WHERE number = 1;
- UPDATE teacher SET name = ‘James’ WHERE number = 1;
- …
- 此刻,表 teacher 中 number 为 1 的记录得到的版本链表如下所示
- 版本链
- 当前页面记录:
- 1 | ‘大飞’ | ‘源码系列’ | 120 | 回滚指针
- undo 日志
- 1 | ‘King’ | ‘源码系列’ | 120 | 回滚指针
- 1 | ‘James’ | ‘源码系列’ | 80 | 回滚指针
- 1 | ‘Mark’ | ‘源码系列’ | 80 | 回滚指针
- 1 | ‘Jack’ | ‘源码系列’ | 60 | 回滚指针
- 当前页面记录:
- 版本链
- 假设现在有一个使用 REPEATABLE READ 隔离级别的事务开始执行:
- 使用 REPEATABLE READ 隔离级别的事务
- BEGIN;
- # SELECE1:Transaction 80、120 未提交
- SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’Jack’
- 这个 SELECE1 的执行过程如下:
- 在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内 容就是[80, 120],min_trx_id 为 80,max_trx_id 为 121,creator_trx_id 为 0。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的 内容是’James’,该版本的 trx_id 值为 80,在 m_ids 列表内,所以不符合可见性要 求,根据 roll_pointer 跳到下一个版本。
- 下一个版本的列name的内容是’Mark’,该版本的trx_id值也为80,也在m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列 name 的内容是’‘Jack’',该版本的 trx_id 值为 60,小于 ReadView 中的 min_trx_id 值,所以这个版本是符合要求的,最后返回给用户的 版本就是这条列 name 为’‘Jack’'的记录。
- 之后,我们把事务 id 为 80 的事务提交一下,
- 然后再到事务id为120的事务中更新一下表teacher 中 number 为1 的记录:
- BEGIN;
- # 更新了一些别的表的记录
- UPDATE teacher SET name = ‘King’ WHERE number = 1;
- UPDATE teacher SET name = ‘大飞’ WHERE number = 1;
- 此刻,表 teacher 中 number 为 1 的记录的版本链就长这样:
- 然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 number 为 1 的记录,如下:
- # 使用 REPEATABLE READ 隔离级别的事务
- BEGIN;
- # SELECE1:Transaction 80、120 均未提交
- SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’‘Jack’’
- # SELECE2:Transaction 80 提交,Transaction 120 未提交
- SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’‘Jack’‘’
- 这个 SELECE2 的执行过程如下:
- 因为当前事务的隔离级别为 REPEATABLE READ,而之前在执行 SELECE1 时已 经生成过 ReadView 了,所以此时直接复用之前的 ReadView,之前的 ReadView 的 m_ids 列表的内容就是[80, 120],min_trx_id 为 80,max_trx_id 为 121, creator_trx_id 为 0。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的 内容是’大飞’,该版本的 trx_id 值为 120,在 m_ids 列表内,所以不符合可见性要 求,根据 roll_pointer 跳到下一个版本。
- 下一个版本的列 name 的内容是’King’,该版本的 trx_id 值为 120,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列 name 的内容是’James’,该版本的 trx_id 值为 80,而 m_ids 列表中是包含值为 80 的事务 id 的,所以该版本也不符合要求,同理下一个列 name 的内容是’Mark’的版本也不符合要求。继续跳到下一个版本。
- 下一个版本的列name的内容是’Jack’,该版本的trx_id值为60,小于ReadView 中的 min_trx_id 值 80,所以这个版本是符合要求的,最后返回给用户的版本就 是这条列 c 为’‘Jack’'的记录。
- 也就是说两次 SELECT 查询得到的结果是重复的,记录的列 c 值都是’‘‘Jack’’‘, 这就是可重复读的含义。如果我们之后再把事务 id 为 120 的记录提交了,然后 再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 number 为 1 的 记录,得到的结果还是’Jack’,具体执行过程大家可以自己分析一下。
MVCC 下的幻读解决和幻读现象
-
前面我们已经知道了,REPEATABLE READ 隔离级别下 MVCC 可以解决不可重 复读问题,那么幻读呢?MVCC 是怎么解决的?幻读是一个事务按照某个相同条 件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一 个事务添加的新记录。
-
我们可以想想,在 REPEATABLE READ 隔离级别下的事务 T1 先根据某个搜索 条件读取到多条记录,然后事务 T2 插入一条符合相应搜索条件的记录并提交, 然后事务 T1 再根据相同搜索条件执行查询。结果会是什么?按照 ReadView 中的 比较规则:
-
3、如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值, 表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以 被当前事务访问。
-
4、如果被访问版本的* trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间**(min_trx_id < trx_id < max_trx_id)**,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经 被提交,该版本可以被访问。
-
不管事务 T2 比事务 T1 是否先开启,事务 T1 都是看不到 T2 的提交的。请自 行按照上面介绍的版本链、ReadView 以及判断可见性的规则来分析一下。
-
但是,在 REPEATABLE READ 隔离级别下 InnoDB 中的 MVCC 可以很大程度地 避免幻读现象,而不是完全禁止幻读。怎么回事呢?我们来看下面的情况:
-
我们首先在事务 T1 中:
-
select * from teacher where number = 30;
-
很明显,这个时候是找不到 number = 30 的记录的。
-
我们在事务 T2 中,执行:
-
通过执行 insert into teacher values(30,‘Luffy’,‘ELK’);,我们往表中插入了一条 number = 30 的记录。
-
此时回到事务 T1,执行:
-
update teacher set domain=‘RabbitMQ’ where number=30;
-
select * from teacher where number = 30;
-
嗯,怎么回事?事务 T1 很明显出现了幻读现象。
-
在 REPEATABLE READ 隔离级别下,T1 第一次执行普通的 SELECT 语句时生成 了一个 ReadView,之后 T2 向 teacher 表中新插入一条记录并提交。
-
ReadView 并不能阻止 T1 执行 UPDATE 或者 DELETE 语句来改动这个新插入 的记录(由于 T2 已经提交,因此改动该记录并不会造成阻塞),但是这样一来, 这条新记录的 trx_id 隐藏列的值就变成了 T1 的事务 id。之后 T1 再使用普通的 SELECT 语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返 回给客户端。因为这个特殊现象的存在,我们也可以认为 MVCC 并不能完全禁 止幻读。
MVCC 小结
- 从上边的描述中我们可以看出来,所谓的 MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用 READ COMMITTD、REPEATABLE READ 这两种隔离级别的事务在执行普通的 SELECT 操作时访问记录的版本链的过程, 这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
- READ COMMITTD、REPEATABLE READ 这两个隔离级别的一个很大不同就是: 生成 ReadView 的时机不同,READ COMMITTD 在每一次进行普通 SELECT 操作前 都会生成一个 ReadView,而 REPEATABLE READ 只在第一次进行普通 SELECT 操作 前生成一个 ReadView,之后的查询操作都重复使用这个 ReadView 就好了,从而 基本上可以避免幻读现象。
- 我们之前说执行 DELETE 语句或者更新主键的 UPDATE 语句==并不会立即把对 应的记录完全从页面中删除,而是执行一个所谓的 delete mark 操作,相当于只 是对记录打上了一个删除标志位,这主要就是为 MVCC 服务的==。
- 另外,所谓的 MVCC 只是在我们进行普通的 SEELCT 查询时才生效,截止到 目前我们所见的所有 SELECT 语句都算是普通的查询,至于什么是个不普通的查 询,我们马上就会讲到。