文件名不在 inode 里,读文件也不一定碰磁盘——页缓存和目录项这两件事,决定了构建工具第二次为什么快。
同一份代码第一次 webpack --watch 要 30 秒,改一行再构建只要 2 秒,很多人以为是缓存插件神了。真正起作用的是三样东西:webpack 的持久化缓存文件、操作系统的 page cache、以及 node_modules 的目录项已经在内存里。
文件系统把「元数据」和「文件名」拆开存:
所以 open("/a/b.js") 是逐级解析路径:先在根目录的映射表里找 a 的 inode,再从 a 的目录数据里找 b.js。内核用 dcache 缓存这些解析结果,这也是为什么遍历十万个文件的目录特别慢——每个目录项都要查一次。
ls -li # 第一列就是 inode 号
stat package.json # 看 inode、块数、三个时间戳
df -i # 看 inode 使用率:磁盘没满也可能 inode 用尽
| 维度 | 硬链接 hard link | 软链接 symbolic link |
|---|---|---|
| inode | 与源文件相同 | 独立 inode |
| 内容 | 就是同一份数据块 | 存的是目标路径字符串 |
ls -l 显示 | 普通文件,链接数 +1 | l 类型,带 -> target |
| 跨文件系统 | 不行 | 可以 |
| 指向目录 | 一般不允许 | 可以 |
| 源文件删除后 | 数据仍在,直到链接数为 0 | 变成悬空链接(dangling) |
ln report.txt report-hard.txt # 硬链接:两个名字,一份数据
ln -s report.txt report-soft.txt # 软链接:一个存路径的小文件
rm report.txt # 硬链接仍可读;软链接失效
pnpm 的核心就是把同一个包硬链接进不同项目的 node_modules,磁盘上只存一份,安装速度也因此快得多。
内核把磁盘上的文件按 4 KB 页缓存在内存里,就是 Page Cache。read() 的实际动作是:
copy_to_user 到你的缓冲区,不碰磁盘dirty_ratio、dirty_expire_centisecs 异步刷盘free -h 里的 buff/cache 就是它。它几乎会用满空闲内存,但随时可回收,不是「内存泄漏」。缓存命中时一次读只要几微秒,未命中机械盘要几毫秒,差三个数量级。
import { readFile, createReadStream } from 'node:fs'
// 一次性读入:内存峰值 ≈ 文件大小,还会多一份拼接缓冲
const buf = await readFile('./huge.log')
// 流式:内存峰值 ≈ highWaterMark(默认 64 KB)
createReadStream('./huge.log', { highWaterMark: 64 * 1024 })
.on('data', (chunk) => { /* 处理一块 */ })
.on('end', () => {})
readFile 要先分配一个和文件等大的 Buffer,1 GB 的日志就是 1 GB 的常驻内存,在容器里很容易触发 OOM。createReadStream 每次只读一块,靠背压(write() 返回 false 就暂停读取)控制节奏,用 stream.pipeline 还能自动处理错误与销毁。
文件大小未知或可能很大时用流;几 KB 的配置文件用 readFile 更省事,代码也更好读。
| 维度 | read() | mmap() |
|---|---|---|
| 数据拷贝 | 内核缓冲区 → 用户缓冲区,一次 copy | 无拷贝,页表直接指向 page cache |
| 触发方式 | 主动调用,同步返回 | 访问内存时触发 page fault,按需加载 |
| 适用 | 顺序读、一次性小文件 | 随机访问大文件、多进程共享 |
| 风险 | 大文件占内存 | SIGBUS(文件被截断)、需要 munmap/msync |
read 是「把数据搬过来」,mmap 是「把文件挂到地址空间上」。MAP_SHARED 的写入会回写到文件,MAP_PRIVATE 则是写时复制、只改自己的视图。数据库、动态链接器加载 .so、V8 映射快照文件都用 mmap,因为它省掉一次拷贝,还能让多个进程共享同一份物理页。
webpack 二次构建为什么快。 webpack 5 的 cache: { type: 'filesystem' } 把模块图与转译结果写到 node_modules/.cache/webpack,第二次直接反序列化,省掉 babel/tsc 的 CPU;同时源文件已经在 page cache 里,stat 和 read 都是内存操作。冷启动(清缓存 + 重启机器)才是真实耗时。
node_modules 大了为什么拖慢一切。 一个中型项目轻松有 3~8 万个文件:
stat 变慢,df -i 可能先于磁盘容量告急fs.inotify.max_user_watches 只有 8192,经常需要调大node_modules/.bin 里的命令全是软链接,执行时多一层路径解析Docker 里 IO 为什么慢。 overlayfs 把镜像层设为只读的 lowerdir,容器层是可写的 upperdir。第一次修改某个文件要 copy-up:把整个文件从下层复制到上层,再改。改一个 500 MB 的镜像内文件,就要先复制 500 MB。数据库、node_modules 这类小文件密集的目录更明显,所以要把它们放到 volume 或 bind mount 上,直接走宿主文件系统。
很多人以为 rm 之后磁盘空间立刻释放。 实际上只要还有进程持有这个文件的 fd(lsof | grep deleted 能看到),inode 和数据块就不会回收,日志文件被删了但服务没重启,磁盘照样满。
很多人以为软链接和 Windows 快捷方式一样只是「壳」。 实际上硬链接才是同一份数据的另一个名字,链接数为 0 才真正删除;软链接只是一个存路径的小文件,多一层解析,目标没了就悬空。
很多人以为 Docker 慢是 CPU 或网络问题。 实际上大量小文件写入时的 copy-up 与 overlay 层查找才是主因,用 docker run -v $(pwd):/app 挂载源码目录,构建速度经常能差出数倍。
inode 是什么,为什么文件名不在里面? inode 存文件元数据与数据块位置,文件名存在目录项里,目录项负责「名字 → inode 号」的映射。这样同一份数据可以有多个名字(硬链接),重命名也只是改目录项,不用动数据。
硬链接和软链接的区别? 硬链接共享同一个 inode,链接数加一,删除其中一个名字数据仍在,不能跨文件系统;软链接有独立 inode,内容是目标路径,可以跨文件系统、可以指向目录,目标删除后失效。
page cache 是什么,为什么第二次读快?
内核把文件页缓存在内存里,第一次读触发磁盘 IO 并预读,后续读直接内存拷贝,不访问磁盘。它用空闲内存做缓存且可随时回收,所以 free 里 cache 高不是内存泄漏。
为什么大文件要用流而不是 readFile?
readFile 一次性分配与文件等大的缓冲区,大文件会直接吃掉数百 MB 到数 GB 内存;流按 highWaterMark 分块读,配合背压把内存控制在常量级,也不会因为一次大分配拖慢事件循环。