第 12 章 摊销与批处理:把随机变成确定

还记得这本书开头,我留给你的那个问题吗?

我说:Linux 内核里有三个设计,看着八竿子打不着——多级页表、CFS 调度器的红黑树、ext4 的日志——但它们骨子里是同一个套路。

答案是:摊销(amortization)——把随机的大代价,换成可预测的小代价。

现在,你已经读完了前面十一章。这一章,我们把前面埋下的所有伏笔,在这里汇合。


引子:三个设计的”同一个套路”

先回看那三个设计,它们的”同一性”在哪里:

多级页表(第 1 章):

问题:如果为整个地址空间建页表,要 32PB(不可能)。
套路:不为"空的地址"建表,只在"用到时"才建。
  → 把"一次性建 32PB 的表"的大代价,
    摊薄成"用到哪一页,建哪一页"的小代价。
  → 代价从"O(地址空间)"变成"O(实际用量)"。

CFS 的红黑树(第 11 章):

问题:每次调度都要找"vruntime 最小"的任务,怎么找才快?
套路:平时维护一棵红黑树(插入删除 O(log n)),
  → 把"每次调度 O(n) 找最小"的大代价,
    摊薄成"平时 O(log n) 维护 + 调度时 O(log n) 取最小"的小代价。
  → 代价从"每次 O(n)"变成"每次 O(log n)"。

ext4 的日志(第 3 章):

问题:随机更新文件系统元数据,掉电就可能损坏(随机的大风险)。
套路:先把"要做什么"写进日志(顺序写,快),再慢慢改实际数据。
  → 把"随机改元数据"的大风险,
    摊薄成"顺序写日志"的小代价 + "空闲时回放"。
  → 代价从"随机写的不确定"变成"顺序写的确定"。

看出来了吗?同一个套路:

面对一个”随机、昂贵、不可预测”的代价,想办法把它变成”可预测、可摊薄”的小代价,分摊到多次操作里。

这就是摊销。它是操作系统(以及所有高性能系统)最底层的智慧。


本章路线图

第一幕:摊销的本质
  Q1  什么是"摊销"?它和"优化"有什么区别?
  Q2  摊销的三种形态:批量、预取、延迟

第二幕:三种形态的实例
  Q3  批量(batching):组提交、Nagle 算法、消息合并
  Q4  预取(prefetching):预读、缓存预热、指令预取
  Q5  延迟(deferring):延迟分配、写回、惰性计算

第三幕:摊销的代价
  Q6  摊销换来了什么?又付出了什么?(尾延迟!)
  Q7  为什么"平均延迟"好看,但 P99 会爆炸?

第四幕:工程实战
  Q8  什么时候该批量,什么时候不该批量?
  Q9  一个"摊销"的统一框架:怎么判断"该不该摊"?

第一幕:摊销的本质

Q1 什么是”摊销”?它和”优化”有什么区别?

摊销(amortization),在算法分析里叫”摊还分析”(amortized analysis):

一个操作,单次可能很贵,但如果你把它"分摊"到一系列操作里,
平均每个操作的成本就低了。

例:动态数组扩容
  - 大部分时候,往数组加元素是 O(1)
  - 偶尔(数组满了)要扩容,O(n)
  - 但"平均"下来,每次加元素是 O(1)(摊还)

关键:偶尔的"贵",被"便宜"的操作摊薄了。

摊销和”优化”的区别:

优化(optimization):让"单次操作"更快
  例:把 O(n²) 算法换成 O(n log n)

摊销(amortization):让"多次操作"的平均成本更低
  例:把 N 次独立的写,合并成 1 次批量的写

区别:
  优化是"把事情做得更快"
  摊销是"改变事情的次数和时机",让总成本更低

摊销的三个核心手段(这一章的主线):

1. 批量(batching):把多次小操作,合并成一次大操作
   - 摊薄"每次的固定开销"(系统调用、网络、磁盘寻道)

2. 预取(prefetching):提前做"未来会用到"的操作
   - 把"未来的大代价",提前分摊到"现在的空闲时间"

3. 延迟(deferring):推迟"可能不需要"的操作
   - 如果"不需要"的情况经常发生,推迟就省了这笔开销

一句话概括摊销:

不改变”要做的总工作量”,但改变”工作的时机”和”粒度”,让代价从”随机的大”变成”可预测的小”。

Q2 摊销的三种形态:批量、预取、延迟

先总览三种形态,后面逐个深挖。

批量(batching):合并多次操作。

思想:N 次小操作 → 1 次大操作,摊薄固定开销。

例:
  - 系统调用:100 次 write → 1 次 writev(一次系统调用写 100 块)
  - 网络:100 条小消息 → 1 条大消息(省 99 次网络往返)
  - 磁盘:100 次随机写 → 1 次顺序写(省 99 次寻道)

