转载:http://www.uml.org.cn/sjjm/201909121.asp

经典 Partial Page Write 问题

介绍 Double Write 之前我们有必要了解 Partial Page Write(部分页失效)问题。

InnoDB 的 Page Size 一般是 16KB,其数据校验也是针对这 16KB 来计算的,将数据写入到磁盘是以 Page 为单位进行操作的。我们知道,由于文件系统对一次大数据页(例如 InnoDB 的 16KB)大多数情况下不是原子操作(文件系统 IO 的最小单位是 4K,磁盘 IO 的最小单位是 512 字节),这意味着如果服务器宕机了,可能只做了部分写入。16K 的数据,写入 4K 时,发生了系统断电 /os crash ,只有一部分写是成功的,这种情况下就是 Partial Page Write 问题。

有经验的 DBA 可能会想到,如果发生写失效,MySQL 可以根据 redo log 进行恢复。这是一个办法,但是必须清楚地认识到,redo log 中记录的是对页的物理修改,如偏移量 800,写 ‘aaaa’ 记录。如果这个页本身已经发生了损坏,再对其进行重做是没有意义的。MySQL 在恢复的过程中检查 Page 的 checksum,checksum 就是检查 Page 的最后事务号,发生 Partial Page Write 问题时,Page 已经损坏,找不到该 Page 中的事务号。在 InnoDB 看来,这样的数据页是无法通过 checksum 验证的,就无法恢复。即时我们强制让其通过验证,也无法从崩溃中恢复,因为当前 InnoDB 存在的一些日志类型,有些是逻辑操作,并不能做到幂等。

为了解决这个问题,InnoDB 实现了 Double Write Buffer,简单来说,就是在写数据页之前,先把这个数据页写到一块独立的物理文件位置(ibdata),然后再写到数据页。这样在宕机重启时,如果出现数据页损坏,那么在应用 redo log 之前,需要通过该页的副本来还原该页,然后再进行 redo log 重做,这就是 Double Write。Double Write 技术带给 InnoDB 存储引擎的是数据页的可靠性。

即 Double Write 的目的是为了保证数据写入的可靠性,避免 Partial Page Write 的情况。

Double Write 的参数设置:

  1. mysql> show variables like "%doublewrite%";
  2. +--------------------+-------+
  3. | Variable_name | Value |
  4. +--------------------+-------+
  5. | innodb_doublewrite | ON |
  6. +--------------------+-------+
  7. 1 row in set (0.00 sec)

Double Write 体系结构及工作流程

体系结构

Double Write 由两部分组成,一部分是 InnoDB 内存中的 Double Write Buffer,大小为 2M,另一部分是物理磁盘上 ibdata 系统表空间中大小为 2MB,共 128 个连续的 Page,既 2 个分区。其中 120 个用于批量写脏,另外 8 个用于 Single Page Flush。做区分的原因是批量刷脏是后台线程做的,不影响前台线程。而 Single Page Flush 是用户线程发起的,需要尽快的刷脏并替换出一个空闲页出来。

对于批量刷脏,每次找到一个可做 Flush 的 Page,对其持有 S lock,然后将该 Page 拷贝到 Double Write Buffer 中,当 Double Write Buffer 满后,将 Double Write Buffer 中的 Page 全部刷到 ibdata 中,注意这是同步写操作;然后再唤醒后台 IO 线程去写数据页。当后台 IO 线程完成写操作后,会去更新 Double Write Buffer 中的计数以腾出空间,释放 block 上的 S 锁,完成写入。

对于 Single Page Flush,则做的是同步写操作,在挑出一个可以刷脏的 Page 后,先加入到 Double Write Buffer 中,刷到 ibdata,然后写到用户表空间,完成后,会对该用户表空间做一次 fsync 操作。

Single Page Flush 在 Buffer Pool 中 Free Page 不够时触发,通常由前台线程发起,由于每次 Single Page Flush 都会导致一次 fsync 操作,在大并发负载下,如果大量线程去做 Flush,很显然会产生严重的性能下降。Percona 在 5.6 版本中做了优化,可以选择由后台线程 lru manager 来做预刷,避免用户线程陷入其中。

