MySQL如何保证数据不丢呢?

157 阅读16分钟

MySQL如何保证数据不丢呢?
redo log和binlog保证持久化到磁盘,就能确保 MySQL 异常重启后,数据可以恢复。
本文内容如下图所示:

MySQL如何保证数据不丢失.png

binlog

什么是binlog?

在MySQL中,binlog,主要用于记录所有对数据库所做的更改操作,包括数据定义语言(DDL)和数据操作语言(DML)语句。又被称为“二进制日志”。

binlog的写入机制

写入逻辑:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。

一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了 binlog cache 的保存问题。

系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。

事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。

可以看到,每个线程有自己 binlog cache,但是共用同一份 binlog 文件。

  • 图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。
  • 图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。

write 和 fsync 的时机,是由参数 sync_binlog 控制的:

  1. sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
  2. sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
  3. sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。

因此,在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。

但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。

相关问题

追问 1:MySQL 怎么知道 binlog 是完整的?

回答:一个事务的 binlog 是有完整格式的: statement 格式的 binlog,最后会有 COMMIT; row 格式的 binlog,最后会有一个 XID event。

另外,在 MySQL 5.6.2 版本以后,还引入了 binlog-checksum 参数,用来验证 binlog 内容的正确性。对于 binlog 日志由于磁盘原因,可能会在日志中间出错的情况,MySQL 可以通过校验 checksum 的结果来发现。所以,MySQL 还是有办法验证事务 binlog 的完 整性的。

追问 2:redo log 和 binlog 是怎么关联起来的?

回答:它们有一个共同的数据字段,叫 XID。崩溃恢复的时候,会按顺序扫描 redo log:

如果碰到既有 prepare、又有 commit 的 redo log,就直接提交; 如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应 的事务

追问 3:处于 prepare 阶段的 redo log 加上完整 binlog,重启就能恢 复,MySQL 为什么要这么设计?

回答:其实,这个问题跟数据与备份的一致性有关。在时刻 B, 也就是 binlog 写完以后 MySQL 发生崩溃,这时候 binlog 已经写入了,之后就会被从库 (或者用这个 binlog 恢复出来的库)使用。 所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。

追问 4:如果这样的话,为什么还要两阶段提交呢?干脆先 redo log 写 完,再写 binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是 一样的逻辑

回答:其实,两阶段提交是经典的分布式系统问题,并不是 MySQL 独有的。

如果必须要举一个场景,来说明这么做的必要性的话,那就是事务的持久性问题。 对于 InnoDB 引擎来说,如果 redo log 提交完成了,事务就不能回滚(如果这还允许回 滚,就可能覆盖掉别的事务的更新)。而如果 redo log 直接提交,然后 binlog 写入的时 候失败,InnoDB 又回滚不了,数据和 binlog 日志又不一致了。 两阶段提交就是为了给所有人一个机会,当每个人都说“我 ok”的时候,再一起提交。

追问 5:不引入两个日志,也就没有两阶段提交的必要了。只用 binlog 来 支持崩溃恢复,又能支持归档,不就可以了?

回答:不可以。 如果说历史原因的话,那就是 InnoDB 并不是 MySQL 的原生存储引擎。MySQL 的原生引 擎是 MyISAM,设计之初就有没有支持崩溃恢复。 InnoDB 在作为 MySQL 的插件加入 MySQL 引擎家族之前,就已经是一个提供了崩溃恢复 和事务支持的引擎了。 InnoDB 接入了 MySQL 后,发现既然 binlog 没有崩溃恢复的能力,那就用 InnoDB 原有 的 redo log 好了。

追问 6:那能不能反过来,只用 redo log,不要 binlog?

回答:如果只从崩溃恢复的角度来讲是可以的。可以把 binlog 关掉,这样就没有两阶段提交了,但系统依然是 crash-safe 的。 业界各个公司的使用场景的话,就会发现在正式的生产库上, binlog 都是开着的。

