引言
MySQL 中的日志非常重要,包括实例内的事务以及实例间的主从复制均基于日志实现。
计划通过多篇文章分析多种日志,从而串联日志、事务、复制三个模块之间的关系,本文是第一篇文章,介绍两阶段提交。
其中首先介绍为什么需要两阶段提交,然后简单分析两阶段提交的实现,期间介绍相关知识点,包括分布式事务与崩溃恢复。
概念
两份日志
MySQL 中最重要的两份日志是 redo log 与 binlog。
为什么会有两份日志,原因是使用场景不同。
其中:
- redo log 用于实现事务的持久性,具体是通过 crash-safe 能力;
- binlog 用于实现主从复制与数据恢复。
两份日志主要有以下三点不同;
- redo log 是 InnoDB 存储引擎层实现的特有的日志,binlog 是 Server 层实现的通用的日志;
- redo log 是物理日志,binlog 是逻辑日志;
- redo log 是循环写入,binlog 是追加写入。
两阶段提交
为了保证两份日志之间的逻辑一致,也就是数据与备份的一致性,引入两阶段提交(two-phase commit protocol,2PC)。
为什么需要两阶段提交,那么如果没有两阶段提交,会发生什么呢?
由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。
假设执行 update,将值从 1 改为 2。
假设:
- 先写 redo log 后写 binlog,如果 redo log 写完后 MySQL 进程异常重启,redo log 崩溃恢复后值为 2,但是基于 binlog 备份恢复值为 1,并导致备份恢复少了一个事务;
- 先写 binlog 后写 redo log,如果 binlog 写完后 MySQL 进程异常重启,基于 binlog 备份恢复值为 2,但是 redo log 还没写因此崩溃恢复后事务无效,值为 1,并导致备份恢复多了一个事务。
显然,如果没有两阶段提交,无法保证数据与日志的一致性。
那么,有两阶段提交时会怎么样呢?
首先,介绍下两阶段提交的过程,其中将 redo log 的提交拆分为两个步骤,包括 prepare 与 commit,期间写入 binlog。
因此,如果在两阶段提交的不同时刻,MySQL 异常重启会发生什么呢?
- 如果在时刻 A 重启,也就是 redo log prepare 之后,写入 binlog 之前,崩溃恢复时发现 redo log 没有 commit,因此回滚。binlog 还没写,因此不会传到备库,数据与日志保持一致;
- 如果在时刻 B 重启,也就是写入 binlog 之后,redo log commit 之前,崩溃恢复时发现 redo log 虽然没有 commit,但是 redo log 有完整的 prepare,且对应的事务 binlog 完整,因此提交事务。binlog 写入,因此会传到备库,数据与日志保持一致。
崩溃恢复
从前一节的描述中可以发现崩溃恢复时根据两阶段提交的进度进行处理。
参考 MySQL 45 讲,崩溃恢复(crash-recovery)时的完整判断逻辑为:
- 如果 redo log 里面的事务完整,也就是已经有了 commit 标识,直接提交;
- 如果 redo log 里面的事务只有完整的 prepare,进一步判断对应的事务 binlog 是否存在且完整:
- 如果是,提交事务;
- 否则,回滚事务。
因此,redo log prepare 后 commit 前崩溃恢复时可能发生回滚或提交,具体与 binlog 的完整性有关。
显然,时刻 B 发生 crash 的情况对应 redo log prepare 完整,且 binlog 完整的场景,因此事务提交。
这里可以提出以下两个问题:
1)如何判断 binlog 完整
2)如何根据 redo log 定位对应的 binlog
接下来分别回答这两个问题。
1)如何判断 binlog 完整
判断 binlog 的完整性有以下两种方式:
- 在事务提交时记录 XID event 到 binlog 中以标记事务的结束。这个机制确保了事务的完整性和一致性,无论使用哪种复制格式;
- 在 MySQL 5.6.2 版本以后,还引入了 binlog-checksum 参数,用于验证 binlog 内容的正确性。通过为 binlog 中的每个事件添加校验和(checksum),MySQL 能够检测到写入 binlog 时由于磁盘错误等原因导致的数据损坏。
如下所示,测试显示 row 与 statement 两种 bnlog 格式中事务的最后一个 event 都是 XID event。
2)如何根据 redo log 定位对应的 binlog
redo log 与 binlog 有一个共同的数据字段,称为 XID。
崩溃恢复的时候,会按顺序扫描 redo log:
- 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
- 如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应的事务。
其中:
- redo log 扫描的起点是 InnoDB 最后一次 checkpoint 操作的 lsn(last_checkpoint_lsn)。
- XID 与分布式事务有关,下一节中介绍。
这里可以提出另一个问题,根据事务的持久性,到什么进度后事务将无法回滚?
理论上 MySQL 中通过 redo log 实现事务的持久性,因此 redo log 刷盘后就可以保证对数据库的修改是永久性的,即使发生崩溃也不会丢失,当然也不会回滚。
不过根据事务的两阶段提交协议,binlog 写入代表事务提交,同样不可能发生回滚。
因此,事务无法回滚的关键点是事务的提交,而不是单纯的 redo log 或 binlog 的写入。在事务提交的过程中,两阶段提交机制确保了 redo log 和 binlog 的一致性,这个提交过程标志着事务从可回滚转变为不可回滚。
XA 事务
分布式事务是一种跨多个独立的数据库、系统或网络区域的事务处理方法。
XA 事务是一种遵循 XA 规范的分布式事务,因此 XA 事务是分布式事务的一种实现。
XA 事务依赖两阶段提交(2PC)协议实现分布式事务的一致性和原子性。
两阶段提交是最常见的分布式事务协议,用于保证分布式事务的原子性,显然并不是 MySQL 独有的。
根据 XA 规范,两阶段提交的实现过程中包括两个角色:
- 资源管理器(Resource Manager),可以称为执行器,用于管理分布式数据库的一个本地事务;
- 事务管理器(Transaction Manager),可以称为协调器,用于协调事务的提交、回滚、崩溃恢复。
两阶段提交中将提交操作分为两个阶段:
- prepare 阶段,协调器询问所有执行器,是否可以提交事务,如果任何一个执行器的本地事务无法提交时,分布式事务都需要通知所有执行器进行回滚操作;
- commit 阶段,协调器在收到每一个执行器的提交确认后,通知执行器各自提交自己的本地事务。
MySQL 中的 XA 事务分为外部 XA 与内部 XA。其中:
- 外部 XA,MySQL 服务器作为执行器,连接服务器的客户端程序作为协调器,对应多个支持分布式事务的数据库实例,比如多套 MySQL(使用分库分表中间件)、Oracle + MySQL;
- 内部 XA,对应单个 MySQL 实例,分为以下两种场景:
- 没有开启 binlog,SQL 语句涉及一个或多个支持事务的存储引擎;
开启 binlog,SQL 语句涉及一个或多个支持事务的存储引擎。
其中,由于 binlog 与存储引擎是独立单元,可以将 binlog 也看作一个存储引擎,因此需要通过 XA 事务实现 binlog 与存储引擎的数据一致性和原子性,从而保证全部操作要么全部提交,要么全部回滚。
在分布式事务中,XID作为全局事务的唯一标识符,用于跟踪和协调不同数据库实例中的事务部分。这个标识符在事务的所有参与者之间是共享的,以确保事务的一致性和完整性。
因此在 XA 事务中,XID用于在多个数据库实例之间协调事务。
在 MySQL 中,XID(Transaction Identifier)是事务的唯一标识符,用于标记事务的提交。
binlog 中一个事务由一系列事件(event)组成,这个序列由 BEGIN 事件开始,以 XID 事件结束(对于提交的事务)。
因此如果事务被回滚,不会记录 XID 事件,而是记录一个 ROLLBACK 事件。
参考 chatgpt,XID 与 GTID 的主要区别包括:
- XID:是事务的标识符,用于标记事务的结束,主要用于事务的恢复和复制过程中确定事务边界。对于分布式事务,所有 MySQL 实例使用相同的 XID 来提交事务;
- GTID(全局事务标识符):是 MySQL 5.6 及更高版本中引入的,用于唯一标识每个事务。每个 GTID 都是全局唯一的,即使在不同的 MySQL 实例中也是如此。GTID 使得跟踪和复制事务变得更加简单和可靠。
实现
prepare
参考文章 MySQL 事务二阶段提交 与 MySQL 核心模块揭秘 | 07 期 | 二阶段提交 (1) prepare 阶段,prepare 阶段做的事情分为两类:
- binlog prepare,对应 binlog_prepare 函数,什么都不做;
- InnoDB prepare,对应 innobase_xa_prepare 函数,具体做五件事情:
- 把分配给事务的所有 Undo segment 的状态 TRX_UNDO_STATE 从 TRX_UNDO_ACTIVE 修改为 TRX_UNDO_PREPARED;
- 把事务 XID 写入所有 Undo segment 中当前提交事务的 Undo Log Segment Header;
- 把内存中的事务对象状态从 TRX_STATE_ACTIVE 修改为 TRX_STATE_PREPARED,标识事务已经进入二阶段提交的 prepare 阶段;
- 如果当前提交事务的隔离级别是读未提交(READ-UNCOMMITTED)或读已提交(READ-COMMITTED),InnoDB 会释放事务给记录加的共享、排他 GAP 锁;
- 调用 trx_flush_logs(),处理 redo log 刷盘的相关逻辑,其中实际上并不会将 redo log 刷盘,也就是同样什么都不做。
其中 undo log 非常重要,原因是:
- TRX_UNDO_STATE 用于崩溃恢复过程中,标记哪些事务需要恢复,哪些事务不用恢复。
- XID 用于崩溃恢复过程中,决定数据库崩溃时处于 prepared 阶段的事务,是要回滚还是要提交。
参考文章 XA事务与两阶段提交。
Undo页面链表的第一个页面的结构见下图,其中记录了一些关于这个事务的一些属性。
其中 Undo Log Segment Header 结构见下图,其中 TRX_UNDO_STATE 字段表示事务所处的状态。
其中 Undo Log Header 结构见下图。
其中:
- TRX_UNDO_XID_EXISTS:表示有没有 XID 信息;
- XID信息:表示具体的 XID 是什么。
TRX_UNDO_STATE 的取值包括:
- TRX_UNDO_ACTIVE:活跃状态,也就是一个活跃的事务正在往这个段里边写入 undo log;
- TRX_UNDO_CACHED:被缓存的状态。处在该状态的 Undo 页面链表等待着之后被其他事务重用;
- TRX_UNDO_TO_FREE:对于 insert undo 链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。Undo 页面链表可以被马上清理;
- TRX_UNDO_TO_PURGE:对于 update undo 链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。Undo 页面链表不可以被马上清理,而是加入 History 链表用于 MVCC,等待 purge 线程清理;
- TRX_UNDO_PREPARED:包含处于 prepare 阶段(这个阶段是在分布式事务中会出现)的事务产生的 undo log。
commit
commit 阶段做的事情同样分为两类:
- binlog 刷盘,对应 flush 函数,将事务执行过程中产生的 binlog 写入硬盘;
- InnoDB commit,对应 innobase_commit 函数,完成存储引擎层面的事务提交。
具体 commit 阶段的实现与组提交有关,计划下一篇文章中介绍。
因此,在客户端执行 commit 语句或自动 commit 时,MySQL 开启内部 XA 事务,分两阶段完成 XA 事务的提交。
崩溃恢复
崩溃恢复全过程分为多个阶段,其中与事务两阶段提交有关的阶段包括:
- 恢复数据页,通过 doublewrite buffer 修复部分页写入(partial page write)导致的数据页损坏;
- 读取 redo log,从 last_checkpoint_lsn 开始读取 redo log;
- 应用 redo log 到数据页,将没有写入数据页的日志重做一遍,从而保证事务的持久性;
- 初始化事务子系统,从 undo 表空间文件读取未完成的事务;
- 处理未完成事务,其中:
- 如果事务 XID 对应 binlog 已写入文件,事务提交;
- 如果事务 XID 对应 binlog 未写入文件,事务回滚。
- 清理已提交事务,对应 TRX_STATE_COMMITTED_IN_MEMORY,包括 DDL 与 DML;
- 回滚未提交事务,对应 TRX_STATE_ACTIVE,包括 DDL 与 DML;
- 处理 prepare 事务,对应 TRX_STATE_PREPARED,其中:
未完成事务的状态可能是以下三种之一:
- TRX_STATE_ACTIVE,表示事务还没有进入提交阶段。
- TRX_STATE_PREPARED,表示事务已经提交了,但是只完成了二阶段提交的 PREPARE 阶段,还没有完成 COMMIT 阶段。
- TRX_STATE_COMMITTED_IN_MEMORY,表示事务已经完成了二阶段提交的 2 个阶段,还剩一些收尾工作没做,这种状态的事务修改的数据已经可以被其它事务看见了。
其中未提交事务 TRX_STATE_ACTIVE 对应 redo log 已经刷盘的未提交事务,包括以下三种场景:
- 后台线程定时将 redo log buffer 中的日志刷盘时将事务执行中间过程的 redo log 持久化到磁盘;
- redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半时,后台线程会主动写盘,即使事务并没有提交;
- 并行的事务提交时,顺带将这个事务的 redo log buffer 持久化到磁盘。假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。
因此,为了保证事务的原子性,需要在崩溃恢复时将这些未提交事务回滚,而找到这些未提交事务依赖 undo log。
结论
MySQL 通过事务的两阶段提交实现数据与日志的一致性。
其中数据指 redo log,日志指 binlog,可以认为是两个不同的存储引擎,因此基于分布式事务的 XID 协议实现一致性。
具体实现中将 redo log 的提交拆分为两个步骤,包括 prepare 与 commit,期间写入 binlog。
因此,写入的不同阶段异常重启时:
- redo log commit crash,binlog 完整,因此事务提交;
- binlog crash,redo log 没有 commit,且没有写入 binlog,因此事务回滚。
具体是在崩溃恢复过程中基于两阶段提交保证事务的一致性。
其中:
- redo log application 阶段用于将没有写入数据页的日志重做一遍,把系统恢复到崩溃前的状态,其中都是提交,没有回滚;
- 初始化事务子系统阶段从表空间中找到各个 Undo 页面链表的首个页面的页号,然后根据事务的状态处理未完成事务。其中:
- TRX_STATE_ACTIVE,表明是未提交事务,因此回滚事务;
- TRX_STATE_PREPARED,进一步判断 XID 对应 binlog 是否存在,如果有,提交事务,否则回滚事务;
- TRX_STATE_COMMITTED_IN_MEMORY,表明是已提交事务,因此提交事务,具体是清理已提交事务。
因此,可以将崩溃恢复过程中使用的日志的顺序理解为 redo log、undo log、binlog。