如果发生了极端情况(断电),InnoDB 再次启动后,发现了一个 Page 数据已经损坏,那么此时就可以从 Double Write 中进行数据恢复了。

工作流程

当一系列机制(main 函数触发、checkpoint 等)触发数据缓冲池中的脏页进行刷新到 data file 的时候,并不直接写磁盘,而是会通过 memcpy 函数将脏页先复制到内存中的 Double Write Buffer,之后通过 Double Write Buffer 再分两次,每次 1MB 顺序写入共享表空间(ibdata)的物理磁盘上。然后马上调用 fsync 函数,同步脏页进磁盘上。

由于在这个过程中,Double Write 页的存储时连续的,因此写入磁盘为顺序写,性能很高,完成 Double Write 后,再将脏页写入实际的各个表空间文件,这时写入就是离散的了。

各模块协作情况如下图(第一步应为脏页产生的 redo 记录 log buffer,然后 log buffer 写入 redo log file,为简化次要步骤直接连线表示):
InnoDB Double Write - 图1
查看 Double Write 工作情况,可以执行命令:

  1. mysql> show status like "%InnoDB_dblwr%";
  2. +----------------------------+------------+
  3. | Variable_name | Value |
  4. +----------------------------+------------+
  5. | Innodb_dblwr_pages_written |61932183 |
  6. | Innodb_dblwr_writes |15237891 |
  7. +----------------------------+------------+
  8. 2 rows in set (0.01 sec)

以上数据显示,Double Write 一共写了 61932183 个页,一共写了 15237891 次,从这组数据我们可以分析,之前讲过在开启 Double Write 后,每次脏页刷新必须要先写 Double Write,而 Double Write 存在于磁盘上的是两个连续的区,每个区由连续的页组成,一般情况下一个区最多有 64 个页,所以一次 IO 写入应该可以最多写 64 个页。而根据以上我这个系统 Innodb_dblwr_pages_written 与 Innodb_dblwr_writes 的比例来看,一次大概在 4 个页左右,远远还没到 64,所以从这个角度也可以看出,系统写入压力并不高。

如果操作系统在将页写入磁盘的过程中发送了崩溃,在恢复过程中,InnoDB 存储引擎可以从工序表空间中的 Double Write 中找到该页的副本,将其复制到表空间文件,再应用 redo log。

下面显示了一个由 Double Write 进行恢复的过程:

  1. 090924 11:36:32 mysqld restarted
  2. 090924 11:26:33 InnoDB: Database was not shut down normally!
  3. InnoDB: Starting crash recovery.
  4. InnoDB: Reading tablespace information from the .ibd files...
  5. InnoDB: Crash recovery may have faild for some .ibd files!
  6. InnoDB: Restoring possible half-written data pages from the doublewrite.
  7. InnoDB: buffer...

Double Write 的缺点

位于共享表空间上的 Double Write Buffer 实际上也是一个文件,引入了一次额外写的开销,每个数据页都被要求写两次。由于需要大量的 fsync 操作,所以它会降低 MySQL 的整体性能,但是并不会降低到原来的 50%。这主要是因为:

  • Double Write 是一个连续的存储空间,所以硬盘在写数据的时候是顺序写,而不是随机写,这样性能更高。
  • 将数据从 Double Write Buffer 写到真正的 Segment 中的时候,系统会自动合并连续空间刷新的方式,每次可以刷新多个 Page。

预计开启 Double Write 的性能降低 5% ~ 25%(IO Bound 场景下降的最厉害)。

Double Write 默认开启,参数 skip_innodb_doublewrite 虽然可以禁止使用 Double Write 功能,但还是强烈建议大家使用 Double Write。避免部分写失效问题,当然,如果你的数据表空间放在本身就提供了部分写失效防范机制的文件系统上,如 ZFS/FusionIO/DirectFS 文件系统,在这种情况下,就可以不开启 Double Write 了。

Double Write 在恢复的时候是如何工作的?

