卷 V · 插队CH 20深度 20/24

批处理:省了开销,赔了延迟

批处理是工程师最熟悉的优化之一,也是最少被算过账的一个。这一章把它的账算完,你会发现一条 U 形曲线——而绝大多数系统都站在这条曲线的错误一侧。

★ U 形曲线攒批 vs 排队第一个批最值钱

▷ 先估一个数

一个写入服务:每次写有 5 毫秒的固定开销(建连接、写日志、fsync),加上每条记录 0.5 毫秒的处理时间。每秒来 180 条记录。

现在你要决定:攒多少条一起写?

如果不攒(一条一条写),端到端延迟是 277.75 毫秒。攒到 16 条再写,延迟会变成多少?

A 更小——批处理就是为了提高效率,攒得越多越好 B 大约 100 毫秒——改善了,但没有那么多 C 大约 56 毫秒 D 大约 12 毫秒——攒 16 条是最优的

两股相反的力

批处理同时在做两件方向相反的事。

往下的力:摊薄固定开销

每批的服务时间是 开销 + 批大小 × 每条。批越大,每条记录分摊到的开销越小:

# 每条记录实际占用的服务台时间
b=1   (5 + 1×0.5) ÷ 1  = 5.50 ms/条
b=2   (5 + 2×0.5) ÷ 2  = 3.00 ms/条
b=4   (5 + 4×0.5) ÷ 4  = 1.75 ms/条
b=16  (5 + 16×0.5) ÷ 16 = 0.81 ms/条

# 于是利用率跟着掉
ρ = 180 × (5/b + 0.5) ÷ 1000
b=1   ρ = 0.990      ★ 贴在墙上
b=2   ρ = 0.540
b=4   ρ = 0.315
b=16  ρ = 0.146

利用率从 0.990 掉到 0.146,按第 9 章那条曲线,排队时间会暴跌

往上的力:攒批要等

但要凑满一批,先到的那些记录得等后面的。

# 泊松到达下,一批里第 i 个平均要等 (b−i)/λ
# 整批平均:
攒批等待 = (b − 1) ÷ (2λ)

b=1   0.00 ms      不用等
b=2   2.78 ms
b=4   8.33 ms
b=16  41.67 ms     ★ 已经是延迟的主要成分了

这一项是线性增长的,而且它是纯粹的浪费——服务台在这段时间里可能是空闲的,却故意不干活。用第 17 章的话说,攒批违反了保功原则

加起来:U 形

批大小利用率攒批排队服务合计
10.9900.00272.255.50277.75
20.5402.783.526.0012.30
30.3905.562.086.5014.13
40.3158.331.617.0016.94
80.20319.441.149.0029.59
160.14641.671.1113.0055.78

最优是 b=2,合计 12.30 毫秒

而两端都很惨:b=1 是 277.75 毫秒(22.6 倍),b=16 是 55.78 毫秒(4.5 倍)。

◆ 第一个批最值钱

看 b=1 到 b=2 这一步:延迟从 277.75 掉到 12.30降到 1/22.6

而 b=2 到 b=3,延迟了。

为什么第一步收益这么大?因为 b=1 时利用率是 0.990——它贴在墙上。272.25 毫秒里,98% 是排队

所以批处理在这个例子里买的不是效率,是活命:它把系统从墙脚下拽了回来。一旦拽回来(b=2 时 ρ=0.540,已经在曲线的平坦段),再加大批就只剩下攒批的代价了。

这条规律很一般:批处理的收益几乎全部集中在「让 ρ 离开墙脚」的那几步上。如果你的系统利用率本来就不高,批处理只会让延迟变差。

怎么在实践中选批大小

三条可操作的规则:

1. 先算 ρ(b),找出「刚好离开墙脚」的那个 b。目标不是最大化吞吐,是把 ρ 降到 0.6–0.7 附近。再往上加批,收益已经很小了。

2. 用「超时 + 大小」双触发,而不是只看大小。这是工业界的标准做法:攒够 N 条,或者等够 T 毫秒,谁先到就发。这样在高流量时按大小走(吃摊薄红利),在低流量时按超时走(不会让第一条记录傻等)。

而 T 该设多少?就是你能接受的攒批延迟。上面那张表告诉你,攒批延迟约等于 (b−1)/(2λ),所以给定 T,实际批大小上限是 2λT + 1

3. 注意批处理会推高 cs²这一点几乎总是被漏掉。如果批大小是动态的(有多少发多少),那么服务时间就变得参差不齐——而第 8 章说过,cs² 直接乘在等待上。固定大小的批比动态大小的批,在延迟上更可预测,代价是低流量时要等超时。

∑ 算一遍:最优批大小的闭式解

把总延迟写成 b 的函数,求导,可以得到一个近似的最优值:

总延迟(b) ≈ (b−1)/(2λ)  +  ρ(b)/(1−ρ(b)) × Sb/2  +  Sb

  其中 Sb = 开销 + b×每条,  ρ(b) = λ(开销/b + 每条)

# 在"利用率不高"的区间(右半段),排队项可以忽略,于是
总延迟 ≈ (b−1)/(2λ) + 开销 + b×每条

# 这是 b 的**增函数** —— 右半段永远是越批越差。
# 所以最优点一定出现在"排队项还没可忽略"的地方,
# 也就是刚刚离开墙脚的那个 b。

