两阶段提交
在讲述MySQL的事务模型之前,有必要介绍一下两阶段提交,参考《Designing Data-Intensive Application》
- 【1】应用程序通知协调者开始事务,并告知协调者事务的内容
- 【2】协调者给每个参与者发送单节点的读/写事务(对应于图中两个write data)
- 【3】协调者给每个参与者发送Prepare请求
- 【4】如果参与者回复“Yes”,意味着参与者放弃自身中止事务的权力,无论出现何种状况也必须自身提交事务
- 【5】协调者收到所有参与者的回复后,做出最终决定(只有所有回复都是“Yes”,最终决定才是“Commit”)
- 【6】如果协调者最终决定是“Commit”,意味着无论发生何种状况,协调者也要保证所有参与者都提交成功
两阶段提交的原子性由【4】和【6】保证,称作“Two Points of No Return”
注意:在【2】【3】间,协调者没必要再去询问每一位参与者“【1】中的读/写事务有没有执行成功?”,因为如果有参与者在执行失败的话,可以在收到Prepare请求时回复“No”
- 称【1】为事务开始阶段
- 称【2】为事务执行阶段
称【3】-【6】为事务提交阶段
检查是否有活跃的事务还未提交,如果有的话则调用ha_commit_trans提交之前的事务,并释放之前事务持有的MDL锁
-
事务执行
【InnoDB】
- 产生Undo(内存)日志,产生“修改Undo日志行为”的Redo(内存)日志
- MTR:修改内存中的数据页,产生“修改数据页行为”的Redo(内存)日志
- 将修改的数据页(脏页)加入Flush List中(Cleaner线程异步地刷脏页到磁盘的数据文件中)
- 将线程产生的私有的Redo日志提交到公共的Redo Log Buffer
- 【Server】
- 产生Binlog(内存)日志
- 【语句提交】
- 【Binlog Prepare】binlog_prepare:什么都不做
- 【InnoDB Prepare】innobase_xa_prepare:释放可能持有的自增键锁
- 【Binlog Commit】binlog_commit:什么都不做
- 【InnoDB Commit】innobase_commit:同【InnoDB Prepare】
可以看到,在事务执行阶段,MySQL加入了“语句提交”这一两阶段提交过程,但是Binlog在Prepare和Commit阶段的内容都为空,因此实质上只是InnoDB的单节点提交
事务提交
- 【Binlog Prepare】binlog_prepare:什么都不做
- 【InnoDB Prepare】innobase_xa_prepare:
- 【1】将事务状态置为PREPARED
- 【2】将Redo日志写入磁盘
【组提交】ordered_commit:
- 【1】Flush Stage: 将Binlog内容写入Binlog日志文件(内存,尚未写盘)
- 【2】Sync Stage: 将Binlog文件写盘
- 【3】Commit Stage:存储引擎提交(此处以InnoDB为例)
- 清理insert_undo日志
- 将update_undo日志加入回滚段的History-List(Purge线程定期清理)
- 释放事务持有的所有锁(比如,修改同一行记录的事务需要有互斥锁)
- 清理(如果有的话)Savepoint列表
- 内存中事务结构体的状态置为COMMITTED(准确的将是TRX_STATE_COMMITTED_IN_MEMORY**)**
- Undo日志中事务的状态TRX_UNDO_CACHED / TRX_UNDO_TO_FREE / TRX_UNDO_TO_PURGE(在Crash Recovery时,读到这三种状态都会将事务内存结构置为TRX_STATE_COMMITTED_IN_MEMORY)
MySQL的事务提交是不是标准的两阶段提交?
可以看到,【组提交】中的【1】【2】为Binlog提交,【3】为InnoDB提交,因此MySQL的事务提交阶段是两阶段提交,我们来看一下是怎么保证“Two Points of No Return”:
Binlog Prepare什么都不做,Binlog确信自身一定可以把Binlog内容写入磁盘
- InnoDB Prepare将Redo日志写入磁盘,则InnoDB一定可以成功提交,其实此时就可以认为事务在InnoDB中提交成功了
但注意一点,在两阶段提交算法中,如果存在参与者返回“No”,协调者是需要再向所有参与者发送“Abort”命令,中止事务,使所有参与者恢复到到事务开始之前的状态,在MySQL里是如何实现的?
- 当存在协调者返回“No”:Binlog Prepare或者InnoDB Prepare出错,(一般来讲)MySQL Crash
- 协调者发送“Abort”命令中止事务,使所有参与者恢复到事务开始之前的状态:此时相当于进行到【Binlog Prepare】或者【InnoDB Prepare】,事务状态为Active / Prepared,但是Binlog尚未写入磁盘(Binlog在【组提交】时才写入磁盘),根据MySQL Crash Recovery的原理,这个事务会被回滚(Binlog层和InnoDB层都会回滚,但Binlog层可能不需要做什么处理)
这里有一个问题,如果进行到【InnoDB Prepare】,并且Redo日志已写入磁盘,事务还会被回滚吗?答案是会被回滚,就是做Undo操作,此时Redo日志看起来像这样:
2PC | MySQL |
---|---|
参与者会返回“No” | Binlog Prepare或者InnoDB Prepare出错,(一般来讲)导致MySQL Crash |
参与者确认能Commit,返回“Yes” | - Binlog未做确定性保障 - InnoDB将Redo日志写盘,事务在引擎层已经持久化 |
协调者发送“Abort”命令中止事务 | 根据MySQL Crash Recovery的原理,这个事务会被回滚(Binlog层和InnoDB层都会回滚,但Binlog层可能不需要做什么处理) |
如果一个参与者回复“Yes”却提交失败,这是两阶段提交不允许的。但是MySQL依然可以通过崩溃恢复来保证所有参与者(Binlog和InnoDB)的数据一致性
不用两阶段提交行不行?
如果不用两阶段提交,把执行过程顺序写下来:
【过程1】
- 【InnoDB - 1】将事务状态置为Prepare
- 【InnoDB - 2】将Redo日志写入磁盘(猜测这里已经将事务执行中生成的Redo日志持久化到磁盘了)
- 【Server - 1】Flush Stage: 将Binlog内容写入Binlog日志文件(内存,尚未写盘)
- 【Server - 2】Sync Stage: 将Binlog文件写盘
- 【InnoDB - 3】Commit Stage:存储引擎提交(此处以InnoDB为例)
- 清理Undo日志(Purge线程异步清理)
- INSERT的Undo日志直接删除(INSERT数据行的回滚是“空”)
- UPDATE的Undo日志需要放到History List上,等待Purge线程根据事务可见性删除
- 释放事务持有的所有锁(比如,修改同一行记录的事务需要有互斥锁)
- 清理(如果有的话)Savepoint列表
- 清理Undo日志(Purge线程异步清理)
- 【InnoDB - 4】事务状态置为Commit
注意,这依然是两阶段提交(比如执行完【InnoDB - 1,2】才能执行【Server - 1,2】),只是不显示的指明哪些是InnoDB的Prepare了,哪些是Binlog的Commit之类的了。那我们把顺序变一变:
【过程2】
- 【Server - 1】Flush Stage: 将Binlog内容写入Binlog日志文件(内存,尚未写盘)
- 【Server - 2】Sync Stage: 将Binlog文件写盘
- 【InnoDB - 1】将事务状态置为Prepare
- 【InnoDB - 2】将Redo日志写入磁盘(猜测这里已经将事务执行中生成的Redo日志持久化到磁盘了)
- 【InnoDB - 3】Commit Stage:存储引擎提交(此处以InnoDB为例)
- 清理Undo日志(Purge线程异步清理)
- 释放事务持有的所有锁(比如,修改同一行记录的事务需要有互斥锁)
- 清理(如果有的话)Savepoint列表
- 【InnoDB - 4】事务状态置为Commit
这就不是两阶段提交了,需要Server层全部执行完,再执行InnoDB层。那么,这会有问题吗?举个例子,Server层执行成功,InnoDB层执行失败Crash:
- Recovery时可以在Binlog中查找事务内容,InnoDB重新执行
这个方法类似于在一阶段提交时,协调者对失败的参与者发起(无限)重试请求。见下图,由于一阶段提交对于重试次数的上限无法保证(两阶段提交可以看做是重试次数上限为“2”的一阶段提交),结论是:一阶段提交平均来讲,会增大服务不可用时间和数据不一致时间
、
具体到MySQL呢?比如在【InnoDB-2】将Redo日志写入磁盘操作失败
- 【过程1】对Binlog和InnoDB尚未产生任何影响
- 【过程2】Binlog已写入文件(一个参与者已经提交成功),导致两个参与者的数据开始不一致(在实际场景下,Binlog和InnoDB的数据不一致表示为从库和主库的数据不一致)