MySQL-的-crashsafe-原理解析

3次阅读

共计 6578 个字符,预计需要花费 17 分钟才能阅读完成。

本文首发于 vivo 互联网技术 微信公众号 
链接:https://mp.weixin.qq.com/s/5i9wmJs4_Er7RaYfNnETyA[](https://mp.weixin.qq.com/s/5i…
作者:xieweipeng

MySQL 作为当下最流行的开源关系型数据库,有一个很关键和基本的能力,就是必须能够保证数据不会丢。那么在这个能力背后,MySQL 是如何设计才能保证不管在什么时间崩溃,恢复后都能保证数据不会丢呢?有哪些关键技术支撑了这个能力?本文将为我们一一揭晓。

一、前言

MySQL 保证数据不会丢的能力主要体现在两方面:

  1. 能够恢复到任何时间点的状态;
  2. 能够保证 MySQL 在任何时间段突然奔溃,重启后之前提交的记录都不会丢失;

对于第一点将 MySQL 恢复到任何时间点的状态,相信很多人都知道,只要保留有足够的 binlog,就能通过重跑 binlog 来实现。

对于第二点的能力,也就是本文标题所讲的crash-safe。即在 InnoDB 存储引擎中,事务提交过程中任何阶段,MySQL 突然奔溃,重启后都能保证事务的完整性,已提交的数据不会丢失,未提交完整的数据会自动进行回滚。这个能力依赖的就是 redo log 和 unod log 两个日志。

因为 crash-safe 主要体现在事务执行过程中突然奔溃,重启后能保证事务完整性,所以在讲解具体原理之前,先了解下 MySQL 事务执行有哪些关键阶段,后面才能依据这几个阶段来进行解析。下面以一条更新语句的执行流程为例,话不多说,直接上图:

从上图可以清晰地看出一条更新语句在 MySQL 中是怎么执行的,简单进行总结一下:

  1. 从内存中找出这条数据记录,对其进行更新;
  2. 将对数据页的更改记录到 redo log 中;
  3. 将逻辑操作记录到 binlog 中;
  4. 对于内存中的数据和日志,都是由后台线程,当触发到落盘规则后再异步进行刷盘;

上面演示了一条更新语句的详细执行过程,接下来咱们通过解答问题,带着问题来剖析这个 crash-safe 的设计原理。

二、WAL 机制

问题:为什么不直接更改磁盘中的数据,而要在内存中更改,然后还需要写日志,最后再落盘这么复杂?

这个问题相信很多同学都能猜出来,MySQL 更改数据的时候,之所以不直接写磁盘文件中的数据,最主要就是性能问题。因为直接写磁盘文件是随机写,开销大性能低,没办法满足 MySQL 的性能要求。所以才会设计成先在内存中对数据进行更改,再异步落盘。但是内存总是不可靠,万一断电重启,还没来得及落盘的内存数据就会丢失,所以还需要加上写日志这个步骤,万一断电重启,还能通过日志中的记录进行恢复。

写日志虽然也是写磁盘,但是它是顺序写,相比随机写开销更小,能提升语句执行的性能(针对顺序写为什么比随机写更快,可以比喻为你有一个本子,按照顺序一页一页写肯定比写一个字都要找到对应页写快得多)。

这个技术就是大多数存储系统基本都会用的 WAL(Write Ahead Log) 技术,也称为日志先行的技术,指的是对数据文件进行修改前,必须将修改先记录日志。保证了数据一致性和持久性,并且提升语句执行性能。

三、核心日志模块

问题:更新 SQL 语句执行流程中,总共需要写 3 个日志,这 3 个是不是都需要,能不能进行简化?

更新 SQL 执行过程中,总共涉及 MySQL 日志模块其中的三个核心日志,分别是 redo log(重做日志)、undo log(回滚日志)、binlog(归档日志)。 这里提前预告,crash-safe 的能力主要依赖的就是这三大日志。

接下来,针对每个日志将单独介绍各自的作用,然后再来评估是否能简化掉。

1、重做日志 redo log