# 可行性下界(ρ 必须小于 1):
b > λ×开销 ÷ (1 − λ×每条)
  = 180×0.005 ÷ (1 − 180×0.0005)
  = 0.9 ÷ 0.91 = 0.989      → b ≥ 1 勉强可行(ρ=0.990)

最后那个下界很有意思:它告诉你在什么流量下「不批处理」根本不可行。如果 λ 涨到 190/秒,那么 b > 1.05,也就是说 b=1 时 ρ > 1,系统撑不住

这解释了一个常见的线上故障模式:流量缓慢增长,某一天突然从「有点慢」变成「完全不可用」——因为它越过了 b=1 的可行性边界。而修复方法是加批,不是加机器。

✎ 术语正名:「吞吐 vs 延迟的权衡」

「批处理是吞吐和延迟的经典权衡」——这句话流传很广,但它把事情说反了一半。

看上面那张表:从 b=1 到 b=2,吞吐能力提高了,延迟也降低了。这不是权衡,这是两边都赢。

真正的权衡只发生在曲线的右半段:ρ 已经很低了,再加大批只增加攒批延迟。

所以准确的说法是:批处理先是双赢,越过某个点之后才变成权衡。而那个点在哪,取决于你的 λ 和固定开销——它是可以算出来的,不需要凭感觉。

这个区分很重要,因为「这是个权衡」这句话经常被用来终止讨论:既然是权衡,那就看业务需求吧。而实际上,如果你在左半段,就根本没什么好权衡的。

▸ 在现实里

Kafka 的 linger.msbatch.size这就是双触发机制,而且它默认 linger.ms=0——也就是「有多少发多少,不等」。这个默认值在低流量时是对的(不引入攒批延迟),在高流量时会自然形成批(因为发送线程忙不过来时消息会堆积)。这是一个很聪明的自适应设计:批大小由系统的忙碌程度自己决定。

数据库的组提交(group commit)。多个事务的 WAL 一起 fsync。fsync 的固定开销极大(毫秒级),而每条记录的增量成本极小——正是这一章那个模型。组提交是把 ρ 从 0.99 拽到 0.5 的典型案例,收益是数量级的。

GPU 推理的动态批处理。大模型推理里,攒批能大幅提高 GPU 利用率(矩阵乘法的固定开销很大)。但攒批延迟会直接体现在「首 token 时间」上。这是这条 U 形曲线在 2020 年代最热的应用场景,而各家推理框架的差异很大程度上就是它们在这条曲线上取的位置不同。

反例:不要给已经很闲的系统加批。如果你的服务利用率只有 10%,加批处理只会让延迟变差。这个错误很常见,因为「批处理提高效率」被当成了普适真理。先量 ρ,再决定。

✗ 这个直觉是错的
批处理是用延迟换吞吐——批越大吞吐越高,延迟越差,选一个能接受的平衡点就行。 在利用率高的时候,加大批会同时改善吞吐和延迟,因为它把系统从墙脚下拽回来了。只有在利用率已经低的时候,它才退化成纯粹的延迟成本。

这个错误直觉的实际代价是:它让人在最该批处理的时候不敢批。「我们的延迟已经很紧张了,不能再加批处理」——而实际上,如果你的延迟紧张是因为 ρ 太高(大多数情况都是),那么加批恰恰是最快的解药。

诊断方法很简单:看看延迟里有多少是排队。上面那张表里,b=1 时 277.75 毫秒的延迟中,272.25 毫秒(98%)是排队。这种情况下,任何能降低 ρ 的手段都会让延迟暴跌,而批处理是其中最便宜的。

反过来,如果你的延迟里排队只占 10%,那么加批确实只会让事情变糟。同一个动作,在曲线的两侧效果完全相反——而分辨你在哪一侧只需要一个数:ρ。

◇ 结算

C大约 56 毫秒——精确值是 55.78 毫秒

比不批处理(277.75)好了五倍,但比最优的 b=2(12.30)差了 4.5 倍

攒 16 条的时候,55.78 毫秒里有 41.67 毫秒(75%)是在干等着凑够一批——服务台在这段时间里基本是闲的(ρ 只有 0.146)。你把一个「排队问题」换成了一个「故意不干活」的问题。

A 「攒得越多越好」——这是最常见的直觉,也是这一章要打的靶子。它只在曲线的左半段成立。 B 「大约 100 毫秒」——量级偏大。这个答案大概来自「改善一半多一点」的模糊感觉。实际上从 b=1 到 b=16 改善了 80%,只是错过了最优点。 D 「攒 16 条是最优的」——最优的是 b=2,合计 12.30 毫秒。这个答案的数字是对的,只是配错了批大小。大多数人会猜最优批大小是十几或几十,而实际最优往往是个位数——因为攒批延迟涨得比想象中快。

这一章的一句话

批处理的收益几乎全部来自「把利用率拽离墙脚」;一旦拽回来了,再加大批就只剩下干等着的成本。

卷 V 结束。前二十章都在假设一件事:队是无限长的,所有人最终都会被服务。下一卷去掉这个假设。第 21 章会告诉你,把队列上限从 10 放宽到 500,吞吐只多了 7.5%,而平均逗留从 5.1 涨到 20.0——大缓冲区几乎不增加吞吐,它只是把「拒绝」换成了「等到超时」。而只超载 10%、撑十分钟,就会欠下 60000 个请求的债,要花 5 分钟才还得清。