5、文件系统

1、文件系统

图解 | 你管这破玩意叫文件系统

image

1.1、文件系统的基本组成

Linux 最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的
Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构

  • 索引节点,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等
    索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间
    目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或子文件
  • 目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系
    多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存

目录项和目录是一个东西吗

虽然名字很相近,但是它们不是一个东西,「目录」是个文件,持久化存储在磁盘,而「目录项」是内核一个数据结构,缓存在内存
如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存
下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率

那文件数据是如何存储在磁盘的呢

磁盘读写的最小单位是扇区,扇区的大小只有 0.5KB 大小,很明显,如果每次读写都以这么小为单位,那这读写的效率会非常低
所以文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 4KB,也就是一次性读写 8 个扇区

索引节点(inode)是存储在硬盘上的数据,为了加速文件的访问,通常会把索引节点(inode)加载到内存中
我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的

  • 超级块:当文件系统挂载时进入内存
  • 索引节点区:当文件被访问时进入内存

image

1.2、虚拟文件系统

文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统(Virtual File System,VFS)
VFS 定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员不需要了解文件系统的工作原理,只需要了解 VFS 提供的统一接口即可

Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类
文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录

  • 磁盘的文件系统:它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统
  • 内存的文件系统:这类文件系统的数据不是存储在硬盘的,而是占用内存空间
    我们经常用到的 /proc 和 /sys 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据
  • 网络的文件系统:用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等

在 Linux 文件系统中,用户空间、系统调用、虚拟文件系统、缓存、文件系统以及存储之间的关系如下图
image

1.3、软链接和硬链接

硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode
但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的
由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件
image

软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径
所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已
image

2、文件 I / O

文件的读写方式各有千秋,对于文件的 I / O 分类也非常多,常见的有

  • 缓冲与非缓冲 I / O:是否利用标准库缓冲
  • 直接与非直接 I / O:否利用操作系统的缓存
  • 阻塞与非阻塞 I / O VS 同步与异步 I / O

2.1、缓冲与非缓冲 I / O

文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I / O 分为缓冲 I / O 和非缓冲 I / O

  • 缓冲 I / O:利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件
  • 非缓冲 I / O:直接通过系统调用访问文件,不经过标准库缓存

这里所说的「缓冲」特指标准库内部实现的缓冲
很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是减少系统调用的次数,毕竟系统调用是有 CPU 上下文切换的开销的

2.2、直接与非直接 I / O

我们都知道磁盘 I / O 是非常慢的,所以 Linux 内核为了减少磁盘 I / O 次数
在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是「页缓存」,只有当缓存满足某些条件的时候,才发起磁盘 I / O 的请求

根据是「否利用操作系统的缓存」,可以把文件 I / O 分为直接 I / O 与非直接 I / O

  • 直接 I / O:不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘
  • 非直接 I / O:读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘

如果你在使用文件操作类的系统调用函数时,指定了 O_DIRECT 标志,则表示使用直接 I / O,如果没有设置过,默认使用的是非直接 I / O
如果用了非直接 I / O 进行写数据操作,内核什么情况下才会把缓存数据写入到磁盘

  • 在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上
  • 用户主动调用 sync,内核缓存会刷到磁盘上
  • 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上
  • 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上

2.3、阻塞与非阻塞 VS 同步与异步

阻塞 I / O

阻塞 I / O:当用户程序执行 read,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回
注意:阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程
image

非阻塞 I / O

非阻塞 I / O:非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行
此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果
注意:这里最后一次 read 调用,获取数据的过程,是一个同步的过程(需要等待),这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程

举个例子,访问管道或 socket 时,如果设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I / O 的方式访问,而不做任何设置的话,默认是阻塞 I / O
应用程序每次轮询内核的 I / O 是否准备好,感觉有点傻乎乎,因为轮询的过程中,应用程序啥也做不了,只是在循环
image

I / O 多路复用