核心:每次操作有"固定开销"(系统调用、网络 RTT、寻道),
      批量能把固定开销摊薄到 N 次上。

预取(prefetching):提前做未来会做的事。

思想:把"未来的工作"提前到"现在空闲时"做。

例:
  - 磁盘预读:读第 N 块时,顺便读 N+1 块(反正磁头在这)
  - 指令预取:CPU 提前取后续指令(流水线)
  - 缓存预热:服务启动时预加载热点数据

核心:利用"空闲"和"局部性",把未来的代价提前摊掉。

延迟(deferring):推迟可能不需要的操作。

思想:如果"某个操作的结果可能不会被用到",那就先不做,
      等"确定要用"时再做。

例:
  - 延迟分配:malloc 了 8GB,但物理页只在"真正写"时才分配
  - 写回:write 到 page cache 就返回,真正落盘推迟
  - 惰性计算:函数式语言里的 lazy evaluation

核心:省掉"结果没被用到的操作"。

三种形态的共同点:

都是”改变操作的时机和粒度”,让”代价”从”随机的大”变成”可预测的小”。

  • 批量:把”小代价”攒成”大代价”,摊薄固定开销
  • 预取:把”未来的代价”提前,利用空闲
  • 延迟:把”可能的代价”推迟,赌它不发生

而它们的共同代价(后面会讲):不确定性增加,尤其影响尾延迟(P99)。


第二幕:三种形态的实例

Q3 批量(batching):组提交、Nagle 算法、消息合并

批量的核心:摊薄”固定开销”。看三个经典例子。

实例一:数据库的组提交(group commit)。

问题:每个事务要 fsync 一次(把 WAL 落盘),fsync 很贵(毫秒级)。

不批量:100 个事务 = 100 次 fsync = 100 × 10ms = 1 秒

组提交:把一段时间(比如 10ms)内的所有事务,攒一批,
  一起写 WAL,一次 fsync。
  100 个事务 = 10 次 fsync(每批 10 个)= 10 × 10ms = 100ms

快 10 倍!代价:每个事务的"落盘时间"从"立刻"变成"最多等一批"。

实例二:TCP 的 Nagle 算法。

问题:发送小数据包(比如 1 字节),每个包都有 TCP/IP 头(40 字节),
  网络利用率极低。

Nagle 算法:攒数据,直到:
  1. 攒够一个满包(MSS)
  2. 或者收到前一个包的 ACK
  才发送

效果:小数据被合并成大数据包,网络利用率提升
代价:小数据延迟增加(要等攒够或 ACK)

实例三:消息队列的批量发送。

问题:每条消息一次网络往返,开销大。

批量:生产者攒 100 条消息,一次发送。
  → 网络往返从 100 次降到 1 次

Kafka 的生产者就是默认批量发送的。

批量的通用逻辑:

每次操作的成本 = 固定开销 + 可变开销

例:
  系统调用:固定开销(陷入内核)~1μs + 可变开销(拷贝数据)
  网络:固定开销(RTT)~1ms + 可变开销(传输数据)
  磁盘:固定开销(寻道)~10ms + 可变开销(读写数据)

批量 = 让 N 次操作共享 1 次固定开销:
  单次成本 = 固定 + 可变
  批量 N 次 = 固定 + N × 可变
  → 固定开销被摊薄到 N 次上

批量的精髓:固定开销(系统调用、RTT、寻道)是”每次都要付”的,批量让它”N 次付一次”。

关键问题:你的操作里,“固定开销”占多大比例?

  • 固定开销占比大 → 批量收益大
  • 固定开销占比小(可变开销为主)→ 批量收益小

例:发 1 字节的消息,固定开销(40 字节头 + RTT)远大于可变开销(1 字节数据)→ 批量收益巨大。

Q4 预取(prefetching):预读、缓存预热、指令预取

预取的核心:利用”空闲”和”局部性”,把未来的代价提前摊掉。

实例一:磁盘预读(readahead)。

问题:顺序读文件,如果每次读一块都"现读现等",磁盘 I/O 延迟高。

预读:你读第 N 块时,内核"顺便"把 N+1、N+2 也读进 page cache。
  因为你"顺序读"的概率很大(空间局部性)。

效果:
  你读第 N+1 块时,它已经在 page cache 里了(命中,快!)
  → 磁盘 I/O 的延迟被"预取"摊掉了

代价:
  如果你不是"顺序读"(读到 N 就跳走了),
  预读的 N+1、N+2 就是浪费(读了没用)

实例二:缓存预热(cache warm-up)。

问题:服务重启,缓存空了,流量打进来全 miss,数据库被打垮(第 9 章冷启动)。