redo log 也称为事务日志,由 InnoDB 存储引擎层产生。记录的是数据库中每个页的修改,而不是某一行或某几行修改成怎样,可以用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置,因为修改会覆盖之前的)。

前面提到的 WAL 技术,redo log 就是 WAL 的典型应用,MySQL 在有事务提交对数据进行更改时,只会在内存中修改对应的数据页和记录 redo log 日志,完成后即表示事务提交成功,至于磁盘数据文件的更新则由后台线程异步处理。由于 redo log 的加入,保证了 MySQL 数据一致性和持久性(即使数据刷盘之前 MySQL 奔溃了,重启后仍然能通过 redo log 里的更改记录进行重放,重新刷盘),此外还能提升语句的执行性能(写 redo log 是顺序写,相比于更新数据文件的随机写,日志的写入开销更小,能显著提升语句的执行性能,提高并发量),由此可见 redo log 是必不可少的。

redo log 是固定大小的,所以只能循环写,从头开始写,写到末尾就又回到开头,相当于一个环形。当日志写满了,就需要对旧的记录进行擦除,但在擦除之前,需要确保这些要被擦除记录对应在内存中的数据页都已经刷到磁盘中了。在 redo log 满了到擦除旧记录腾出新空间这段期间,是不能再接收新的更新请求,所以有可能会导致 MySQL 卡顿。(所以针对并发量大的系统,适当设置 redo log 的文件大小非常重要!!!)

2、回滚日志 undo log

undo log 顾名思义,主要就是提供了回滚的作用,但其还有另一个主要作用,就是多个行版本控制(MVCC),保证事务的原子性。在数据修改的流程中,会记录一条与当前操作相反的逻辑日志到 undo log 中(可以认为当 delete 一条记录时,undo log 中会记录一条对应的 insert 记录,反之亦然,当 update 一条记录时,它记录一条对应相反的 update 记录),如果因为某些原因导致事务异常失败了,可以借助该 undo log 进行回滚,保证事务的完整性,所以 undo log 也必不可少。

3、归档日志 binlog

binlog 在 MySQL 的 server 层产生,不属于任何引擎,主要记录用户对数据库操作的 SQL 语句(除了查询语句)。之所以将 binlog 称为归档日志,是因为 binlog 不会像 redo log 一样擦掉之前的记录循环写,而是一直记录(超过有效期才会被清理),如果超过单日志的最大值(默认 1G,可以通过变量 max_binlog_size 设置),则会新起一个文件继续记录。但由于日志可能是基于事务来记录的(如 InnoDB 表类型),而事务是绝对不可能也不应该跨文件记录的,如果正好 binlog 日志文件达到了最大值但事务还没有提交则不会切换新的文件记录,而是继续增大日志,所以 max_binlog_size 指定的值和实际的 binlog 日志大小不一定相等。

正是由于 binlog 有归档的作用,所以 binlog 主要用作主从同步和数据库基于时间点的还原。

那么回到刚才的问题,binlog 可以简化掉吗?这里需要分场景来看:

  1. 如果是主从模式下,binlog 是必须的,因为从库的数据同步依赖的就是 binlog;
  2. 如果是单机模式,并且不考虑数据库基于时间点的还原,binlog 就不是必须,因为有 redo log 就可以保证 crash-safe 能力了;但如果万一需要回滚到某个时间点的状态,这时候就无能为力,所以建议 binlog 还是一直开启;

根据上面对三个日志的详解,我们可以对这个问题进行解答:在主从模式下,三个日志都是必须的;在单机模式下,binlog 可以视情况而定,保险起见最好开启。

四、两阶段提交

问题:为什么 redo log 要分两步写,中间再穿插写 binlog 呢?

从上面可以看出,因为 redo log 影响主库的数据,binlog 影响从库的数据,所以 redo log 和 binlog 必须保持一致才能保证主从数据一致,这是前提。

相信很多有过开发经验的同学都知道分布式事务,这里的 redo log 和 binlog 其实就是很典型的分布式事务场景,因为两者本身就是两个独立的个体,要想保持一致,就必须使用分布式事务的解决方案来处理。而将 redo log 分成了两步,其实就是使用了两阶段提交协议(Two-phase Commit,2PC)。

