第 07 章 文件系统与 Page Cache——世界上最伟大的一层谎言
本章追一个问题:你
write()返回成功的那一刻,数据在哪? 上一章我们知道malloc是空头合同。这一章你会发现:write也是。 而且这个”谎言”是整个系统性能的支柱——没有它,你的机器慢一百倍。但每个谎言都要还,还的时候就是fsync那 200ms 的卡顿。
一、一个让人不安的实验
墨:老哥,先做实验。写个脚本往磁盘写 1GB:
dd if=/dev/zero of=/tmp/test.bin bs=1M count=1024
# 1073741824 bytes (1.1 GB) copied, 0.42 s, 2.6 GB/s2.6 GB/s。 你机器的磁盘是什么?
你:一块普通 NVMe,标称顺序写 1.5GB/s 左右。
墨:那问题来了——你怎么用 1.5GB/s 的磁盘,写出 2.6GB/s 的成绩?
你:……超频了?
墨:再试一次,加个 oflag=direct(绕过缓存直写磁盘):
dd if=/dev/zero of=/tmp/test.bin bs=1M count=1024 oflag=direct
# 1073741824 bytes (1.1 GB) copied, 0.85 s, 1.26 GB/s1.26 GB/s —— 这才是磁盘的真实速度。
墨:所以第一次那 2.6GB/s 是假的。数据根本没到磁盘,它进了内存。dd 返回时,一大半数据还在 page cache 里当脏页躺着(上一章讲的),内核会在后台慢慢写。
write()返回成功,只意味着”内核收下了你的数据,并承诺以后写到磁盘”。它不意味着数据落盘了。这就是本章标题里那个”谎言”。它有个正式名字:write-back caching(写回缓存)。
你:那要是这时候断电?
墨:那一大半数据就没了。你的 write 明明返回了 0 错误。
墨:老哥,你现在应该有点不安。但我要说的是:这个谎言是必须的,而且是伟大的。 我们先讲它为什么必须存在,再讲怎么在需要时把它撕掉(fsync),最后讲撕掉的代价(那 200ms)。
二、page cache:整个系统最重要的一块内存
2.1 它解决什么问题?
墨:先建立一个数量级感觉。第 03 章那张延迟表还记得吗?我把关键几行再摆出来:
| 操作 | 延迟 | 换算成”人的尺度”(1ns = 1 秒) |
|---|---|---|
| L1 缓存命中 | ~1 ns | 1 秒 |
| 内存访问 | ~80 ns | 1 分钟 |
| NVMe SSD 随机读 | ~100 μs | 28 小时 |
| SATA SSD 随机读 | ~150 μs | 42 小时 |
| 机械磁盘随机读 | ~10 ms | 116 天! |
墨:内存和磁盘之间差 1000 到 10 万倍。这不是”快一点慢一点”的区别,这是”打个电话”和”寄一封信到火星”的区别。
所以内核做了一个必然的决定:
把所有读过、写过的文件内容,都在内存里留一份。 这份拷贝叫 page cache。
- 读:先查 page cache,命中就是内存速度(纳秒级),根本不碰磁盘;
- 写:写进 page cache 标记为脏,立即返回,后台慢慢刷盘。
2.2 为什么”把内存全用光”是正确的?
墨:上一章我们说 free 小是健康的。现在你能理解为什么了:
page cache 是无本万利的。 一页干净的缓存(和磁盘内容一致):
- 如果被命中 → 省掉一次磁盘 IO,收益巨大(几万倍);
- 如果内存紧张 → 直接丢弃,零成本(磁盘上有原件,不需要写回);
所以留着它没有任何风险,只有收益。 内核的策略是:把所有空闲内存都拿来做 page cache,直到有人真的要用内存时再让位。
📦 类比:图书馆的开架阅览室
- 磁盘 = 地下书库,取一本要填单子等半小时(慢);
- page cache = 开架阅览室的书架,伸手就拿(快);
- 图书馆的策略是:书架有多大就摆多满。反正书库里有原件,书架上的书随时可以拿走腾位置。
- 你说”书架空着才好看”,那是荒谬的——空着的书架就是纯粹的浪费。
墨:这个类比还能继续用:
- 脏页 = 读者在书上写了批注,但书库里的原件还没更新。这本书不能随便拿走腾位置,必须先抄回书库(写回磁盘)。这就是为什么脏页比干净页”贵” —— 回收干净页是免费的,回收脏页要付一次磁盘 IO。
- 上一章
vm.dirty_ratio那套参数,管的就是”允许多少本书带着未抄回的批注”。
2.3 一次 read 的完整微观旅程
墨:把链条走一遍,你才知道每一环上能卡在哪。
你的代码: read(fd, buf, 4096)
↓ 【第 04 章那道墙】syscall 指令,进内核
↓
sys_read → vfs_read() ← VFS 层(下一节讲这个抽象)
↓
根据 fd 找到 struct file → 找到 inode → 找到该文件的 address_space
↓
在 page cache 里查:这个文件的第 N 页在内存里吗?
↓
├─【命中】(cache hit)
│ ↓ 直接 copy_to_user() 把数据从内核页拷到你的 buf
│ ↓ 返回。全程 纳秒~微秒级,没碰磁盘
│
└─【未命中】(cache miss) —— 真正的故事开始
↓ 分配一个空闲页(上一章的 buddy!内存紧张时这里可能触发 direct reclaim)
↓ 向文件系统(ext4/xfs)请求:这个文件的第 N 页,在磁盘哪个块?
↓ ← 这一步可能自己也要读磁盘(读 inode、读 extent 树/间接块)
↓ 构造 bio → 提交到块层 → IO 调度器排队 → 驱动 → 磁盘 【第 08 章】
↓ 【进程在这里睡眠!状态变成 D(不可中断睡眠)】
↓ ... 等磁盘中断回来(几十微秒到几十毫秒)... 【第 09 章】
↓ 数据到页里,标记页为 up-to-date,唤醒进程
↓ copy_to_user → 返回
墨:注意几个关键点:
- 命中和未命中,差 3~5 个数量级。 所以”缓存命中率”是文件 IO 性能的第一变量,比换更快的磁盘影响大得多。
- 未命中时进程进入
D状态(不可中断睡眠)。 这就是你ps aux看到D状态进程的含义——它在等磁盘,而且这个等待连kill -9都杀不掉(因为它在内核态等 IO 完成,不能被信号打断)。- ⚠️ 实战意义:大量
D状态进程 = IO 子系统出问题了(慢盘、NFS 卡死、存储故障)。这是判断”IO 是否是瓶颈”的一个极强信号。ps -eo stat,pid,comm | grep '^D'数一下。
- ⚠️ 实战意义:大量
load average里包含D状态进程! 这解释了一个千古困惑:为什么 CPU 使用率只有 10%,load average 却有 50? 因为 load 统计的是”可运行 + 不可中断等待”的任务数。50 个进程卡在等磁盘,load 就是 50,而 CPU 闲着。认知纠正:Linux 的 load average 不是”CPU 负载”,是”系统积压任务数”。 高 load + 低 CPU = 典型的 IO 瓶颈信号。这个区分能救你很多次。
2.4 预读:内核在猜你下一步要读什么
墨:还有一个重要机制。你顺序读一个大文件,每次 read(4096)。如果内核老老实实一页一页读磁盘,那就是 25 万次随机 IO 读一个 1GB 文件——慢死。
所以内核搞了 readahead(预读):发现你在顺序读,就提前把后面的几十上百页一起读进来。你下次 read 直接命中缓存。
- 这就是为什么顺序读比随机读快几十倍(不只是磁盘寻道的原因,还有预读的功劳);
- 内核会动态调整预读窗口:你越表现得像顺序读,窗口越大;你一跳到别处随机读,窗口缩小。
相关参数:/sys/block/<dev>/queue/read_ahead_kb(默认 128KB)
- 控制什么:预读窗口上限。
- 权衡:
- 调大(如 4096 = 4MB):大文件顺序读吞吐显著提升(视频、数据仓库全表扫描、备份)。代价是——如果实际是随机读,预读的都是没用的数据,白白浪费磁盘带宽和 page cache(把有用的缓存挤出去)。
- 调小(如 16KB):适合纯随机小 IO(数据库 OLTP、KV 存储),避免无谓预读放大。
- 一句话:预读是内核在赌你会顺序读。你的负载越顺序,越该调大;越随机,越该调小。
⚠️ 典型误配:一台跑 OLTP 数据库的机器把 read_ahead_kb 设成 4MB。结果每次随机读一条 16KB 的记录,磁盘被要求读 4MB。磁盘带宽被放大 250 倍,IOPS 直接崩。这是”抄别人的调优参数”最经典的翻车方式——别人的负载是顺序的,你的是随机的。
📦 应用怎么主动告诉内核?——posix_fadvise / madvise
内核的猜测毕竟是猜。所以有接口让应用主动声明访问模式:
POSIX_FADV_SEQUENTIAL:我要顺序读,请加大预读;POSIX_FADV_RANDOM:我要随机读,别预读了;POSIX_FADV_DONTNEED:这段我读完了,不会再用,请把缓存丢掉;POSIX_FADV_WILLNEED:我马上要用这段,请预先读进来。
FADV_DONTNEED特别有用:备份程序、日志归档、大文件拷贝这类”只读一遍”的操作,如果不主动丢弃,会把整个 page cache 冲刷一遍,毁掉别人的热缓存(上一章讲的双 LRU 只能部分缓解)。像rsync、tar的某些实现、nocache工具就是干这个的。这是个重要的设计哲学:内核尽力猜,但应用永远比内核更了解自己的访问模式。所以内核提供”表达意图”的接口,而不是只靠猜。 (对比 K8s 那本里
readinessProbe—— 也是”应用比平台更知道自己好不好”。)
三、VFS:一层抽象伺候所有文件系统
墨:上面链条里有个 vfs_read()。这个 VFS(虚拟文件系统)层,是 Unix 设计哲学最漂亮的体现之一,值得单讲。
3.1 它解决什么问题?
墨:你想想内核面对的局面:
- 磁盘上可能是 ext4、XFS、Btrfs;
- 网络上可能是 NFS、CIFS;
- 内存里可能是 tmpfs;
- 还有一堆压根不是”文件”的东西被伪装成文件:
/proc(进程信息)、/sys(内核对象)、/dev(设备)、pipe、socket。
而用户程序只想用一套 API:open/read/write/close/lseek。
你:所以 VFS 就是个统一接口层?
墨:对,但我要你看清它抽象的具体是什么。VFS 定义了四个核心对象:
| 对象 | 代表什么 | 类比 |
|---|---|---|
| superblock | 一个已挂载的文件系统整体 | 一栋楼的产权信息 |
| inode | 一个文件的元数据(大小、权限、时间、数据块在哪) | 一个房间的档案(注意:不含文件名!) |
| dentry | 目录项——把文件名和 inode 关联起来 | 门牌号 → 房间档案的指针 |
| file | 一个打开的文件实例(含读写位置 offset) | 你手里那把钥匙 + 你走到第几步 |
墨:这四个对象的划分极其精妙,我挑两个点让你体会:
① 为什么文件名不在 inode 里?
你想想:硬链接(ln a b)是什么?两个名字指向同一个文件。如果文件名存在 inode 里,一个 inode 怎么存两个名字?
把”名字”抽出来放在 dentry 里,硬链接就自然成立了——两个 dentry 指向同一个 inode。inode 里有个 nlink 计数,记录”有几个名字指着我”。你 rm 一个文件,其实是删掉一个 dentry 并把 nlink 减 1;只有 nlink 减到 0(且没人打开着),才真正释放数据块。
⚠️ 这解释了一个经典生产故障:你 rm 了一个 100G 的日志文件,df 显示磁盘空间没有释放!
为什么?因为还有进程打开着这个文件(比如那个正在写日志的服务)。dentry 删了(ls 看不到了),但 struct file 还在,inode 的引用计数不为 0,数据块不能释放。
排查与修复:
lsof | grep deleted # 找到还打开着已删除文件的进程
lsof +L1 # 更直接:列出 nlink=0 但还被打开的文件
# 修法:重启该进程,或者(如果是日志)用 truncate 而不是 rm:
: > /var/log/huge.log # 或 truncate -s 0 file教训:清日志永远用
truncate/: >,不要用rm。 用rm会让空间在进程重启前不释放,而且服务还在往那个”看不见的文件”里写。 更好的做法是用logrotate配copytruncate,或让应用支持SIGHUP重开日志文件。
② file 对象为什么要独立存在?
因为两个进程打开同一个文件,各自的读写位置(offset)是独立的。offset 属于”这次打开”而不属于”这个文件”,所以必须有个独立的 struct file 来存。
而 fork() 之后父子进程共享同一个 struct file —— 所以它们的 offset 是联动的!这解释了一个诡异现象:父子进程同时写同一个 fd,不会互相覆盖(offset 共享,依次往后写);但如果各自 open 同一个文件,就会互相覆盖(各自独立 offset)。
这套”superblock / inode / dentry / file”四层划分,是把”文件”这个概念里混在一起的四种关注点(文件系统整体 / 文件本身 / 名字 / 打开状态)彻底分离的结果。 分离之后,硬链接、符号链接、独立 offset、
/proc这种伪文件系统,全都自然成立,不需要特例。好抽象的标志:新需求来了不用加特例,现有概念一组合就出来了。
3.2 dentry cache:为什么路径查找需要缓存?
墨:你 open("/var/log/app/today.log"),内核要做什么?
逐级查找:根目录 / 的 inode → 找 var 的 dentry → 得到 var 的 inode → 找 log → … → 一共 4 次目录查找,每一次都可能要读磁盘!
所以内核疯狂缓存 dentry(叫 dcache)。这就是上一章 slabtop 里看到 dentry 占十几 G 的原因——它是路径查找的加速器。
⚠️ 相关参数复习(上一章讲过,这里补场景):vm.vfs_cache_pressure
- 一个跑 CI 的机器,每天创建删除几百万个小文件,
dentry缓存膨胀到 20G,挤压了构建任务的内存。 - 这种场景
vfs_cache_pressure调高(200~500)是对的:这些路径大多不会重复访问,缓存价值低,该积极回收。 - 反过来,一个 web 服务器反复访问同一批静态文件路径,调低(50)保留 dentry,路径查找全命中。
3.3 参数穿插:fs.* 家族——文件系统层的额度管理
墨:趁着 VFS 讲完,把 /proc/sys/fs 下面的核心参数交代清楚。它们的共同主题是**“额度”**——内核不信任用户程序,所以处处设上限。
fs.file-max —— 全系统 fd 总量
- 控制什么:整个系统所有进程能打开的文件描述符总数。
- 控制背景:每个打开的文件在内核里是
struct file+struct inode的引用,占内核内存(不可换出!)。上限防止”全系统 fd 耗尽”这种全局性灾难。 - 默认值:现代内核按内存自动算,通常很大(几十万到几百万),很少成为瓶颈。
- 诊断:
cat /proc/sys/fs/file-nr→ 输出已分配 已用但空闲 上限。第一个数逼近第三个数才需要担心。
fs.nr_open —— 单进程 fd 的硬天花板
- 控制什么:
RLIMIT_NOFILE能被设到的最大值(默认 1048576)。 - 为什么需要它:防止管理员把某个进程的
ulimit -n设成一个荒谬的值(如 10 亿),导致内核为 fd 表分配巨量内存。 - 三层关系再强调一次(第 04 章提过,这里给完整图):
fs.file-max ← 全系统总量上限
↑
fs.nr_open ← 单进程能被设到的最大值
↑
RLIMIT_NOFILE ← 该进程实际的软/硬限制(ulimit -n / systemd LimitNOFILE)
↑
进程实际打开的 fd 数
⚠️ EMFILE vs ENFILE,一个字母之差,根因完全不同:
EMFILE(Too many open files):这个进程超了RLIMIT_NOFILE。→ 改ulimit -n/ systemdLimitNOFILE,或者查 fd 泄漏。ENFILE(Too many open files in system):全系统超了fs.file-max。→ 改fs.file-max,或者找哪个进程在疯狂开 fd。
排查 fd 泄漏:
ls /proc/<pid>/fd | wc -l # 该进程当前 fd 数
ls -l /proc/<pid>/fd | awk '{print $11}' | sort | uniq -c | sort -rn | head
# ↑ 按目标分类统计,一眼看出是 socket 泄漏还是文件泄漏
cat /proc/<pid>/limits | grep "open files" # 该进程实际生效的限制fs.aio-max-nr —— 异步 IO 的请求额度
- 控制什么:全系统能同时在飞的 native AIO 请求数(默认 65536)。
- 场景:MySQL InnoDB 用 native AIO 做数据文件读写。多实例部署时容易撞上限,报
io_setup() failed with EAGAIN。 - 权衡:调高(如 1048576)= 更多并发异步 IO,代价是内核为这些请求上下文分配的内存。这个内存不大,所以在 DB 机器上调高基本是无脑正确的。
fs.inotify.max_user_watches —— 文件监听额度
- 控制什么:每个用户能建立的 inotify 监听数(默认常见 8192 或 65536)。
- 控制背景:每个 watch 在内核里占约 1KB 不可换出的内存。上限防止一个用户把内核内存吃穿。
- 谁会撞上:
- IDE(VSCode/IntelliJ)监听大项目目录;
webpack/vite的热重载;- K8s 的 kubelet / fluentd 监听大量容器日志文件;
- Dropbox/同步工具。
- 现象:
ENOSPC (No space left on device)—— ⚠️ 这个错误码极具欺骗性! 你会以为磁盘满了,df一看空间充足,查半天。实际是 inotify watch 用完了。 - 权衡:调高(524288)= 能监听更多文件,代价是内核内存(52万 × 1KB ≈ 512MB 上限,实际按需分配)。在 K8s 节点和开发机上,这几乎是必调项。
# 诊断:看谁在用 inotify
for p in /proc/*/fd/*; do readlink $p 2>/dev/null | grep -q inotify && echo $p; done \
| cut -d/ -f3 | sort | uniq -c | sort -rn | headfs.protected_hardlinks / fs.protected_symlinks(默认 1)
- 安全参数,顺带说,因为它体现了一种设计思路。
- 防什么:经典的 symlink 攻击——攻击者在
/tmp里建一个符号链接指向/etc/shadow,骗一个 root 进程去写它。 - 机制:开启后,只有当符号链接的所有者 == 使用者,或目录 sticky 且所有者匹配时,才允许跟随。
- 权衡:极少量兼容性风险,换来一整类攻击的消失。默认开启是对的,别关。
四、撕掉谎言:fsync 和它的代价
墨:现在回到开头。page cache 的谎言很美好,但有些程序受不了这个谎言——数据库说”我告诉用户事务提交成功了,断电不能丢”。
这就需要 fsync(fd):强制把这个文件的所有脏页写到物理介质,并且等它真的完成才返回。
4.1 一次 fsync 到底做了多少事?
墨:很多人以为 fsync 就是”把脏页刷下去”。远远不止。以 ext4 为例:
fsync(fd)
↓
1. 找到这个文件所有的脏页,提交写请求到块层
↓
2. 等这些写请求全部完成(磁盘中断回来)
↓
3. 还没完!文件的 **元数据**(inode:大小、修改时间、数据块指针)也变了,
这些元数据在文件系统的 journal(日志)里,也得刷!
↓
4. 提交 journal 事务:写 journal 描述块 → 写数据 → 写 commit 块
而且 commit 块必须在前面都落盘后才能写(**顺序约束**)
↓
5. 发 **FLUSH/FUA 命令**给磁盘,要求它把自己的**易失性写缓存**也刷到闪存/盘片
(磁盘自己也有 DRAM 缓存,也会撒谎!)
↓
6. 等磁盘确认。返回。
墨:看到关键了吗?fsync 里至少有 2~3 次”必须等待完成”的同步点,而且中间有严格的顺序约束——不能并行。
这就是
fsync慢的本质:它不是”写得多”,是”必须串行地等好几次往返”。 磁盘单次写可能 100μs,但一次fsync因为要走 3 次串行往返 + FLUSH,可能是 1~10ms;如果磁盘正忙(队列里排着别人的请求),可能 几十到几百毫秒。
4.2 那 200ms 的日志卡顿——真实事故
墨:走个诊断环。这个案子很典型。
现象
一个 Go 写的支付网关,P99 延迟正常 25ms,但每隔几秒会有一个请求突然 200~400ms。规律很怪:
- 不是固定周期,但大致每 3-8 秒一次;
- 被卡住的请求随机,不是特定接口;
- CPU、内存、网络监控全部正常;
- 数据库侧 RT 正常。
推断一:GC 停顿(推错)
Go 的 GC?验证:GODEBUG=gctrace=1 看 GC 日志。STW 停顿全部 <1ms(Go 1.14+ 的 GC 早就不是问题了)。否掉。
推断二:下游超时重试(推错)
验证:全链路埋点显示,卡住的时间发生在业务逻辑内部,不在任何网络调用上。而且卡的位置……在写审计日志那一行。
墨:线索来了!”写日志”那行代码卡 300ms。你的第一反应是什么?
你:磁盘慢?
推断三:磁盘慢(部分对,但没找到根因)
验证:iostat -x 1:
Device r/s w/s rkB/s wkB/s await w_await %util
nvme0n1 12 340 200 8200 0.9 1.1 6.2
await 只有 1ms,%util 6%。磁盘闲得很,而且很快。 一块 NVMe,不可能让你卡 300ms。
推断三否掉(至少不是”磁盘整体慢”)。 但矛盾出现了:磁盘很快,写日志却卡 300ms。 矛盾就是线索。
关键一步:直接问内核,这个进程在等什么
墨:这时候不要再猜了。当一个进程”莫名卡住”时,最直接的手段是看它的内核栈:
# 找到卡住的线程(D 状态或长时间不动的)
cat /proc/<pid>/task/<tid>/stack或者更好的办法,用 offcputime(bpftrace/BCC 工具)看”进程离开 CPU 在等什么”:
# 抓 5 秒内所有超过 100ms 的 off-CPU 事件及其内核栈
offcputime-bpfcc -m 100000 5输出里出现了这样的栈:
finish_wait
jbd2_log_wait_commit ← !!!
ext4_sync_file
do_fsync
__x64_sys_fsync
...
- nginx-audit (12345)
312 ms
墨:jbd2_log_wait_commit —— 这是 ext4 的 journal 提交等待!
真根因:被别人的 journal 事务连带
画面拼起来了:
- 这个审计日志库,每写一条日志就调一次
fsync(为了”日志不丢”); fsync要提交 ext4 的 journal 事务;- ext4 的 journal 是全文件系统共享的、事务是批量提交的——jbd2 会把一段时间内所有的元数据变更攒成一个事务一起提交;
- 同一个文件系统上,还有另一个进程在疯狂写大文件(一个数据导出任务)。它产生了大量元数据变更,让 journal 事务变得很大;
- 你的小小一行日志的
fsync,必须等这个包含了别人几百 MB 变更的大事务整个提交完成!
根因一句话:
fsync的粒度不是”你的文件”,而是”整个文件系统的 journal 事务”。你被邻居的写入连带了。
墨:老哥,这个机理特别值得记。它解释了一大类”我明明只写一点点,却卡很久”的怪事。而且它说明:iostat 的 await 是”单个 IO 请求的平均等待”,它完全无法暴露”我在等一个包含几百个 IO 的事务整体完成”这种延迟。 平均值会骗人。
调整 → 观察 → 再改进
改进 1:去掉每条日志的 fsync(治本,但要想清楚权衡)
- 审计日志真的需要”每条都保证落盘”吗?业务评估后结论:可以接受最坏丢 1 秒的日志(有上游对账兜底)。
- 改成:攒批 + 每秒一次
fsync(或者干脆交给内核的周期写回)。 - 权衡:拿”最坏丢 1 秒日志”换”P99 稳定”。这是个业务决策,不是技术决策——必须让业务方明确签字接受。
- 观察:P99 从 400ms 尖刺降到 28ms,尖刺完全消失。
改进 2:把日志盘和数据盘分开(隔离)
- 根因里有”被邻居连带”这一环。把审计日志放到独立的文件系统/独立块设备,journal 就不共享了。
- 权衡:多一块盘的成本,换 IO 路径隔离。对金融类系统很值。
改进 3:如果必须每条 fsync,用 fdatasync 代替
fsync会同步数据 + 全部元数据(包括mtime这种其实没人在乎的);fdatasync只同步数据 + 必要的元数据(比如文件变长了必须更新大小,但只改mtime就不刷)。- 收益:少一次 journal 往返,在追加写日志的场景能省掉相当一部分开销。
-
这是个非常实用的知识点:追加写日志的场景,
fdatasync通常比fsync快一截,语义上又足够安全。 PostgreSQL、RocksDB 等都提供了这个选项(wal_sync_method)。
改进 4:文件系统挂载选项调优
/dev/nvme0n1 /data ext4 defaults,noatime,data=ordered,commit=5 0 0
noatime:不更新文件的”最后访问时间”。- 为什么重要:默认情况下,你每次
read一个文件,内核都要更新它的atime—— 这是一次元数据写! 一个纯读的负载,居然会产生大量写 IO,荒谬但真实。 - 权衡:失去 atime 信息(极少有程序依赖它,除了某些邮件程序和
tmpwatch类工具)。换来读密集负载的写 IO 大幅减少。 现代系统的默认是relatime(只在 atime 早于 mtime 或超过一天时才更新),已经缓解了大半,但noatime更彻底。
- 为什么重要:默认情况下,你每次
commit=N:journal 提交间隔(默认 5 秒)。调大减少 journal 开销,代价是崩溃时丢更多。data=writebackvsorderedvsjournal:journal:数据也写进 journal(最安全,最慢,写两遍);ordered(默认):数据先落盘,再提交元数据 journal(平衡);writeback:不保证顺序(最快,但崩溃后文件可能出现旧数据甚至垃圾)。- 权衡极其清晰:这三档就是”崩溃一致性强度”和”写性能”的三个刻度。 数据库通常自己管一致性(有自己的 WAL),所以可以用
writeback让文件系统别多做功。
改进 5:加监控
- 把
offcputime这类工具的能力固化:监控”进程在 fsync 上的耗时分布”(可用 bpftrace 常驻采样,或应用侧埋点); - 监控
/proc/pressure/io。 - 理由:这个问题查了两天,因为传统 IO 指标(
await/%util)完全无法暴露它。
复盘
现象:P99 随机 300ms 尖刺,CPU/内存/网络/DB 全正常
→ 推断1 GC → gctrace 否掉
→ 推断2 下游超时 → 埋点否掉,但定位到"写日志"那行
→ 推断3 磁盘慢 → iostat 否掉(await 1ms,%util 6%)
→ 关键矛盾:磁盘很快,写日志却卡 300ms ← 矛盾即线索
→ 换工具层级:不再看聚合指标,直接看内核栈(offcputime / /proc/pid/stack)
→ jbd2_log_wait_commit ← 一击命中
→ 根因:每条日志 fsync + ext4 journal 是全文件系统共享的批量事务 + 被邻居大写入连带
→ 改进:攒批 fsync(业务权衡)+ 独立文件系统 + fdatasync + noatime + 监控
→ 验证:P99 28ms,尖刺消失
三个方法论收获:
- 平均值会骗人,尤其是 IO 的平均值。
await=1ms和”某个请求卡 300ms”完全可以同时成立——因为你等的不是”一个 IO”,是”一个事务整体”。看 P99/P999 和分布,不是看平均。 - 当聚合指标解释不了现象时,降到”看内核栈”这一层。
/proc/<pid>/stack、offcputime、perf—— 这些工具直接回答”这个线程此刻卡在哪一行内核代码”,是跳过所有猜测的终极手段。第 25 章工具箱会系统讲。 - “矛盾即线索”。 “磁盘很快但写文件很慢”这个矛盾,不该让你困惑,该让你兴奋——它精确地告诉你:延迟不在你以为的那一层。把矛盾当成路标。
五、Direct IO:主动放弃 page cache 的人
墨:讲完谎言的代价,该讲谁会拒绝这个谎言了。
open(..., O_DIRECT) 绕过 page cache,数据直接在用户缓冲区和磁盘之间传输。谁会这么干?
数据库。MySQL InnoDB 的 innodb_flush_method=O_DIRECT 是标准配置。为什么?
墨:你想一下,数据库自己有一个巨大的 buffer pool(缓存池)。如果不用 O_DIRECT:
- 一份数据在 InnoDB buffer pool 里有一份;
- 在 page cache 里又有一份;
- 双重缓存! 内存白白浪费一半;
- 而且内核的 LRU 策略不知道数据库的访问语义,可能把数据库认为最热的页给淘汰了;
- 数据库自己的淘汰算法(LRU + midpoint insertion,防扫描冲刷)比内核的通用策略更懂自己的负载。
O_DIRECT的设计意图:让”比内核更懂自己访问模式的应用”,接管缓存管理权。这是个重要的设计哲学:通用层提供好的默认,但必须给专业用户一个”我自己来”的逃生口。 (同一个母题:第 05 章的
nohz_full、第 04 章的io_uring、K8s 的 CRD/调度器插件……都是”通用机制 + 专家逃生口”这个 pattern。)
⚠️ O_DIRECT 的坑(别乱用):
- 对齐要求严格:缓冲区地址、偏移量、长度都必须按块大小(通常 512B 或 4KB)对齐,不然直接
EINVAL; - 失去预读和写合并:小 IO 性能可能暴跌,因为每个 IO 都真的打到磁盘;
- 不等于持久化!
O_DIRECT绕过 page cache,但磁盘自己的写缓存还在。要真持久化,还是得fsync或用O_DSYNC/FUA。这是最常见的误解。
📦 完整的持久化保证层次(必须理解清楚):
你的 buf → [page cache] → [磁盘的 DRAM 写缓存] → [闪存/盘片]
↑ ↑ ↑
write() 到这 O_DIRECT 到这 只有 fsync/FUA 到这
- 只
write():数据在 page cache。掉电全丢。 write() + fsync():数据经 page cache 到闪存(fsync 会发 FLUSH 让磁盘刷自己的缓存)。安全。O_DIRECT write():跳过 page cache,但停在磁盘写缓存。掉电可能丢!O_DIRECT + fsync()或O_DIRECT|O_DSYNC:到闪存。安全。
这就是那个著名的”fsync 骗局”话题:有些廉价 SSD/U 盘在收到 FLUSH 命令时撒谎(立刻返回成功但没真刷),导致数据库在断电测试中丢数据。这是硬件层面的谎言,内核也拿它没办法。做金融级存储选型时,必须做真实的掉电测试(
diskchecker.pl那类工具)。
六、参数总表:文件系统这一层的旋钮
| 参数 / 选项 | 控制什么 | 拿什么换什么 |
|---|---|---|
fs.file-max | 全系统 fd 总量 | 调高:支持更多总连接 ↔ 内核内存(不可换出) |
fs.nr_open | 单进程 fd 硬天花板 | 防止把 ulimit 设成荒谬值,基本不用动 |
RLIMIT_NOFILE (ulimit -n / systemd LimitNOFILE) | 单进程实际 fd 限制 | 调高:支持高并发连接 ↔ 单进程失控波及面更大。注意继承来源(systemd 而非 shell) |
fs.aio-max-nr | 全系统在飞 AIO 请求数 | 调高:多实例 DB 可用 ↔ 少量内核内存。DB 机器基本无脑调高 |
fs.inotify.max_user_watches | 每用户 inotify 监听数 | 调高:支持监听大目录树(IDE/K8s/日志采集) ↔ 内核不可换出内存。错误码是骗人的 ENOSPC |
fs.protected_symlinks/_hardlinks | 符号/硬链接攻击防护 | 默认 1,别关。极小兼容风险换一整类攻击消失 |
/sys/block/*/queue/read_ahead_kb | 预读窗口 | 调大:顺序读吞吐飞升 ↔ 随机负载下带宽和缓存被严重浪费。必须匹配负载模式 |
vm.vfs_cache_pressure | dentry/inode 回收积极度 | 调高:内存释放快 ↔ 路径查找变慢 |
挂载 noatime / relatime | 是否更新访问时间 | noatime:读负载不再产生元数据写 ↔ 失去 atime(极少程序需要) |
挂载 data=journal/ordered/writeback | 崩溃一致性强度 | 三个刻度,从”最安全最慢”到”最快但崩溃可能见到垃圾数据” |
挂载 commit=N(ext4) | journal 提交间隔 | 调大:journal 开销小 ↔ 崩溃丢更多 |
nobarrier/barrier=0 | 是否发 FLUSH 屏障 | 危险:关掉快很多 ↔ 掉电可能文件系统损坏。只在有电池保护的 RAID 卡上考虑 |
O_DIRECT(应用层) | 绕过 page cache | 避免双重缓存、应用自主管缓存 ↔ 失去预读/合并、对齐要求严、仍需 fsync 才持久 |
fsync vs fdatasync | 同步范围 | fdatasync 省掉非必要元数据的 journal 往返,追加写日志场景更快且足够安全 |
posix_fadvise(DONTNEED) | 主动丢弃缓存 | 备份/一次性读取时保护别人的热缓存 ↔ 需要应用配合 |
七、这章的认知钉子
write()返回成功不代表落盘。 page cache 的 write-back 是一个”必须存在的谎言”——它让写延迟从毫秒级降到纳秒级。代价是断电丢数据,和fsync时的一次性还债。- page cache 是系统里最重要的一块内存,把内存用满做缓存是正确的。 干净页回收是零成本的,留着只有收益。
- 高 load + 低 CPU = IO 瓶颈信号。 因为 load 统计包含
D状态(不可中断等 IO)的进程。这个区分能救你很多次。 - VFS 的 superblock/inode/dentry/file 四层划分,把”文件”里混杂的四种关注点彻底分离,于是硬链接、独立 offset、
/proc伪文件系统全都自然成立。好抽象的标志是”新需求不用加特例”。 rm一个被打开的文件不释放空间。 清日志用truncate,不用rm。用lsof +L1查。fsync慢的本质是”多次串行往返 + 顺序约束”,而且它的粒度可能是整个文件系统的 journal 事务 —— 你会被邻居的大写入连带。- 平均值会骗人。
await=1ms与”某请求卡 300ms”可以同时成立。当聚合指标解释不了现象时,降到看内核栈那一层(/proc/pid/stack、offcputime)。 - 矛盾即线索。 “磁盘快但写文件慢”这类矛盾,精确地告诉你延迟不在你以为的那一层。
O_DIRECT体现”通用机制 + 专家逃生口”的设计哲学,但它不等于持久化——磁盘自己还有写缓存。ENOSPC不一定是磁盘满(可能是 inotify watch 用完、也可能是 inode 用完df -i)。错误码会骗人,要交叉验证。
**
实战补遗一:page cache 的”好心办坏事”——fadvise 用错与 drop_caches 自残
墨:第 7 章说 page cache 是白嫖的加速。但”缓存”用错了方向也会害人。讲两个:一个是 fadvise 用反,一个是 drop_caches 自残(接第 0 章那个 cron 翻车)。
你:fadvise 不是告诉内核”这段我马上用/以后不用”吗?用反了会怎样?
墨:讲个真实事故。事故一:fadvise(DONTNEED) 用反方向。一个备份程序读大文件,程序员”好心”在读完每段后调 posix_fadvise(fd, off, len, POSIX_FADV_DONTNEED),意思是”这段我读完了,内核你可以扔了”。但他对整个文件每段都标了 DONTNEED——结果:备份程序顺序读文件 A,内核刚缓存就被他主动扔掉;同时另一个服务正在随机读同一个文件 A(热点查询),本来能命中 page cache,现在缓存被备份程序持续清空,命中率从 95% 掉到 5%,随机读全打磁盘,延迟暴涨。
推断:DONTNEED 的语义是”我(调用者)以后不用了”,但它不分调用者——内核扔的是”这段文件的缓存”,所有进程共享的 page cache 一起没。备份程序以为”我只扔我自己的”,实际把全局缓存搞没了。验证:cat /proc/vmstat | grep pgfault 和 cache 命中率(sar -B 的 frmpg/bufpg)看随机读服务 majflt( major fault,要读盘)暴涨。
调整:备份程序别对整个文件标 DONTNEED,或改用 POSIX_FADV_NOREUSE(只影响预读策略、不主动扔已缓存页),让热点查询服务的缓存活下来。再观察:随机读命中率回 90%+,延迟回归。
事故二:drop_caches 当”清内存”用(接第 0 章)。有人看 free 的 buff/cache 高,写 cron 每小时 echo 3 > /proc/sys/vm/drop_caches”释放内存”。结果:每小时整点,所有依赖 page cache 的服务(数据库、Web 静态、日志检索)缓存被清空,接下来几分钟大量请求打磁盘,IO 尖峰、延迟抖动。这就是”好心办坏事”——cache 高说明缓存在干活,你手动清掉,等于每次把热身好的引擎拆了重装。
你:那 page cache 会不会”涨到没边”挤掉应用内存?
墨:正常不会——内核内存紧时会自动回收 page cache 给匿名页(第 6 章讲的水位 + LRU)。available 内存足时,内核故意留着 cache 白嫖性能。只有一种情况要手动干预:某个一次性大文件读取(如备份扫全盘)污染了缓存、挤掉了真正的热数据——这时针对那个文件用 fadvise(DONTNEED)(精准,不伤全局)比全局 drop_caches(无差别,误伤所有)高明一万倍。
🔧 思考题:你的服务启动时要预热缓存(把热点数据读进 page cache),你该用 fadvise(WILLNEED) 还是直接顺序读一遍?两者有区别吗?
【参考答案】
POSIX_FADV_WILLNEED 是”异步预读提示”:内核后台把这段读进 cache,不阻塞你的调用,适合”我待会儿要用、现在先备着”的预热。直接 read 一遍是”同步读”:你的线程卡在 IO 上把数据搬进 cache,但副作用是这次 read 本身走了一遍 IO(虽然顺带填了 cache)。
区别与选型:
- 预热且不愿阻塞业务:
WILLNEED(异步,业务线程不卡)。但注意 WILLNEED 的预读量受内核限制,超大文件可能预读不全,且它只是”建议”,内核忙时可忽略; - 预热且不介意当前慢(如启动脚本):直接顺序
cat/read一遍,简单粗暴保证填满,且顺序读能触发内核的**顺序预读(read_ahead)**把带宽吃满,比 WILLNEED 更可靠地填满 cache; - 千万别用
drop_caches当”清内存”——那是事故源。预热用 WILLNEED/顺序读,清缓存用针对单文件的 DONTNEED,全局操作是最后手段。
口诀:page cache 的”增”(预热)用 WILLNEED/顺序读,“减”(精准回收)用 DONTNEED 单文件,全局 drop_caches 是手术刀不是日常保养,和”定期放血养生”一样荒谬。
实战补遗二:O_DIRECT 与 buffered IO 的取舍——Go 里 io.Copy 为何有时比零拷贝慢一倍
墨:老哥,你要往磁盘写一个 2G 的文件,用 Go 的 io.Copy(dst, src) 一行搞定。有人跟你说”用零拷贝 sendfile 快一倍”。你信吗?先想:快在哪?
你:少一次内核态到用户态的拷贝?
墨:对,但只说对一半。关键不是”拷贝次数”,是”过不过 page cache”。我把 buffered、direct、零拷贝三者的账算给你看,这是文件系统那章最容易混的地方。
现象:一个导出服务,把内存里的大对象流式写盘,用 io.Copy 到 os.File,QPS 一高 CPU 就满,吞吐上不去。换成 mmap + 直接写,反而更慢。
推断:以为 io.Copy 慢在”用户态中转拷贝”。
可能推错:其实 io.Copy 走的是 buffered write——数据先进 page cache,内核后台回写。它几乎不阻塞、CPU 主要花在 copy_to_user/copy_from_user,这部分开销不大。我那次”更慢”是因为换成了 O_DIRECT(绕过 page cache),结果每个写都同步等磁盘,反而被磁盘 latency 卡死。
调整:用 fio 分别测三种模式(见第 8 章):
buffered:写吞吐高、延迟低(异步),但占用 page cache、可能触发回写风暴;O_DIRECT:绕过 cache,写的延迟 = 真实磁盘延迟,适合”不要污染 cache 的大块顺序写”,但 CPU 省了拷贝、却等 IO;- 零拷贝
splice/sendfile:只在”读文件→写 socket”这种场景省掉用户态拷贝,磁盘→网卡直通,CPU 最低。
再观察:我的导出服务是写盘不是发网络,零拷贝 sendfile 用不上(它要求一端是 socket)。真正优化是:顺序大块写用 bufio 加大缓冲 + 接受 buffered write 的异步回写,而不是盲目 O_DIRECT。
改进:写盘选 buffered(让内核管回写),只有”数据只写一次绝不再读”(如日志归档)才考虑 O_DIRECT 避免 cache 污染。Go 里 os.File 默认 buffered,syscall.Open 带 O_DIRECT 需要内存对齐,坑很多,非必要别碰。
🔧 思考题:零拷贝 sendfile 为什么要求”一端必须是 socket”?如果我是”读文件 A → 写文件 B”,还能零拷贝吗?
【参考答案】
sendfile(out_fd, in_fd, ...) 的内核实现是:直接从文件的 page cache 把页”挪”到 socket 的发送缓冲区,全程在内核态,不进用户态内存,所以叫零拷贝(对用户态而言零拷贝)。它要求 out_fd 是 socket,因为发送路径是 socket 专属的;in_fd 是文件(走 page cache)。这是历史实现限制——早期 sendfile 只支持”文件→socket”。
“读文件 A → 写文件 B”:传统 sendfile 不支持两个都是普通文件(老内核)。现代 Linux 有 copy_file_range() 系统调用,专门做”文件到文件”的内核态拷贝,可以不经过用户态;还有 splice 可以拼任意 pipe 端点。但在 Go 里这些还没完全暴露成顺手的 API,多数情况还是 io.Copy 走 buffered,对磁盘→磁盘拷贝性能足够(瓶颈在磁盘带宽,不在 CPU 拷贝)。
一句话选型:发网络用 sendfile/splice 省 CPU;落盘用 buffered 让内核异步回写;只有”绝不重用的大块数据”才考虑 O_DIRECT 规避 cache 污染——而 O_DIRECT 要求缓冲区内存按扇区对齐(通常 512B 或 4K),Go 里直接用容易踩 EINVAL。
实战补遗三:fadvise 正确姿势与双重缓存——page cache 和你的应用缓存重叠了
墨:老哥,你读过一遍大文件,再读第二遍特别快——为什么?你主动管过缓存吗?
你:是 page cache 命中吧,内核自动的。
墨:对,但很多人”主动优化”反而帮倒忙——比如用 madvise/fadvise 乱标记,或者应用自己又缓存了一份,和 page cache 叠出”双重缓存”白白浪费内存。这节讲 fadvise 的正确姿势和双重缓存陷阱。
现象:一个服务读一批大文件做处理,开发者怕 page cache 占内存,调 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) 想”读完就扔缓存”,结果第二次读巨慢(每次都重新读盘)。另一个服务在应用层用 map 缓存了热点数据,同时文件又在 page cache 里——同一份数据内存里两份,机器内存吃紧。
推断:fadvise DONTNEED 能省内存,应用缓存能加速。
可能推错:第一,FADV_DONTNEED 是”建议内核尽快回收这段缓存”,不是”立刻扔”。你读完标 DONTNEED,内核可能还没回收,但你第二次读如果隔得近,缓存还在就命中;如果内核压力大了回收了,第二次就读盘变慢。更糟的是:你只读一次的大文件标 DONTNEED 合理(别污染 cache),但反复读的热点文件标 DONTNEED 是自残——每次都重新读盘。第二,“应用缓存 + page cache 双重缓存”:你用 Go map 存了解析后的数据,同时原始文件还在 page cache(因为被读过),两份都在内存。如果数据大,等于内存利用率减半。
调整:fadvise 四个语义用对:
FADV_WILLNEED:预读,告诉内核”马上要读这段”,提前放进 cache(顺序读大文件前调,减少读阻塞)。FADV_RANDOM:关闭预读(随机读小文件,预读反而浪费 IO),内核不再盲目预读后续。FADV_SEQUENTIAL:顺序访问,内核更激进预读(放大 readahead)。FADV_DONTNEED:只读一次的大文件(如日志归档、一次性导出)用完建议回收,反复访问的热点文件千万别标。
再观察:双重缓存的解法——如果应用层已经缓存了解析后的对象(结构化数据),那原始文件的 page cache 就没用了,可以 FADV_DONTNEED 主动让内核回收原始文件缓存,把内存留给应用缓存(避免两份)。但如果应用缓存的是”原始字节”,和 page cache 重复,不如别在应用层缓存、直接用 page cache(让内核管,且多进程共享)。原则是”同一份数据只在内存里留一份”:要么应用缓存(弃用 page cache 对应部分),要么靠 page cache(不重复缓存)。
改进:
- 一次性的大文件读:
FADV_SEQUENTIAL+ 读完FADV_DONTNEED,利用预读又不污染 cache。 - 反复访问的热点:别 DONTNEED,让 page cache 命中;应用层别再缓存一份原始字节(双重浪费)。
- 应用缓存的是”加工后对象”:原始文件可 DONTNEED 释放,避免双重缓存。
- 监控
sar -B的faults/pgmajfault(主要缺页=真读盘),判断缓存命中率,别凭感觉标 fadvise。
🔧 思考题:为什么”应用层缓存热点数据 + page cache 也留着原始文件”是双重缓存浪费?什么情况下 page cache 比应用层缓存更优(值得弃用应用缓存)?
【参考答案】
双重缓存的浪费:假设一个 2G 文件被读,page cache 里有完整的 2G 原始字节;同时你的 Go 服务把解析结果(比如 1.5G 的对象)存在 map 里做应用缓存。内存里现在有两份相关数据——原始 2G(page cache)+ 解析后 1.5G(应用),共 3.5G。但原始 2G 对你的服务已经没用了(你要的是解析后的对象),它留着只是占内存,甚至挤掉别的进程的 page cache。这就是”同一份数据的两种形态都在内存”,浪费且可能触发不必要的回收/换页。
解法二选一:
- 应用缓存”解析后对象” → 原始文件读完即可
FADV_DONTNEED释放 page cache,内存只留 1.5G(解析对象),省 2G。 - 不应用缓存、直接用 page cache → 每次读文件让内核从 page cache 命中,内存只留 2G(且多进程共享这份 page cache,比各自应用缓存更省)。
什么时候 page cache 优于应用缓存(该弃用应用层缓存):
- 多进程共享同一文件:page cache 是内核级、全进程共享一份;应用层缓存每个进程各存一份,N 个进程 = N 份,内存爆炸。page cache 天然去重。
- 文件大、访问模式接近原始字节:应用缓存要自己管理淘汰(LRU)、易写错泄露;page cache 由内核用成熟的 LRU + 压力回收自动管,省心且不易错。
- 读多写少、且不需要”加工后结构”:直接读文件让内核缓存最省事。
什么时候应用缓存更优(该弃用 page cache 对应部分):
- 你需要的是加工后的对象(解析、聚合结果),原始字节没用 → 应用缓存 + 原始文件 DONTNEED。
- 访问模式高度随机且 key 维度复杂,page cache 的”文件偏移”粒度不匹配你的访问(page cache 按文件页,你按逻辑 key)→ 应用层 key 缓存命中率更高。
一句话原则:同一份数据在内存里只留一份形态。要么信任内核的 page cache(多进程共享、自动回收),要么用应用缓存但主动释放原始文件的 page cache(FADV_DONTNEED),别让两份叠着。fadvise 是”建议”不是”命令”,内核在内存压力下仍可能回收;真正的”强制释放”只有 drop_caches(全局、粗暴,见第 0 章 sysctl 实战补遗里 drop_caches 自造 IO 风暴的坑)。
下一章预告(第 08 章 块层与 IO 栈)**:page cache 未命中之后,那个 bio 请求进了块层。这一章我们跟着它走完最后一段:IO 调度器怎么排队和合并、mq-deadline/bfq/none 各自在赌什么、为什么 NVMe 时代要把调度器关掉、队列深度和 nr_requests 的权衡、以及那些”磁盘 %util 100% 但其实没满”的经典误读。顺带把 iostat 每一列到底什么意思彻底讲清——你会发现有一半人读错了。
🔧 思考题(带参考答案)
思考题 1(基础): 一台服务器 df -h 显示某分区已用 60%,还有 400G 空闲,但程序创建文件时报 No space left on device。列出至少三种可能原因和排查方法。
【参考答案】
ENOSPC 是个极具欺骗性的错误码,至少四种可能:
① inode 耗尽(最常见)
- 文件系统在格式化时就固定了 inode 数量。海量小文件会把 inode 用完,而空间还剩很多。
- 排查:
df -i(看IUse%,100% 就确诊)。 - 修法:清理小文件;或重建文件系统时用
mkfs.ext4 -i调低 bytes-per-inode(XFS 是动态 inode,不会有这问题——这也是选 XFS 的一个理由)。
② 已删除但仍被打开的文件占着空间
rm了大文件但进程还打开着,dentry 删了(du看不到)但数据块没释放,所以df和du对不上。- 排查:
lsof +L1或lsof | grep deleted;对比df和du -sh的差额。 - 修法:重启持有该 fd 的进程,或以后用
truncate/: >清日志。
③ 保留块(reserved blocks)
- ext4 默认给 root 保留 5% 空间(设计意图:磁盘满时 root 还能登录抢救、系统还能写日志)。普通用户在 95% 时就会遇到 ENOSPC。
- 排查:
tune2fs -l /dev/xxx | grep -i reserved。 - 修法:
tune2fs -m 1 /dev/xxx降到 1%(大盘上 5% 是巨大浪费)。注意别设成 0,那样磁盘满时无法抢救。
④ 根本不是磁盘满 —— inotify watch 耗尽
inotify_add_watch在超过fs.inotify.max_user_watches时返回ENOSPC。这个错误码选得极其误导。- 排查:
cat /proc/sys/fs/inotify/max_user_watches,并统计当前用量。 - 修法:调高该参数。
⑤(补充)配额限制
- 如果启用了磁盘配额(quota),用户/组超配额也报 ENOSPC。
- 排查:
quota -u <user>/repquota -a。
⑥(补充)文件系统碎片导致无法分配连续块
- 较少见,某些文件系统在极度碎片化时无法为大文件分配空间。
- 排查:
e4defrag -c(ext4)、xfs_db的 frag 命令。
方法论收获:错误码是提示,不是结论。 ENOSPC、EMFILE、ENOMEM 这几个都有多种截然不同的根因。遇到错误码先列举所有可能来源,再逐一用命令排除,而不是看到”space”就去 df。
思考题 2(机制): 为什么一个纯读取的工作负载(比如 web 服务器读静态文件),在默认配置下会产生大量磁盘写 IO?怎么消除?
【参考答案】
原因:atime 更新。
POSIX 要求文件系统记录每个文件的”最后访问时间”(atime)。默认配置下,每次 read() 一个文件,内核都要更新该文件 inode 的 atime 字段 —— 这是一次元数据修改,会产生脏页,最终写到磁盘。
于是一个”纯读”的负载,产生了:
- 大量 inode 元数据写;
- 这些写还会进 journal(如果是 ext4 的
ordered模式,元数据变更要走 journal); - 在海量小文件读取场景(如图片服务器),这个写 IO 量可以和读 IO 量同一个量级,荒谬地浪费磁盘带宽和 IOPS。
消除方法:
① 挂载 noatime(最彻底)
/dev/sda1 /data ext4 defaults,noatime 0 0
- 完全不更新 atime。
- 代价:失去 atime 信息。绝大多数程序不依赖它;少数例外:某些邮件程序(判断”邮箱是否被读过”)、
tmpwatch/tmpreaper(按访问时间清理临时文件)、某些备份策略。上生产前确认没有依赖者。
② 挂载 relatime(现代默认,折中)
- 只在以下情况更新 atime:① atime 早于 mtime/ctime;② atime 超过 24 小时未更新。
- 这保留了”这个文件今天被访问过吗”这种粗粒度语义,同时把写 IO 减少了 99%+。
- 现代 Linux 内核默认就是
relatime,所以这个问题在新系统上已经大幅缓解。但noatime仍然更彻底(尤其对 24 小时内首次访问的海量文件)。
③ 挂载 nodiratime
- 只对目录不更新 atime(文件仍更新)。是个中间选项,现在很少单独用。
验证方法:
# 挂载前后对比:纯读负载下的写 IO 量
iostat -x 1 # 看 w/s、wkB/s
# 或者精确点:
cat /proc/diskstats # 采样对比写扇区数延伸思考(这题的真正价值):这个例子揭示了一个重要的性能思维——
“我的负载是只读的”这个直觉,在有元数据的系统里往往是错的。 读操作会更新访问统计、会更新缓存状态、会写审计日志、会更新计数器……这些”隐性写”是很多性能问题的隐藏来源。
同样的 pattern 到处都有:数据库的”只读查询”会写临时表、会更新统计信息;HTTP 的”GET 请求”会写访问日志;Redis 的
GET会更新 LRU 信息。排查 IO 问题时,永远要问一句:这个”读”操作背后有没有隐性的写?
思考题 3(事故推演): 一个 Kafka broker,磁盘是 NVMe(标称 1.5GB/s 顺序写),但实际写入吞吐只有 200MB/s。iostat 显示 %util 100%、await 15ms、w/s 约 3000、avgrq-sz 很小。请分析可能原因和排查方向。
【参考答案】
关键线索是 avgrq-sz 很小 + w/s 3000 + 吞吐只有 200MB/s。算一下:200MB/s ÷ 3000 = ~66KB 每个请求——对顺序写来说太小了,NVMe 喜欢大块顺序写。
所以问题定位为:本该是大块顺序写的负载,被拆成了大量中小块请求,退化成了近似随机写。
可能原因(按可能性排):
① 过于频繁的 fsync/flush
- Kafka 配置
flush.messages=1或flush.ms很小 → 每条/每几条消息就fsync一次。 - 每次
fsync都强制切断写合并、发 FLUSH 命令、等一次完整往返。写合并的机会被彻底破坏。 - 排查:检查
log.flush.interval.messages/log.flush.interval.ms;用bpftrace/funccount统计fsync调用频率;offcputime看是否大量时间在jbd2_log_wait_commit/blkdev_issue_flush。 - 修法:Kafka 的设计哲学本来就是”依赖副本而非依赖 fsync” —— 推荐让 OS 管理刷盘(
flush.ms用默认的很大值),靠min.insync.replicas保证持久性。这是典型的”用分布式冗余替代单机 fsync”的架构取舍。
② 多分区并发写导致的”伪随机”
- 一个 broker 上有几百个 partition,每个 partition 一个 log 文件。每个文件内部是顺序写,但几百个文件交替写,在磁盘上就是随机写!
- 排查:
ls /kafka-logs | wc -l看分区数;观察是否分区数越多吞吐越差。 - 修法:减少单 broker 分区数;或者增大
log.segment.bytes和应用层的攒批(batch.size/linger.ms),让每次写单个文件的连续量更大。 - 这是个很重要的认知:“顺序写”是相对于单个文件的,多文件并发时在设备层面未必顺序。
③ 脏页写回配置导致的碎片化写回
- 如果
vm.dirty_bytes设得很小,内核会频繁地小批量写回,失去合并机会。 - 排查:
grep -E "Dirty|Writeback" /proc/meminfo观察形态;检查vm.dirty_*配置。 - 修法:适度调大(但注意上一章讲的”写回海啸”风险,要平衡)。
④ IO 调度器配置不当
- NVMe 上如果用了
bfq(为机械盘设计的复杂调度器),会引入不必要的排序开销和延迟。 - 排查:
cat /sys/block/nvme0n1/queue/scheduler。 - 修法:NVMe 通常应该用
none(设备自身有几万队列深度和内部调度)或mq-deadline。第 08 章细讲。
⑤ 队列深度不足
nr_requests太小、或应用没有足够并发让队列填满,NVMe 的并行能力(多队列)没被利用。- 排查:
cat /sys/block/nvme0n1/queue/nr_requests;iostat -x的aqu-sz(平均队列长度)如果很小,说明没喂饱设备。
⑥ 文件系统层面的放大
- ext4 的 journal 在高元数据变更下产生额外写;
- 如果开了
data=journal,数据被写两遍。 - 排查:
mount | grep kafka-logs看挂载选项;对比iostat的写量和应用层实际写入量(写放大倍数是个关键指标)。 - 修法:
data=writeback(Kafka 自己有副本和校验)、noatime、考虑用 XFS(Kafka 官方推荐,大文件顺序写场景表现更好)。
⑦ %util 100% 的误读警告
- ⚠️ 对 NVMe 这种支持高并发的设备,
%util100% 不代表饱和!%util的定义是”有 IO 在处理的时间占比”,一个能同时处理 64 个请求的 NVMe,哪怕只有 1 个请求在跑,%util也是 100%。 - 正确的饱和度指标是
aqu-sz(平均队列长度)和await的趋势,以及和厂商标称 IOPS/带宽的对比。 - 这是第 08 章会重点纠正的经典误读。
排查顺序建议:
- 先算写放大:应用层写入量 vs
iostat实际写量。倍数大 → 文件系统/journal/双写问题。 - 统计
fsync频率(bpftrace)。频率高 → ①。 - 看分区数和
avgrq-sz的关系 → ②。 - 检查调度器、
nr_requests、aqu-sz→ ④⑤。 - 用
biosnoop/biolatency(BCC)看单个 IO 的延迟分布和请求大小分布 —— 这是最直接的证据。
思考题 4(进阶·总账): 你要为一个”自研的分布式存储引擎”做单机 IO 层的设计决策。请回答:① 用不用 page cache?② 怎么保证持久化?③ 文件系统怎么选和挂载?④ 你会给运维提供哪些可观测指标?每项说明权衡。
【参考答案】
这是一道综合设计题,要把本章所有机制串起来做取舍。
① 用不用 page cache?—— 答案是”分开看,混合策略”
-
数据文件:用
O_DIRECT+ 自建 buffer pool。- 理由:避免双重缓存(page cache + 自己的 cache 各存一份,浪费一半内存);自己的淘汰策略比内核的通用 LRU 更懂访问模式(可以做 midpoint insertion 防扫描冲刷、可以按 key 热度而非页热度、可以做预取预测)。
- 代价:必须自己实现缓存管理、对齐处理、预读逻辑——工程量大,bug 风险高。而且失去内核预读和写合并,小 IO 性能可能不如预期,必须自己攒批。
- 权衡结论:如果引擎的核心竞争力就是缓存效率(数据库类),值得;如果是简单的 blob 存储,直接用 page cache 更省事且效果不差。
-
WAL(预写日志):可以用 page cache +
fdatasync,也可以O_DIRECT|O_DSYNC。- WAL 是纯追加顺序写,page cache 的写合并对它有帮助;
- 但因为每次提交都要持久化,缓存的收益有限。
- 建议:提供配置项让用户选(像 PostgreSQL 的
wal_sync_method),因为最优选择取决于具体硬件(有掉电保护的企业级 SSD vs 普通 SSD,结论不同)。
② 怎么保证持久化?—— 分层设计 + 明确的语义承诺
- 必须理解完整的持久化链:用户缓冲 → page cache → 磁盘写缓存 → 闪存。只有
fsync/fdatasync/FUA 才穿透到最后一层。 - WAL 优先(write-ahead logging):数据修改先顺序写 WAL 并
fdatasync,再异步更新数据文件。这样只有 WAL 需要同步(顺序写,快),数据文件可以慢慢刷。 - 用
fdatasync而非fsync:WAL 是追加写,不需要同步无关元数据(mtime),省一次 journal 往返。 - 攒批提交(group commit):多个并发事务的 WAL 攒到一起,一次
fdatasync服务多个事务。这是数据库的标准手法,把”每事务一次 fsync”摊薄成”每批一次”。- 权衡:增加单事务延迟(要等攒批),换来吞吐大幅提升。提供
commit_delay类参数让用户调。
- 权衡:增加单事务延迟(要等攒批),换来吞吐大幅提升。提供
- 提供可配置的持久化等级(这是关键的产品决策):
sync:每次提交fdatasync,最安全最慢;batch:攒批 fsync,丢最近 N ms;async:交给 OS 写回,最快,崩溃丢最多。- 理由:持久化强度是业务决策,不该由引擎独断。像 Redis 的
appendfsync always/everysec/no。
- 必须做真实掉电测试:有些 SSD 对 FLUSH 撒谎。用
diskchecker.pl或真的拔电源验证。不测就是在赌硬件的诚信。
③ 文件系统选择与挂载
- 选 XFS(而非 ext4),理由:
- 动态 inode(不会 inode 耗尽);
- 大文件、大 IO、高并发场景表现更好(延迟分配、extent 管理更优);
- 对多线程并发写同一文件系统的扩展性更好(ext4 的 journal 有更强的全局串行点——正是本章那个事故的机理)。
- 代价:XFS 无法缩小(只能扩大);某些极端崩溃场景的修复工具链不如 ext4 成熟。
- 挂载选项:
noatime:消除读产生的元数据写。必选。nodiratime(被 noatime 包含)。- ext4 的话用
data=writeback:理由——引擎自己有 WAL 和校验和保证一致性,不需要文件系统再做一遍顺序保证。代价:崩溃后未 fsync 的文件可能包含旧数据/垃圾,但引擎的校验和会检出并回放 WAL 修复。这是”上层已有保证,下层别做重复功”的经典取舍。 - 绝不用
nobarrier/barrier=0:除非有电池保护的 RAID 卡,否则掉电会导致文件系统损坏。这不是性能优化,是拿数据赌博。
- 独立文件系统/独立设备放 WAL:
- 理由:本章那个事故的核心教训——journal/fsync 会被同文件系统的邻居连带。WAL 的延迟是全局关键路径,必须隔离。
- 代价:多一个设备/分区的运维复杂度。对存储引擎绝对值得。
④ 必须暴露的可观测指标
这一项最容易被忽略,但它决定了运维能否诊断问题。 分四类:
a) 引擎自身的
- buffer pool 命中率、脏页比例、淘汰速率;
- WAL 写入量、
fdatasync次数与耗时分布(P50/P99/P999,不是平均!); - group commit 的批大小分布;
- 写放大倍数(实际磁盘写量 / 逻辑写入量)——这是存储引擎的核心健康指标。
b) 内核 IO 层的
/proc/pressure/io(PSI);iostat的await/aqu-sz(不要只看%util,NVMe 上它会骗人);Dirty/Writeback量(/proc/meminfo);- 块层延迟分布(可用
biolatency定期采样)。
c) 内存层的
- 若用
O_DIRECT,监控自己的 buffer pool; - 若用 page cache,监控
MemAvailable、allocstall_*(直接回收)、PSI memory; - 进程 RSS 与预期的差额(第 06 章那个 glibc arena 的教训)。
d) 兜底的”卡在哪”能力
- 提供一个内置的慢 IO 追踪:当单次 IO 超过阈值时,记录当时的内核栈或至少记录调用点和耗时。
- 理由:本章事故里,查了两天最后靠
offcputime看内核栈才定位。如果引擎自己就能吐出”这次 fsync 卡了 300ms,发生在 XX 文件”,那就是 5 分钟定位。
串起来的一句话:单机存储引擎的 IO 设计,核心是三个决策——
① 缓存管理权在谁手里(page cache vs 自建,取决于你是否真的比内核更懂自己);② 持久化的边界画在哪(WAL + 攒批 + 可配置等级,把业务决策交还业务);③ 关键路径的隔离(WAL 独立设备,避免被邻居的 journal 事务连带)。 而所有这些取舍是否正确,只能靠可观测性来验证 —— 所以第四项(指标)不是附加功能,是前三项的前提。