预热:服务启动时,"提前"把热点数据加载进缓存。
  → 流量打进来时,缓存已经"热"了,命中率高

代价:
  预热要"预测"哪些数据是热点,预测错了就是浪费
  且预热本身要花时间和资源(读数据库、填充缓存)

实例三:CPU 指令预取 + 分支预测。

问题:CPU 执行指令要取指,取指有延迟。

指令预取:CPU 提前把"后面的指令"取进来(流水线)。
分支预测:CPU 预测分支走向,提前取"预测方向"的指令。

效果:CPU 不用"等"取指,流水线不空转。
代价:分支预测错了,要冲刷流水线(浪费,但概率低)。

预取的通用逻辑:

预取 = 用"现在空闲的资源" + "对未来访问的预测",提前做未来的事。

前提:
  1. 有"空闲"(现在不忙,可以做点"额外"的事)
  2. 能"预测"(局部性,未来访问大概率是"附近"的)

代价:
  预测错了,预取就是浪费(甚至有害,比如预取挤掉热数据)

预取的精髓:把”未来的延迟”用”现在的空闲”提前消化。

关键问题:你的”预测”有多准?

  • 预测准(顺序访问、明确的热点)→ 预取收益大
  • 预测不准(随机访问、热点变化快)→ 预取有害

例:顺序读文件,预读几乎必中(收益大);随机读,预读几乎必错(浪费)。

Q5 延迟(deferring):延迟分配、写回、惰性计算

延迟的核心:赌”某个操作的结果不会被用到”,推迟到”确定要用”才做。

实例一:内存的延迟分配(第 1 章)。

问题:malloc(8GB),如果立刻分配物理内存,8GB 就占住了。

延迟分配:malloc 只"承诺"8GB(建虚拟地址),
  物理页在"真正写"时才分配(缺页异常)。

效果:
  如果你 malloc 了 8GB 但只用了 100MB,
  那 7.9GB 的物理内存根本没分配 → 省了!
代价:
  写的时候才分配,第一次写会触发缺页(慢一点)

实例二:写回(writeback,第 3 章)。

问题:write 数据,如果立刻落盘,每次写都等磁盘(慢)。

写回:write 到 page cache 就返回(快),
  真正落盘推迟到"后台回写"(30 秒后,或脏页超阈值)。

效果:
  写操作飞快(写内存,不是写磁盘)
  多次写同一个页,还能合并(只落盘一次)
代价:
  掉电丢数据(page cache 里的脏页还没落盘)

实例三:惰性计算(lazy evaluation)。

问题:计算一个值,如果结果"没被用到",计算就是浪费。

惰性计算:不立刻计算,等"结果被用到"时才算。

例(函数式语言):
  let x = expensive_computation()  // 不立刻算
  // 如果 x 从没被使用,expensive_computation 从没执行 → 省了

延迟的通用逻辑:

延迟 = 推迟一个"可能不需要"的操作,赌它"不需要"。

前提:
  1. 操作"可能不需要"(结果可能没被用)
  2. 推迟不改变"正确性"(需要时再补做,结果一样)

代价:
  1. 如果"需要"了,推迟导致"用的时候才做"(延迟增加)
  2. 推迟的"状态"要管理(比如脏页要追踪)

延迟的精髓:把”乐观”发挥到极致——先假设”不需要”,真的需要了再说。

关键问题:“不需要”的概率有多高?

  • 概率高(malloc 的 8GB 大部分不写、写回的数据很少被立刻读)→ 延迟收益大
  • 概率低(数据马上要被读、计算马上要被用)→ 延迟有害(白推迟,还增加复杂度)

例:fork 的 COW(第 1 章)就是延迟的极致——先假设”子进程不会写所有页”,写的时候才复制。


第三幕:摊销的代价

Q6 摊销换来了什么?又付出了什么?(尾延迟!)

前面讲的都是摊销的”收益”,现在讲”代价”。这是理解摊销的关键,也是很多人忽略的。

摊销的收益(回顾):

1. 吞吐提升:批量摊薄固定开销,吞吐大增
2. 资源节省:延迟分配、预取利用空闲,资源利用率高
3. 延迟降低(平均):预取让"平均延迟"变低

摊销的代价:

1. 延迟的"不确定性"(最核心的代价)
   - 批量:单个操作要"等凑够一批",延迟不可预测
   - 预取:预测错了,预取反而挤掉热数据
   - 延迟:真正需要时才发现"还没做",延迟陡增

2. 状态的复杂性
   - 写回:要追踪"哪些是脏页"
   - 延迟分配:要处理"承诺了但没兑现"
   - 批量:要处理"凑不齐一批怎么办"