为了解决这种傻乎乎轮询方式,于是 I / O 多路复用技术就出来了,如 select、poll
它是通过 I / O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作

这个做法大大改善了 CPU 的利用率,因为当调用了 I / O 多路复用接口,如果没有事件发生,那么当前线程就会发生阻塞,这时 CPU 会切换其他线程执行任务
等内核发现有事件到来的时候,会唤醒阻塞在 I / O 多路复用接口的线程,然后用户可以进行后续的事件处理

整个流程要比阻塞 IO 要复杂,似乎也更浪费性能
但 I / O 多路复用接口最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求
用户可以注册多个 socket,然后不断地调用 I / O 多路复用接口读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的
而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的

下图是使用 select I / O 多路复用过程,注意:read 获取数据的过程(数据从内核态拷贝到用户态的过程),也是一个同步的过程,需要等待
image

异步 I / O

真正的异步 I / O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待
当我们发起 aio_read 之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的
内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作,过程如下图
image

2.4、总结

I / O 是分为两个过程的

  • 数据准备的过程
  • 数据从内核空间拷贝到用户进程缓冲区的过程

阻塞 I / O 会阻塞在「过程 1」和「过程 2」,而非阻塞 I / O 和基于非阻塞 I / O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I / O
异步 I / O 则不同,「过程 1」和「过程 2」都不会阻塞
image

3、Page Cache

3.1、Page Cache 是什么

为了理解 Page Cache,我们不妨先看一下 Linux 的文件 I / O 系统,如下图所示
红色部分为 Page Cache,可见 Page Cache 的本质是由 Linux 内核管理的内存区域
我们通过 mmap 以及 buffered I / O 将文件读取到内存空间实际上都是读取到 Page Cache 中
image

3.2、如何查看系统的 Page Cache

通过读取 /proc/meminfo 文件,能够实时获取系统内存情况

$ cat /proc/meminfo
...
Buffers:            1224 kB
Cached:           111472 kB
SwapCached:        36364 kB
Active:          6224232 kB
Inactive:         979432 kB
Active(anon):    6173036 kB
Inactive(anon):   927932 kB
Active(file):      51196 kB
Inactive(file):    51500 kB
...
Shmem:             10000 kB
...
SReclaimable:      43532 kB
...

根据上面的数据,你可以简单得出这样的公式(等式两边之和都是 112696 KB):Buffers + Cached + SwapCached = Active(file) + Inactive(file) + Shmem + SwapCached
两边等式都是 Page Cache:Page Cache = Buffers + Cached + SwapCached
通过阅读下面的小节,就能够理解为什么 SwapCached 与 Buffers 也是 Page Cache 的一部分

3.3、page 与 Page Cache

page 是内存管理分配的基本单位,Page Cache 由多个 page 构成,并不是所有 page 都被组织为 Page Cache
page 在操作系统中通常为 4KB 大小(32 bits / 64 bits),而 Page Cache 的大小则为 4KB 的整数倍

Linux 系统上供用户可访问的内存分为两个类型

  • File-backed pages:文件备份页也就是 Page Cache 中的 page,对应于磁盘上的若干数据块,对于这些页最大的问题是脏页回盘
  • Anonymous pages:匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行是内存空间(例如方法栈、局部变量表等属性)

为什么 Linux 不把 Page Cache 称为 block cache,这不是更好吗

这是因为从磁盘中加载到内存的数据不仅仅放在 Page Cache 中,还放在 buffer cache 中
例如通过 Direct I / O 技术的磁盘文件就不会进入 Page Cache 中,当然这个问题也有 Linux 历史设计的原因,毕竟这只是一个称呼,含义随着 Linux 系统的演进也逐渐不同

下面比较一下 File-backed pages 与 Anonymous pages 在 Swap 机制下的性能