下面对更新语句的执行流程进行简化,看一下 MySQL 的两阶段提交是如何实现的:

从图中可看出,事务的提交过程有两个阶段,就是将 redo log 的写入拆成了两个步骤:prepare 和 commit,中间再穿插写入 binlog。

如果这时候你很疑惑,为什么一定要用两阶段提交呢,如果不用两阶段提交会出现什么情况,比如先写 redo log,再写 binlog 或者先写 binlog,再写 redo log 不行吗?下面我们用反证法来进行论证。

我们继续用 update T set c=c+1 where id= 2 这个例子,假设 id= 2 这一条数据的 c 初始值为 0。那么在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于 redo log 已经写完了,系统重启后会通过 redo log 将数据恢复回来,所以恢复后这一行 c 的值是 1。但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,不管是现在的从库还是之后通过这份 binlog 还原临时库都没有这一次更新,c 的值还是 0,与原库的值不同。

同理,如果先写 binlog,再写 redo log,中途系统 crash 了,也会导致主从不一致,这里就不再详述。

所以将 redo log 分成两步写,即两阶段提交,才能保证 redo log 和 binlog 内容一致,从而保证主从数据一致。

两阶段提交虽然能够保证单事务两个日志的内容一致,但在多事务的情况下,却不能保证两者的提交顺序一致,比如下面这个例子,假设现在有 3 个事务同时提交:

T1 (--prepare--binlog---------------------commit)
T2 (-----prepare-----binlog----commit)
T3 (--------prepare-------binlog------commit)

解析:

redo log prepare 的顺序:T1 --》T2 --》T3
binlog 的写入顺序:T1 --》T2 --》T3
redo log commit 的顺序:T2 --》T3 --》T1

结论:由于 binlog 写入的顺序和 redo log 提交结束的顺序不一致,导致 binlog 和 redo log 所记录的事务提交结束的顺序不一样,最终导致的结果就是主从数据不一致。

因此,在两阶段提交的流程基础上,还需要加一个锁来保证提交的原子性,从而保证多事务的情况下,两个日志的提交顺序一致。所以在早期的 MySQL 版本中,通过使用 prepare_commit_mutex 锁来保证事务提交的顺序,在一个事务获取到锁时才能进入 prepare,一直到 commit 结束才能释放锁,下个事务才可以继续进行 prepare 操作。通过加锁虽然完美地解决了顺序一致性的问题,但在并发量较大的时候,就会导致对锁的争用,性能不佳。除了锁的争用会影响到性能之外,还有一个对性能影响更大的点,就是每个事务提交都会进行两次 fsync(写磁盘),一次是 redo log 落盘,另一次是 binlog 落盘。大家都知道,写磁盘是昂贵的操作,对于普通磁盘,每秒的 QPS 大概也就是几百。

五、组提交

问题:针对通过在两阶段提交中加锁控制事务提交顺序这种实现方式遇到的性能瓶颈问题,有没有更好的解决方案呢?

答案自然是有的,在 MySQL 5.6 就引入了 binlog 组提交,即 BLGC(Binary Log Group Commit)。binlog 组提交的基本思想是,引入队列机制保证 InnoDB commit 顺序与 binlog 落盘顺序一致,并将事务分组,组内的 binlog 刷盘动作交给一个事务进行,实现组提交目的。具体如图:

第一阶段(prepare 阶段):

持有 prepare_commit_mutex,并且 write/fsync redo log 到磁盘,设置为 prepared 状态,完成后就释放 prepare_commit_mutex,binlog 不作任何操作。

