MySQL 系列 两阶段提交

MySQL 技术内幕:InnoDB存储引擎

Posted by lichao modified on February 1, 2024

日志是 MySQL 数据库的重要组成部分。日志文件中记录着 MySQL 数据库运行期间发生的变化,也就是说用来记录 MySQL 数据库的客户端连接状况、SQL 语句的执行情况和错误信息等。当数据库遭到意外的损坏时,可以通过日志查看文件出错的原因,并且可以通过日志文件进行数据恢复。

MySQL整体来看,其实就有两块:一块是Server层,它主要做的是MySQL功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。redo log是InnoDB引擎特有的日志,用来保证事务安全的。而Server层也有自己的日志,称为binlog(归档日志),主要用来做主从复制和即时点恢复时使用的。

在MySQL中,如果每一次的更新操作都需要写进磁盘,磁盘要找到对应的那条记录,然后再更新,整个过程IO成本、查找成本都很高。MySQL使用WAL技术来提升更新效率,WAL的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘,只要redo log和binlog保证持久化到磁盘,就能确保MySQL异常重启后,数据可以恢复。 具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到redo log里面,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个脏内存更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。

因为最开始MySQL里并没有InnoDB引擎。MySQL自带的引擎是MyISAM,但是MyISAM没有crash-safe的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。

crash-safe 指 MySQL 服务器宕机重启后,能够保证: -所有已经提交的事务的数据仍然存在。 -所有没有提交的事务的数据自动回滚。

这两种日志有以下三点不同:

  • redo log 是 InnoDB 引擎特有的。binlog是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如 “给 ID = 2 这一行的 c 字段加 1”。
  • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

在主从复制结构中,要保证事务的持久性和一致性,需要对日志相关变量设置为如下: -如果启用了二进制日志,则设置 sync_binlog = 1,即每提交一次事务同步写到磁盘中。 -总是设置 innodb_flush_log_at_trx_commit = 1,即每提交一次事务都写到磁盘中。

详情请看-binlog

详情请看-redo log

两阶段提交

两阶段提交

MySQL 没有开启 binlog 的情况下,通过 redo log 将所有已经在存储引擎内部提交的事务应用 redo log 恢复,所有已经 prepare 但是没有 commit 的 transactions 将会应用 undo log 做 rollback。然后客户端连接时就能看到已经提交的数据存在数据库内,未提交被回滚地数据需要重新执行。

在两阶段锁协议中,所有加锁的资源,都是在事务提交或者回滚的时候才释放的。

MySQL开启binlog的情况下,MySQL为了保证 master 和 slave 的数据一致性,就必须保证 binlog 和redo log 的一致性。 为此,MySQL 引入二阶段提交(two phase commit or 2pc),MySQL 内部会自动将普通事务当做一个 XA 事务(内部分布式事物)来处理:

  • 自动为每个事务分配一个唯一的 ID(XID)。
  • COMMIT 会被自动的分成 Prepare 和 Commit 两个阶段。
  • Binlog 会被当做事务协调者(Transaction Coordinator),Binlog Event 会被当做协调者日志。

两阶段提交

以上的图片中可以看到,事务的提交主要分为两个主要步骤:

  1. 准备阶段(Storage Engine(InnoDB)Transaction Prepare Phase): 此时 SQL 已经成功执行,并生成 xid 信息及 redo 和 undo 的内存日志。然后调用 prepare 方法完成第一阶段,papare 方法实际上什么也没做,将事务状态设为 TRX_PREPARED,并将 redo log 刷磁盘。
  2. 提交阶段(Storage Engine(InnoDB)Commit Phase)
    1. 记录协调者日志,即 binlog 日志: 如果事务涉及的所有存储引擎的 prepare 都执行成功,则调用TC_LOG_BINLOG::log_xid 方法将 SQL 语句写到 binlog(write() 将 binlog 内存日志数据写入文件系统缓存,fsync() 将 binlog 文件系统缓存日志数据永久写入磁盘)。此时,事务已经铁定要提交了。否则,调用 ha_rollback_trans 方法回滚事务,而 SQL 语句实际上也不会写到 binlog。
    2. 告诉引擎做 commit: 最后,调用引擎的commit完成事务的提交。会清除undo信息,刷redo日志,将事务设为TRX_NOT_STARTED状态。
  3. 调用引擎的 commit 完成事务的提交。会清除 undo 信息,刷 redo 日志,将事务设为TRX_NOT_STARTED状态。

由上面的二阶段提交流程可以看出,一旦步骤2中的操作完成,就确保了事务的提交,即使在执行步骤3时数据库发送了宕机。此外需要注意的是,每个步骤都需要进行一次fsync操作才能保证上下两层数据的一致性。步骤2的fsync参数由sync_binlog=1控制,步骤3的fsync由参数innodb_flush_log_at_trx_commit=1控制,俗称“双1”,是保证CrashSafe的根本。

