磁盘调度与缓存:外存访问优化

做后端的人迟早会遇到这样一个场景:监控上 CPU 利用率不高、内存也够,可接口就是慢,一翻火焰图,时间全耗在磁盘 I/O 上。你换了块更贵的 SSD,确实快了点,但高并发一上来又卡。问题的根子,往往不在"磁盘快不快",而在"磁盘被怎么访问"。

要把这件事讲清楚,得先回答一个最基础的问题:磁盘为什么慢,慢在哪个环节。 只有把磁盘读写一次数据的真实过程拆开看,你才能理解操作系统为什么要费那么大劲去重排请求顺序(磁盘调度)把数据留在内存里(缓存)

所以这一篇还是先讲机制:先看清一次磁盘访问的时间到底花在哪、调度算法是怎么重排请求的、页缓存又是怎么拦住大部分磁盘访问的,然后再回过头讲这些设计在对抗什么。

一、先拆开看:机械盘读一次数据,时间花在哪

虽然现在 SSD 越来越普及,但理解磁盘调度必须从机械硬盘(HDD)讲起——因为调度算法就是为它的物理特性而生的。一块机械盘读写一次数据,时间由三部分组成:

  • 寻道时间(seek time):磁头从当前所在的磁道,移动到目标数据所在磁道的时间。这是机械臂的物理移动,最慢,通常几毫秒;
  • 旋转延迟(rotational latency):磁头到了正确的磁道,还要等盘片转到目标扇区转到磁头下方。转速 7200 转的盘,转半圈平均约 4 毫秒;
  • 传输时间(transfer time):真正读出数据的时间,相对前两者小得多。

关键数字感:寻道和旋转动辄几毫秒,而内存访问是几十纳秒级——磁盘比内存慢了大约十万倍。这个量级差,是磁盘调度和缓存存在的全部理由。

看清这个构成你就明白了:磁盘访问最大的敌人是寻道。 如果一批请求让磁头在盘面上来回乱窜(一会儿磁道 10、一会儿磁道 2000、又回到磁道 50),光寻道就能把时间耗光。磁盘调度要做的,就是重新安排这批请求的处理顺序,让磁头少跑路

SSD 没有机械臂,没有寻道和旋转延迟,所以传统寻道型调度对它意义不大;但 SSD 仍然有自己的特性(如顺序读写优于随机、写放大、并发队列深度),后面会专门讲。

二、磁盘调度算法:怎么重排请求让磁头少跑路

假设此刻磁头在磁道 53,请求队列里要访问的磁道是:98, 183, 37, 122, 14, 124, 65, 67。不同算法处理顺序不同,磁头总移动距离也天差地别。我们逐个看机制。

FCFS(先来先服务):谁先到就先服务谁,完全不重排。磁头按 98→183→37→122→14→124→65→67 走,来回横跳,移动距离最大。它的唯一优点是公平、不会饿死任何请求,但性能最差。

SSTF(最短寻道时间优先):每次都选离当前磁头最近的那个请求。磁头在 53,最近的是 65,去 65;再选离 65 最近的……贪心地就近处理。它显著减少了寻道,但有个致命问题:饥饿。如果近处请求源源不断,远处的请求(比如磁道 183)可能永远等不到服务。

SCAN(电梯算法):这是最经典的一招。磁头像电梯一样,朝一个方向一直走到底,沿途顺便处理所有请求,到头了再掉头往回扫。比如磁头从 53 一路向大磁道方向走,处理 65、67、98、122、124、183,到边界后掉头,再处理 37、14。它的妙处在于:既大幅减少了无谓的来回,又保证了每个请求最多等"一个来回"就会被处理,不会饿死。这也是它被叫"电梯算法"的原因——电梯不会因为你在 3 楼就一直在低层转悠,它扫到顶再下来。

C-SCAN(循环 SCAN):SCAN 的变体。磁头走到一端后,不沿途返回处理,而是快速直接回到起点,再朝同方向扫。这样每个磁道的"等待时间"更均匀,避免了 SCAN 中间磁道被照顾两次、边缘磁道被冷落的不公平。

flowchart LR A["请求队列<br/>98 183 37 122<br/>14 124 65 67"] --> B{调度算法} B -->|FCFS| C["按到达顺序<br/>磁头来回横跳<br/>移动最远"] B -->|SSTF| D["每次选最近<br/>快但可能饿死远端"] B -->|SCAN/电梯| E["朝一个方向扫到底<br/>掉头再扫<br/>高效且不饿死"]

调度算法的核心思想就一句话:把磁头的物理移动距离压到最小,同时不让任何请求饿死。 SCAN/电梯算法是性能与公平之间最被广泛采用的折中,Linux 早期的 deadline、CFQ 调度器都是在这套思路上加了"超时兜底"等改进。

三、缓存:比"调度得更聪明"更狠的一招——干脆不访问磁盘

调度再聪明,磁盘也还是磁盘,再快也比内存慢十万倍。于是有了更釜底抽薪的思路:

与其优化"怎么访问磁盘",不如尽量让访问根本不落到磁盘上——这就是缓存(cache)的逻辑。

