3.2.3 缓存技术
文件系统的缓存(Cache)的作用主要用来解决磁盘访问速度慢的问题。缓存技术是指在内存中存储文件系统的部分数据和元数据而提升文件系统性能的技术。由于内存的访问延时是机械硬盘访问延时的十万分之一(见图3-30,以寄存器为基准单位1s),因此采用缓存技术可以大幅提升文件系统的性能。文件系统缓存从读和写两个角度来解决问题,并且应用在多个领域。
图3-30 存储性能金字塔
文件系统缓存的原理主要还是基于数据访问的时间局部性和空间局部性特性。时间局部性和空间局部性是应用访问数据非常常见的特性。所谓时间局部性就是如果一块数据之前被访问过,那么最近很有可能会被再次访问。具体的实例就是文本编辑器,在写代码或写文档的过程中,通常会对一个某一个区域进行不断的修改。空间局部性则是指在访问某一个区域之后,通常会访问临近的区域。比如,视频文件通常是连续播放的,当前访问某一个区域后,紧接着就是访问后面区域的内容。
以Linux文件系统为例,在文件系统初始化时会创建一个非常大的用于管理inode的哈希表。哈希表的大小与系统内存的大小相关,对于2GB左右的内存,哈希表的大小有百万个,对于服务器等大内存的计算机,该哈希表的大小可达千万个甚至上亿个。因此,当打开文件之后,文件对应的inode就会缓存在该哈希表中。这样,当再次访问该文件时就不需要从磁盘读取inode的数据,而是直接从内存读取inode的数据,其访问性能得到大幅提升。
还有一个应用是对用户数据的缓存,这里包含读缓存和写缓存。划分为读/写缓存主要是在读/写的不同路径实现的功能特性不同。读缓存更多是实现对磁盘数据的预读,而写缓存则主要是对写入数据的延迟。虽然读/写缓存的特性有所差异,但本质是减少对磁盘的访问。
3.2.3.1 缓存的替换算法
由于内存的容量要比磁盘的容量小得多,因此文件系统的缓存自然也不会太大,这样缓存只能存储文件系统数据的一个子集。当用户持续写入数据时就会面临缓存不足的情况,此时就涉及如何将缓存数据刷写到磁盘,然后存储新数据的问题。
这里将缓存数据刷写到磁盘,并且存储新数据的过程称为缓存替换。缓存替换有很多种算法,每种算法用于解决不同的问题。接下来介绍几种常见的缓存替换算法。
1.LRU算法
LRU(Least Recently Used)算法依据的是时间局部性原理,也就是如果一个数据最近被使用过,那么接下来有很大的概率还会被使用。因此该算法会将最近没有使用过的缓存释放。
LRU算法通常使用一个链表来实现,刚被使用过的缓存会被插到表头的位置,而经常没有被使用过的数据则慢慢被挤到链表的尾部。为了更加清晰地理解LRU算法的原理,结合图3-31进行说明。
图3-31 LRU算法原理示意图
本实例以全命中进行介绍。假设缓存中有6个数据块,在第1行,方块中的数字表示该数据块的编号。假设第一次访问(可以是读或写)的是3号数据块,由于3号数据块被访问过,因此将其移动到链表头。
第二次访问的是4号数据块,按照相同的原则,该数据块也被移动到链表头。
以此类推,当经过4轮访问后,被访问过的数据块都被前移了,而没有被访问过的数据块(如1号数据块和2号数据块)则被慢慢挤到了链表的后面。这在一定程度上预示着这两个数据块在后面被访问的可能性也比较小。
如果是全命中也就不存在缓存被替换的情况。实际情况是会经常出现缓存空间不足,而需要将其中的数据释放(视情况确定是否需要刷新到磁盘)来存储新的数据。此时,LRU算法就派上用场了,该算法将尾部的数据块拿来存储新数据,然后放到链表头,如图3-32所示。如果这个数据块里面是脏数据则需要刷写到磁盘,否则直接释放即可。
图3-32 LRU算法缓存替换流程示意图
LRU算法原理和实现都比较简单,用途却非常广泛。但是LRU算法有一个缺点,就是当突然有大量连续数据写入时会替换所有的缓存块,从而导致之前统计的缓存使用情况全部失效,这种现象被称为缓存污染。
为了解决缓存污染问题,有很多改进的LRU算法,比较常见的有LRU-K[4]、2Q[5]和LIRS[6]等算法。
以LRU-K算法为例,为了避免缓存污染问题,该算法将原来的LRU链表由一个拆分为两个。其中,一个链表用于存储临时的数据,可以理解为辅助缓存;另一个链表采用LRU算法进行维护。
2.LFU算法
LFU(Least Frequently Used)算法是根据数据被访问的频度来决定释放哪一个缓存块的。访问频度最低的缓存块会被最先释放。
图3-33所示为LFU算法缓存替换流程示意图。其中,第1行是原始状态,方块中的数字表示该缓存块被访问的次数。新数据的加入和缓存块的淘汰都是从尾部进行的。假设某一个数据(虚线框)被访问了4次,则其访问次数从12变成16,因此需要移动到新的位置,也就是第2行。
图3-33 LFU算法缓存替换流程示意图
本节以链表为例说明LFU算法的原理以便于大家理解,但是在工程实现时是绝对不会用链表来实现的。因为当数据块的访问次数变化时需要找到新的位置,链表查找操作是非常耗时的。为了能够实现快速查找,一般采用搜索树来实现。
LFU算法也有其缺点,如果某个数据块在很久之前的某个时间段被高频访问,而以后不再被访问,那么该数据会一直停留在缓存中。但是由于该数据块不会被访问了,所以减少了缓存的有效容量。也就是说,LFU算法没有考虑最近的情况。
本节主要介绍了LRU和LFU两种非常基础的替换算法。除了上述替换算法,还有很多替换算法,大多以LRU算法和LFU算法的理论为基础,如2Q、MQ、LRFU[7]、TinyLFU和ARC[8]等算法。限于篇幅,本节不再赘述,大家可以自行阅读相关的书籍。
3.2.3.2 预读算法
预读算法是针对读数据的一种缓存算法。预读算法通过识别I/O模式方式来提前将数据从磁盘读到缓存中。这样,应用读取数据时就可以直接从缓存读取数据,从而极大地提高读数据的性能。
预读算法最为重要的是触发条件,也就是在什么情况下触发预读操作。通常有两种情况会触发预读操作:一种是当有多个地址连续地读请求时会触发预读操作;另一种是当应用访问到有预读标记的缓存时会触发预读操作。这里,预读标记的缓存是在预读操作完成时在缓存页做的标记,当应用读到有该标记的缓存时会触发下一次的预读,从而省略对I/O模式的识别。
为了更加清晰地解释预读算法,我们通过图3-34来介绍一下缓存预读操作流程。当文件系统识别I/O模式需要预读时,会多读出一部分内容(称为同步预读),如时间点1(第1行)所示。同时,对于同步预读的数据,文件系统会在其中某个缓存块上打上标记。这个标记是为了在缓存结束前能够尽早触发下一次的预读。
图3-34 缓存预读操作流程
在时间点2中,当应用继续读取数据时,由于读到了有标记的缓存块,因此会同时触发下一次的预读。此时数据会被从磁盘上读取,缓存空间增加。
在时间点3和时间点4中,应用可以直接从缓存读取数据。由于没有读到有标记的缓存块,因此也不会触发下一次的预读。在时间点5中,由于有预读标记,因此又会触发预读的流程。
通过上述分析可以看出,由于预读特性将数据提前读到缓存中。应用可以直接从缓存读取数据,而不用再访问磁盘,因此整个访问性能将得到大幅提升。