第二个阶段(commit 阶段):这里拆分成了三步,每一步的任务分配给一个专门的线程处理:

  1. Flush Stage(写入 binlog 缓存)

    ① 持有 Lock_log mutex [leader 持有,follower 等待]

    ② 获取队列中的一组 binlog(队列中的所有事务)

    ③ 写入 binlog 缓存

  2. Sync Stage(将 binlog 落盘)

    ①释放 Lock_log mutex,持有 Lock_sync mutex[leader 持有,follower 等待]

    ②将一组 binlog 落盘(fsync 动作,最耗时,假设 sync_binlog 为 1)。

  3. Commit Stage(InnoDB commit,清楚 undo 信息)

    ①释放 Lock_sync mutex,持有 Lock_commit mutex[leader 持有,follower 等待]

    ② 遍历队列中的事务,逐一进行 InnoDB commit

    ③ 释放 Lock_commit mutex

每个 Stage 都有自己的队列,队列中的第一个事务称为 leader,其他事务称为 follower,leader 控制着 follower 的行为。每个队列各自有 mutex 保护,队列之间是顺序的。只有 flush 完成后,才能进入到 sync 阶段的队列中;sync 完成后,才能进入到 commit 阶段的队列中。但是这三个阶段的作业是可以同时并发执行的,即当一组事务在进行 commit 阶段时,其他新事务可以进行 flush 阶段,实现了真正意义上的组提交,大幅度降低磁盘的 IOPS 消耗。

针对组提交为什么比两阶段提交加锁性能更好,简单做个总结:组提交虽然在每个队列中仍然保留了 prepare_commit_mutex 锁,但是锁的粒度变小了,变成了原来两阶段提交的 1 /4,所以锁的争用性也会大大降低;另外,组提交是批量刷盘,相比之前的单条记录都要刷盘,能大幅度降低磁盘的 IO 消耗。

六、数据恢复流程

问题:假设事务提交过程中,MySQL 进程突然奔溃,重启后是怎么保证数据不丢失的?

下图就是 MySQL 重启后,提供服务前会先做的事 — 恢复数据的流程:

对上图进行简单描述就是:奔溃重启后会检查 redo log 中是完整并且处于 prepare 状态的事务,然后根据 XID(事务 ID),从 binlog 中找到对应的事务,如果找不到,则回滚;找到并且事务完整则重新 commit redo log,完成事务的提交。

下面我们根据事务提交流程,在不同的阶段时刻,看看 MySQL 突然奔溃后,按照上述流程是如何恢复数据的。

  1. 时刻 A (刚在内存中更改完数据页,还没有开始写 redo log 的时候奔溃):

    因为内存中的脏页还没刷盘,也没有写 redo log 和 binlog,即这个事务还没有开始提交,所以奔溃恢复跟该事务没有关系;

  2. 时刻 B (正在写 redo log 或者已经写完 redo log 并且落盘后,处于 prepare 状态,还没有开始写 binlog 的时候奔溃):

    恢复后会判断 redo log 的事务是不是完整的,如果不是则根据 undo log 回滚;如果是完整的并且是 prepare 状态,则进一步判断对应的事务 binlog 是不是完整的,如果不完整则一样根据 undo log 进行回滚;

  3. 时刻 C (正在写 binlog 或者已经写完 binlog 并且落盘了,还没有开始 commit redo log 的时候奔溃):

    恢复后会跟时刻 B 一样,先检查 redo log 中是完整并且处于 prepare 状态的事务,然后判断对应的事务 binlog 是不是完整的,如果不完整则一样根据 undo log 回滚,完整则重新 commit redo log;

  4. 时刻 D (正在 commit redo log 或者事务已经提交完的时候,还没有反馈成功给客户端的时候奔溃):

    恢复后跟时刻 C 基本一样,都会对照 redo log 和 binlog 的事务完整性,来确认是回滚还是重新提交。

七、总结

至此对 MySQL 的 crash-safe 原理细节就基本讲完了,简单回顾一下:

  1. 首先简单介绍了 WAL 日志先行技术,包括它的定义、流程和作用。WAL 是大部分数据库系统实现一致性和持久性的通用设计模式。;
  2. 接着对 MySQL 的日志模块,redo log、undo log、binlog、两阶段提交和组提交都进行了详细介绍;
  3. 最后讲解了数据恢复流程,并从不同时刻加以验证。

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:Labs2020 联系。

正文完
 0