如果是写 Double Write Buffer 本身失败,那么这些数据不会被写到磁盘,InnoDB 此时会从磁盘载入原始的数据,然后通过 InnoDB 的事务日志来计算出正确的数据,重新写入到 Double Write Buffer。

如果 Double Write Buffer 写成功的话,但是写磁盘失败,InnoDB 就不用通过事务日志来计算了,而是直接用Buffer 的数据再写一遍。

如上图中显示,在恢复的时候,InnoDB 直接比较页面的 checksum,如果不对的话,InnoDB 存储引擎可以从共享表空间的 Double Write 中找到该页的一个最近的副本,将其复制到表空间文件,再应用 redo log,就完成了恢复过程。因为有副本所以也不担心表空间中数据页是否损坏,但 InnoDB 的恢复通常需要较长的时间。

MariaDB/MySQL/Facebook/Percona 5.7 的改进

MariaDB/MySQL 改进

MariaDB 使用参数 innodb_use_atomic_writes 来控制原子写行为,当打开该选项时,会使用 O_DIRECT 模式打表空间,通过 posix_fallocate 来扩展文件(而不是写 0 扩展),当在启动时检查到支持 atomic write 时,即使开启了 innodb_doublewrite,也会关闭掉。

Oracle MySQL 同样支持 FusionIO 的 Atomic Write 特性(Fusion-io Non-Volatile Memory (NVM) file system),对于支持原子写的文件系统,也会自动关闭 double write buffer。

Facebook 改进

实际上这不能算是改进,只是提供了一个新的选项。在现实场景中,宕机是非常低概率的事件。大部分情况下 dblwr 都是用不上的。但如果我们直接关闭 dblwr,如果真的发生例如掉电宕机了,我们需要知道哪些 Page 可能损坏了。

因此 Facebook MySQL 提供了一个选项,可以写 Page 之前,只将对应的 Page Number 写到 dblwr 中(而不是写全 Page),在崩溃恢复时,先读出记录在 dblwr 中的 Page Number,检查对应的数据页是否损坏,如果损坏了,那就需要从备库重新恢复该实例。

Percona 5.7 改进

Percona Server 的每个版本都对 InnoDB 的刷脏逻辑做了不少的优化,进入 5.7 版本也不例外。在官方 5.7 中已经实现了多个 Page Cleaner,我们可以把 Page Cleaner 配置成和 Buffer Pool Instance 的个数相同,可以更好的实现并行刷脏。

但是官方版本中,Page Cleaner 既要负责刷 Flush List,同时也要做 LRU Flush (但每个 Buffer Pool Instance 不超过 innodb_lru_scan_depth)。而这两部分任务是可以独立进行的。

因此 Percona Server 增加了多个 LRU Flush 线程,可以更高效的进行 LRU Flush,避免用户线程陷入 Single Page Flush 状态。每个 Buffer Pool Instance 拥有自己的 LRU Flush 线程和 Page Cleaner 线程。LRU Flush 基于当前 Free List 的长度进行自适应计算。 每个 LRU 线程负责自己的那个 Buffer pool。因此不同 LRU Flush 线程的繁忙程度可能是不一样的。

在解决上述问题后,Buffer Pool Instance 的并行效率大大的提升了。但是对于所有的刷脏操作,都需要走到Double Write Buffer。这意味着 dblwr 成为了新的瓶颈。为了解决这个问题,dblwr 进行了拆分,每个 Buffer Pool Instance 都有自己的 dblwr 区域。这样各个 LRU Flush 线程及 Page Cleaner 线程在做 Page Flush 时就不会相互间产生锁冲突,从而提升了系统的扩展性。

你可以通过参数来配置一个独立于 ibdata 之外的文件来存储 dblwr,文件被划分成多个区域,分区数为 Buffer Pool Instance 的个数,每个分区的大小为 2 * srv_doublewrite_batch_size,每个 batch size 默认配置为 120 个Page,其中一个用于刷 Flush List,一个用于刷 LRU。

如果 fast shutdown 设置为2,dblwr 文件在正常 shutdown 时会被删除掉,并在重启后重建。

作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/buapds 来源:殷建卫 - 架构笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。