内存是一种珍惜资源,当内存不够用时,内存管理单元(Memory Mangament Unit)需要提供调度算法来回收相关内存空间
内存空间回收的方式通常就是 swap,即交换到持久化存储设备上

  • File-backed pages(Page Cache)的内存回收代价较低
    Page Cache 通常对应于一个文件上的若干顺序块,因此可以通过顺序 I / O 的方式落盘
    另一方面,如果 Page Cache 上没有进行写操作(没有脏页),甚至不会将 Page Cache 回盘,因为数据的内容完全可以通过再次读取磁盘文件得到
    Page Cache 的主要难点在于脏页回盘,这个内容会在后面进行详细说明
  • Anonymous pages 的内存回收代价较高,这是因为 Anonymous pages 通常随机地写入持久化交换设备
    另一方面,无论是否有写操作,为了确保数据不丢失,Anonymous pages 在 swap 时必须持久化到磁盘

3.4、SwapCached

为什么 SwapCached 也是 Page Cache 的一部分
这是因为当匿名页【Inactive(anon) 以及 Active(anon)】先被交换(swap out)到磁盘上后,然后再加载回(swap in)内存中
由于读入到内存后原来的 Swap File 还在,所以 SwapCached 也可以认为是 File-backed page,即属于 Page Cache,这个过程如下图所示
image

3.5、Page Cache 与 buffer cache

执行 free 命令,注意到会有两列名为 buffers 和 cached,也有一行名为 "-/+ buffers/cache"

~ free -m
             total       used       free     shared    buffers     cached
Mem:        128956      96440      32515          0       5368      39900
-/+ buffers/cache:      51172      77784
Swap:        16002          0      16001

其中 cached 列表示当前的页缓存(Page Cache)占用量,buffers 列表示当前的块缓存(buffer cache)占用量
用一句话来解释:Page Cache 用于缓存文件的页数据,buffer cache 用于缓存块设备(如磁盘)的块数据

  • 页是逻辑上的概念,因此 Page Cache 是与文件系统同级的
  • 块是物理上的概念,因此 buffer cache 是与块设备驱动程序同级的

Page Cache 与 buffer cache 的共同目的都是加速数据 I / O

  • 写数据时首先写到缓存,将写入的页标记为 dirty,然后向外部存储 flush,也就是缓存写机制中的 write-back(另一种是 write-through,Linux 默认情况下不采用)
  • 读数据时首先读取缓存,如果未命中,再去外部存储读取,并且将读取来的数据也加入缓存
    操作系统总是积极地将所有空闲内存都用作 Page Cache 和 buffer cache,当内存不够用时也会用 LRU 等算法淘汰缓存页

在 Linux 2.4 版本的内核之前,Page Cache 与 buffer cache 是完全分离的
但是块设备大多是磁盘,磁盘上的数据又大多通过文件系统来组织,这种设计导致很多数据被缓存了两次,浪费内存
所以在 2.4 版本内核之后,两块缓存近似融合在了一起:如果一个文件的页加载到了 Page Cache,那么同时 buffer cache 只需要维护块指向页的指针就可以了
只有那些没有文件表示的块,或者绕过了文件系统直接操作(如 dd 命令)的块,才会真正放到 buffer cache 里
因此我们现在提起 Page Cache,基本上都同时指 Page Cache 和 buffer cache 两者,本文之后也不再区分,直接统称为 Page Cache
下图近似地示出 32-bit Linux 系统中可能的一种 Page Cache 结构,其中 block size 大小为 1KB,page size 大小为 4KB
image

Page Cache 中的每个文件都是一棵基数树(radix tree,本质上是多叉搜索树),树的每个节点都是一个页
根据文件内的偏移量就可以快速定位到所在的页,如下图所示,关于基数树的原理可以参见英文维基,这里就不细说了
image

3.6、Page Cache 与预读

操作系统为基于 Page Cache 的读缓存机制提供预读机制(PAGE_READAHEAD),一个例子是

  • 用户线程仅仅请求读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据
    由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下
  • 但是操作系统出于局部性原理会选择将磁盘块 offset [4KB, 8KB)、[8KB, 12KB) 以及 [12KB, 16KB) 都加载到内存,于是额外在内存中申请了 3 个 page