事务的两阶段提交协议保证了无论在任何情况下,事务要么同时存在于存储引擎和 binlog 中,要么两个里面都不存在,这就保证了主库与从库之间数据的一致性。如果数据库系统发生崩溃,当数据库系统重新启动时会进行崩溃恢复操作,存储引擎中处于 prepare 状态的事务会去查询该事务是否也同时存在于 binlog 中,如果存在就在存储引擎内部提交该事务(因为此时从库可能已经获取了对应的 binlog 内容),如果 binlog 中没有该事务,就回滚该事务。例如:当崩溃发生在第一步和第二步之间时,明显处于 prepare 状态的事务还没来得及写入到 binlog 中,所以该事务会在存储引擎内部进行回滚,这样该事务在存储引擎和 binlog 中都不会存在;当崩溃发生在第二步和第三步之间时,处于 prepare 状态的事务存在于 binlog 中,那么该事务会在存储引擎内部进行提交,这样该事务就同时存在于存储引擎和 binlog 中。

为了保证数据的安全性,以上列出的 3 个步骤都需要调用 fsync 将数据持久化到磁盘。由于在引擎内部 prepare 完成的事务可以通过binlog 恢复,所以通常情况下第三个 fsync 是可以省略的。

另外,MySQL 内部两阶段提交需要开启 innodb_support_xa=true,默认开启。这个参数就是支持分布式事务两段式事务提交。redo 和 binlog 数据一致性就是靠这个两段式提交来完成的,如果关闭会造成事务数据的丢失。

为什么必须有“两阶段提交”呢?这是为了让两份日志之间的逻辑一致

binlog会记录所有的逻辑操作,并且是采用“追加写”的形式。

当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,可以这么做:

  • 首先,找到最近的一次全量备份,从这个备份恢复到临时库;
  • 然后,从备份的时间点开始,将备份的binlog依次取出来,重放到误删表之前的那个时刻。然后就可以把表数据从临时库取出来,按需要恢复到线上库去。

由于redo log和binlog是两个独立的逻辑,如果不用两阶段提交,要么就是先写完redo log再写binlog,或者采用反过来的顺序,会有什么问题:

  • 先写redo log后写binlog。假设在redo log写完,binlog还没有写完的时候,MySQL进程异常重启。redo log写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行c的值是1。但是由于binlog没写完就crash了,这时候binlog里面就没有记录这个语句。因此,之后备份日志的时候,存起来的binlog里面就没有这条语句。然后会发现,如果需要用这个binlog来恢复临时库的话,由于这个语句的binlog丢失,这个临时库就会少了这一次更新,恢复出来的这一行c的值就是0,与原库的值不同。
  • 先写binlog后写redo log。如果在binlog写完之后crash,由于redo log还没写,崩溃恢复以后这个事务无效,所以这一行c的值是0。但是binlog里面已经记录了“把c从0改成1”这个日志。所以,在之后用binlog来恢复的时候就多了一个事务出来,恢复出来的这一行c的值就是1,与原库的值不同。

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

redo log commit 和写bin log 是两个非原子性的操作,假如写bin log成功,但是 redo log commit 失败会怎么办?

第二阶段中是以binlog的写入与否作为事务是否成功提交的标志。

  • 如果数据库在记录此事务的binlog之前和过程中发生crash。数据库在恢复后认为此事务并没有成功提交,则会回滚此事务的操作。与此同时,因为在binlog中也没有此事务的记录,所以从库也不会有此事务的数据修改。
  • 如果数据库在记录此事务的binlog之后发生crash。此时,即使是redo log中还没有记录此事务的commit 标签,数据库在恢复后也会认为此事务提交成功(因为在上述两阶段过程中,binlog写入成功就认为事务成功提交了)。它会扫描最后一个binlog文件,并提取其中的事务ID(xid),InnoDB会将那些状态为Prepare的事务(redo log没有记录commit 标签)的xid和Binlog中提取的xid做比较,如果在Binlog中完整存在,则提交该事务,否则回滚该事务。这也就是说,binlog中记录的事务,在恢复时都会被认为是已提交事务,会在redo log中重新写入commit标志,并完成此事务的重做(主库中有此事务的数据修改)。与此同时,因为在binlog中已经有了此事务的记录,所有从库也会有此事务的数据修改。

为什么需要保证二进制日志的写入顺序和InnoDB层事务提交顺序一致性呢?

上面提到单个事务的二阶段提交过程,能够保证存储引擎和 binlog 日志保持一致,但是在并发的情况下怎么保证InnoDB层事务日志和MySQL数据库二进制日志的提交的顺序一致?当多个事务并发提交的情况,如果 binlog 和存储引擎顺序不一致会造成什么影响?

这是因为备份及恢复需要,例如通过 xtrabackup 或 ibbackup 这种物理备份工具进行备份时,并使用备份来建立复制,如下图: binlog乱序示意图