3. 资源的"暂时性"占用
   - 预取:预取的数据占缓存,可能挤掉热数据
   - 批量:批量缓冲区占内存

最关键的是第一点:延迟的不确定性,即”尾延迟”问题。

Q7 为什么”平均延迟”好看,但 P99 会爆炸?

这是摊销最隐蔽、也最致命的代价。用一个例子说透:

例子:批量刷盘的日志系统。

设计:日志写到内存缓冲,每 100 条或每 10ms 批量 fsync 一次。

平均延迟:很好!
  - 大部分日志,写内存就返回(~1μs)
  - 只有"刚好触发批量刷盘"的那条,要等 fsync(~10ms)

P99 延迟:爆炸!
  - 假设每 100 条触发一次 fsync
  - 那"第 100 条"这条日志,要等 fsync 完成(10ms)
  - 也就是 1% 的日志,延迟是 10ms,其余 99% 是 1μs
  - P99 = 10ms,而 P50 = 1μs
  → 平均延迟 ~100μs(好看),但 P99 = 10ms(爆炸)

这就是摊销的”尾延迟”问题:

摊销把"昂贵的操作"集中到"少数时刻",
导致"少数请求"的延迟远高于平均。

表现:
  - 平均延迟(avg)好看
  - 但 P99、P999 很高(少数请求被"集中"的代价拖累)

为什么尾延迟重要?

在分布式系统里,一个请求往往要调多个下游:
  请求 → 服务 A → 服务 B → 服务 C

如果每个服务的 P99 是 10ms,3 个服务串行:
  请求的 P99 = 3 × 10ms = 30ms(串行叠加)

更糟的是"尾延迟放大":
  一个请求只要"碰上一个服务的慢时刻",整个请求就慢。
  服务越多,请求"碰上慢时刻"的概率越高。
  (10 个服务,每个 99% 快,请求全快的概率 = 0.99^10 ≈ 90%)
  → 10% 的请求会碰上至少一个慢服务

所以尾延迟(P99/P999)比平均延迟重要得多。

工程启示:摊销(批量、预取、延迟)几乎总会引入尾延迟。

你在享受摊销的”吞吐提升”时,必须监控”尾延迟”是否恶化。

缓解手段:

  • 批量大小调小(减少”凑批”的等待)
  • 批量加”时间上限”(不管够不够一批,超时就发)
  • 预取限制范围(避免挤掉热数据)
  • 延迟操作加”兜底”(超过一定时间强制完成)

核心:摊销是”用尾延迟换吞吐”,你要清楚这个交换,并监控尾延迟。


第四幕:工程实战

Q8 什么时候该批量,什么时候不该批量?

批量的决策,取决于几个关键因素。

该批量,当:

1. 固定开销大,可变开销小
   例:发小消息(固定开销 RTT 主导)→ 批量收益大
   例:发大文件(可变开销传输主导)→ 批量收益小

2. 操作频率高,且能容忍延迟
   例:日志(高频,且容忍 10ms 延迟)→ 批量
   例:用户请求(低频,且要求快)→ 别批量

3. 有"自然的批量边界"
   例:一个请求里的多条数据(天然一批)
   例:同一时间的多条消息(时间窗口批)

不该批量,当:

1. 延迟敏感,且不能容忍"凑批等待"
   例:支付请求(要求立刻,不能等凑批)
   例:心跳(必须准时发)

2. 单条数据量已经很大
   例:发大文件,批量合并没意义(本来一次就够)

3. 批量缓冲区会占用大量内存
   例:批量缓冲无限增长(第 16 章背压的问题)

4. 批量增加复杂度,但收益很小
   例:QPS 只有 10,批量省不了多少

一个关键的折中:批量 + 时间上限。

纯批量(凑够 N 条才发)→ 延迟不可控(可能永远凑不够)

批量 + 时间上限(凑够 N 条,或超过 T 毫秒,就发)→ 延迟有界
  - 吞吐好(大部分时候凑够 N 条)
  - 延迟可控(最坏等 T 毫秒)

这是 Kafka、日志库、消息队列的标准做法。

批量的决策口诀:

固定开销大 → 批量;延迟敏感 → 别批量;两者都占 → 批量 + 时间上限。

核心是回答两个问题:

  1. 我的操作里,固定开销占比多大?(决定批量收益)
  2. 我能容忍多长的”凑批等待”?(决定批量可行性)

Q9 一个”摊销”的统一框架:怎么判断”该不该摊”?

这一章的收尾,给出一个可复用的判断框架。

框架:判断”该不该摊销”的四个问题:

问题一:代价是"随机的大"还是"稳定的"?

  如果是"随机的大代价"(随机写、随机缺页、突发 I/O),
  摊销能把它变成"可预测的小代价" → 值得摊

  如果代价本来就可预测,摊销意义不大。

