第 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、日志库、消息队列的标准做法。
批量的决策口诀:
固定开销大 → 批量;延迟敏感 → 别批量;两者都占 → 批量 + 时间上限。
核心是回答两个问题:
- 我的操作里,固定开销占比多大?(决定批量收益)
- 我能容忍多长的”凑批等待”?(决定批量可行性)
Q9 一个”摊销”的统一框架:怎么判断”该不该摊”?
这一章的收尾,给出一个可复用的判断框架。
框架:判断”该不该摊销”的四个问题:
问题一:代价是"随机的大"还是"稳定的"?
如果是"随机的大代价"(随机写、随机缺页、突发 I/O),
摊销能把它变成"可预测的小代价" → 值得摊
如果代价本来就可预测,摊销意义不大。
问题二:操作有"固定开销"吗?占比大吗?
固定开销(系统调用、RTT、寻道)占比大 → 批量收益大
全是可变开销 → 批量收益小
问题三:能容忍"不确定性"吗?
摊销几乎总会引入"延迟不确定性"(尾延迟)
你的场景能容忍 P99 抖动吗?
能 → 摊;不能(硬实时)→ 别摊
问题四:摊销的"前提假设"成立吗?
批量假设"操作能攒起来"(延迟不敏感)
预取假设"能预测未来访问"(有局部性)
延迟假设"结果可能不被用到"(不需要的概率高)
假设不成立 → 摊销不仅无效,还可能有害
一个”摊销决策表”:
| 手段 | 前提假设 | 收益 | 代价 | 失效场景 |
|---|---|---|---|---|
| 批量 | 能攒、能容忍延迟 | 摊薄固定开销 | 尾延迟 | 延迟敏感 |
| 预取 | 能预测未来 | 提前消化延迟 | 预测错浪费 | 随机访问 |
| 延迟 | 结果可能不用 | 省掉无用操作 | 用时才做慢 | 结果必用 |
回到开头那三个设计,用框架验证:
多级页表(延迟/按需):
假设:地址空间大部分是空的(实际用量小)
收益:页表从 32PB 降到几十 KB
代价:查表变慢(用 TLB 补救)
假设成立 → 完美
CFS 红黑树(预处理摊销):
假设:调度频繁,值得"平时维护红黑树"
收益:调度从 O(n) 降到 O(log n)
代价:插入删除 O(log n)(平时多付出)
假设成立(调度确实频繁)→ 值得
ext4 日志(批量/重排):
假设:顺序写比随机写快得多
收益:元数据更新从"随机写"变"顺序写"
代价:写日志的开销 + 掉电回放
假设成立(机械盘顺序写 >> 随机写)→ 值得
这一章的最终落脚点:
摊销是”用不确定性换确定性、用尾延迟换吞吐、用空间换时间”的一种系统化权衡。
它不是”银弹”,而是”杠杆”——在正确的前提假设下,它能带来数量级的提升;在错误的前提下,它会引入尾延迟、浪费资源。
而判断”该不该摊”,永远是那四个问题:
- 代价随机吗?
- 有固定开销吗?
- 能容忍不确定性吗?
- 前提假设成立吗?
这套框架,是你后面做任何性能优化、架构设计时,都能反复用的思维工具。
思考题
思考题 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 等参数调优
(攒一批,或延迟几毫秒)
它们本质都是"批量 + 时间上限"的组提交。
这道题考察什么:
- 知不知道组提交的本质(合并 fsync,摊薄固定开销)
- 知不知道代价(延迟增加、尾延迟)
- 知不知道”批量大小”的权衡(吞吐 vs 延迟)
- 知不知道”批量 + 时间上限”这个折中方案
思考题 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% 分位
→ 发现"尾延迟"问题
这道题的核心教训:
“批量”几乎总会引入”尾延迟”——批里的”最后一个”,要等”前面所有”。
所以:
- 批量要”适度”(不是越大越好)
- 批量要加”时间上限”(延迟有界)
- 要监控”尾延迟”(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. 前提假设成立吗? │
│ │
│ 【一句话】 │
│ 摊销用尾延迟换吞吐、用空间换时间、用可预测性换性能; │
│ 前提假设成立则数量级提升,否则引入尾延迟和浪费。 │
│ │
│ 【第三部完 · 第四部预告】 │
│ 性能讲完了,但性能有个前提:系统"不出错"。 │
│ 下一部讲可靠性:承认出错是常态,系统调用、日志、隔离、背压。 │
└──────────────────────────────────────────────────────────────┘