2 MySQL 如何保证数据不丢失
redo log,bin log 的写入流程
前面我们介绍了 WAL 机制,得到的结论是:只要 redo log 和 binlog 保证持久化到磁盘,就能确保 MySQL 异常重启后,数据可以恢复。今天,我们就再一起看看 MySQL 写入 binlog 和 redo log 的流程,看看 MySQL 是如何保证数据不丢失的。
1. binlog 写入机制
binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。
系统给 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
控制的:
- write 和 fsync 的时机,是由参数 sync_binlog 控制的:
- sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
- sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。
在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。
但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。
2. redolog 写入机制
事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的:
- redo log buffer 里面的内容,每次生成后不要要直接持久化到磁盘,如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。
- 事务还没提交的时候,redo log buffer 中的部分日志可能被持久化到磁盘
2.1 redolog 的三种状态
redolog 有三种状态:
- 存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分;
- 写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分
- 持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分,持久化到磁盘相对于写入 buffer 和 page 要慢的多
innodb_flush_log_at_trx_commit 控制了 redo log 的写入策略:
- 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
- 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
- 设置为 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 写入到磁盘中。
- 一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。
- 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。
我们介绍两阶段提交的时候说过,时序上 redo log 先 prepare, 再写 binlog,最后再把 redo log commit。
如果把 innodb_flush_log_at_trx_commit 设置成 1,那么 redo log 在 prepare 阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于 prepare 的 redo log,再加上 binlog 来恢复的。
每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB 就认为 redo log 在 commit 的时候就不需要 fsync 了,只会 write 到文件系统的 page cache 中就够了。
通常我们说 MySQL 的“双 1”配置,指的就是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。
3. 组提交机制
3.1 LSN
日志逻辑序列号(log sequence number,LSN)是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。
如图 3 所示,是三个并发事务 (trx1, trx2, trx3) 在 prepare 阶段,都写完 redo log buffer,持久化到磁盘的过程,对应的 LSN 分别是 50、120 和 160。
从图中可以看到
- trx1 是第一个到达的,会被选为这组的 leader;
- 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
- trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
- 这时候 trx2 和 trx3 就可以直接返回了。
以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间。在介绍两阶段提交的时候,我曾经给你画了一个图,现在我把它截过来。
我把“写 binlog”当成一个动作。但实际上,写 binlog 是分成两步的:
- 先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
- 调用 fsync 持久化。
为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。也就是说,上面的图变成了这样:
这样,binlog 也可以组提交了。在执行图 5 中第 4 步把 binlog fsync 到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少 IOPS 的消耗。
不过通常情况下第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。
如果你想提升 binlog 组提交的效果,可以通过设置一下两个参数实现:
- 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 也无效了。
现在你就能理解了,WAL 机制主要得益于两个方面:
- redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
- 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。
4. MySQL 的 IO 性能优化
如果你的 MySQL 现在出现了性能瓶颈,而且瓶颈在 IO 上,可以通过哪些方法来提升性能呢?
- 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
- 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。
- 将 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 异常重启时就不会丢数据了,相比之下风险会更小。
5. crash-safe 保证
为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?MySQL 这么设计的主要原因是,binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。
事务执行期间,还没到提交阶段,如果发生 crash 的话,redo log 肯定丢了,这会不会导致主备不一致呢?不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。binlog 在 binlog cache 不够时也只会写入临时文件中,而不会持久化 binlog file 中。
数据库的 crash-safe 保证的是:
- 如果客户端收到事务成功的消息,事务就一定持久化了;
- 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
- 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。
6. 双非 “1” 配置
存在下列场景时,线上生产库会设置成“非双 1”:
- 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”
- 备库延迟,为了让备库尽快赶上主库
- 用备份恢复主库的副本,应用 binlog 的过程,这个跟上一种场景类似
- 批量导入数据的时候
一般情况下,把生产库改成“非双 1”配置,是设置 innodb_flush_logs_at_trx_commit=2、sync_binlog=1000。
7. redo log 更新的流程
下面我将详细分析两个事务(TX1 和 TX2)并发操作时,在 redo log 中区分 prepare 和 commit 阶段的全过程。考虑以下交叉执行场景,使用 MySQL 标准的两阶段提交(2PC)流程:
场景设定
时间点 | 事务 TX1 | 事务 TX2 | 全局 LSN |
---|---|---|---|
t1 | BEGIN; UPDATE t1 SET a=10 | - | 1000 |
t2 | - | BEGIN; UPDATE t2 SET b=20 | 1010 |
t3 | UPDATE t1 SET a=20 | - | 1020 |
t4 | - | PREPARE (prepare阶段) | 1030 |
t5 | PREPARE (prepare阶段) | COMMIT (commit阶段) | 1040/1050 |
t6 | COMMIT (commit阶段) | - | 1060 |
7.1 redo log 更新过程
1. 事务操作阶段 (t1-t3)
|
|
2. TX2 prepare 阶段 (t4)
|
|
关键操作:
- 追加 prepare 记录:
LSN=1030 (Type=0x22, XID=TX2)
- 刷盘策略:
- 默认
innodb_flush_log_at_trx_commit=1
时强制刷盘 - 刷盘内容:仅新追加的 prepare 记录(LSN=1030)
- 不包含 TX2 的数据修改(1010) - 这些可能已提前刷入
- 默认
3. TX1 prepare 和 TX2 commit (t5)
|
|
关键操作:
- TX1 prepare:
- 追加
LSN=1040: MLOG_PREPARE(0x22, TX1)
- 追加
- TX2 commit:
- 追加
LSN=1050: MLOG_COMMIT(0x23, TX2)
- 强制刷盘范围:LSN=1050(只刷 commit 记录)
- 不刷 TX1 的 prepare 记录(1040) - 它还在 buffer 中
- 追加
4. TX1 commit (t6)
|
|
关键操作:
- 追加
LSN=1060: MLOG_COMMIT(0x23, TX1)
- 刷盘范围:LSN=1020(U3)+1040(prepare)+1060(commit)
- 因为 1020-1060 之间还有未刷盘记录
磁盘最终物理结构
LSN | 类型 | 内容 | 事务 | 阶段 |
---|---|---|---|---|
1000 | 0x1A | UPDATE t1 a=10 | TX1 | 操作记录 |
1010 | 0x1A | UPDATE t2 b=20 | TX2 | 操作记录 |
1020 | 0x1A | UPDATE t1 a=20 | TX1 | 操作记录 |
1030 | 0x22 | PREPARE | TX2 | prepare |
1050 | 0x23 | COMMIT | TX2 | commit |
1060 | 0x23 | COMMIT | TX1 | commit |
注意:
- TX1 的 prepare 记录(1040) 未刷盘,因为被后续操作覆盖
- prepare 和 commit 记录都是独立写入,不修改已有日志