操作系统在内存里维护一块叫**页缓存(page cache)**的区域,缓存最近访问过的磁盘数据(以页为单位)。读流程变成这样:

  1. 应用发起读请求;
  2. 系统先查页缓存里有没有这块数据;
  3. 命中(cache hit):直接从内存返回,根本不碰磁盘,纳秒级返回;
  4. 未命中(cache miss):才真正发起磁盘 I/O,读出数据后顺便放进页缓存,供后续命中。

这套机制为什么有效?靠的是局部性原理:程序访问的数据往往集中在少数热点上(时间局部性——刚访问的很快还会访问;空间局部性——访问了某块,附近的块大概率也要访问)。所以系统读磁盘时常常预读(read-ahead / prefetch):你读第 1 块,它顺手把后面几块也读进缓存,赌你接下来要顺序读——对顺序访问场景,这一赌几乎稳赢。

这也解释了一个常让人困惑的现象:Linux 上 free 显示内存被用掉很多,但其实大部分是 page cache,属于"可回收"的。系统拿空闲内存当磁盘缓存用,内存一旦紧张就立刻让出来,这是好事不是内存泄漏。

四、写缓存与写回策略:快和安全的拉扯

读可以缓存,写也可以。当应用写数据时,系统有两种策略:

  • 写直达(write-through):数据同时写入缓存和磁盘,写操作要等磁盘真正落盘才返回。安全但慢,每次写都得等磁盘;
  • 写回(write-back):数据先只写进缓存就立刻返回,标记为"脏页(dirty page)",稍后由系统在合适时机(脏页太多、定时、或显式 sync)批量刷回磁盘。快,但有风险

写回为什么快?因为它做了两件事:一是让写操作不必等磁盘,应用瞬间得到返回;二是合并与重排——攒一批脏页一起刷,既能合并对同一块的多次写,又能交给调度算法排成顺序写,把寻道开销摊薄。

但写回的代价很直接:

写回策略下,数据"写成功"只是写进了内存缓存。如果此时突然断电或宕机,还没刷盘的脏页就丢了。这就是持久性(durability)和性能之间永恒的拉扯。

这个拉扯是数据库、文件系统设计的核心矛盾之一。数据库为此发明了 WAL(预写日志):真正的数据页可以慢慢写回,但关键的修改先以顺序追加的方式写进日志并强制落盘(fsync),这样既享受了顺序写的快,又保证了崩溃后能靠日志恢复。后端写代码调用 fsync、数据库配置 innodb_flush_log_at_trx_commit,本质上都是在这条"快 vs 安全"的滑杆上选位置。

五、SSD 时代:寻道消失了,但优化没消失

SSD 用闪存颗粒存储,没有磁头和盘片,所以没有寻道和旋转延迟,随机访问比 HDD 快几个数量级。那磁盘调度是不是没用了?并不是,只是关注点变了:

  • 顺序仍快于随机:SSD 内部以"页"读、以"块"擦,顺序读写能更好地利用并行通道,随机小写还会触发"写放大",所以顺序 I/O 依然更友好;
  • 并发队列深度重要:SSD 内部有多个并行通道,喜欢"一次给我一批请求"而不是"一个个串行喂",所以现代内核对 SSD 常用 none(多队列直通)或 mq-deadline 调度器,把重排让给设备自己做;
  • 写放大与磨损:写操作会引起额外的内部数据搬移,且闪存有擦写寿命,所以减少不必要的小写、用好缓存攒批,对 SSD 同样关键。

结论:换 SSD 解决的是"单次访问延迟",但访问模式(顺序 vs 随机、有没有命中缓存、是不是大量小 I/O)的影响,常常比硬件本身更大。优化时,先看访问模式,再谈换硬件。

六、把机制连起来:一次读请求的完整旅程

把前面的零件装在一起,一次读请求真实的路径是这样的:

  1. 应用调用 read(),陷入内核;
  2. 内核先查页缓存——命中就直接返回,整个磁盘环节跳过;
  3. 未命中,生成一个 I/O 请求,进入块层的 I/O 调度队列
  4. 调度器对队列里的请求重排/合并(HDD 上按电梯算法减少寻道,SSD 上可能直通),然后下发给设备;
  5. 设备完成后通过中断通知内核(这正是上一篇 I/O 管理讲的机制);
  6. 数据被放进页缓存,并返回给应用;同时可能触发预读,把后续块也提前拉进缓存。

你看,磁盘调度、缓存、预读、中断不是孤立的知识点,它们是同一条 I/O 路径上前后衔接的环节。理解了这条链路,你排查 I/O 慢的问题时就知道该看哪一段:是缓存命中率太低(iostat 看 I/O 量、free 看 cache),还是请求模式太随机(应用层访问模式),还是设备本身到瓶颈了(iostat%utilawait)。

七、为什么要这样设计:用两层手段对抗一个慢设备

