Skip to content

第八章:存储数据路径

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_rw

writei() 修改的是 buffer cache 中的块;log_write() 把该块加入当前文件系统 transaction;最后一个未完成文件系统操作执行 end_op() 时会触发 commit。底层 virtio_disk_rw() 提交请求后睡眠,直到 completion interrupt。

这条路径没有 Linux 式后台 writeback,也没有 NVMe。它先把“文件块—缓存块—日志块—设备请求”的关系讲清楚。

Buffer cache

xv6 的 bio.c 维护固定数量 struct bufbget(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 doorbell

SQ/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_operationsaddress_space_operations 或 iomap 路径接入。可从 VFS 的 address_space 文档 看到这些字段和回调,而不是把 page cache 理解成一块独立、无结构的“内核缓冲区”。

预读

内核观察到顺序读取时会预读后续页面,让设备 I/O 与应用计算重叠。猜对时延迟被隐藏;随机访问下过度预读浪费带宽和缓存。

回写

写回则聚合、排序脏页,提高设备吞吐。它不能无限推迟:脏数据占比、年龄或显式同步会触发回写。若生产脏页快于设备落盘,内核必须 throttle 写进程,让背压回到应用。

脏页节流将存储设备的处理能力反馈给写入进程。若应用长期以内存速度产生脏页而不受限制,脏页会持续占用内存,并在后续形成集中回写和长时间停顿。

具体状态可从 /proc/meminfoDirty/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.cdrivers/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

对同一文件连续读取两次并比较耗时时,应先确认第二次读取是否命中页缓存,再判断结果能否反映设备性能。

练习

  1. xv6 buffer cache 为什么同时承担缓存和“同一块只有一个内存副本”的同步责任?
  2. 一次 xv6 缓存未命中的读取经过哪些软件层和设备步骤?
  3. SSD 的 FTL 为什么会产生写放大?NVMe 多队列解决的是哪一类瓶颈?
  4. Linux buffered write() 返回后,数据可能停在哪些易失层?
  5. direct I/O 绕过页缓存后,为什么仍不能自动获得持久化承诺?

上一章:设备 I/O · 下一章:文件系统语义 →