编者语
最近在看 Redis 持久化的相关原理,antirez 在他的 《Redis 持久化解密》(链接见文末)一文中说过,数据库中带有持久化的写操作分为如下几个步骤:
1.客户端发送写操作命令和数据;(数据在客户端内存)
2.服务端通过网络收到客户端发来的写操作和数据;(数据在服务端内存)
3.服务端修改内存中的数据,同时调用系统函数write进行操作,将数据往磁盘中写;(数据在服务端的系统内存缓冲区)
4.操作系统将缓冲区中的数据转移到磁盘控制器上(数据在磁盘缓存中)
5.磁盘控制器将数据写到磁盘的物理介质中(数据真正落到磁盘上)
对于持久化,我们需要分析下面两种情况时能否保证数据不丢失:
数据库异常崩溃
整个系统断电
对于 Redis 这种应用程序(数据库服务)来说,只要步骤3操作成功,即使数据库崩溃,数据也会有内核保证写入到磁盘中,不会发生丢失;但是如果整个系统断电这种场景来说,必须要等到步骤5操作成功,才可认为写操作是真正的持久化成功。
而从步骤3到步骤5中间会涉及到大量 Linux IO 的原理,特别是Page Cache 和 Buffer Cache 等缓存。我在网上搜索相关学习资料很久,发现本篇文章十分详尽,争取原作者同意,并且其博客最下面有协议,非商业使用可以随意署名转载,所以就转载过来,供大家一起学习。
文章比较长,所以我进行一部分删减,列出剩下内容中比较重要的知识点,大家可以选取自己感兴趣的部分进行阅读,节约大家的时间。
Linux IO 缓存体系,stdio和内核缓存的区别,Page Cache和Buffer Cache的区别。
Buffered IO、mmap(2)、Direct IO的区别。
Write Through和Write back两种缓存更新策略。
写在前边
在开始正式的讨论前,我先抛出几个问题:
谈到磁盘时,常说的 HDD 磁盘和 SSD 磁盘最大的区别是什么?这些差异会影响我们的系统设计吗?
单线程写文件有点慢,那多开几个线程一起写是不是可以加速呢?
write(2)函数成功返回了,数据就已经成功写入磁盘了吗?此时设备断电会有影响吗?会丢失数据吗?
write(2)调用是原子的吗?多线程写文件是否要对文件加锁?有没有例外,比如O_APPEND方式?
坊间传闻,mmap(2)的方式读文件比传统的方式要快,因为少一次拷贝。真是这样吗?为什么少一次拷贝?
如果你觉得这些问题都很简单,都能很明确的回答上来。那么很遗憾这篇文章不是为你准备的,你可以关掉网页去做其他更有意义的事情了。如果你觉得无法明确的回答这些问题,那么就耐心地读完这篇文章,相信不会浪费你的时间。
受限于个人时间和文章篇幅,部分议题如果我不能给出更好的解释或者已有专业和严谨的资料,就只会给出相关的参考文献的链接,请读者自行参阅。
言归正传,我们的讨论从存储器的层次结构开始。
存储器的金字塔结构
受限于存储介质的存取速率和成本,现代计算机的存储结构呈现为金字塔型[1]。越往塔顶,存取效率越高、但成本也越高,所以容量也就越小。得益于程序访问的局部性原理[2],这种节省成本的做法也能取得不俗的运行效率。从存储器的层次结构以及计算机对数据的处理方式来看,上层一般作为下层的 Cache 层来使用(广义上的 Cache )。比如寄存器缓存 CPU Cache 的数据,CPU Cache L1~L3层视具体实现彼此缓存或直接缓存内存的数据,而内存往往缓存来自本地磁盘的数据。
本文主要讨论磁盘IO操作,故只聚焦于 Local Disk 的访问特性和其与 DRAM 之间的数据交互。
无处不在的缓存
如图,当程序调用各类文件操作函数后,用户数据(User Data)到达磁盘(Disk)的流程如图所示[3]。图中描述了 Linux 下文件操作函数的层级关系和内存缓存层的存在位置。中间的黑色实线是用户态和内核态的分界线。
从上往下分析这张图,首先是C语言 stdio 库定义的相关文件操作函数,这些都是用户态实现的跨平台封装函数。stdio中实现的文件操作函数有自己的stdio buffer,这是在用户态实现的缓存。此处使用缓存的原因很简单——系统调用总是昂贵的。如果用户代码以较小的 size 不断的读或写文件的话,stdio 库将多次的读或者写操作通过buffer进行聚合是可以提高程序运行效率的。stdio库同时也支持fflush(3)函数来主动的刷新 buffer,主动的调用底层的系统调用立即更新 buffer 里的数据。特别地,setbuf(3)函数可以对 stdio 库的用户态 buffer 进行设置,甚至取消 buffer 的使用。
系统调用的read(2)/write(2)和真实的磁盘读写之间也存在一层 buffer,这里用术语Kernel buffer cache来指代这一层缓存。在Linux下,文件的缓存习惯性的称之为Page Cache,而更低一级的设备的缓存称之为Buffer Cache. 这两个概念很容易混淆,这里简单的介绍下概念上的区别:Page Cache用于缓存文件的内容,和文件系统比较相关。文件的内容需要映射到实际的物理磁盘,这种映射关系由文件系统来完成;Buffer Cache用于缓存存储设备块(比如磁盘扇区)的数据,而不关心是否有文件系统的存在(文件系统的元数据缓存在Buffer Cache中)。
综上,既然讨论 Linux 下的 IO 操作,自然是跳过 stdio 库的用户态这一堆东西,直接讨论系统调用层面的概念了。对 stdio 库的 IO 层有兴趣的同学可以自行去了解。从上文的描述中也介绍了文件的内核级缓存是保存在文件系统的Page Cache中的。所以后面的讨论基本上是讨论 IO 相关的系统调用和文件系统Page Cache的一些机制。
Linux内核中的IO栈
这一小节来看 Linux 内核的 IO 栈的结构。先上一张全貌图[4]:
由图可见,从系统调用的接口再往下,Linux下的 IO 栈致大致有三个层次:
文件系统层,以 write(2) 为例,内核拷贝了write(2)参数指定的用户态数据到文件系统 Cache 中,并适时向下层同步
块层,管理块设备的 IO 队列,对 IO 请求进行合并、排序(还记得操作系统课程学习过的IO调度算法吗?)
设备层,通过 DMA 与内存直接交互,完成数据和具体设备之间的交互
结合这个图,想想Linux系统编程里用到的Buffered IO、mmap(2)、Direct IO,这些机制怎么和 Linux IO 栈联系起来呢?上面的图有点复杂,我画一幅简图,把这些机制所在的位置添加进去:
这下一目了然了吧?传统的 Buffered IO 使用read(2)读取文件的过程什么样的?假设要去读一个冷文件(Cache中不存在),open(2)打开文件内核后建立了一系列的数据结构,接下来调用read(2),到达文件系统这一层,发现Page Cache中不存在该位置的磁盘映射,然后创建相应的Page Cache并和相关的扇区关联。然后请求继续到达块设备层,在 IO 队列里排队,接受一系列的调度后到达设备驱动层,此时一般使用DMA方式读取相应的磁盘扇区到 Cache 中,然后read(2)拷贝数据到用户提供的用户态buffer 中去(read(2)的参数指出的)
整个过程有几次拷贝?从磁盘到Page Cache算第一次的话,从Page Cache到用户态buffer就是第二次了。而mmap(2)做了什么?mmap(2)直接把Page Cache映射到了用户态的地址空间里了,所以mmap(2)的方式读文件是没有第二次拷贝过程的。那Direct IO做了什么?这个机制更狠,直接让用户态和块 IO 层对接,直接放弃Page Cache,从磁盘直接和用户态拷贝数据。好处是什么?写操作直接映射进程的buffer到磁盘扇区,以 DMA 的方式传输数据,减少了原本需要到Page Cache层的一次拷贝,提升了写的效率。对于读而言,第一次肯定也是快于传统的方式的,但是之后的读就不如传统方式了(当然也可以在用户态自己做 Cache,有些商用数据库就是这么做的)。
除了传统的 Buffered IO 可以比较自由的用偏移+长度的方式读写文件之外,mmap(2)和 Direct IO 均有数据按页对齐的要求,Direct IO 还限制读写必须是底层存储设备块大小的整数倍(甚至 Linux 2.4 还要求是文件系统逻辑块的整数倍)。所以接口越来越底层,换来表面上的效率提升的背后,需要在应用程序这一层做更多的事情。所以想用好这些高级特性,除了深刻理解其背后的机制之外,也要在系统设计上下一番功夫。
Page Cache 的同步
广义上 Cache 的同步方式有两种,即Write Through(写穿)和Write back(写回). 从名字上就能看出这两种方式都是从写操作的不同处理方式引出的概念(纯读的话就不存在 Cache 一致性了,不是么)。对应到 Linux 的Page Cache上所谓Write Through就是指write(2)操作将数据拷贝到Page Cache后立即和下层进行同步的写操作,完成下层的更新后才返回。而Write back正好相反,指的是写完Page Cache就可以返回了。Page Cache到下层的更新操作是异步进行的。
Linux下Buffered IO默认使用的是Write back机制,即文件操作的写只写到Page Cache就返回,之后Page Cache到磁盘的更新操作是异步进行的。Page Cache中被修改的内存页称之为脏页(Dirty Page),脏页在特定的时候被一个叫做 pdflush (Page Dirty Flush)的内核线程写入磁盘,写入的时机和条件如下:
当空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘,以便释放内存。
当脏页在内存中驻留时间超过一个特定的阈值时,内核必须将超时的脏页写回磁盘吧
用户进程调用sync(2)、fsync(2)、fdatasync(2)系统调用时,内核会执行相应的写回操作。
刷新策略由以下几个参数决定(数值单位均为1/100秒):
# flush每隔5秒执行一次 root@082caa3dfb1d / $ sysctl vm.dirty_writeback_centisecs vm.dirty_writeback_centisecs = 500 # 内存中驻留30秒以上的脏数据将由flush在下一次执行时写入磁盘 root@082caa3dfb1d / $ sysctl vm.dirty_expire_centisecs vm.dirty_expire_centisecs = 3000 # 若脏页占总物理内存10%以上,则触发flush把脏数据写回磁盘 root@082caa3dfb1d / $ sysctl vm.dirty_background_ratio vm.dirty_background_ratio = 10
默认是写回方式,如果想指定某个文件是写穿方式呢?即写操作的可靠性压倒效率的时候,能否做到呢?当然能,除了之前提到的fsync(2)之类的系统调用外,在open(2)打开文件时,传入O_SYNC这个 flag 即可实现。这里给篇参考文章[5],不再赘述(更好的选择是去读TLPI相关章节)。
文件读写遭遇断电时,数据还安全吗?相信你有自己的答案了。使用O_SYNC或者fsync(2)刷新文件就能保证安全吗?现代磁盘一般都内置了缓存,代码层面上也只能讲数据刷新到磁盘的缓存了。当数据已经进入到磁盘的高速缓存时断电了会怎么样?这个恐怕不能一概而论了。不过可以使用hdparm -W0命令关掉这个缓存,相应的,磁盘性能必然会降低。
写在最后
每天抽出不到半个小时,零零散散地写了一周,这是说是入门都有些谬赞了,只算是对Linux下的 IO 机制稍微深入的介绍了一点。无论如何,希望学习完 Linux 系统编程的同学,能继续的往下走一走,尝试理解系统调用背后隐含的机制和原理。探索的结果无所谓,重要的是探索的过程以及相关的学习经验和方法。前文提出的几个问题我并没有刻意去解答所有的,但是读到现在,不知道你自己能回答上几个了?
-关注我
推荐阅读
TCP/IP的底层队列
基于Redis和Lua的分布式限流
AbstractQueuedSynchronizer超详细原理解析
TCP拥塞控制算法简介
参考文献
[1] 图片引自《Computer Systems: A Programmer’s Perspective》Chapter 6 The Memory Hierarchy, 另可参考https://zh.wikipedia.org/wiki/%E5%AD%98%E5%82%A8%E5%99%A8%E5%B1%B1
[2] Locality of reference,https://en.wikipedia.org/wiki/Locality_of_reference
[3] 图片引自《The Linux Programming Interface》Chapter 13 FILE I/O BUFFERING
[4] Linux Storage Stack Diagram, https://www.thomas-krenn.com/en/wiki/Linux_Storage_Stack_Diagram
[5] O_DIRECT和O_SYNC详解, http://www.cnblogs.com/suzhou/p/5381738.html
[6] http://librelist.com/browser/usp.ruby/2013/6/5/o-append-atomicity/
[7] Coding for SSD, https://dirtysalt.github.io/coding-for-ssd.html
[8] fio作者Jens Axboe是Linux内核IO部分的maintainer,工具主页 http://freecode.com/projects/fio/
[9] How to benchmark disk, https://www.binarylane.com.au/support/solutions/articles/1000055889-how-to-benchmark-disk-i-o
[10] 深入Linux内核架构, (德)莫尔勒, 人民邮电出版社
redis 持久化解密 http://oldblog.antirez.com/post/redis-persistence-demystified.html