机制讲完,回答"为什么"。外存又慢又必须用(内存断电就没、容量也有限),操作系统对抗它的策略可以归纳成两个层次:

  • 没法避免访问磁盘时,就让访问更高效——这是磁盘调度,通过重排请求减少寻道、合并相邻请求、把随机变顺序;
  • 能不访问磁盘就尽量不访问——这是缓存,用内存挡住绝大部分读、用写回攒批延迟写,靠局部性原理让命中率足够高。

这套"调度 + 缓存"的组合拳,不是磁盘独有的智慧,而是计算机系统对付"快慢悬殊的两层资源"的通用范式。CPU 和内存之间用 Cache,内存和磁盘之间用 page cache,本地和远程之间用 CDN/Redis——全是同一个思路:热点留在快的一层,冷数据放在慢的一层,并尽量把访问顺序优化得对慢设备友好。

八、和后端 / SRE 实践的联系

这些机制直接对应着你日常的性能优化和排查:

  • 日志、数据库为什么爱用顺序写:Kafka、LSM-Tree(RocksDB/LevelDB)、数据库 WAL 都把随机写转成顺序追加,正是为了规避寻道、用满写回攒批的红利;
  • 看懂 iostat 的关键列%util(设备繁忙度)、await(单次 I/O 平均等待时间,含排队)、r/s w/s(IOPS)。await 高而 %util 没满,往往是请求在调度队列里排队,可能是 I/O 模式太碎;
  • page cache 是朋友不是敌人:别看到 free 里内存被吃掉就慌,那多半是缓存。真正要关注的是 cache 命中率和脏页比例;
  • fsync 的代价要心里有数:每次 fsync 都强制落盘、绕开写回的好处。写得太频繁会拖垮吞吐,所以高吞吐场景常用"组提交(group commit)"把多次提交的 fsync 合并;
  • 随机小 I/O 是性能杀手:哪怕用了 SSD,海量随机 4KB 小读写也会很难受。优化方向通常是合并请求、增大块、用缓存挡读、用顺序写替代随机写。

九、动手做一个小实验:亲眼看缓存的威力

不用复杂环境,一条命令就能感受缓存:

1
2
3
4
5
6
7
8
9
10
11
# 准备一个测试文件(约 1GB)
dd if=/dev/zero of=test.bin bs=1M count=1024

# 清空页缓存(Linux,需要 root)
sync && echo 3 > /proc/sys/vm/drop_caches

# 第一次读:真正落到磁盘,慢
time cat test.bin > /dev/null

# 第二次读:数据已在 page cache 里,飞快
time cat test.bin > /dev/null

你会看到第二次比第一次快得离谱——因为第二次根本没碰磁盘,全在内存命中。再用 iostat -x 1 一边读一边观察 %utilawait,第一次磁盘繁忙、第二次磁盘几乎没动。这个对比会让"缓存挡住磁盘访问"从一句话变成你亲眼所见的事实。

学习这一部分最容易踩的坑

1. 以为换 SSD 就万事大吉

SSD 解决的是单次延迟,解决不了"访问模式太烂"。海量随机小 I/O、缓存命中率低、频繁 fsync,换了 SSD 照样慢。优化要先看模式。

2. 把 page cache 当成内存泄漏

free 里被 cache 占用的内存是可回收的,是系统在帮你加速。把它当问题去"清理",反而会降低命中率、拖慢系统。

3. 以为"写成功"就等于"数据安全"

写回策略下,写返回只代表进了内存缓存,没断电保证。要持久化必须 fsync(或 O_DIRECT/O_SYNC),这正是数据库 WAL 存在的理由。

4. 觉得磁盘调度只是考试概念

电梯算法的"重排减少寻道"思想,活在每一个数据库的顺序写设计、每一次 I/O 合并里。理解它,你才看得懂为什么 LSM-Tree、WAL 要那样设计。

总结

外存优化的全部出发点,是"磁盘比内存慢十万倍"这个残酷事实。操作系统用两层手段对抗它,核心要点如下:

  • 机械盘的访问时间主要花在寻道和旋转上,寻道是头号敌人,磁盘调度就是为减少它而生;
  • 调度算法在性能与公平间权衡:FCFS 公平但慢,SSTF 快但会饿死,SCAN/电梯算法是被广泛采用的折中;
  • 页缓存用局部性原理挡住大部分读,并用预读赌顺序访问;写回策略用"先写内存、攒批刷盘"换取写性能;
  • 写回的代价是持久性风险,fsync 和数据库 WAL 都是在"快 vs 安全"之间做选择;
  • SSD 消除了寻道,但顺序优于随机、减少小写、用好缓存依然成立,访问模式常比硬件更重要
  • "调度 + 缓存"是对付快慢两层资源的通用范式,从 CPU Cache 到 CDN 一脉相承。

把这套机制握在手里,下次再遇到"CPU 不高但接口慢",你的第一反应就会是去看 I/O 路径的每一环,而不是盲目加机器。

参考资源

  • 《操作系统导论》(OSTEP)I/O 设备与磁盘调度章节
  • 《现代操作系统》(Andrew S. Tanenbaum)
  • Linux Block Layer Documentation
  • Brendan Gregg《性能之巅:系统、企业与云可观测性》