问题二:操作有"固定开销"吗?占比大吗?

  固定开销(系统调用、RTT、寻道)占比大 → 批量收益大
  全是可变开销 → 批量收益小

问题三:能容忍"不确定性"吗?

  摊销几乎总会引入"延迟不确定性"(尾延迟)
  你的场景能容忍 P99 抖动吗?
  能 → 摊;不能(硬实时)→ 别摊

问题四:摊销的"前提假设"成立吗?

  批量假设"操作能攒起来"(延迟不敏感)
  预取假设"能预测未来访问"(有局部性)
  延迟假设"结果可能不被用到"(不需要的概率高)

  假设不成立 → 摊销不仅无效,还可能有害

一个”摊销决策表”:

手段前提假设收益代价失效场景
批量能攒、能容忍延迟摊薄固定开销尾延迟延迟敏感
预取能预测未来提前消化延迟预测错浪费随机访问
延迟结果可能不用省掉无用操作用时才做慢结果必用

回到开头那三个设计,用框架验证:

多级页表(延迟/按需):
  假设:地址空间大部分是空的(实际用量小)
  收益:页表从 32PB 降到几十 KB
  代价:查表变慢(用 TLB 补救)
  假设成立 → 完美

CFS 红黑树(预处理摊销):
  假设:调度频繁,值得"平时维护红黑树"
  收益:调度从 O(n) 降到 O(log n)
  代价:插入删除 O(log n)(平时多付出)
  假设成立(调度确实频繁)→ 值得

ext4 日志(批量/重排):
  假设:顺序写比随机写快得多
  收益:元数据更新从"随机写"变"顺序写"
  代价:写日志的开销 + 掉电回放
  假设成立(机械盘顺序写 >> 随机写)→ 值得

这一章的最终落脚点:

摊销是”用不确定性换确定性、用尾延迟换吞吐、用空间换时间”的一种系统化权衡。

它不是”银弹”,而是”杠杆”——在正确的前提假设下,它能带来数量级的提升;在错误的前提下,它会引入尾延迟、浪费资源。

而判断”该不该摊”,永远是那四个问题:

  1. 代价随机吗?
  2. 有固定开销吗?
  3. 能容忍不确定性吗?
  4. 前提假设成立吗?

这套框架,是你后面做任何性能优化、架构设计时,都能反复用的思维工具。


思考题

思考题 1

面试官问:“什么是组提交(group commit)?它为什么能提升数据库的写入吞吐?它的代价是什么?“请回答,并说明”批量大小”怎么权衡。

参考答案

核心答案:组提交是把多个事务的 fsync(刷盘)合并成一次,摊薄 fsync 的固定开销。代价是单个事务的延迟增加(要等凑批)。

先理解为什么需要组提交:

数据库要保证"事务持久化"(ACID 的 D),
最简单的方式:每个事务提交时,fsync 一次 WAL(预写日志)。

问题:fsync 很贵(毫秒级):
  机械硬盘:~10ms
  SATA SSD:~1ms
  NVMe:~0.1ms

如果不组提交:
  100 个事务 = 100 次 fsync = 100 × 10ms = 1 秒(机械盘)
  → 吞吐只有 100 TPS(太慢了!)

组提交的原理:

把一段时间内的多个事务,攒成一批,
一起写 WAL,只 fsync 一次。

实现:
  1. 事务 A 提交,写 WAL 到缓冲区,等待
  2. 事务 B 提交,写 WAL 到缓冲区,等待
  3. ... 攒够一批(或超时)
  4. 一次 fsync,把所有事务的 WAL 都落盘
  5. 这批事务一起返回"提交成功"

效果:
  100 个事务 = 10 次 fsync(每批 10 个)= 10 × 10ms = 100ms
  → 吞吐提升 10 倍

代价:

1. 延迟增加:每个事务要"等凑够一批"才能落盘
   - 单事务的延迟从"立刻 fsync"变成"等一批"

2. 尾延迟:如果某批"迟迟凑不齐",这批事务等很久
   - 低流量时,一个事务要等"凑够 N 个"或"超时 T"

3. 实现复杂度:要管理"批量缓冲区"、"超时"、"一批事务的提交状态"

“批量大小”怎么权衡?

批量大(比如 100 个一批):
  - 吞吐高(fsync 摊得越薄)
  - 延迟差(要等凑够 100 个)

批量小(比如 5 个一批):
  - 延迟好(很快凑够)
  - 吞吐低(fsync 摊得少)

折中方案:批量大小 + 时间上限
  - 攒够 N 个,或超过 T 毫秒,就 fsync
  - 比如:攒够 32 个,或超过 10ms

