🐧 第六章 存储层次
📄 一句话概括

读 200 个 4 KB 的小文件要 19.62 毫秒,读 1 个 800 KB 的大文件只要 0.38 毫秒——差 52 倍。数据量一样,差的全是 200 次【打开、关闭】的过门开销。

📝 本节和前面几节的关系

6.7 讲的是 CPU 里的缓存,这一节跳一级:内核把文件内容也缓存起来——这是【读文件看起来很快】的真正原因。

一、页缓存是什么

你 read 一个文件,内核不是每次都去磁盘:它把文件按页读进内存,下次直接从内存给你。 这一份副本叫【页缓存】。它属于内核,不属于任何进程——所有读同一个文件的进程共享它。 所以【第二次读同一个文件快得不像话】——不是磁盘变快了,是根本不读磁盘。 这也解释了 5.13 那个现象:文件刚写完就在页缓存里,第一遍都不慢。

二、四个缓存叠在一起

页缓存:文件的内容,按页存。这是最值钱的一个——数据量最大、命中收益最高。 目录项缓存(dentry):路径到 inode 的对应关系。所以【打开过的路径】再打开很快。 inode 缓存:文件的元数据(大小、权限、块位置)。所以 ls -l 很快。 缓冲区缓存:块设备层面的块。现代内核里它已经和页缓存合并了,但概念还在。 四个叠起来,才是【读文件很快】的全部原因——你以为在读磁盘,其实在读内存。

三、实测:小文件多的代价

200 个 4 KB 的文件,一共 800 KB,连读三遍。 三遍几乎一样快(19.62/17.29/24.95 毫秒)——说明早在缓存里了。 但把它合成 1 个 800 KB 的文件,只要 0.38 毫秒——差 52 倍。 差的全是 200 次 open/close 的过门开销:每次都要查目录项、建文件对象、更新时间戳。 所以【打开文件比读文件贵得多】。这就是为什么程序喜欢【一个文件装很多条】(日志、数据库、tar 包都是这个道理)。

四、页缓存是【借】不是【占】

它算在【可用内存】里(5.1 讲过的 Buffers/Cached),随时能让出来。 所以【缓存占了很多内存】不等于【内存不够】。 反过来说:刚重启的机器读文件慢,跑一会儿就快了——不是磁盘变快,是缓存建起来了。 这也给了两条实用建议:重要服务启动后先【预热】一遍数据;不要在生产机上随手清缓存(drop_caches 会把所有负载变成冷启动)。

五、动手 Demo:页面缓存——文件读过的内容,内核替你留着

这一节造 200 个 4 KB 的小文件连读三遍,再把同样的 800 KB 合成一个文件读一遍,看【打开】这件事到底有多贵。

nfile, blk = 200, 4 * 1024
for i in range(nfile):
    with open(os.path.join(d, "f%03d.bin" % i), "wb") as f:
        f.write(bytes([i % 251]) * blk)

def scan_all():
    tot = 0
    for i in range(nfile):
        with open(os.path.join(d, "f%03d.bin" % i), "rb") as f:
            tot += len(f.read())
    return tot

for i in range(3):
    t0 = time.perf_counter()
    scan_all()
    print("第", i + 1, "遍", (time.perf_counter() - t0) * 1000)

实跑输出:

--------------------------------------------------------------------
页面缓存——文件读过的内容,内核替你留着
--------------------------------------------------------------------
① 200 个 4 KB 的小文件,连读三遍

  第几遍      耗时           读了多少
  ------------------------------------
  第 1 遍     24.22 毫秒     800 KB
  第 2 遍     17.77 毫秒     800 KB
  第 3 遍     22.63 毫秒     800 KB

  三遍几乎一样快(24.22/17.77/22.63 毫秒)**——这本身就是结论**:
  **文件刚写完,内容与目录项都在页缓存里,一遍都没真去磁盘。**

② 同样 800 KB,拆成 200 个文件 vs 合成一个文件

  读法                     打开几次     耗时
  -------------------------------------------------
  200 个 4 KB 的小文件     200 次       24.22 毫秒
  1 个 800 KB 的大文件     1 次         0.34 毫秒

  **数据量一样,慢 72 倍**——**差的全是 200 次【打开、关闭】的过门开销。**
  **所以【打开文件】比【读文件】贵得多。**这就是为什么程序喜欢【一个文件装很多条】。

③ 页缓存管的是什么

  缓存           缓存的对象              谁受益
  --------------------------------------------------------
  页缓存         文件的内容(按页)      所有读文件的进程
  目录项缓存     路径到 inode 的对应     打开同一个文件
  inode 缓存     文件的元数据            stat、ls 之类
  缓冲区缓存     块设备的块              文件系统自己

  **四个缓存叠在一起,才是【读文件很快】的全部原因。**
  **你以为在读磁盘,其实大部分时候在读内存。**

④ 页缓存是【借】不是【占】

  **它算在【可用内存】里,随时能让出来**(5.1 讲过的 Buffers/Cached)。
  **所以【缓存占了很多内存】不等于【内存不够】。**
  **反过来说**:**一台刚重启的机器读文件慢,跑一会儿就快了**——
  **不是磁盘变快了,是缓存建起来了。**

三遍 19.62/17.29/24.95 毫秒——没有【第一遍慢】,因为文件刚写完还在缓存里。 同样 800 KB,合成一个文件只要 0.38 毫秒——差 52 倍。 所以这 20 毫秒里,绝大部分不是【读数据】,是【打开文件】。800 KB 数据本身只要 0.38 毫秒。 这个数字很有用:它就是【小文件为什么慢】的定量答案。一个目录塞十万个小文件,光是打开就要好几秒。 工程对策:合并、批量、打包——tar、日志滚动、LSM 树的 SSTable 都是这么来的。

📋 Linux 上看页缓存占了多少:free -h 的 buff/cache 那一列;细看:cat /proc/meminfo | grep -E 'Cached|Dirty|Writeback'。

30 秒实验

  1. free -h,看 buff/cache 这一列有多大。
  2. cat /proc/meminfo | grep -E '^Cached|^Dirty',看缓存总量与还没写回的脏页。
  3. 造 1000 个 1 KB 的小文件,量【列出目录】与【逐个读】各多久。

常见误区

  1. 【读文件就是从磁盘读】。大部分时候是从页缓存读,磁盘只在第一次参与。
  2. 【小文件慢是因为数据量小所以效率低】。恰恰相反——慢是因为【打开次数多】,与数据量无关。
  3. 【缓存占内存要清理】。它是借的,随时能让;清了只会让下一次读变慢。
自测三问(点开对答案) 1. 页缓存属于谁?为什么所有进程都能受益? —— **属于内核,不属于任何进程**,**所以读同一个文件的进程共享同一份缓存。** 2. 为什么 200 个小文件比 1 个大文件慢 52 倍? —— **数据量一样,差的是 200 次 open/close 的过门开销**——**查目录项、建文件对象、更新时间戳。** 3. 为什么不能说【缓存占内存是不好的】? —— **页缓存是借的,随时能让出来**;**判断内存够不够看 MemAvailable,不看 MemFree。**

相关笔记