下图代表了操作系统的预读机制:应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用 readahead 机制完成了 16KB 数据的读取
image

4、Page Cache 与文件持久化

现代 Linux 的 Page Cache 正如其名,是对磁盘上 page(页)的内存缓存,同时可以用于读 / 写操作
任何系统引入缓存,就会引发一致性问题:内存中的数据与磁盘中的数据不一致,例如常见后端架构中的 Redis 缓存与 MySQL 数据库就存在一致性问题
Linux 提供多种机制来保证数据一致性,但无论是单机上的内存与磁盘一致性,还是分布式组件中节点 1 与节点 2、节点 3 的数据一致性问题
理解的关键是 trade-off:吞吐量与数据一致性保证是一对矛盾

首先需要我们理解一下文件的数据,文件 = 数据 + 元数据
文件的元数据包括:文件大小、创建时间、访问时间、属主属组等信息
元数据用来描述文件的各种属性,也必须存储在磁盘上,因此我们说保证文件一致性其实包含了两个方面:数据一致 + 元数据一致

我们考虑如下一致性问题:如果发生写操作并且对应的数据在 Page Cache 中,那么写操作就会直接作用于 Page Cache 中
此时如果数据还没刷新到磁盘,那么内存中的数据就领先于磁盘,此时对应 page 就被称为 Dirty page,当前 Linux 下以两种方式实现文件一致性

  • Write Through(写穿):向用户层提供特定接口,应用程序可主动调用接口来保证文件一致性
    Write Through 以牺牲系统 I / O 吞吐量作为代价,向上层应用确保一旦写入,数据就已经落盘,不会丢失
  • Write back(写回):系统中存在定期任务(表现形式为内核线程),周期性地同步文件系统中文件脏数据块,这是默认的 Linux 一致性方案
    Write back 在系统发生宕机的情况下无法确保数据已经落盘,因此存在数据丢失的问题,不过在程序挂了,例如被 kill -9,Page Cache 中的数据还是会确保落盘

上述两种方式最终都依赖于系统调用,主要分为如下三种系统调用

方法 含义
fsync(intfd) fsync(fd):将 fd 代表的文件的「脏数据和脏元数据」全部刷新至磁盘中
fdatasync(int fd) fdatasync(fd):将 fd 代表的文件的「脏数据」刷新至磁盘,同时对「必要的元数据」刷新至磁盘中
这里所说的必要的概念是指:对接下来访问文件有关键作用的信息,如文件大小,而文件修改时间等不属于必要信息
sync() sync():对系统中所有的脏的文件「数据 + 元数据」刷新至磁盘中

image

5、Page Cache 的优劣势

5.1、优势

  • 加快数据访问
    如果数据能够在内存中进行缓存,那么下一次访问就不需要通过磁盘 I / O 了,直接命中内存缓存即可
  • 减少 I / O 次数,提高系统磁盘 I / O 吞吐量
    得益于 Page Cache 的缓存以及预读能力,而程序又往往符合局部性原理
    因此通过一次 I / O 将多个 page 装入 Page Cache 能够减少磁盘 I / O 次数,进而提高系统磁盘 I / O 吞吐量

5.2、劣势

  • 最直接的缺点是需要占用额外物理内存空间,物理内存在比较紧俏的时候可能会导致频繁的 swap 操作,最终导致系统的磁盘 I / O 负载的上升
  • 对应用层并没有提供很好的管理 API,几乎是透明管理,应用层即使想优化 Page Cache 的使用策略也很难进行
    因此一些应用选择在用户空间实现自己的 page 管理,而不使用 page cache,例如 MySQL InnoDB 存储引擎以 16KB 的页进行管理
  • 在某些应用场景下比 Direct I / O 多一次磁盘读 I / O 以及磁盘写 I / O