如上图,事务按照 T1、T2、T3 顺序开始执行,将二进制日志(按照T1、T2、T3顺序)写入日志文件系统缓冲,调用 fsync() 进行一次group commit 将日志文件永久写入磁盘,但是存储引擎提交的顺序为 T2、T3、T1。当 T2、T3 提交事务之后,若通过在线物理备份进行数据库恢复来建立复制时,因为在 innoDB 存储引擎层会检测事务 T3 在上下两层都完成了事务提交,不需要在进行恢复了,此时主备数据不一致(搭建Slave时,change master to的日志偏移量记录 T3 在事务位置之后)。

为了解决以上问题,在早期的 MySQL 5.6 版本之前,通过 prepare_commit_mutex 锁以串行的方式来保证 MySQL 数据库上层二进制日志和 innodb 存储引擎层的事务提交顺序一致,然后会导致组提交(group commit)特性无法生效。为了满足数据的持久化需求,一个完整事务的提交最多会导致 3 次 fsync 操作。为了提高 MySQL 在开启 binlog 的情况下单位时间内的事务提交数,就必须减少每个事务提交过程中导致的fsync的调用次数。所以,MySQL 从 5.6 版本开始加入了 binlog group commit 技术(MariaDB 5.3版本开始引入)。

MySQL 数据库内部在 prepare redo 阶段获取 prepare_commit_mutex 锁,一次只能有一个事务可获取该 mutex。通过这个臭名昭著 prepare_commit_mutex 锁,将 redo log 和 binlog 刷盘串行化,串行化的目的也仅仅是为了保证 redo log 和 Binlog一致,继而无法实现 group commit,牺牲了性能。整个过程如下图: binlog顺序示意图

上图可以看出在prepare_commit_mutex,只有当上一个事务commit后释放锁,下一个事务才可以进行prepare操作,并且在每个事务过程中Binary log没有fsync()的调用。由于内存数据写入磁盘的开销很大,如果频繁fsync()把日志数据永久写入磁盘数据库的性能将会急剧下降。此时MySQL数据库提供sync_binlog参数来设置多少个binlog日志产生的时候调用一次fsync()把二进制日志刷入磁盘来提高整体性能。

上图所示 MySQL 开启 binlog 时使用 prepare_commit_mutex 和 sync_log 保证二进制日志和存储引擎顺序保持一致,prepare_commit_mutex 的锁机制造成高并发提交事务的时候性能非常差而且二进制日志也无法 group commit。

这个问题早在 2010 年的 MySQL 数据库大会中提出,Facebook MySQL 技术组,Percona 公司都提出过解决方案,最后由 MariaDB 数据库的开发人员 Kristian Nielsen 完成了最终的”完美”解决方案。在这种情况下,不但 MySQL 数据库上层二进制日志写入是 group commit 的,InnoDB 存储引擎层也是 group commit 的。此外还移除了原先的锁 prepare_commit_mutex,从而大大提高了数据库的整体性。MySQL 5.6 采用了类似的实现方式,并将其称为 BLGC(Binary Log Group Commit),并把事务提交过程分成三个阶段,Flush stage、Sync stage、Commit stage。

BLGC(Binary Log Group Commit)组提交

一个事物提交时,对应redo log buffer日志需持久化到磁盘。这时候会带小于此事物日志逻辑序列号(log sequence number,LSN)的其他事物的日志一起持久化到磁盘,即是组提交;

MySQL 5.6 BLGC 技术出现后,不但 MySQL 数据库 server 层 binlog 写入是 group commit 的,InnoDB 存储引擎层 redo log 也是 group commit 的。

日志逻辑序列号(log sequence number,LSN): LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。

如下图所示,是三个并发事务(trx1, trx2, trx3)在 prepare 阶段,都写完 redo log buffer,持久化到磁盘的过程,对应的 LSN 分别是 50、120 和 160。

组提交

从图中可以看到:

  1. trx1 是第一个到达的,会被选为这组的 leader。
  2. 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160。
  3. trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘。
  4. 这时候 trx2 和 trx3 就可以直接返回了。

所以,一次组提交里面,组员越多,节约磁盘IOPS的效果越好。但如果只有单线程压测,那就只能一个事务对应一次持久化操作了。 在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。 为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间。

组提交优化 实际上,写binlog是分成两步的,先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件,调用 fsync 持久化。 MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了 bin log write 之后。

这么一来,binlog 也可以组提交了。在执行上图中第4步把binlog fsync到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少IOPS的消耗。 不过通常情况下第3步执行得会很快,所以binlog的write和fsync间的间隔时间短,导致能集合到一起持久化的binlog比较少,因此binlog的组提交的效果通常不如redo log的效果那么好。 如果想提升binlog组提交的效果,可以通过设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count来实现。

  • binlog_group_commit_sync_delay参数,表示延迟多少微秒后才调用fsync;
  • binlog_group_commit_sync_no_delay_count参数,表示累积多少次以后才调用fsync。

这两个条件是或的关系,也就是说只要有一个满足条件就会调用fsync。所以,当binlog_group_commit_sync_delay设置为0的时候,binlog_group_commit_sync_no_delay_count也无效了。

参考文献

知乎 知乎

MySQL 中Redo与Binlog顺序一致性问题