因为 binlog 有着 redo log 无法替代的功能。 一个是归档。redo log 是循环写,写到末尾是要回到开头继续写的。这样历史日志没法保 留,redo log 也就起不到归档的作用。 一个就是 MySQL 系统依赖于 binlog。binlog 作为 MySQL 一开始就有的功能,被用在了 很多地方。其中,MySQL 系统高可用的基础,就是 binlog 复制。

redo log

好了,到了给大家介绍redo log了**。**

redo log是什么?

Redo Log 是 MySQL 的 InnoDB 存储引擎中用于实现事务持久性和崩溃恢复的关键机制。它记录了对数据库页所做的所有更改,确保即使在系统崩溃的情况下,也能够通过重放这些日志条目来恢复未完成的事务,保证数据的一致性和完整性。

redo log 的写入机制

事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的。

redo log buffer 里面的内容,是不是每次生成后都要直接持久化到磁盘呢? 答案是,不需要。

如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。

事务还没提交的时候,redo log buffer 中的部分日志有没有可能被持久化到磁盘呢?

答案是,确实会有。

这个问题,要从 redo log 可能存在的三种状态说起。这三种状态,对应的就是图 2 中的三个颜色块。


这三种状态分别是:

  1. 存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分;
  2. 写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分;
  3. 持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分。

日志写到 redo log buffer 是很快的,wirte 到 page cache 也差不多,但是持久化到磁盘的速度就慢多了。

为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值:

  1. 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
  2. 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
  3. 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。

InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。

注意,事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。

实际上,除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中。

  1. 一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。 注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。
  2. 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。 假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。

从 MySQL 看到的 TPS 是每秒两万的话,每秒就会写四万次磁盘。但是,我用工具测试出来,磁盘能力也就两万左右,怎么能实现两万的 TPS?

首先说下日志逻辑序列号(log sequence number,LSN),LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。

LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。

一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。

在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。

写 binlog 是分成两步的:

  1. 先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
  2. 调用 fsync 持久化。

相关问题

追问 1:redo log 一般设置多大?

回答:redo log 太小的话,会导致很快就被写满,然后不得不强行刷 redo log,这样 WAL 机制的能力就发挥不出来了。 所以,如果是现在常见的几个 TB 的磁盘的话,就不要太小气了,直接将 redo log 设置为 4 个文件、每个文件 1GB 吧。

追问 2:正常运行中的实例,数据写入后的最终落盘,是从 redo log 更新 过来的还是从 buffer pool 更新过来的呢?

回答:这个问题其实问得非常好。这里涉及到了,“redo log 里面到底是什么”的问题。 实际上,redo log 并没有记录数据页的完整数据,所以它并没有能力自己去更新磁盘数据 页,也就不存在“数据最终落盘,是由 redo log 更新过去”的情况。 1. 如果是正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。 最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与 redo log 毫无关系。 2. 在崩溃恢复场景中,InnoDB 如果判断到一个数据页可能在崩溃恢复的时候丢失了更 新,就会将它读到内存,然后让 redo log 更新内存内容。更新完成后,内存页变成脏 页,就回到了第一种情况的状态。

追问 3:redo log buffer 是什么?是先修改内存,还是先写 redo log 文件?

redo log buffer 就是一块内存,用来先存 redo 日志的。也就是说,在执行第一个 insert 的时候,数据的内存被修改了,redo log buffer 也写入了日志。 但是,真正把日志写到 redo log 文件(文件名是 ib_logfile+ 数字),是在执行 commit 语句的时候做的

WAL机制

WAL机制是什么?

WAL是一种确保数据库系统即使在崩溃后也能保持ACID属性的技术,尤其是在保证数据持久性和一致性方面。

WAL机制的应用

WAL机制在InnoDB中的应用为redo log、undo log、Checkpoints