这样:
  - 高流量:攒够 32 个就刷,吞吐高
  - 低流量:超过 10ms 就刷,延迟有界(最多等 10ms)

权衡的关键变量:

1. 流量(QPS):流量高,容易凑够一批;流量低,靠时间上限兜底
2. 延迟要求:延迟敏感,批量要小、时间上限要短
3. 硬件:机械盘 fsync 贵,批量收益大;NVMe fsync 便宜,批量收益小

实际数据库的做法:

MySQL:innodb_flush_log_at_trx_commit 参数
  0:每秒刷一次(最快,丢 1 秒数据)
  1:每次提交刷(最安全,最慢)
  2:每次提交写 OS 缓存,每秒刷(折中)

PostgreSQL:默认有组提交,通过 wal_writer_delay 等参数调优
  (攒一批,或延迟几毫秒)

它们本质都是"批量 + 时间上限"的组提交。

这道题考察什么:

  1. 知不知道组提交的本质(合并 fsync,摊薄固定开销)
  2. 知不知道代价(延迟增加、尾延迟)
  3. 知不知道”批量大小”的权衡(吞吐 vs 延迟)
  4. 知不知道”批量 + 时间上限”这个折中方案

思考题 2

你的服务要批量处理消息(消息队列消费者),一批拉 100 条处理。现在出现一个问题:P99 延迟很高(500ms),但平均延迟只有 20ms。请分析可能的原因,并给出优化方案。

参考答案

先分析”平均 20ms,P99 500ms”这个特征:

平均 20ms,P99 500ms,说明:
  - 大部分消息处理很快(20ms 左右)
  - 少数消息处理极慢(500ms)
  - 这是一个典型的"尾延迟"问题

结合"批量拉 100 条",最可能的原因是"批处理的尾延迟"。

原因一:批处理的”最后一条”效应(最可能)。

假设一批 100 条,消费者拉取 100 条后,逐条处理:
  第 1 条:处理 20ms
  第 2 条:处理 20ms
  ...
  第 100 条:处理 20ms

但"第 100 条"这条消息,从"生产者发出"到"被处理",要等:
  1. 前 99 条被处理完(99 × 20ms = 1980ms)
  2. 才轮到它

所以:
  前 99 条:延迟 ~20ms(快)
  第 100 条:延迟 ~2000ms(极慢!它等了前面 99 条)

如果每条消息的延迟是"从发出到被处理",
那"批尾"的消息延迟 = 批大小 × 单条处理时间。

这是”批量”的典型尾延迟:批里的”最后一个”要等”前面所有”。

原因二:批处理期间,某条消息触发了慢操作。

100 条消息里,有 1 条触发了慢操作(比如数据库慢查询):
  99 条:20ms
  1 条:500ms(慢查询)

那么:
  这 1 条自己延迟 500ms
  而且它阻塞了整个批次(如果串行处理),
  后面的消息都要等它 → 尾延迟放大

原因三:批处理的”攒批等待”。

消费者是"攒够 100 条才处理":
  低流量时,凑够 100 条要等很久
  → 第 1 条消息要等"凑够 100 条"才开始处理
  → 这条的延迟 = 攒批等待时间

优化方案:

方案一:缩小批量(降低尾延迟的放大系数)。

批量从 100 降到 20:
  → 批尾消息的延迟从"99 条的处理时间"降到"19 条的处理时间"
  → 尾延迟降低 5 倍

代价:批量收益下降(拉取、提交的固定开销摊得少了)

方案二:批量 + 时间上限(攒批不无限等)。

不"攒够 100 条",而是"攒够 100 条,或超过 50ms,就处理":
  → 低流量时,最多等 50ms 就开始处理
  → 尾延迟有界

方案三:批内并行处理(打破”串行等待”)。

100 条消息,不串行处理,而是并行(或小批量并行):
  → 批尾消息不用等"前面 99 条"
  → 尾延迟降低

代价:并行引入并发复杂度(竞态、顺序问题)

方案四:识别并隔离慢操作。

如果批里的"慢操作"是瓶颈:
  - 把慢操作(数据库查询)和快操作(内存处理)分开
  - 慢操作异步化,不阻塞批次

代价:复杂度上升

方案五:监控”单条消息延迟”而不是”平均延迟”。

关键:监控要能区分"平均"和"尾延迟":
  - 平均 20ms 是"好看的",但掩盖了尾延迟问题
  - 要看 P99、P999,以及"延迟分布"(直方图)

用 Prometheus 的 histogram:
  看 50%、90%、99%、99.9% 分位
  → 发现"尾延迟"问题

这道题的核心教训:

“批量”几乎总会引入”尾延迟”——批里的”最后一个”,要等”前面所有”。

