1 引言
如果让你来实现一下 MySQL 持久化的功能,你准备如何实现?如果不考虑性能,接口完全使用同步机制实现,好像也不会出现什么问题,可是不考虑性能是不现实的,IO操作多么耗时,每次写磁盘?那 MySQL 恐怕完全无法支持企业级应用。
考虑加缓存,写磁盘之前先写内存,然后异步持久化,由此带来最棘手的问题,如何保证数据一致性?
我们要保证的是,即使数据持久化变为异步操作,在服务器发生宕机、断电等意外情况时,依旧能够将数据正确的持久化——已写入内存的数据不会因为意外情况导致未持久化(数据丢失)、异步持久化数据的过程保证原子性,要么全部持久化成功,要么全部失败,不能写入不完整的数据(极端情况下一个数据由 128 字节组成,但只持久化了 64 字节)。
来看 MySQL 如何提升性能、并解决数据一致性的问题。
2 直接写磁盘效率低下解决方案
2.1 InnoDB buffer pool的引入
不同的存储引擎有不同的实现,这里来看 InnoDB 是如何增加缓存层的。
MySQL InnoDB Buffer Pool,MySQL InnoDB 缓冲池。里面缓存着大量数据(数据页),使 CPU 读取或写入数据时,不直接和低速的磁盘打交道,直接和缓冲区进行交互,从而解决了因为磁盘性能慢导致的数据库性能差的问题。
数据页:InnoDB 中,数据管理的最小单位为页,默认是 16KB。详见:计算机存储术语:扇区,磁盘块,页。
InnoDB buffer pool详解:MySQL–如何加速读写速度?详解Buffer Pool。
2.2 数据一致性的保障
在了解了 buffer pool 之后,要保证数据一致性,则要进行刷脏。如果每次写操作都要进行同步刷脏,则性能依旧会很差,因此一般都会在一定时机进行批量刷脏(即异步批量刷脏)。这时,要保证的是如何在宕机、断电的情况下,脏页依旧能够正确刷入磁盘,即InnoDB 的刷脏策略是如何实现的?
2.2.1 Write-Ahead Log机制
InnoDB 刷脏时采用的是WAL(Write Ahead Log)机制,即先写日志,再写入磁盘。InnoDB 通过 redo log 日志实现了 WAL,让 MySQL 拥有了崩溃恢复能力。一般来说,任何要实现数据持久化的系统,都需要采用 Write-Ahead Log 机制。
WAL机制解决了如下问题:
- 数据持久化不受影响(日志本身就保存在磁盘上,写日志成功就意味着数据已经持久化)
- 提高了写性能
- 避免了在发生宕机、断点等情况下,给磁盘中写入不完整数据情况的发生
关于顺序IO和随机IO我在这里提一嘴,感觉网上说的都不是很清晰:
磁盘的默认行为是随机IO,顺序 IO 需要应用程序或者文件系统进行控制,产生随机 IO 的原因是磁盘上的数据随时在变化(增、删),这样会导致磁盘产生空间碎片(本来1,2,3是在同一磁道顺序存储,但删除了 2 后,这部分空间就会被释放,然后可能写入其他数据),随着时间的推移,原本的顺序 IO 就会变成随机 IO。
我们说的写日志,如果不提前指定日志的大小,告诉磁盘一次性分配多大的连续空间,即使我们是追加写日志,磁盘实际还是在做随机 IO。
数据库中的数据本身是无组织的,伴随着增删,最终会演变为随机 IO,而 B+ 树索引是有结构和一定顺序的数据,因此可以“粗暴”的理解为往磁盘上读写索引是顺序 IO。
其实顺序 IO 比随机 IO 快已经是好几年前的说法了,目前由于硬件的提升,像 SSD 等硬盘的出现,随机 IO 已经不比顺序 IO慢多少了。
最后,Java 中有专门控制顺序 IO 的 API,感兴趣的可以了解下。
1.对于第二点的说明——redo log如何提升写性能?
随机写转顺序写:如果没有 redo log,则脏页的刷新,从内存刷至磁盘完全是随机IO,而redo log中记录的是事务的更新内容(好比将主键id=1的数据行,其age字段改为0,是一种行为日志),而非真正的数据,因此,通过 redo log 可将随机的脏页写入变成顺序的日志刷盘,真正的脏页刷新流程由后台线程去做。
更详细的解释可参考:顺序io和随机io分别在什么情况下出现?为什么日志是顺序io,数据是随机io?- 白竹蓝的回答。
2.对于第三点的说明——redo log如何保证数据一致性?
如果不考虑 bin log,我们可以大致理解为 MySQL 写数据的流程是这样的:buffer pool 中更新好数据后,同步写 redo log,redo log 写成功后,当前事务才能提交成功,否则事务失败。剩下的工作,redo log 刷脏,交给异步线程去做即可。由于 redo log 的写入是同步操作,因此 buffer pool 同步至 redo log 保证了数据一致性。由于 redo log 本身已经落盘,因此不管发生宕机还是断电,MySQL 都有能力从上次消费 redo log 的地方开始接着消费,从而保证了数据的最终一致性(不会发生数据丢失)。
但事实是,企业级应用中,MySQL 一般都是主从架构,由于 bin log 的存在,因此 redo
log 采用了两阶段提交,既保证了主从架构的数据一致性,也保证了本机的 crash-safe,关于 redo log 的两阶段提交,详见MySQL 为什么需要两阶段提交?。
3 详解 redo log
接下来系统的学习一下 redo log。
3.1 redo log简介
InnoDB 引擎独有的日志模块,redo log 通常是物理日志,记录的是数据页的物理修改。
3.2 redo log作用
- 提高 MySQL 写操作效率——直接写磁盘是随机写,写 redo log 是顺序写
- 防止在发生故障的时间点,尚有脏页未写入磁盘,在重启 MySQL 服务的时候,根据 redo log 进行重做,从而达到事务的持久性这一特性
3.3 需要什么样的redo log
redo log 的维护增加了一份写盘数据,写盘时间会直接影响系统吞吐。显而易见,redo log 的数据量要尽量少。其次,系统崩溃总是发生在始料未及的时候,当 MySQL 重启时,系统并不知道 redo log 中哪些 Page 已经落盘,因此 redo 操作要保证幂等。最后,为了便于通过并发的方式加快重启恢复速度,redo 应该是基于 Page 的,即一个 redo 只涉及一个 Page 的修改。
物理日志 vs 逻辑日志
物理日志(physical log)占用一片连续的磁盘空间。其主要目的是为系统进行快速恢复提供原始数据映像。MySQL 中的物理日志更偏向于记录了在某个数据页上做了什么修改。
逻辑日志(logical logs)是由若干块独立的磁盘空间构成,而每一块都是连续的磁盘空间。MySQL 中的逻辑日志更偏向于记录的是一条 SQL 语句的原始逻辑,例如给某一个字段 +1。
3.4 redo log buffer
在了解 redo log 之前,首先要认识一下 redo log buffer。
redo log 包括两部分:一是内存中的重做日志缓冲(redo log buffer),该部分日志是易失的;二是磁盘上的重做日志文件(redo log file),该部分日志是持久的。
注:下文中所述的“刷到磁盘上”都指刷到 redo log file 中。
那么为什么会加入 redo log buffer 呢?
在概念上,innodb 通过 force log at commit 机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的 redo log file 中进行持久化。
以一个 update 操作为例,来了解 redo log 的更新流程:
综上,在事务提交前就要将 redo log 的内容写入磁盘,并且由于 redo log 记录的是数据库物理页的变化,因此 redo log 文件中一个事务可能有多条记录,最后一个提交的事务记录会覆盖所有未提交的事务记录。
例如事务 T1,可能在 redo log 中记录了 T1-1, T1-2, T1-3, T1*
共 4 个操作,其中 T1* 表示最后提交时的日志记录,所以对应数据页的最终状态是 T1* 对应的操作结果。
如果没有 redo log buffer,每次产生的事务记录都要刷到磁盘上,便会造成磁盘 IO 升高,性能降低,并且也不利于大事务的运行。
为了确保每次事务提交后 redo log buffer 中的内容都能写入到 redo log file 中,InnoDB 存储引擎会调用一次操作系统的 fsync 操作(即 fsync() 系统调用)。因为 MySQL 是工作在用户空间的,因此 redo log buffer 处于用户空间的内存中,要写入到磁盘上的 redo log file 中,中间还要经过操作系统内核空间的 os buffer,调用 fsync() 的作用就是将 OS buffer 中的日志刷到磁盘上的 redo log file 中。
从 redo log buffer 写日志到磁盘的 redo log file 中,过程如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NY26tQyV-1685706808344)(//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/04d854f862cf4edba91f332a1901c8b0~tplv-k3u1fbpfcp-zoom-1.image)]
之所以要经过一层 OS buffer,是因为 open 日志文件的时候,open 没有使用 O_DIRECT 标志位。该标志位意味着绕过操作系统层的 OS buffer,IO 直接写到底层存储设备。不使用该标志位意味着将日志进行缓冲,缓冲到了一定容量,或者显式调用 fsync() 才会将缓冲中的内容刷到存储设备。比如写 abcde,不使用 O_DIRECT 将只发起一次系统调用,使用 O_DIRECT 将发起 5 次系统调用。
MySQL 支持用户自定义在 commit 时如何将 redo log buffer 中的日志刷 redo log file 中。这种控制通过变量 innodb_flush_log_at_trx_commit 的值来决定。该变量有 3 种值:0、1、2,默认为 1。但注意,这个变量只是控制 commit 动作是否刷新 redo log buffer 到磁盘。
-
当设置为 1 的时候,事务每次提交时都会将 redo log buffer 中的日志写入 os buffer 并调用 fsync() 刷到 redo log file 中(同步)。这种方式即使系统崩溃也不会丢失任何数据,但是因为每次提交都要写入磁盘,IO 的性能较差
-
当设置为 0 的时候,事务提交时不会将 redo log buffer 中的日志写入到 os buffer,而是每秒写入 os buffer 并调用 fsync() 写入到 redo log file 中,也就是说设置为 0 时是(大约)每秒刷新写入到磁盘中的。这种情况下当 MySQL 进程崩溃时,便会丢失 1 秒钟的数据
-
当设置为 2 的时候,每次提交都写入到 os buffer,然后每秒调用 fsync() 将 os buffer 中的日志写入到 redo log file。这种情况下只有当操作系统崩溃或者系统掉电,才会丢失 1 秒钟的数据
上面说到的“最后 1s”并不是绝对的,有时会丢失更多数据。有时由于调度问题,每秒刷写(once-per-second flushing) 并不能保证 100% 执行。对于一些数据一致性和完整性要求不高的应用,配置为 2 就足够了,如果为了最高性能,可以设置为 0;有些应用,如支付服务,对一致性和完整性要求很高,所以即使最慢,也要设置为 1。