Appearance
第八章:存储数据路径
write(fd, buf, 4096) 返回时究竟完成了什么,取决于内核实现和设备协议。先看 xv6 的短路径,再扩展到 Linux 页缓存、NVMe 控制器和 NAND。
本章分析数据路径及其中的易失状态;下一章在此基础上讨论崩溃一致性和持久化语义。
xv6 存储路径
xv6 的普通文件写入可以沿以下函数追踪:
text
sys_write
→ filewrite
→ begin_op
→ writei
→ bread
→ log_write
→ end_op
→ commit
→ bwrite
→ virtio_disk_rwwritei() 修改的是 buffer cache 中的块;log_write() 把该块加入当前文件系统 transaction;最后一个未完成文件系统操作执行 end_op() 时会触发 commit。底层 virtio_disk_rw() 提交请求后睡眠,直到 completion interrupt。
这条路径没有 Linux 式后台 writeback,也没有 NVMe。它先把“文件块—缓存块—日志块—设备请求”的关系讲清楚。
Buffer cache
xv6 的 bio.c 维护固定数量 struct buf。bget(dev, blockno) 在双向链表中查找块;命中后增加 refcnt 并取得 sleeplock,未命中则复用一个 refcnt == 0 的 buffer。bread() 在 buffer 尚无有效数据时调用磁盘,bwrite() 写回,brelse() 释放引用并把 buffer 移到 MRU 位置。
buffer cache 同时承担两项职责:
- 避免反复读取同一磁盘块;
- 保证内核中同一
(dev, blockno)只有一个缓存副本,便于文件系统同步。
它缓存的是磁盘块,不是 Linux 中按文件偏移组织的统一 page cache。先理解这个较小结构,再看 Linux folio、address_space 和 writeback。
现代存储栈
存储层次
DRAM 可按字节随机访问,断电丢失;SSD 以页编程、以更大的擦除块回收,写入前还可能需要垃圾回收;文件系统却提供按字节读写、可变长度文件和目录名字。
各层通过缓存、映射和队列衔接不同的访问粒度与性能特征:
text
应用字节流
→ VFS / 文件系统逻辑块
→ page cache 中的内存页
→ 块 I/O 请求
→ NVMe submission queue
→ 控制器缓存 / FTL
→ NAND page 与 erase block每一层都可能执行缓存、重排或批处理,因此也构成独立的错误与持久化边界。
SSD 地址转换
NAND flash 通常不能原地覆盖。控制器把逻辑块地址(LBA)映射到物理 flash page;更新会写到新位置,再让旧页失效。垃圾回收把仍有效的数据搬走并擦除整个 block。
这套 Flash Translation Layer(FTL)带来几个现象:
- 顺序逻辑写不一定对应连续物理位置;
- 后台垃圾回收会制造延迟尖峰;
- 实际 flash 写入量可能大于主机写入量,即写放大;
- over-provisioning 和 TRIM/Discard 能帮助控制器回收;
- 磨损均衡避免少数物理块过早失效。
主机通过稳定的 LBA 访问设备,FTL 在内部实施地址重映射和写时分配。该接口隐藏物理位置,但垃圾回收造成的尾延迟和介质寿命仍会影响可观察行为。
NVMe 队列
传统接口的单队列与锁不适合多核和高度并行的 SSD。NVMe 支持多组 submission/completion queue,通常让不同 CPU 减少争用。驱动把命令描述符放入内存队列,写 doorbell;控制器 DMA 读取请求并把完成项写回。
一次提交可以落到四个可检查的动作:
text
host 写 SQ entry
→ host 写 SQ tail doorbell
→ controller DMA 写 CQ entry,并翻转 phase tag
→ host 消费 CQ entry,写 CQ head doorbellSQ/CQ 位于主存,doorbell 位于控制器 MMIO 寄存器。host 不能只看某个内存槽是否非零,因为 ring 会绕回;CQ entry 的 phase tag 用来区分这一轮的新完成和上一轮残留。完整字段与所有权规则可查 NVMe Base Specification 2.2。
足够的队列深度使控制器能够并行利用多个通道;设备饱和后,新增请求只会增加排队时间。数据库和延迟敏感服务通常需要限制在途请求数量。
NVMe 完成表示设备接受并完成了命令定义的工作,不自动回答数据在突然掉电时是否存活。控制器易失写缓存、flush/FUA 和电容保护仍决定持久性边界。
页缓存
普通 buffered I/O 通常先与 page cache 交互。读取命中缓存就不访问设备;写入先修改缓存页并标记 dirty,稍后由 writeback 批量落盘。
这解释了几个常见现象:
- 第二次读文件更快,可能只是内存命中;
write()很快返回,不表示设备已写;- “已用内存很高”可能是可回收文件缓存,不是泄漏;
- 大量脏页积累后,后续写入可能突然被节流或承担回写。
页缓存还统一了 read/write 与文件 mmap 的数据来源,使二者通常访问同一组缓存页。direct I/O 与映射并存时,则需要额外处理缓存一致性。
在 Linux 中,文件的 struct address_space 把 page-cache folio 按文件偏移组织起来,并记录 dirty、writeback 等状态。VFS 和文件系统通过 file_operations、address_space_operations 或 iomap 路径接入。可从 VFS 的 address_space 文档 看到这些字段和回调,而不是把 page cache 理解成一块独立、无结构的“内核缓冲区”。
预读
内核观察到顺序读取时会预读后续页面,让设备 I/O 与应用计算重叠。猜对时延迟被隐藏;随机访问下过度预读浪费带宽和缓存。
回写
写回则聚合、排序脏页,提高设备吞吐。它不能无限推迟:脏数据占比、年龄或显式同步会触发回写。若生产脏页快于设备落盘,内核必须 throttle 写进程,让背压回到应用。
脏页节流将存储设备的处理能力反馈给写入进程。若应用长期以内存速度产生脏页而不受限制,脏页会持续占用内存,并在后续形成集中回写和长时间停顿。
具体状态可从 /proc/meminfo 的 Dirty/Writeback 和 /proc/vmstat 观察;具体路径可从 balance_dirty_pages() 追到后台 flusher。若设备回写失败,mapping 会记录 wb_err,随后由 fsync() 等调用汇合并报告。错误不是抽象地“晚一点出现”,而是沿 address_space 的 error cursor 传播。
I/O 路径
Direct I/O 尝试绕过 page cache,常由数据库等拥有自己缓存与调度的系统使用。它可以避免双重缓存和复制,也通常要求地址、长度和偏移满足对齐约束。
绕过页缓存不会绕过设备控制器缓存,也不自动提供持久性;文件系统元数据仍需更新。小粒度随机 direct I/O 还可能失去内核的合并与预读能力。
若应用已实现缓存淘汰、预取和一致性,direct I/O 可以避免应用缓存与页缓存重复管理;普通应用通常更适合使用内核页缓存。
块层
文件系统把逻辑操作转成块请求。块层可合并相邻请求、维护优先级、选择硬件队列并实施限速。旋转磁盘时代,减少寻道是核心;NVMe 时代,CPU 扩展性、队列映射和延迟控制更重要。
不同 cgroup 或进程共享设备时,一个后台顺序写可能挤压前台小读。I/O 调度和 cgroup 控制试图把带宽、IOPS 或延迟预算分开。但底层 SSD 的内部并行和垃圾回收并不完全可见,软件只能近似治理。
Linux 的 blk-mq 把请求放入 per-CPU software staging queue,再映射到较少或等量的 hardware dispatch queue。NVMe 驱动最终把 block request 转成 struct nvme_command。代码入口分别可从 block/blk-mq.c 和 drivers/nvme/host/pci.c 追踪。
分层错误传播
一次 I/O 可能遭遇:内存分配失败、文件系统只读、设备超时、介质错误、热拔插、控制器复位。错误还可能延迟报告:先前 buffered write 已返回,后台回写后来失败,应用下一次 fsync() 或 close() 才有机会得知。
close() 不是持久化屏障,其返回值也可能报告延迟错误。设计存储协议时,必须明确哪个调用汇合此前的异步错误,以及错误后能否重试而不重复业务操作。
实验
先在 xv6 中连续读取同一块,确认第一次经过 virtio_disk_rw(),第二次由 bcache 命中。将 NBUF 调小并制造并发访问,观察缓存块的引用计数和替换。随后追踪一次事务从 log_write() 到 commit() 的路径。下面的工具用于观察现代 Linux 存储栈。
bash
# 设备、队列与挂载关系
lsblk -o NAME,TYPE,SIZE,FSTYPE,MOUNTPOINTS
cat /sys/block/nvme0n1/queue/nr_requests 2>/dev/null
# 页缓存、脏页和回写状态
grep -E 'Cached|Dirty|Writeback' /proc/meminfo
# 观察块设备延迟与队列(若已安装)
iostat -xz 1对同一文件连续读取两次并比较耗时时,应先确认第二次读取是否命中页缓存,再判断结果能否反映设备性能。
练习
- xv6 buffer cache 为什么同时承担缓存和“同一块只有一个内存副本”的同步责任?
- 一次 xv6 缓存未命中的读取经过哪些软件层和设备步骤?
- SSD 的 FTL 为什么会产生写放大?NVMe 多队列解决的是哪一类瓶颈?
- Linux buffered
write()返回后,数据可能停在哪些易失层? - direct I/O 绕过页缓存后,为什么仍不能自动获得持久化承诺?