目录

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。

/images/mysql/MySQL45%E8%AE%B2/binlog_writer.png

整个写入的过程如上图,可以看出:

  1. 每个线程有自己 binlog cache,但是共用同一份 binlog 文件。
  2. write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快
  3. fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS

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

  1. write 和 fsync 的时机,是由参数 sync_binlog 控制的:
  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 日志。

2. redolog 写入机制

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

  1. redo log buffer 里面的内容,每次生成后不要要直接持久化到磁盘,如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。
  2. 事务还没提交的时候,redo log buffer 中的部分日志可能被持久化到磁盘

2.1 redolog 的三种状态

/images/mysql/MySQL45%E8%AE%B2/redolog_state.png

redolog 有三种状态:

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

innodb_flush_log_at_trx_commit 控制了 redo log 的写入策略:

  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 里的日志一起持久化到磁盘。

我们介绍两阶段提交的时候说过,时序上 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。

/images/mysql/MySQL45%E8%AE%B2/lsn.png

从图中可以看到

  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 有一个很有趣的优化:拖时间。在介绍两阶段提交的时候,我曾经给你画了一个图,现在我把它截过来。

/images/mysql/MySQL45%E8%AE%B2/log_fync.png

我把“写 binlog”当成一个动作。但实际上,写 binlog 是分成两步的:

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

为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。也就是说,上面的图变成了这样:

/images/mysql/MySQL45%E8%AE%B2/log_fync_waiter.png

这样,binlog 也可以组提交了。在执行图 5 中第 4 步把 binlog fsync 到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少 IOPS 的消耗。

不过通常情况下第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。

如果你想提升 binlog 组提交的效果,可以通过设置一下两个参数实现:

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

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

现在你就能理解了,WAL 机制主要得益于两个方面:

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

4. MySQL 的 IO 性能优化

如果你的 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 异常重启时就不会丢数据了,相比之下风险会更小。

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 保证的是:

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

6. 双非 “1” 配置

存在下列场景时,线上生产库会设置成“非双 1”:

  1. 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”
  2. 备库延迟,为了让备库尽快赶上主库
  3. 用备份恢复主库的副本,应用 binlog 的过程,这个跟上一种场景类似
  4. 批量导入数据的时候

一般情况下,把生产库改成“非双 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)

1
2
3
4
5
6
7
graph LR
    subgraph redo_log_buffer
        A1[LSN=1000: TX1 UPDATE a=10]
        A2[LSN=1010: TX2 UPDATE b=20]
        A3[LSN=1020: TX1 UPDATE a=20]
    end
    D[磁盘: 空]

2. TX2 prepare 阶段 (t4)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
graph LR
    subgraph redo_log_buffer
        A1[LSN=1000: TX1 U1]
        A2[LSN=1010: TX2 U2]
        A3[LSN=1020: TX1 U3]
        A4[LSN=1030: TX2 PREPARE]  /* 添加 prepare 记录 */
    end
    
    subgraph 磁盘(prepare刷盘后)
        B1[LSN=1000: TX1 U1]
        B2[LSN=1010: TX2 U2]
        B3[LSN=1030: TX2 PREPARE]  /* 仅刷 prepare 记录 */
    end

关键操作:

  1. 追加 prepare 记录:LSN=1030 (Type=0x22, XID=TX2)
  2. 刷盘策略:
    • 默认 innodb_flush_log_at_trx_commit=1 时强制刷盘
    • 刷盘内容:仅新追加的 prepare 记录(LSN=1030)
    • 不包含 TX2 的数据修改(1010) - 这些可能已提前刷入

3. TX1 prepare 和 TX2 commit (t5)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
graph LR
    subgraph redo_log_buffer
        A1[LSN=1000: TX1 U1]
        A2[LSN=1010: TX2 U2]
        A3[LSN=1020: TX1 U3]
        A4[LSN=1030: TX2 PREPARE]
        A5[LSN=1040: TX1 PREPARE]  /* TX1 prepare */
        A6[LSN=1050: TX2 COMMIT]   /* TX2 commit */
    end
    
    subgraph 磁盘(t5后)
        B1[LSN=1000: TX1 U1]
        B2[LSN=1010: TX2 U2]
        B3[LSN=1030: TX2 PREPARE]
        B4[LSN=1050: TX2 COMMIT]   /* 新刷 commit 记录 */
    end

关键操作:

  1. TX1 prepare:
    • 追加 LSN=1040: MLOG_PREPARE(0x22, TX1)
  2. TX2 commit:
    • 追加 LSN=1050: MLOG_COMMIT(0x23, TX2)
    • 强制刷盘范围:LSN=1050(只刷 commit 记录)
    • 不刷 TX1 的 prepare 记录(1040) - 它还在 buffer 中

4. TX1 commit (t6)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
graph LR
    subgraph redo_log_buffer
        A1[LSN=1000: TX1 U1]
        A2[LSN=1010: TX2 U2]
        A3[LSN=1020: TX1 U3]
        A4[LSN=1030: TX2 PREPARE]
        A5[LSN=1040: TX1 PREPARE] 
        A6[LSN=1050: TX2 COMMIT]
        A7[LSN=1060: TX1 COMMIT]  /* TX1 commit */
    end
    
    subgraph 磁盘(最终)
        B1[LSN=1000: TX1 U1]
        B2[LSN=1010: TX2 U2]
        B3[LSN=1020: TX1 U3]      /* U3在t6刷盘 */
        B4[LSN=1030: TX2 PREPARE]
        B5[LSN=1050: TX2 COMMIT]
        B6[LSN=1060: TX1 COMMIT]  /* TX1的commit记录 */
    end

关键操作:

  1. 追加 LSN=1060: MLOG_COMMIT(0x23, TX1)
  2. 刷盘范围: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 记录都是独立写入,不修改已有日志