所以:

  1. 批量要”适度”(不是越大越好)
  2. 批量要加”时间上限”(延迟有界)
  3. 要监控”尾延迟”(P99/P999),而不是只看平均

这正是第 12 章”摊销”的核心代价:用尾延迟换吞吐。


思考题 3

“预读(readahead)“是磁盘 I/O 的摊销。但它在什么情况下会”帮倒忙”?请分析,并说明怎么判断”该不该预读”。

参考答案

预读帮倒忙的场景:

场景一:随机访问(预读全浪费)。

预读的假设:顺序访问(读第 N 块,接下来读 N+1)。

但如果是随机访问(读第 N 块,跳去读第 100 块):
  预读的 N+1、N+2 块,根本不会被访问
  → 预读是纯浪费:白白读了磁盘、占了 page cache、挤掉热数据

场景二:预读挤掉热数据(缓存污染)。

预读的数据占了 page cache,
如果这些数据"预读后没被访问"(预测错了),
它们会挤掉真正的热数据(第 9 章的缓存污染)。

例:
  一次"假顺序"访问(读了几块,实际是随机),
  预读了一大堆没用到的块,
  把 page cache 里的热数据挤掉了,
  导致后续访问全 miss。

场景三:预读导致 I/O 争抢。

预读会"提前发起 I/O",
如果系统本来就 I/O 繁忙,
预读的"额外 I/O"会争抢带宽,
拖慢真正需要的 I/O。

场景四:SSD 上预读收益下降。

预读的收益,主要来自"机械盘的寻道延迟"(顺序读省寻道)。

但 SSD 没有"寻道"(随机读也快),
预读的收益大幅下降,
而"预读挤掉热数据"的代价还在。

怎么判断”该不该预读”?

1. 访问模式是顺序还是随机?
   - 顺序 → 预读收益大
   - 随机 → 预读有害(浪费 + 污染)

2. 存储是机械盘还是 SSD?
   - 机械盘 → 预读收益大(省寻道)
   - SSD → 预读收益小(无寻道),甚至有害

3. 预读的数据"用得上"吗?
   - 用得上(顺序访问)→ 预读
   - 用不上(随机)→ 别预读

4. 能"精确预读"吗?
   - 有明确的"未来访问"信息(比如推荐系统知道要推什么)
     → 精确预读(收益大)
   - 只能"猜测"(比如顺序猜测)
     → 猜测预读(可能浪费)

Linux 的做法(自适应预读):

Linux 的预读是"自适应"的:
  - 检测到顺序访问 → 增加预读量
  - 检测到随机访问 → 减少预读量

(通过跟踪"访问模式"来动态调整预读窗口)

Go/应用的实践:

1. 默认让内核做预读(page cache 自动预读)
2. 如果明确知道访问模式:
   - 顺序读大文件 → 用 ReadFull 或大 buffer,让内核预读生效
   - 随机读 → 用 O_DIRECT 或 madvise(MADV_RANDOM) 关掉预读
   - 明确要预读 → madvise(MADV_WILLNEED)
   - 明确不要 → madvise(MADV_DONTNEED) / MADV_RANDOM

这道题的核心:

预读是”用预测换延迟”,预测准了赚,预测错了亏。

判断”该不该预读”,本质是判断”我的访问模式,预测能有多准”:

  • 顺序访问 → 预测必准 → 预读
  • 随机访问 → 预测必错 → 别预读

这和第 12 章的”摊销框架”一致:预读的前提假设是”能预测未来访问”,假设不成立,预读就帮倒忙。


思考题 4(开放题)

“摊销”这个词,在算法里(摊还分析)、操作系统里(批量/预取/延迟)、分布式系统里(组提交/批量复制),都在用。它们有统一的本质吗?请尝试抽象出一个”摊销的统一框架”,并用它分析一个你熟悉的技术。

参考答案

开放题,考察你能不能把”摊销”抽象成”跨领域的统一思想”。

统一的本质:

摊销 = 不改变”总工作量”,但改变”工作的时机、粒度、顺序”,让代价从”随机的大”变成”可预测的小”。

具体来说,摊销做三件事:

1. 重排时机(timing):把"必须现在做"变成"可以稍后做"(延迟)
   或把"未来做"变成"现在做"(预取)

2. 改变粒度(granularity):把"多次小操作"变成"一次大操作"(批量)
   或把"一次大操作"变成"多次小操作"(分批)

3. 重排顺序(ordering):把"随机顺序"变成"顺序"(ext4 日志)
   或把"任意顺序"变成"最优顺序"(磁盘调度)

摊销的统一框架(抽象):

任何"摊销",都可以用四个要素描述:

1. 代价(cost):要摊销的是什么代价?
   (系统调用、寻道、RTT、fsync、计算)

