0 前言
近期在持续学习 mysql innodb 底层技术细节相关内容,继上期锁原理篇之后,本期我们以 innodb 中的事务实现原理作为主题,展开交流探讨.
有关本篇内容的目录大纲展示如下:
章节 | 小节 | 备注 |
1 事务基本概念 | 1.1 原子性 Atomicity | 事务原子性的概念;innodb 如何保证原子性 |
1.2 一致性 Consistency | 事务一致性的概念;innodb 如何保证一致性 | |
1.3 隔离性 Isolation | 事务隔离性的概念;innodb 如何保证隔离性 | |
1.4 持久性 Durability | 事务持久性的概念:innodb 如何保证持久性 | |
2 持久性保证 | 2.1 redo log概念 | redo log设计实现思路;如何保证持久性与高性能 |
2.2 redo log产生机制 | redolog何时产生以及如何落盘 | |
2.3 redo log对比binlog | 分应用范围、存储内容、产生时机进行对比 | |
2.4 redo log存储格式 | log block 格式及重做日志格式介绍 | |
2.5 redo log使用机制 | LSN的概念;数据恢复流程设计 | |
3 原子性保证 | 3.1 undo log概念 | undo log设计实现思路;如何保证事务原子性 |
3.2 undo log对比redo log | 分使用目的、内容粒度、存储位置进行对比 | |
3.3 undo log存储格式 | 不同操作类型下的undo log格式展示 | |
3.4 undo log 产生机制 | 行记录的写流程设计 | |
3.5 undo log 使用机制 | 如何在事务回滚、MVCC、数据恢复流程中应用 | |
3.6 undo log 回收机制 | 回收执行时机及前置校验条件 | |
4 隔离性保证 | 4.1 事务隔离级别 | 四种隔离级别的含义及对应存在的问题 |
4.2 innodb 实现 | innodb针对可重复读隔离级别的优化实现 | |
5 分布式事务 | 5.1 外部XA事务 | 应用场景;两阶段提交流程设计 |
5.2 内部XA事务 | 应用场景;以redo log、binlog 一致性案例加以说明 |
有关本篇涉及到的技术细节,很大程度上参考了 《mysql技术内幕(第2版) 》的内容,在此致敬:
https://book.douban.com/subject/24708143/
1 基本概念
事务是数据库通常都需要具备的一项核心能力,也是其区别于一般文件系统的本质区别之一.
我试着用个人理解对事务的概念进行诠释——所谓事务,指的是一段动态执行过程,它能够将数据库中的数据从一种正确状态转移到另一种满足约束条件的正确状态, 并且这个执行过程中需要满足如下所述的四项性质,也就是经典的事务ACID性质:
1.1 原子性A
原子性 Atomicity,指的是一个事务中的所有操作要能够被组装成一个不可分割的整体,看起来就像在一气呵成地执行一个动作一样,其执行结果要么全部成功,要么全部失败,不会出现割裂的中间状态.
在 innodb 中,针对事务原子性的支持是通过 undo log 机制加以实现的,这部分内容我们放在本文第 3 章详细展开探讨.
1.2 一致性C
一致性 Consistency,指的是在事务执行前后,数据库中的数据状态都处于满足业务预期的、正确一致的状态之下.
一致性可以理解为是数据库追求的最终目标,借由原子性 A、隔离性 I、持久性 D 的加持,共同推进该目标的达成.
1.3 隔离性I
隔离性 Isolation,指的是在多个事务并行的场景下,各事务彼此间不会产生互相干扰,不会因为对方的执行而发生数据视角混乱或者执行逻辑出错.
具体而言,事务隔离性不能一概而论,其依附于不同事务隔离级别的语义,有着各自不同的标准及实现.
在 innodb 中,事务隔离性是通过MVCC机制结合锁机制加以保证的,这部分内容将在本文第 4 章内容中展开介绍.
1.4 持久性D
持久性 Durability 的概念很好理解,指的是因事务提交引起的变更能够永久稳定生效,不会处于易失性的状态.
在 innodb 中,事务隔离性通过 redo log 机制加以保证. redo log 的设计很好地支持 innodb 完成了对数据持久性以及写操作性能之间的平衡,这部分内容在本文第 2 章中详细展开介绍.
接下来各章中,我们针对 innodb 中为实现事务各项性质而采取的对策,逐一展开详细介绍.
2 持久性保证
我们知道,内存属于易失性存储,而磁盘(外存)则属于稳定性较高的存储介质,即便发生程序宕机、机器断电等问题,也不会导致数据的丢失.
因此,所谓持久性,其实现的关键就在于,要将数据变更内容持久化保存到磁盘上.
在 innodb 中,事务持久性是通过重做日志 redo log 加以保证的,在接触学习一个新概念之时,我们应该带着辩证思维而非一味被动接收信息的输入. 这里我们带着如下两个问题进行后续内容的展开学习:
• redo log 方案的实现原理是什么?
• innodb 为什么要选择这个方案?非它不可吗?
2.1 redolog概念
在针对 redo log 开始大讲特讲之前,我们先以一个场景案例作为引子:
• 人物 I:小明是一名会计,就职于一家华夏村五百万强企业.
• 背景设定补充 I: 该公司对于财务审计流程非常严格,针对各项营收与开销,都会分门归类、逐一进行精细化记录. 长年累月下来,公司中形成了一份信息量庞大的“全局账簿” ,包含公司创建至今的全量财务数据
• 背景设定补充 II: 公司在白天营业期间,账目变动会非常频繁,且身为社畜牛马,小明除了维护“账簿”之外,还有会各种琐碎杂事找上门来. 所以频繁翻阅并实时更新这本体量庞大的“账簿” 是一件异常耗费心力的事情
• 人物 II: 公司老板人送外号“查账狂魔”,可能不定期进行查账,要求小明基于财务账簿,制作历史至今的各项收支的明细报表,需要保证内容的实时性与精确性
• 问题思考: 身为打工牛马,小明自然无法反驳老板的要求,于是聪明的小明采取了如下的实现方案,实现了打工人的“自我救赎”
• 实现方案:
1)diff 更新: 在白天忙碌的营业期间,小明通过一块 “小黑板” ,临时记录当天内涉及到的账目变更内容. 从单天粒度来看,内容不会太多,因此“小黑板”可以很小,维护以及更新起来远比“全局账簿”来得轻便;
2)查询响应: 某个时刻,如果老板发起“查账”请求,小明也可以通过“全局账簿”叠加上“小黑板”的内容,保证给到老板准确的答复;
3)diff 归并: 到了下班时间,牛马小明只能选择偷偷加个班,一次性将当天“小黑板”中的全部内容归并到“全局账簿”中,这个过程会比较费力,但好在只是一次性的行为,并且此时是非营业时间,小明手上也没有其它杂活儿,因此更能高度聚焦地把这件事情做好
发现了吗,其实上述例子揭示的思路,在很大程度就和 innodb 中有关 redo log 的设计是具备着共性的.
我们知道,对于 mysql innodb 这样以磁盘作为存储介质的数据库来说,全量数据内容是存储在磁盘上的. 但是在事务执行过程中,针对涉及到的数据记录,会基于局部性原理,以数据所在的页 page(默认 16KB)为单位将对应内容从磁盘加载到内存中. 如果此时事务执行的是一笔更新操作并且事务被成功提交,那么内存中的数据会被更新,且直到该数据被溢写更新回到磁盘之前,该 page 在内存和磁盘中的结果是不一致的,我们称这样的页 page 为脏页 dirty page.
【Tip:这里各位需要甄别清楚脏数据 dirty data 和脏页 dirty page 的区别.
1)脏数据(不合法): 指的在事务中修改了数据但是还未提交的一种非正式数据状态. 这种脏数据本质上不应该被外界读取到,因为该事务后续是否能成功提交还属于未知之数. 在读未提交的事务隔离级别下可能发生的脏读问题,指的就是因为事务隔离程度不够,导致外界读取到了这种非正式态的脏数据,其背后引发的后果可能是很致命的
2)脏页(合法): 脏页本身对应的是正式、合法的数据,它是由于数据库中内存与磁盘之间的状态差异的,但这种差异只是临时的,数据库最终会通过合理的机制,保证脏页的内容最终被溢写到磁盘上,保证两份存储介质的内容一致性】
于是接下来的核心点就在于,在事务提交时,innodb 应该采取怎样的持久化机制,来保证这部分变更的数据能够被持久稳定地存储下来.
一种实现方式是,直接将内存中的 page 溢写回到磁盘文件中其原本从属的位置中,这种方案足够简单粗暴,但是需要明白这背后面临的就是一次磁盘随机写操作,性能方面会显得比较差强人意.
这就好比上述例子中,在白天营业期间,每当有账目变更动作触发时,小明都选择直接去翻阅并更新那份体量庞大的“全局账簿”,这种周而复始、重复枯燥、繁重到窒息的工作量很可能直接让我们这位小明同学“享年三十”.
与之相对的 ,innodb 选择采用另一种方式,类似于上述例子中的“小黑板”方案,这里的小黑板就类似于所谓的 redo log,而 “全局账簿” 则类似于 innodb 中存储了全量数据以及索引信息的 ibd 文件.
在 innodb 中,当遇到事务提交时,内存中对应的 dirty page 不直接从溢写回到 ibd 文件对应位置,而是采取一种类似预写日志(WAL,write ahead log)的思路,以磁盘顺序写的方式生成该 page 对应的 redo log file. 这样既通过磁盘存储形式,保证了对事务持久性的支持,又能通过大幅度规避磁盘随机写操作的发生,在很大程度上提高了事务的执行性能.
需要明白的是,redo log 本身是为了防止数据库宕机而起到的一项保险措施, 在宕机后的数据恢复流程中,通过存量 ibd 文件以及 redo log file 就能还原出最真实精确的数据状态.
而在数据库正常运行场景中,其实是不需要使用到 redo log 的,因为此时哪怕磁盘 ibd 文件内容与内存中的 dirty page 存在差异也不会影响数据的一致性:
• 当对应读写操作涉及到这部分 dirty page 时,会直接复用内存中实时性较高的数据,因此不会有问题
• 如果 dirty page 因内存淘汰策略即将被踢出内存时,也会确保持久化到 ibd 文件中,保证内容的一致性
2.2 redolog产生机制
redo log 本质上由两部分组成:
• 一部分是内存中的重做日志缓存区——redo log buffer,属于易失性存储
• 另一部分是磁盘中的重做日志文件——redo log file,属于持久性存储.
innodb 基于局部性原理,将逻辑上的最小存储单元设定为页 page,而 redo log 同样以 page 为粒度,存储的内容是一个 page 在更新后物理层面上的数据状态.
1)redo log 生成: 每当有事务执行并执行写操作时,会以数据所从属的 page 为单位,生成对应的 redo log,并将其投递到 redo log buffer 中;
2)redo log 持久化: 在事务提交时,会把内存中的 redo log 持久化到磁盘上的 redo log file 中
针对上述第 2)步,这里进一步加以说明,本质上这个持久化动作又可以被拆解为两个小步骤:
2-1)投递文件系统缓冲区: 首先将 redo log 内容提交到文件系统缓冲区中,此时仍可能存在数据丢失的风险,当操作系统崩溃时,这部分内容会丢失
2-2)fsync 强制落盘: 接下来需要执行 fsync 操作,确保内容被落盘写入到 redo log file 中,至此才真正达成了持久化的语义. 这种机制就称之为 Force Log At Commit 机制,而此处的 fsync 操作也就是性能瓶颈所在.
【Tip:针对这项 fsync 操作,此前我在之前发表的文章 etcd存储引擎之存储设计篇的 3.2 小节中也有过介绍,知识之间是触类旁通的,大家可以联系看待. 】
上述是常规的事务提交流程,只有通过 fsync 操作保证 redo log 强制落盘,才能在严格意义上实现事务的持久性语义. 然而正如上文所述的,由于 fsync 操作是整个流程的性能瓶颈所在,所以在实际应用场景中,使用方也可以在稳定性与高性能之间进行权衡取舍,选择延迟或者降低 fsync 的执行频次,舍弃一部分持久性的要求,进而提高整体性能表现.
具体来说可以通过修改参数 innodb_flush_log_at_trx_commit 来调整 redo log 落盘的策略——1)设置为 0:选择不主动落盘,而把 redo log 持久化时机交由 db master thread;2)只将 redo log 投递到文件系统缓冲区而不执行 fsync,把真正落盘的执行时机托付给操作系统
2.3 redolog对比binlog
另外,有读者经常会对重做日志 redo log 和二进制日志 binlog 之间的关系产生疑问. 这里我们从几个角度出发,统一作个对照说明:
• 产生源头:binlog 是 mysql 数据库层面产生的,不依附于任何存储引擎,属于全局共用的二进制日志;redo log 是 innodb 存储引擎专属定制的,供引擎内部使用
• 存储内容:binlog 的记录内容是逻辑层面的增量执行 SQL 语句,主要用途可能用于数据库之间的主从复制,通过重放增量 SQL 的方式复刻出完整的数据内容;redo log 以 page 为粒度存储物理意义上的页数据,其目的是为了兼顾事务的持久性以及写操作的高性能
综合上述两点,有些读者可能会产生一个新的疑惑——其实通过 binlog 是不是也能闭环实现数据的持久化,innodb 引入 redo log 是否存在重复设计?
以下是我个人针对这个问题的一些理解:
redo log 的采用是有必要的,这个问题的核心就在于数据恢复流程的效率问题. 在 innodb 中,每次启动数据库时,都会统一基于 redo log 执行数据恢复流程,而不会刻意区分此前数据库是异常宕机还是正常终止. 同样是通过持久化日志还原数据,基于 binlog 这种逻辑增量记录的方式,其效率是远远不如基于 redo log 这种物理日志的.
此外还有另外一点,在 innodb 中,除了普通数据外,针对第 3 章中即将介绍的回滚日志 undo log 也存在持久化的诉求,这些都需要通过 redo log 的能力加以保证.
最后再针对 binlog 和 redo log 的写入时机进行对比分析.
• binlog:其产生是在事务提交时,以事务为粒度,一次打包产生,按照事务提交先后顺序进行排列,与每个提交事务一一对应;
• redo log:其产生时在事务修改数据时(可能没有提交),以 page 为粒度持续生成并投入到 redo log buffer,最终在事务提交时被强制持久化落盘. 在 innodb 中是能支持多事务并行的,因此多个事务可能会穿插生成 redo log,直到某个事务提交时,则强制将此前对应的一系列 redo log 进行强制落盘.
这里还有另一个隐晦的问题值得探讨一下:如果有多个事务并发对同一 page 下的不同行进行修改,是否可能会误将对方的脏数据连带落盘到 redo log file 中呢?
此处这个问题需要通过 undo log 来进行规避,该问题会本文 3.5 小节中重点探讨,这里不再重复赘述.
2.4 redolog存储格式
在逻辑意义上,每份 redo log 对应一个 page 的粒度;而在物理意义上,redo log 以 log block 为单元进行存储,整个 redo log buffer 可以视为一个队列,而每个 log block 则为队列中的一个元素.
每个 log block 大小固定为 512B,刚好契合了磁盘扇区的大小,其由三部分组成:
• log block header:block 头部元信息部分,共计 12B 大小,由下述字段组成:
1)block hdr no(4B):该 block 在 redo log buffer 中的 index
2)lock block hdr data len(2B):该 block 中已使用的空间大小,单位 Byte. 占满时为 0x200(512B)
3)block first rec group(2B):该 block 中首条新 redo log 对应的偏移量.【比如 log block body 中,前 20B 为上一个 block 末尾 redo log 的内容拼接延续,则该项值为 12(header)+ 20(last log) = 32 】
4)log block checkpoint no(4B):该 block 被写入时的检查点信息
• log block body:核心日志内容,即 redo log 正文部分,大小为 492B
• lock block tailer:block 尾部,共计 8B,包含一份 lock block hdr no(4B)和填充空间(4B)
逻辑意义上的一条 redo log 对应一个 page,其存储位置位于 log block body 当中. 当 block body 仍有空间富余时,多条 redo log 可以共用一个 block;当 block 有剩余空间但不足以承载下一条抵达的 redo log 时,则会对 redo log 进行截断,并将剩余部分放置到下一个相邻 block 中.
不同数据库操作类型会对应不同的 redo log 格式,但总体来看,其大概包含了如下所属的核心信息:
• redo_log_type: 修改生成该份 redo log 的数据库操作类型
• space: 表空间对应的 id
• page_no: 该 redo log 对应的是哪个 page
• redo_log_body: page 中的具体数据内容
2.5 redolog使用机制
基于 redo log 进行数据恢复的过程中,离不开对 LSN 的使用.
LSN 全称 Log Sequence Number,其含义是,在某个时刻下,所有事务写入重做日志的总量.
我们可以把 LSN 理解为一个逻辑时间轴. 比如时刻 A,总共已写入的 redo log 大小为 1000B,则全局 LSN 计数值 1000;接下来在时刻 B,一个事务又写入了 100B 的 redo log,则此时全局的 LSN 计数值被更新为 1100,且刚才生成这笔 100 B 大小的 redo log 也会记录其在生成时刻对应的 LSN 值,反映了其生成的时序.
除了 redo log 之外,ibd 文件中每个 page 中也会有一个 FIL_PAGE_LSN 值,记录了该 page 最后更新时的全局 LSN 计数值,反映了其数据的实时程度. 这样后续在通过 redo log 恢复该 page 数据时,所有 redo.LSN <= FIL_PAGE_LSN 的 redo log 都应该被忽略.
innodb 引擎在启动时,不管上次数据库是正常关闭还是异常宕机,都会基于 redo log 对 ibd 文件开启数据恢复的流程. 由于 redo log 是基于 page 粒度的物理存储日式,因此恢复性能是比较优秀的.
在恢复流程中,会将磁盘上的 redo log file 一一加载到内存的 redo log buffer 中(基于 LSN 先后顺序排列). 此时会有一个全局的 checkpoint LSN,表示此前的 redo log 都已经完整归并到 ibd 中,因此这前半部分的 redo log 是已经可以清除了的.
接下来只需要遍历处理 checkpoint LSN 后半部分的 redo log 即可. 每次在将一笔 redo log 更新到 ibd 文件对应 page 之前,需要额外检查一下 redo log 中的 LSN 和 ibd page 中 FIL_PAGE_LSN 的大小,只有 redo LSN > FIL_PAGE_LSN 时,才进行更新操作.
【ibd page lsn 可能大于 redo lsn,是因为对应的 dirty page 可能因为被提前淘汰出内存,已经进行过磁盘溢写操作,因此其数据的实时程度可能更高】
3 原子性保证
3.1 undolog概念
在 innodb 中,通过 undo log 的设计来实现对事务原子性的保证.
回滚日志 undo log,顾名思义,其最直观的用途是用于支持事务的回滚操作. 任何针对数据行记录的修改操作,都会一种类似写时复制(copy-on-write)策略的方式,在生成新版本数据的同时,也会通过 undo log 保留修改前的数据副本,这样每条行记录根据修改的先后顺序,会形成一条 “版本链”的拓扑结构,其中所谓的 “版本”是通过触发修改行为的事务 id 进行标识,而之所以成“链” ,是同一数据行的 undo log 之间,会通过修改先后顺序,依次通过回滚指针 roll_ptr 指向上一个“版本”的 undo log.
基于上述设计,能够很好地支持到事务原子性的语义:
1)屏蔽中间态数据: 一个事务产生的修改,会通过其事务 id 进行“版本”标识,这样在事务未提交前,其作出的修改都不会被外界所认可,外界的读操作可以借助行记录对应的 undo log,回溯并获取到上一个已提交的正式数据版本
2)全部提交: 当事务提交时,其事务 id 会获得“正名” ,这样一瞬间,其产生的所有行记录对应的数据版本都会被外界所认可,体现了原子性中“全部动作一起成功” 的语义
3)全部回滚:当事务回滚时,其事务 id 会失去“正名” ,其产生的所有行记录对应数据版本都被外界否定,与此同时,可以很方便地借助 undo log 将涉及修改的行记录内容回溯成上一个版本的状态,体现了原子性中“全部动作一起失败” 的语义
此外,因为 undo log 所谓“版本链”的存在,也为外界在读取行记录时提供了一个能够自由选取指定版本的能力,这样就很好地契合了MVCC一致性非锁定读的实现,这部分细节可以参见本文第 4 章内容.
言归正传,undo log 以数据行记录为粒度,其存放在数据库的特殊共享表空间 undo segment 内,可以将其理解为一类特殊的数据,其本身也有自己从属的表结构,也有数据持久化的诉求,因此在 innodb 中会通过 redo log 来保证 undo log 的持久性.
除了比较特别的 insert 操作外,update 和 delete 都可以视为广义上的“更新”操作,在对一行数据作出更新时,需要通过 undo log 的形式对前一个数据版本进行留痕,并且需要以及版本先后顺序进行指针的串联.
部分读者认为在利用 undo log 回滚数据时,是在物理层面上将 innodb 中的数据恢复到执行事务前的样子,这个理解是比较片面的. 因为 innodb 中的数据存储单元是 page 的粒度,而以数据行为粒度的 undo log 只能在逻辑层面上针对该行数据执行逆向操作,以使其逻辑性地恢复到上一个版本的样子. 需要注意的是,此时由于并发事务的存在,page 中的其它行可能也在进行更新操作,因此整个 page 在物理层面上很可能已经演变成截然不同的样子.
3.2 undolog对比redolog
有读者认为,undo log 属于 redo log 的逆向操作,两者呈对立关系. 这个理解是不到位的,这两种日志本身不属于一个维度的东西. 下面通过几个方面对两者展开对比:
• 内容粒度:redo log 以页 page 为粒度,记录一个 page 物理层面的数据内容;undo log 以行 record 为粒度,记录数据行记录前一个版本的数据内容
• 使用目的:redo log 是一种类似于预写日志的内容,用于实现对数据持久性及写操作性能的保证;undo log 采用一种类似写时复制的策略,记录一个行记录上一版本的旧数据,并通过指针串联成链,用以支持事务回滚操作以及MVCC的版本选取策略
• 存储介质:redo log 通过位于磁盘上的 redo log file 进行持久化存储;undo log 属于一类特殊的数据,存放于 innodb 共享表空间 undo segment 中,本身也需要依赖于 redo log 实现数据持久化
3.3 undolog存储格式
前面提到,undo log 本身可以视为一类特殊的数据,因此存储时也会有自己从属的 undo page,这部分内容位于数据库内部的特殊共享表空间 undo segment 当中.
写操作可以在广义上分为插入(insert)和更新(update/delete)两大类. 根据不同的写操作类别,会生成不同类型的 undo log.
针对于 insert 类型的 undo log,除了执行该操作的事务本身之外,对于其他事务都是不可见的,因此无需考虑与 MVCC 有关的内容. 这种 undo log 格式比较简单,可以在事务提交后直接删除,其格式如下:
• next/start: 头部的 next 字段记录下一条 undo log 起始偏移量;尾部的 start 字段记录本条 undo log 的起始偏移量
• type_compl: 对应 undo log 的类型,insert 操作枚举值为 11
• undo no: 本条 undo 记录的编号
• table id: 对应表的编号
• n_unique_index: 该区域记录了表中各个唯一键的内容长度以及对应的具体内容
针对执行 update 操作而形成的 undo log,由于需要对 MVCC 机制进行支持,因此需要形成版本链的拓扑结构,其在 insert 数据格式的基础上,在以下几项内容上会有所不同:
• type_compl: undo 类型,枚举值固定为 12
• trx_id: 执行操作的事务 id(版本)
• roll_ptr: 指向上一版本 undo log 记录的指针(指针)
• update_vector: 本次操作更新的列及其旧值
• n_bytes_below: 后续内容记录各列的完整数据
至于 delete 操作对应生成的 undo log,其与 update 类型的格式基本一致,区别在于type_compl 枚举值变为 14,以及少了 update_vector 模块部分的内容:
3.4 undolog产生机制
在事务执行过程中,每当在对一个数据行记录进行写操作时,不论是 insert update 还是 delete 操作,都会生成对应的 undo log. 其中 insert 操作相对比较简单,下面以 undate 操作为例,对流程步骤加以说明:
1)生成 undo log: 基于更新前的行数据版本,生成对应的 undo log,插入到该行对应 undo log list 的起点位置
2)修改行数据: 接下来再对 page 中对应的行记录进行修改
如此一来,一条 undo log 就成功生成了. 值得一提的是,此刻生成的 undo log,也会在内存中处于一种 dirty page 的状态,需要借由这条 undo 记录本身对应的 redo log 来实现数据持久性的保证.
这里可能有读者产生关于 undo log 为什么需要进行持久化的疑问. 持久化的目的是为了防止因数据库的突然宕机而导致数据内容的丢失,但是 undo log 的内容本身都与运行时的事务强相关,如果数据库一旦发生宕机,那么所有事务自然也就不复存在了,那么为什么还需要依赖到 undo log 的内容呢?
我们从 undo log 两个核心用途出发,进一步对上面的问题进行拆解:
• 支持事务回滚: 如果数据库宕机了,事务自然执行失败了,还需要依赖于 undo log 进行回滚吗?
• 支持 MVCC:如果数据库宕机了,那么所有活跃事务自然也就不存在了,那么也就不存在 MVCC 的使用场景了,还需要用到老版本 undo log 中的内容吗?
这里我们留个疑问,在随后的 3.5 小节中,我们揭示这个谜题的答案.
3.5 undolog使用机制
针对 undo log 其实包含了三大核心用途,下面一一进行讲解:
• 支持事务回滚:
事务执行过程中,在修改行记录前,会通过 undo log 保留前一个版本的数据副本,这样一旦需要对事务进行回滚,就可以很方便地通过 undo log 进行数据状态回溯
• 支持MVCC:
在可重复读的事务隔离级别下,为了支持一致性非锁定读(MVCC)操作,老版本的 undo log不能在新事务提交后立即删除,因为它可能还会被更早的活跃事务依赖到. 因此其需要在 undo log list 中保留一段时间,直到确保没有任何事务依赖到它时,才能通过 purge 线程进行回收删除
• 支持数据恢复流程:
此处来正面回答一下,undo log 也需要通过 redo log 进行持久化的原因.
这里试想一个场景,在一个 page 中,有行 A 和 行 B 两行记录:
1)时刻 1:事务 I 对行 A 进行修改,也生成对应行 A 上一版本数据的 undo log
2)时刻 2:事务 II 对行 B 进行修改(也会生成对应行的 undo log,但在本case中不是重点)
3)时刻 3:事务 II 提交了,这样基于 force log at commit 机制,本次提交行为会把该 page 对应 redo log 持久化到磁盘的 redo log file 中(注意,此时事务 I 还没提交,因此该 page 中行 A 还处于脏数据状态,但同样被连带着持久化到 redo log file 中了 )
4)时刻4:数据库宕机了
串联上述时间线及对应事件后,接下来在数据库重启时,会基于该 page 对应 redo log 进行数据恢复操作,可想而知就可能把行 A 恢复成事务 I 未提交的脏数据状态,在这个环节中,如果行 A 对应的 undo log 因为没来得及持久化而丢失了,那么之前正式版本的数据就无迹可寻了.
基于上述 badcase,可以明确一点, undo log 也是必须通过 redo log 来保证持久性的,否则在数据恢复流程出现正式数据丢失的问题.
3.6 undolog回收机制
在 innodb 中会有一个异步的 purge 线程,专门负责对 undo log 对应的 page 进行内容清理,清理后的 page 可以在后续流程中重新进行分配复用.
在清理 undo log 时,需要遵循指定校验条件,如下图. 额外值得注意的就是,在可重复读的事务隔离级别下,需要保证不存在更小编号的活跃事务存在时,才能回收一笔 undo log. 这部分内容可以参见我之前发表的文章——万字解析mysql innodb 锁机制实现原理的 2.2.1 小节内容,关于 MVCC 中 read view 中 up_limit_id 字段的定义.
4 隔离性保证
4.1 事务隔离级别
事务的隔离性需要建立在具体的隔离级别标准之下,事务隔离级别根据严格程度从低到高可以分为如下四种:
解决脏读****Dirty Read | 解决不可重复读****Unrepeatable Read | 解决幻读****Phantom Problem | |
读未提交****Read Uncommitted | ❌ | ❌ | ❌ |
读已提交****Read Committed | ✅ | ❌ | ❌ |
可重复读****Repeatable Read | ✅ | ✅ | ✅ |
串行化****Serialization | ✅ | ✅ | ✅ |
上表在展示事务隔离级别的同时,也展示了 innodb 在实现对应隔离级别的同时,可能存在哪些因隔离程度不够而导致的数据视角不一致问题.
抛除比较少用到的读未提交(视角一致性太差)和串行化(性能太差),针对读已提交和可重复读,innodb 主要采用的是 MVCC 机制结合锁机制加以保证.
有关事务隔离性的内容,在我之前的文章——万字解析 mysql innodb 锁机制 中花了很大的篇幅进行详细介绍,这里不再赘述.
4.2 innodb实现
innodb 中默认采用的事务隔离级别为可重复读 repeatable read. 与标准 SQL 定义标准有所不同的是,innodb 中,可重复读的事务隔离级别能够规避幻读问题 Phantom Problem. 这里分别从一致性非锁定读(MVCC)和一致性锁定读(LOCK)两个视角出发,一一阐述 innodb 如何规避幻读问题的发生:
• 一致性非锁定读: 在普通 select 操作执行时,忽略在事务期间新插入的行数据,innodb 通过 MVCC 结合一致性视图 Consistent Read View 来实现读取视角的一致性(但事实上,对应范围内的数据条目可能已经发生变化,只是在查询时为了维持视角的一致性,通过这种偏被动的方式选择了视而不见)
• 一致性锁定读: 在带有加锁行为的 select 操作下, innodb 则是通过间隙锁 gap lock,禁止并发事务在相邻间隙内插入新的数据条目,从而通过这种主动防御的机制,在真正意义上避免了幻读问题的发生
5 分布式事务
5.1 外部XA事务
所谓分布式事务,指的是在一个事务涉及操作到多个独立的数据资源,而这些数据资源之间可能是跨节点和组件的,因此属于分布式架构的范畴. 此处探讨的数据资源是比较泛化的,其底层实现可以是类似 mysql 这样的关系型数据库,也可以是其他类型的存储组件,
分布式事务中一类经典的实现方案为 XA (eXtended Architecture). 在 XA 事务架构中,允许不同种类的数据库共同参与分布式事务的协作,只要其能够支持 XA 事务协议即可.
innodb 引擎提供了对 XA 事务(eXtended Architecture)能力的支持,进而能够扮演 XA 架构中一个数据资源代理方的角色,与其它数据资源共同参与到全局分布式事务的协作中. (参与 XA 事务时,innodb 需要将事务隔离级别设置为串行化 SERIALIZABLE)
从 innodb 的视角出发,这种参与分布式事务协作的 XA 事务称为外部 XA 事务,这套 XA 事务架构由一个全局的事务流程调度器 Transaction Manager 和多个资源管理器 Resource Manager 共同组成,而 innodb 就作为其中的一个 Resource Manager.
其整体架构以及各模块的职责如下:
• 流程调度器 TM: 串联整个分布式事务的执行流程,需要向各个资源管理器 RM 发号施令
• 资源管理器 RM: 提供访问数据资源的方法,通常对应为一个独立数据库
XA 事务的调度流程通常采用两阶段提交(2PC,two-phase-commit)的方式:
1)第一阶段,由 TM 向所有 RM 发送 PREPARE 请求,告知未来的操作意图并命令其开始准备;
2)第二阶段, TM 根据第一阶段收集到的来自 RM 的 响应,决定执行 COMMIT 还是 ROLLBACK
与本地事务的核心区别就在于,分布式事务需要额外通过一个 PREPARE 阶段,提高事务执行的容错率. 之所以需要这样设计,其根本原因还是在于分布式事务的执行跨越了多个独立的RM,这种分布式场景问题往往具有比较大的不确定性.
更多有关分布式事务的内容,可以参见我之前发表的文章——万字长文漫谈分布式事务实现原理.
5.2 内部XA事务
5.1 小节讨论的场景属于外部 XA 事务,其将整个 mysql 数据库视为一个整体. 而在 mysql 数据库中,还有另一类内部 XA 事务,指的是从微观视角出发,将 mysql 内部的存储引擎、插件等模块都视为独立的模块,分别扮演不同的 RM 角色.
下面举一个常见的例子加以说明——binlog 与 redo log 之间一致性保证.
在需要进行主从复制的场景,会针对 mysql master 节点启用 binlog 功能. 在一笔事务提交时,需要1)先写 binlog,2)再写 innodb 中的 redo log. 严格来说,这也是两个独立的步骤,倘若第一步 binlog 写成功,但是执行第二步写 redo log 前数据库发生宕机,此时就会发生严重的数据不一致问题:
• 因为 binlog 已经产出了,因此会导致 slave 节点同步到这部分增量内容
• 由于 master 节点没来得及完成 redo log 写入,因此这笔事务对应变更内容持久化失败,也随着数据库的宕机发生数据丢失
最终,master 和 slave 之间出现出现数据不一致问题.
针对上述场景问题,mysql 会在 binlog 与 innodb 之间通过内部 XA 事务的方案加以解决,具体执行步骤如下:
1)一笔事务提交时,先对 innodb 执行一个 prepare 操作,写入事务 xid
2)接下来进行 binlog 写入(事实上,这一步执行完成后,就视为事务提交成功了)
3)最后再进行 innodb redo log 的持久化
值得一提的是,如果上述第2)步成功但是第3)步执行前 mysql 宕机,那么在数据库重启后,会再对 innodb 中已 prepare 的 xid 作一次校验,判断其是否已写入 binlog 成功,是的话,则会补充执行一次事务提交操作.
6 总结
本文和大家介绍了有关 mysql innodb 事务机制的实现原理,下面对本文涉及到的知识点进行总结梳理:
• 事务的核心性质:原子性 atomicity | 一致性 consistentcy | 隔离性 isolation | 持久性 durability
• innodb 针对持久性的实现:采用 redo log 的设计实现持久性与写性能的权衡与保证
• innodb 针对原子性的实现:采用 undo log 的设计支持事务回滚、MVCC机制与数据恢复流程的数据一致性
• innodb 针对隔离性的实现:采用 MVCC 与锁机制保证不同事务隔离级别的语义
• 分布式事务能力:对外,innodb 实现 XA 事务协议支持分布式事务能力;对内,在数据库上层与 innodb 之间通过内部 XA 事务保证 binlog 与 redo log 的一致性