1. Redo Log (重做日志)
  • 作用:记录所有对数据库页(Page)所做的更改。这些更改包括插入、更新和删除等操作。

  • 位置:存储在磁盘上的一个或多个文件中,称为重做日志文件(通常位于 ib_logfile* 文件中)。

  • 工作原理

    • 当事务对数据进行修改时,首先将修改信息写入重做日志缓冲区(Redo Log Buffer),然后异步地刷新到磁盘上的重做日志文件。
    • 数据页本身的修改可能会被延迟,直到检查点(Checkpoint)过程中才真正写回到磁盘上的数据文件。
    • 如果数据库意外崩溃,在恢复期间可以通过重做日志重放未完成的事务,确保所有已提交的更改都被应用到数据文件上。
2. Undo Log (撤销日志)
  • 作用:用于实现多版本并发控制(MVCC)和事务回滚。

  • 工作原理

    • 在事务开始之前,创建一个新的撤销日志条目。
    • 对于每个修改的数据行,生成相应的旧值副本,并将其记录在撤销日志中。
    • 如果事务需要回滚,则使用撤销日志中的信息来恢复原始状态。
    • 撤销日志还允许读取操作查看不同时间点的数据快照,从而支持一致性的非锁定读取。
3. Checkpoints (检查点)
  • 作用:减少数据库重启后的恢复时间。

  • 工作原理

    • 定期触发检查点操作,将内存中的脏页(Dirty Pages,即已经被修改但尚未写入磁盘的数据页)刷新到磁盘。
    • 更新重做日志中的LSN(Log Sequence Number),标记哪些部分的日志已经对应到了磁盘上的数据文件。
    • 这样,在恢复过程中只需要处理自最后一个检查点以来的日志条目即可。

WAL机制有什么用?

WAL机制可以提高性能、增强系统的可靠性,并能够简化恢复过程。

  • 提高性能:通过先写日志再写数据的方式,减少了磁盘I/O次数,因为日志是顺序写入而数据页可能是随机访问。
  • 增强可靠性:即使系统发生故障,也可以通过日志重放确保数据的一致性和完整性。
  • 简化恢复过程:仅需根据最近的检查点和后续的日志条目来进行恢复,缩短了重启时间。

WAL 机制是减少磁盘写,可是每次提交事务都要写 redo log 和 binlog,这磁盘读写次数也没变少呀?

WAL 机制主要得益于两个方面:

  1. redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
  2. 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。

如果你的 MySQL 现在出现了性能瓶颈,而且瓶颈在 IO 上,可以通过哪些方法来提升性能呢?

针对这个问题,可以考虑以下三种方法:

  1. 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
  2. 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。
  3. 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。

不建议你把 innodb_flush_log_at_trx_commit 设置成 0。因为把这个参数设置成 0,表示 redo log 只保存在内存中,这样的话 MySQL 本身异常重启也会丢数据,风险太大。而 redo log 写到文件系统的 page cache 的速度也是很快的,所以将这个参数设置成 2 跟设置成 0 其实性能差不多,但这样做 MySQL 异常重启时就不会丢数据了,相比之下风险会更小

延伸问题

问题 1: 执行一个 update 语句以后,我再去执行 hexdump 命令直接查看 ibd 文件内容,为什么没有看到数据有改变呢?

回答:这可能是因为 WAL 机制的原因。update 语句执行完成后,InnoDB 只保证写完了 redo log、内存,可能还没来得及将数据写到磁盘。

问题 2: 为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?

回答:MySQL 这么设计的主要原因是,binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。

而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。

问题 3: 事务执行期间,还没到提交阶段,如果发生 crash 的话,redo log 肯定丢了,这会不会导致主备不一致呢?

回答:不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。

问题 4: 如果 binlog 写完盘以后发生 crash,这时候还没给客户端答复就重启了。等客户端再重连进来,发现事务已经提交成功了,这是不是 bug?

回答:不是。 整个事务都提交成功了,redo log commit 完成了,备库也收到 binlog 并执行了。但是主库和客户端网络断开了,导致事务成功的包返回不回去,这时候客户端也会收到“网络断开”的异常。这种算是事务成功的,不能认为是 bug。

实际上数据库的 crash-safe 保证的是:

  1. 如果客户端收到事务成功的消息,事务就一定持久化了;
  2. 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
  3. 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。