Direct I / O 即直接 I / O,其名字中的 "直接" 二字用于区分使用 page cache 机制的缓存 I / O
在这里还有另一层语义:其他所有技术中,数据至少需要在内核空间存储一份,但是在 Direct I / O 技术中,数据直接存储在用户空间中,绕过了内核

  • 缓存文件 I / O:用户空间要读写一个文件并不直接与磁盘交互,而是中间夹了一层缓存,即 page cache
  • 直接文件 I / O:用户空间读取的文件直接与磁盘交互,没有中间 page cache 层

Direct I / O 模式如下图所示:此时用户空间直接通过 DMA 的方式与磁盘以及网卡进行数据拷贝
Direct I / O 的读写非常有特点

  • Write 操作:由于其不使用 page cache,所以其进行写文件,如果返回成功,数据就真的落盘了(不考虑磁盘自带的缓存)
  • Read 操作:由于其不使用 page cache,每次读操作是真的从磁盘中读取,不会从文件系统的缓存中读取

image

6、如何避免预读失效和缓存污染

1、操作系统在读磁盘的时候会额外多读一些到内存中,但是最后这些数据也没用到,有什么改善的方法吗
2、批量读数据的时候,可能会把热点数据挤出去,这个又有什么改善的方法呢

乍一看是操作系统的问题,其实这两个题目都是在问如何改进 LRU 算法,因为传统的 LRU 算法存在这两个问题

  • 「预读失效」导致缓存命中率下降(对应第一个题目)
  • 「缓存污染」导致缓存命中率下降(对应第二个题目)

MySQL 和 Linux 是通过改进 LRU 算法来避免「预读失效和缓存污染」而导致缓存命中率下降的问题
Redis 的缓存淘汰算法是通过实现 LFU 算法来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)

6.1、Linux 和 MySQL 的缓存

Linux 的缓存

在应用程序读取文件的数据的时候,Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 Page Cache(如下图中的页缓存)
Page Cache 属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要通过磁盘 I / O 了,命中缓存就直接返回数据即可,加速访问数据
image

MySQL 的缓存

MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池(Buffer Pool),Buffer Pool 属于内存空间里的数据,有了缓冲池后

  • 当读取数据时:如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取
  • 当修改数据时:首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘

image

6.2、传统 LRU 是如何管理内存数据的

Linux 的 Page Cache 和 MySQL 的 Buffer Pool 的大小是有限的,并不能无限的缓存数据
对于一些频繁访问的数据我们希望可以一直留在内存中,而一些很少访问的数据希望可以在某些时机可以淘汰掉
从而保证:常用数据留在内存中,且内存不会因为满了而导致无法再缓存新的数据,要实现这个,最容易想到的就是 LRU(Least recently used)算法

LRU 算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的
当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间
因为 Linux 的 Page Cache 和 MySQL 的 Buffer Pool 缓存的基本数据单位都是页(Page)单位,所以后续以「页」名称代替「数据」,传统的 LRU 算法的实现思路是这样的

  • 当访问的页在内存里:就直接把该页对应的 LRU 链表节点移动到链表的头部
  • 当访问的页不在内存里:除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页

比如下图,假设 LRU 链表长度为 5,LRU 链表从左到右有编号为 1,2,3,4,5 的页
image
如果访问了 3 号页,因为 3 号页已经在内存了,所以把 3 号页移动到链表头部即可,表示最近被访问了
image
而如果接下来,访问了 8 号页,因为 8 号页不在内存里,且 LRU 链表长度为 5
所以必须要淘汰数据,以腾出内存空间来缓存 8 号页,于是就会淘汰末尾的 5 号页,然后再将 8 号页加入到头部
image
传统的 LRU 算法并没有被 Linux 和 MySQL 使用,因为传统的 LRU 算法无法避免下面这两个问题

  • 「预读失效」导致缓存命中率下降
  • 「缓存污染」导致缓存命中率下降

6.3、预读失效

如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效
如果使用传统的 LRU 算法,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉

如果这些「预读页」一直没被访问,就会出现一个很奇怪的问题
不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率

我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的
避免预读失效:让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长
Linux 操作系统和 MySQL Innodb 通过改进传统 LRU 链表来避免预读失效带来的影响,具体的改进分别如下
这两个改进方式,设计思想都是类似的,都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法,不再像传统的 LRU 算法那样,所有数据都只用一个 LRU 算法管理

  • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)
  • MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域

Linux 如何避免预读失效带来的影响

Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)

  • active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页
  • inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页

有了这两个 LRU 链表后,预读页就只需要加入到 inactive list 区域的头部
当页被真正访问的时候,才将页插入 active list 的头部,如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据

假设 active list 和 inactive list 的长度为 5,目前内存中已经有如下 10 个页
image
现在有个编号为 20 的页被预读了,这个页只会被插入到 inactive list 的头部,而 inactive list 末尾的页(10 号)会被淘汰掉
image
即使编号为 20 的预读页一直不会被访问,它也没有占用到 active list 的位置,而且还会比 active list 中的页更早被淘汰出去
如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 active list 的头部
active list 末尾的页(5 号),会被降级到 inactive list,作为 inactive list 的头部,这个过程并不会有数据被淘汰
image

MySQL 如何避免预读失效带来的影响

MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域,young 区域 和 old 区域
young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图
young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是 63:37(默认比例)的关系
划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部
如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据
image

假设有一个长度为 10 的 LRU 链表,其中 young 区域占比 70 %,old 区域占比 30 %
image
现在有个编号为 20 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页(10 号)会被淘汰掉
image
如果 20 号页一直不会被访问,它也没有占用到 young 区域的位置,而且还会比 young 区域的数据更早被淘汰出去
如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 young 区域的头部
young 区域末尾的页(7 号),会被挤到 old 区域,作为 old 区域的头部,这个过程并不会有页被淘汰
image

6.4、缓存污染

虽然 Linux (实现两个 LRU 链表)和 MySQL (划分两个区域)通过改进传统的 LRU 数据结构,避免了预读失效带来的影响
但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题

  • 当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里
  • 然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了
  • 如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了

缓存污染会带来什么问题

缓存污染带来的影响就是很致命的,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I / O,系统性能就会急剧下降

我以 MySQL 举例子,Linux 发生缓存污染的现象也是类似
当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了
等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I / O,MySQL 性能就会急剧下降

注意:缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染
比如:在一个数据量非常大的表,执行了这条语句 select * from t_user where name like "%xiaolin%";
可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描的,接着会发生如下的过程

  • 从磁盘读到的页加入到 LRU 链表的 old 区域头部
  • 当从页里读取行记录时,也就是页被访问的时候,就要将该页放到 young 区域头部
  • 接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里
  • 如此往复,直到扫描完表中的所有记录

经过这一番折腾,由于这条 SQL 语句访问的页非常多,每访问一个页,都会将其加入 young 区域头部,那么原本 young 区域的热点数据都会被替换掉,导致缓存命中率下降
那些在批量扫描时,而被加入到 young 区域的页,如果在很长一段时间都不会再被访问的话,那么就污染了 young 区域

怎么避免缓存污染造成的影响

前面的 LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),这种 LRU 算法进入活跃 LRU 链表的门槛太低了
正式因为门槛太低,才导致在发生缓存污染的时候,很容就将原本在活跃 LRU 链表里的热点数据淘汰了
所以只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉

Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里
  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断
    • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域
    • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就会从 old 区域升级到 young 区域

提高了进入活跃 LRU 链表(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响
在批量读取数据时候,如果这些大量数据只会被访问一次,那么它们就不会进入到活跃 LRU 链表(或者 young 区域)
也就不会把热点数据淘汰,只会待在非活跃 LRU 链表(或者 old 区域)中,后续很快也会被淘汰

posted @ 2023-08-29 13:28  lidongdongdong~  阅读(54)  评论(0编辑  收藏  举报