2. 前提(assumption):摊销依赖什么假设?
   (局部性、能预测、结果可能不用、能攒起来)

3. 手段(mechanism):用什么手段摊销?
   (批量 / 预取 / 延迟 / 重排)

4. 代价(tradeoff):摊销引入了什么新代价?
   (尾延迟、复杂度、状态管理、预测错误的浪费)

判断"该不该摊销" = 前提成立吗?新代价能承受吗?

用这个框架分析”Kafka 的批量发送”:

1. 代价:网络 RTT + 系统调用(每条消息的固定开销)
2. 前提:消息能攒起来(延迟不敏感)、高吞吐场景
3. 手段:批量(攒 N 条消息,一次发送)
4. 新代价:单条消息延迟增加(等凑批)、尾延迟

分析:
  - 前提成立吗?→ Kafka 是高吞吐场景,消息攒起来没问题
  - 新代价能承受吗?→ 延迟增加几毫秒,可接受
  - 结论:值得摊销(批量)

用框架分析”数据库的组提交”:

1. 代价:fsync(每次提交的固定开销,毫秒级)
2. 前提:事务能攒起来(延迟不敏感)、吞吐要求高
3. 手段:批量(攒一批事务,一次 fsync)
4. 新代价:单个事务延迟增加、实现复杂度

分析:
  - 前提成立吗?→ 大多数事务不要求"立刻"落盘
  - 新代价能承受吗?→ 延迟增加几毫秒,可接受
  - 结论:值得

一个更深的洞察:

摊销的”统一本质”,是”用可预测性换性能”。

  • 批量:把”随机的小开销”变成”集中的大开销”(可预测,但尾延迟)
  • 预取:把”未来的开销”提前(利用可预测的局部性)
  • 延迟:把”可能不需要的开销”推迟(赌可预测的”不需要”)

而”可预测性”和”性能”的权衡,是贯穿整个计算机系统的一条主线:

  • 缓存(第 9 章):用”局部性的可预测”,换”访问速度”
  • 调度(第 11 章):用”公平的可预测”,换”整体吞吐”
  • 摊销(本章):用”代价的可预测”,换”平均成本”

理解了”可预测性 vs 性能”这个根本权衡,你就理解了操作系统(和所有高性能系统)的设计逻辑。

这道题的价值:

它考察你能否”跨领域抽象”。看到”算法的摊还分析”、“操作系统的批量预取”、“分布式的组提交”背后的同一个思想,说明你理解了”摊销”的本质,而不是记住了几个具体技术。

而”抽象出本质”的能力,正是这本书反复强调的”举一反三”——掌握了本质,你就能在任何新领域,自己推导出”该怎么摊销”。


一页纸总结

┌──────────────────────────────────────────────────────────────┐
│  第 12 章 · 摊销与批处理:把随机变成确定                          │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  【摊销的本质】                                                 │
│    不改变总工作量,改变时机/粒度/顺序,                          │
│    把"随机的大代价"变成"可预测的小代价"                            │
│                                                              │
│  【三种形态】                                                   │
│    批量(batching):合并多次操作,摊薄固定开销                   │
│    预取(prefetching):利用空闲+局部性,提前消化未来延迟          │
│    延迟(deferring):赌"结果不用",推迟可能不需要的操作            │
│                                                              │
│  【贯穿全书的实例】                                              │
│    多级页表(延迟)、CFS 红黑树(预处理)、ext4 日志(批量/重排)    │
│    fork COW(延迟)、组提交(批量)、预读(预取)、写回(延迟)      │
│                                                              │
│  【摊销的代价:尾延迟】                                          │
│    批量让"批尾"等"批头",少数请求延迟爆炸                         │
│    平均延迟好看,但 P99/P999 高                                  │
│    分布式放大:请求碰上一个慢服务就慢                              │
│                                                              │
│  【折中:批量 + 时间上限】                                       │
│    攒够 N 条,或超时 T,就处理 → 吞吐好 + 延迟有界                │
│                                                              │
│  【统一判断框架】                                               │
│    1. 代价随机吗?2. 有固定开销吗?                              │
│    3. 能容忍不确定性吗?4. 前提假设成立吗?                        │
│                                                              │
│  【一句话】                                                     │
│    摊销用尾延迟换吞吐、用空间换时间、用可预测性换性能;              │
│    前提假设成立则数量级提升,否则引入尾延迟和浪费。                 │
│                                                              │
│  【第三部完 · 第四部预告】                                        │
│    性能讲完了,但性能有个前提:系统"不出错"。                      │
│    下一部讲可靠性:承认出错是常态,系统调用、日志、隔离、背压。      │
└──────────────────────────────────────────────────────────────┘