03 延迟与吞吐的权衡:批处理、流水线与批大小的两难
开篇提问
先给你一个真实的场景,你来判断:
你负责一个消息推送系统,每秒要处理 10 万条消息。现在有两种处理方式:
- 方式 A:来一条处理一条,处理完立刻推送。
- 方式 B:攒够 100 条一起处理、一起推送。
请问:哪种方式延迟低?哪种方式吞吐高?
答案你应该能猜到:A 延迟低,B 吞吐高。但我想让你回答更深的一层:
为什么”攒一批”能提高吞吐?这些效率到底是从哪”变”出来的?如果你把批次设得特别大(比如攒 10 万条再处理),吞吐会不会无限提高?
第二问的答案是否定的,而且它指向一个非常普遍的规律:几乎所有”批量优化”都存在一个收益递减的临界点,过了这个点,效率反而会崩。 找到这个临界点的能力,是工程直觉里最值钱的部分之一。
子主题一:批处理为什么能提吞吐——省掉的是”固定开销”
先回答”效率从哪来”。
任何一次操作,都可以拆成两部分成本:
- 固定开销(per-operation overhead):不管这次处理多少数据,都要付的成本。比如:一次函数调用、一次网络往返、一次磁盘寻道、一次加锁解锁、一次上下文切换。
- 可变开销(per-item cost):随数据量线性增长的成本。比如:真正处理每条数据的计算。
批处理提吞吐的原理,一句话:把”固定开销”从”每条数据付一次”变成”每批数据付一次”。
一条一条处理 100 条消息,要付 100 次网络往返;攒成一批,只付 1 次。省下来的 99 次网络往返,就是吞吐提升的来源。
所以批处理的收益大小,取决于一个关键比值:
固定开销 / 可变开销 = 批处理收益的上限
如果固定开销很大(比如跨机房网络往返,几毫秒),批处理收益巨大(可能提升几十倍)。如果固定开销很小(比如一次内存里的函数调用,几纳秒),批处理收益就很有限。
这就是为什么批处理在涉及 IO(磁盘、网络)的场景收益最显著——因为 IO 的固定开销(寻道、往返延迟、系统调用)比数据处理本身大好几个数量级。
子主题二:批处理的代价——延迟,以及”等”的成本
批处理不是免费的。它省的每一分固定开销,都是用延迟换来的。
原因很直接:要攒够一批,就得等。 第一条消息进来时,你不能立刻处理,你得等后面 99 条来齐——这等待的时间,就变成了它的延迟。
这里有个非常精确的权衡关系,值得你记住:
批大小 × 单条处理时间 ≈ 攒批等待时间(在低负载下)
在低负载下(消息来得慢),要攒够 100 条,可能要等好几秒——延迟爆炸。在高负载下(消息来得快),100 条瞬间就攒满了,几乎不增加延迟。
这就引出了批处理最重要的一个特性:批处理在低负载时严重伤害延迟,在高负载时几乎免费提升吞吐。
这个特性决定了批处理的适用场景:它适合”吞吐压力大、但单条延迟要求宽松”的场景(离线计算、日志上报、批量导入),而不适合”低负载下也要极低延迟”的场景(实时交易、在线游戏)。
子主题三:批大小的两难与”自适应批处理”
现在回答开篇第二问:批大小是不是越大越好?
不是。批大小超过某个点之后,会遇到几个问题:
- 延迟线性增长:批越大,攒批等待越久。
- 内存占用增长:缓冲区要装下整个批次。
- 失败代价变大:一批 10 万条处理到一半失败了,整批重做的代价巨大。
- 收益递减:固定开销已经被摊薄到可忽略,再增大批次也省不了多少(因为剩下的全是可变开销)。
- 尾延迟恶化:攒批等待会让最慢的那条消息的延迟非常难看。
所以真实的批处理系统,几乎都不会用固定批大小,而是用自适应策略。最常见的一种是”三个条件,谁先满足就触发”:
- 数量触发:攒够 N 条就发(保证高负载下的吞吐)
- 时间触发:攒够 T 毫秒就发(保证低负载下的延迟上限)
- 大小触发:攒够 M 字节就发(防止缓冲区爆炸)
这三个条件一起,就形成了一个”既能在高负载下批量提吞吐、又能在低负载下保证延迟上限”的机制。这是工业级批处理的标准做法(Kafka producer、数据库批量写、RPC 批量调用都是这个套路)。
这个设计的精妙在于:它把”吞吐”和”延迟”从一个”二选一”变成了”动态平衡”——负载低时优先延迟,负载高时优先吞吐。这才是真实工程里的答案。
子主题四:流水线——批处理之外的另一条路
批处理不是提升吞吐的唯一手段。还有一条同样重要的路:流水线(pipeline)。
它们的区别很本质:
- 批处理:把多个任务”打包”,共享一次固定开销。它优化的是”固定开销的摊销”。
- 流水线:把一个任务拆成多个阶段,让不同任务的不同阶段重叠执行。它优化的是”资源的并行利用率”。
举个经典的例子——CPU 指令流水线:一条指令要经历”取指、译码、执行、访存、写回”五个阶段。如果不流水,一条指令要占满五个阶段才能开始下一条;流水线化后,第一条在”执行”时,第二条在”译码”,第三条在”取指”——吞吐提升接近 5 倍,而单条指令的延迟几乎没变。
注意最后那句:流水线提升吞吐,但不降低单任务延迟(甚至可能略微增加,因为阶段间有寄存器开销)。这和批处理形成鲜明对比——批处理牺牲延迟换吞吐,流水线几乎不牺牲延迟就换来了吞吐。
所以一个非常重要的工程判断是:如果能用流水线,就优先用流水线;只有在流水线用不上(任务无法拆阶段、或有强依赖)时,才用批处理换吞吐。
现代系统里两者经常结合:CPU 既有流水线又有超标量(相当于指令级批处理),数据库既有查询流水线又有批量写入。
子主题五:延迟的分布——平均值的谎言
最后讲一个能救命的概念:延迟要看分布,不能看平均值。
为什么?因为你的用户感受到的是”他自己那次请求的延迟”,不是”平均延迟”。而延迟的分布通常是长尾的——大部分请求很快,但有少数请求非常慢。
几个必须知道的概念:
- P50(中位数):一半的请求比这快
- P99:99% 的请求比这快,也就是”最慢的 1%“的门槛
- P999:更极端的长尾
P99 比平均值重要得多。 一个系统平均 10ms,但 P99 是 2 秒——意味着每 100 个用户里有 1 个要等 2 秒,而这个用户很可能就是你的大客户。
批处理会显著恶化尾延迟(攒批等待的不确定性),这也是为什么对延迟敏感的系统会对批处理非常谨慎。
而且长尾还有一个可怕的放大效应:一次用户请求往往依赖多个后端服务。如果每个服务的 P99 是 100ms,一个请求依赖 10 个服务,那么”所有服务都正常”的概率是 0.99^10 ≈ 90%——10% 的用户会遇到至少一个慢服务。这就是所谓的”尾部延迟放大”。
应对长尾的手段(后面章节会展开):对冲请求(hedged requests)、超时重试、自适应并发控制。
子主题六:利特尔法则——把延迟和吞吐焊在一起的那一把钥匙
前面五节我们一直在讲”延迟和吞吐互相拉扯”。但你有没有觉得少了点什么?
这两个量之间,到底有没有一个严格的关系?还是只是工程师的经验感觉、一把糊?
有。而且简单到让人想拍桌子:
L = λW
这就是利特尔法则(Little’s Law,也叫 Little’s Result)。它是排队论里极少数”几乎不需要假设”的硬结论。三个符号:
- L:系统里平均有多少个请求——在场的、还没走完的。正在被服务的算,正在排队等的也算。英文常叫 work-in-progress 或 in-flight。
- λ:请求进入系统的平均速率(严格说是长期平均到达率,单位”个/秒”)。
- W:一个请求从进到出的平均逗留时间,就是延迟——排队时间 + 服务时间,全算。
翻成人话:
店里平均站着多少人 = 人来得有多快 × 每个人平均待多久
奶茶店版:平均每分钟进 10 个人(λ=10),每个人从排队到拿走平均花 3 分钟(W=3),那店里平均就挤着 30 个人。10 × 3 = 30。
朴素得像废话,对吧?但它恐怖的地方在适用范围:
- 不需要假设请求到达是随机的;
- 不需要假设处理时间是随机的;
- 不需要假设先来先服务(任何调度规则都成立,包括你写得稀烂的优先级队列);
- 不需要假设单队列还是多队列、单线程还是线程池;
- 只要你把”系统边界”画清楚,它对任何黑盒都成立。
唯一的硬前提是稳态:长期看,进去多少、出来多少,系统没有在持续堆积。
请注意这个前提,它很重要:利特尔法则是恒等式,不是因果律。 它没说”提高吞吐就能降低延迟”——它说的是,这三个量被一根绳子拴住了,你动其中一个,另外两个里至少有一个得跟着动。
对我们这章最有用的,是把它变形两次。
变形一:W = L / λ。 在你的处理能力(L,同时在场的请求数)封顶时,想提高 λ,唯一出路是压低 W。反过来说:延迟一变长,吞吐必然掉。 这不是玄学,是恒等式。
变形二:λ = L / W,也就是:
吞吐 = 并发数 ÷ 延迟
这句话你多半在连接池调优的文章里见过,但很少有人告诉你它其实就是利特尔法则。现在你知道了,那篇”连接池开多大”的文章背后的答案就浮出来了:如果下游平均延迟是 W,你希望这条链路撑住 λ 的吞吐,那你至少需要 λW 个并发槽位(再留点余量)。开少了会怎样?下游明明很闲,你就是压不上去——因为吞吐被并发卡死了,跟下游快不快没关系。这是”下游 CPU 才 20%,我的 QPS 就是上不去”最常见的一个病因。
还有一个更值钱的观察:
L 是第三个变量,而”延迟 vs 吞吐”只有两个。
这恰恰是绝大多数人把这个问题想错的地方。你画一条”延迟-吞吐”的二维曲线时,其实默认了第三个变量(并发/在途数)被某个东西隐含钉死了——通常是线程池大小、连接池大小、TCP 窗口。而真相是:
- 低并发:延迟很好看,但吞吐上不去,资源闲着。
- 中并发:吞吐接近饱和,延迟还行。甜点区。
- 高并发:吞吐不涨了(撞到瓶颈),延迟开始暴涨——多出来的请求全变成排队。
所以完整表述应该是:延迟、吞吐、并发度,三个量,两个自由度。你只能定两个,第三个由公式算出来。 而工程师真正能主动拧的那个旋钮,往往就是并发度——它本质上是一个政策选择:
你愿意让多少个请求同时堵在系统里?
这句话请你刻在脑子里。后面所有内容——缓冲、批处理、批大小、背压、对冲请求——本质上全都是在动 L。
(准确性补一句:实际应用时,λ 要取实际完成率,不是”外面打到系统上的速率”。如果系统在限流、在丢弃,那么外部到达率 > 完成率,拿外部到达率代公式就会算错。代进去的必须是真正穿过边界、走完全程的那部分速率。另外系统不在稳态时——比如正在雪崩——这个等式短期不成立,但它恰好是你判断”是不是已经不稳态了”的标尺。)
子主题七:利特尔法则的三个推论——吞吐天花板、非线性延迟、以及雪崩的数学骨架
有了 L = λW,我们可以把前面那些”经验之谈”全部变成能算的东西。
推论一:吞吐的上限由瓶颈决定,跟你怎么排队无关
设系统里最慢的那个环节(瓶颈)处理一个请求要 S 秒,那么不管你加多少队列、加多少并发,长期吞吐上限就是:
X ≤ 1 / S_bottleneck
队列不生产吞吐,队列只生产延迟。这是很多人搞反的一件事:看到延迟高就想着”加个缓冲扛一扛”,其实你只是在把延迟换个地方藏起来,天花板一动没动。
那批处理为什么能提高吞吐?因为批处理改的不是曲线上的位置,是天花板本身。它把每条数据的有效服务时间从 (F + c) 压到 (F/B + c)——固定开销被摊薄了,于是 S_bottleneck 变小,天花板抬高。
所以”批处理提吞吐”和”加并发提吞吐”是两种完全不同的动作:前者抬高天花板,后者只是让你更接近天花板(并且代价是延迟)。分不清这两者,就会在天花板已经到顶的时候疯狂加线程,然后看着延迟起飞一脸茫然。
推论二:批处理收益递减的精确形状
把批处理写成公式。一批 B 条,每批的成本 = 固定开销 F + B × 单条成本 c。那么吞吐:
X(B) = B / (F + B·c)
分子是这批的条数,分母是这批花的时间。把它归一化一下,令纯可变开销决定的理论天花板 X∞ = 1/c,则:
X(B) / X∞ = B / (B + F/c)
看清楚这个式子的含义——“固定开销 ÷ 单条开销”(F/c)就是批次的有效量级:
- B = F/c 时,拿到理论天花板的 50%;
- B = 3F/c 时,拿到 75%;
- B = 9F/c 时,拿到 90%;
- B → ∞ 时,无限逼近 100%,但永远到不了。
这就是”收益递减”的精确形状,一条双曲线。批次超过 F/c 之后,你每多花一倍的等待时间,换来的吞吐增量都在快速缩水。
而且这个式子顺手解释了另一个长期让人困惑的现象:为什么不同系统的”合理批大小”能差出好几个数量级?
因为 F/c 差了好几个数量级。
- 跨机房网络写:F 是毫秒级的往返,c 是微秒级的序列化,F/c 成千上万 → 批次开到几 KB、几十 KB 都合理。
- 本地磁盘顺序写:F 是一次系统调用 + 可能的 fsync,c 是追加一条记录 → F/c 也不小,几百到几千,这就是为什么数据库批量插入效果好。
- 纯内存函数调用:F 是几纳秒的调用开销,c 是几纳秒的计算,F/c ≈ 1 → 批处理毫无意义,甚至因为有循环和内存分配开销而变慢。
所以”批大小该设多少”这个问题,第一步不是去调参,而是去估算你系统的 F/c。估出来,量级就有了;剩下的是微调。
那延迟那一边呢?低负载、到达速率 λ 时,攒够 B 条平均要等大约 B/(2λ)——平均而言,一条消息要等”半个批次”的时间才能凑齐。于是:
W ≈ B/(2λ) +(整批的处理时间)
把两条放一起看,拐点就自动出现了:当 B·c 已经明显大于 F 时,分母里的 F 变得无关紧要(吞吐饱和),而分子 B 还在让等待时间线性变长(延迟恶化)。 这就是开篇那个”效率崩掉”的临界点,而且它是可算的,不是靠感觉。
推论三:利用率越接近 100%,延迟发散得越离谱
排队论最基本的结论之一(概念表述,不吹精确数字):在到达和处理都有随机性的系统里,平均延迟随着利用率 ρ 上升而非线性增长,ρ 越接近 1,曲线越陡,最后发散。
你可以这么记这个形状:ρ = 50% 时队列基本是空的;ρ = 80% 时开始能感觉到排队;ρ 到 90% 以上时,延迟已经开始失控;ρ 逼近 100% 时,延迟趋于无穷。吞吐却在 ρ=100% 之后就完全不动了。
合起来就是那条经典的”延迟-吞吐曲线”:吞吐涨到某个点之后就平了,而延迟在那里开始竖直上升。这个拐点俗称”膝盖”(knee)。
工程结论极其粗暴:如果你在乎尾延迟,就永远不要让一个服务长期跑在高利用率上。 留 30%~50% 的余量不是浪费,是买保险。那些”我们把 CPU 压到 90% 省钱”的决策,代价会以尾延迟的形式,在最不合时宜的时刻还给你。
还有一层更深的东西。排队论里那个通用一点的结论(M/G/1 的 Pollaczek–Khinchine 公式)告诉我们:平均排队等待时间里,除了服务时间的均值,还有一项正比于服务时间的方差:
等待时间 ∝ ρ·S·(1 + cv²) / (1 − ρ),其中 cv 是服务时间的变异系数
翻译成人话:服务时间的波动有多大,和它平均有多长,一样重要。
这一刀砍下去,批处理就现出原形了:
- 批处理降低了每条数据的平均服务时间(好事);
- 但批处理放大了服务时间的粒度与波动——系统一会儿在处理一个巨大的批次,一会儿在处理一个小批次,服务时间忽长忽短(坏事,cv 变大)。
在高利用率下,方差放大会把排队延迟炸掉。 所以你会观察到一个反直觉的现象:某天负载升高、批也变大了,你以为”批大效率高”,结果尾延迟反而崩了——因为大批量带来的方差惩罚,超过了它省下的固定开销。
这也解释了为什么工业系统偏爱”数量 + 时间 + 大小三重触发”而不是”固定大批次”:三重触发把批大小的上限钉住了,也就把方差钉住了。 它不是在优化吞吐,它是在控制方差。
推论四:雪崩的数学骨架
把推论一、二、三串起来,你会得到一张清晰的”系统怎么死的”路线图:
- 某个下游变慢 → W 上升;
- 你的并发 L 是固定的(线程池就那么大)→ 由 λ = L/W,你的吞吐当场腰斩;
- 上游看你超时了 → 重试 → λ 不降反增;
- λ 增大 → 排队更多 → W 更大 → 回到第 2 步。
这就是拥塞崩溃(congestion collapse)。注意第 2 步是恒等式,不是概率——只要并发有上限、延迟翻一倍,吞吐就是脚踝斩,没有侥幸。
而唯一能打断这个正反馈的手段,不是”更快的重试”,是减少进入系统的量:限流、熔断、背压、降低超时后重试的比例(重试预算)。所有雪崩预案翻来覆去就一件事:在 λ 这一侧动刀,而不是在 W 这一侧死磕。
子主题八:批处理在真实系统里的四张面孔
理论讲完了,看看它在你每天用的东西里长什么样。同一套”三重触发”逻辑,换了四张脸。
面孔一:Kafka producer 的 accumulator
Kafka 生产者不是你调 send() 它就发。它内部按分区攒批,每个分区一个 batch,攒在内存里(所谓 record accumulator)。发不发送由两个旋钮决定:
- batch.size:攒到这么大就发(对应”大小触发”);
- linger.ms:最多等这么久就发(对应”时间触发”)。
linger.ms 就是这套设计里最诚实的一个旋钮:它明明白白地告诉你”我愿意为了吞吐而主动增加多少延迟”。默认值通常是个小几毫秒到上百毫秒的量级(具体默认值请查你所用版本的文档,别背数字),设为 0 就是”来一条发一条”,设大就是”用延迟换吞吐”。
这里有三个实战要点,比旋钮本身更值钱:
第一,低流量下 linger.ms 是纯粹的延迟税。 如果你的 topic 每秒只有几条消息,那 batch.size 永远填不满,每条消息都要干等 linger.ms 才被发出去。流量越小,批处理的性价比越低——这是子主题二那个结论的现场版。所以低吞吐的 topic 应该把 linger.ms 调小甚至归零,别无脑套模板。
第二,批处理有个隐藏的第三重收益:压缩率。 Kafka producer 的压缩是按批做的。批次越大,重复内容越多,压缩比越好,网络和存储都省。这一项收益经常被忽略,但它有时比省下的网络往返还大——尤其是日志类、JSON 类这种冗余度高的数据。
第三,批变大之后,内存是硬约束。 producer 端有一块总的 buffer 内存,攒批时占着不放;如果生产速度持续超过发送速度,buffer 会被填满,之后 send() 会阻塞或抛异常(取决于配置)。这个阻塞本质上就是背压:它在告诉你”下游跟不上了”。很多人把这个阻塞当成 Kafka 的 bug 去调大 buffer,结果只是把延迟藏起来——又回到推论一:天花板没动,你只是把痛苦延后了。
面孔二:数据库批量插入
朴素写法:1000 条数据,1000 次 INSERT,1000 次事务提交。
这里被重复付了一千次的固定开销有哪些?网络往返、SQL 解析与计划生成、锁的获取释放、以及最要命的那个——事务日志的刷盘(fsync)。
批量化在两个层面上发生:
- 语句层面:
INSERT INTO t VALUES (...), (...), (...)一条语句写多行,或者用驱动提供的批量接口(如 JDBC 的addBatch/executeBatch)。省掉的是解析、往返、计划的次数。注意:有些驱动的默认实现只是把多条语句塞进一次网络往返,并没有真正合并成多值语句,效果差很多——用过 JDBC 批量插入却没感觉到提速的人,八成是踩了这个坑(要靠驱动参数显式开启”重写批量语句”之类的能力)。 - 事务层面:把 1000 次提交合并成 1 次提交。省掉的是 fsync 的次数——从上千次降到一次。
最狠的那一刀在事务层,不在语句层。 因为 fsync 是唯一一个”每次都真的要等物理设备”的开销。很多人的批量插入只做了语句合并,事务还是一条一提交,然后抱怨”批量插入怎么没快多少”——你省下了往返,没省下刷盘。
但批事务有三个必须知道的副作用:
- 锁持有时间变长:一个事务改 10 万行,这些行上的锁要持有到提交。并发事务的等待时间直接上升,别人的尾延迟替你买单。
- 崩溃回滚代价变大:大事务回滚要重放大量 undo,恢复时间可能是分钟级。
- 复制延迟放大:从库要等整个大事务应用完才能追上,主备延迟会出现尖刺;再叠加长事务导致的”无法及时清理历史版本”,还可能拖垮读性能。
所以数据库批量的正确姿势是**“批语句 + 中等粒度的事务”**:语句合并到几千行一批,事务切成几千到几万行一批提交,而不是一百万行一个事务。这又是一次”别把方差搞大”的实践。
顺带一提,数据库的 COPY / LOAD DATA 这类专用导入通道,本质上就是把”解析、计划、日志、刷盘”这一整套固定开销全部重新设计了一遍以实现最大化摊销——它比批量 INSERT 快,不是因为魔法,而是因为它连你没意识到的那些固定开销也省了。
面孔三:RPC 批量调用
把 N 次 getUser(id) 合成一次 batchGetUsers(ids)。省掉 N−1 次网络往返,账很好算,但批量 RPC 有四个独有的坑:
坑一:批内队头阻塞。 一批里最慢的那一条决定了整批的返回时间。如果批里有 100 个 id,其中 1 个 id 的数据不在缓存里、要回源数据库,那另外 99 个都得陪着等。你把 99 个快请求和 1 个慢请求绑在一条船上了。 这就是为什么很多批量接口在批很大的时候,P99 反而比单次调用差。
坑二:部分失败的语义。 一次调用返回 100 条结果,其中第 37 条失败了。返回体怎么设计?是整批报错、还是每条带一个子状态码?如果接口设计成”整批失败”,那批量越大,失败概率越高(1 条失败概率 p,100 条里至少 1 条失败的概率是 1−(1−p)
坑三:请求体变大带来的次生开销。 大请求意味着更大的序列化/反序列化开销、更大的 GC 压力、更容易触发 MTU 分片或超出网关的请求体上限。你把 N 次小开销换成了 1 次大开销,中间有个 crossover 点。
坑四:它跟调用方的节奏耦合。 批量要生效,得”同一时刻确实有一批”。如果调用方是串行逻辑、每次只能拿到一个 id,那你加了批量接口也没人用。批量接口不是加个 API 就完事,它要求调用方的代码结构也变成批量的(比如在服务编排层做”请求合并 / request coalescing”)。
顺便:批量和流水线在 RPC 里是可以二选一的。 批量是”一次发 N 个”,流水线是”不等上一个回,就连着发 N 个”。流水线没有批内队头阻塞(每条独立返回),代价是需要请求带 ID 以便对应响应、需要连接层支持乱序响应。能用流水线,就别用批量——这正是子主题四那个判断的现实版。
面孔四:TCP Nagle 算法,以及为什么你必须知道 TCP_NODELAY
Nagle 算法是批处理思想钻进操作系统内核的一个标本,而且是有史以来争议最大的一个。
它想解决的问题:如果一个应用每次只 write() 几个字节(想想 Telnet 时代每敲一个字符发一个包),那一个字节的数据要套上几十字节的 TCP 头 + IP 头,网络被小包(tinygram)淹没。这跟你一条一条往数据库插数据是同一个问题,只是固定开销换成了报文头。
它的规则(一句话):如果连接上还有未被确认的小数据段,就把后续的小数据先攒着,直到攒够一个 MSS,或者收到对端的 ACK。
注意它的精妙之处:它不是无脑攒,而是用”有没有在途未确认数据”当作攒批的触发信号。网络上有一个往返的延迟作为天然的时间窗口,在途期间到达的数据全部合并进一个包。这就是一个自适应的、以 RTT 为时间触发条件的批处理。
那它为什么会变成害人精? 因为它会和另一个善意的设计——延迟确认(delayed ACK)——互相锁死:
- 你发了一个不完整的请求片段(数据段小于 MSS);
- Nagle 说:“还有未确认的数据在途,后面的小数据先攒着。”
- 对端收到片段,延迟确认说:“等一等,攒够两个再确认,或者等我的定时器超时再说。”
- Nagle 在等 ACK,延迟确认在等数据——双方都在等对方。
- 最后只能等延迟确认定时器超时,这个停顿是毫秒到几百毫秒量级(取决于实现,别背数字)。
这就是经典的 write-write-read 反模式:你的应用分两次 write(先写 header,再写 body),两次都小于 MSS,于是第二个 write 被 Nagle 按住,而对端在等完整的请求才给响应。你的请求卡在那里等一个本来可以立刻发出的包。
在请求-响应式协议(RPC、游戏、数据库协议、交互式 SSH)上,这个停顿直接吃掉了你的延迟预算。
所以:什么时候开 TCP_NODELAY?
- 开了对的场景:交互式、小消息、请求-响应、对延迟敏感的 RPC(Redis/Memcached、gRPC、游戏服务器、实时行情、SSH)。这基本上就是现代后端服务的主流场景。
- 可以不开的场景:大块流式传输(文件、备份),数据本身就能填满 MSS,Nagle 根本不会触发,开不开无所谓。
- 开了要小心什么:TCP_NODELAY 不是”免死金牌”。 如果你一边关掉 Nagle,一边在代码里疯狂地一个字段一次
write(),那你就会亲手制造 Nagle 当年要消灭的那种小包风暴——每个小 write 立刻变成一个独立报文。
正确的组合拳是:
在应用层把该合并的合并好(自己攒批、一次 write 一个逻辑完整的帧),然后开 TCP_NODELAY 让它立刻发出去。
也就是:应用层负责攒批(你最懂语义,知道一帧到哪儿结束),内核层负责立刻发(别自作聪明地替我再等一轮)。 这个分工才是干净的。很多”关了 Nagle 反而变慢”的诡异案例,根因都是应用层没做好自己的攒批。
顺手记一个诊断直觉:如果你的服务 P50 很好看、P99 却时不时冒出一个诡异的台阶(像是卡在一个固定的时间量级上),去看一眼是不是 Nagle + 延迟确认在握手言欢。
子主题九:流水线在硬件与软件里的四张面孔
子主题四说”能流水线就别批处理”。现在看看这条原则在四个完全不同的尺度上是怎么落地的——你会发现同一套逻辑,从晶体管一直贯穿到分布式系统。
面孔一:CPU 指令流水线——吞吐翻五倍,延迟一秒没少
一条指令要经历:取指 → 译码 → 执行 → 访存 → 写回。
不流水:一条指令占满整条通路,走完才轮到下一条。五个阶段里,四个在陪跑——译码器闲着、ALU 闲着、访存单元闲着。
流水化:第 1 条在”执行”时,第 2 条在”译码”,第 3 条在”取指”。理想情况下每个周期流出一条指令,吞吐接近 5 倍,而单条指令从头到尾的延迟几乎没变(甚至因为级间寄存器而略微变差)。
这就是流水线最性感的地方:它提升吞吐,但不拿延迟来换。 它拿的是”资源利用率”——把原来闲着的部件全部塞满。
顺便,把流水线推到极致就是超标量和乱序执行:发射多条指令并行、把后面不依赖的指令提到前面执行。这时候”窗口里的指令”本身就是一种隐式批处理。所以现代 CPU 是流水线 + 隐式批处理的合体,跟子主题四结尾说的对上了。
而流水线的经典代价在此也一并出现:冒险(hazard)。数据冒险(下一条要用上一条的结果)、控制冒险(分支预测失败要清空整条流水线)、结构冒险(两个阶段抢同一个部件)。流水线的天敌是”依赖”——一旦前后有强依赖,流水线就气泡化,吞吐塌回原样。 记住这一点,后面 HTTP/1.1 的失败就是它的翻版。
面孔二:HTTP/1.1 pipelining——一个被依赖杀死的流水线
HTTP/1.1 允许”流水线”:客户端可以在没收到第 1 个响应时就发出第 2、第 3 个请求。听起来完美——不就是省往返吗?
它失败了,而且失败得很彻底,主流实现要么不启用、要么直接不支持。 为什么?
原因一:响应必须按请求顺序回来。 服务器处理完第 3 个请求也得等着,因为它不能先发第 3 个响应(客户端无法判断哪个响应对应哪个请求——没有流 ID)。于是一个慢请求会堵住它后面所有的响应。这就是队头阻塞(head-of-line blocking),是流水线最经典的死法:流水线的天敌是依赖,而”顺序约束”就是一种人造的依赖。
原因二:失败语义极其恶心。 连接在第 3 个请求发出后断了:第 1、2 个请求到底执行了没有?如果第 2 个是 POST(非幂等),你敢重发吗?流水线把”请求-响应”的一一对应给打散了,却没有引入标识符来重新建立对应关系。 这是设计上的缺失,不是实现问题。
原因三:部署现实太脏。 中间有各种代理、网关、负载均衡,只要链条上有一个环节实现得不规范(把流水线当串行处理),整条流水线就退化甚至出错。而客户端很难探测中间盒子支不支持。一个需要全链路配合才能生效的特性,在开放的互联网上基本等于不存在。
原因四:收益被替代方案吃掉了。 浏览器早就用”开 6~8 条并行连接”绕过了单连接的串行限制。多连接本身就是一种批处理(并发维上的),虽然粗糙但有效。
那这个问题最后是怎么解决的?HTTP/2 的多路复用。
给每个请求一个流 ID,把报文切成帧,不同流的帧可以在一条连接上交织传输,接收端按流 ID 重新组装。响应可以乱序回来,谁先处理完谁先走。队头阻塞在应用层被消灭了——因为”顺序”这个人造依赖被流 ID 解除了。
但故事没完。 HTTP/2 只是把队头阻塞往下推了一层:所有流复用同一条 TCP 连接,而 TCP 是严格按序、可靠交付的。丢一个包,TCP 层要等它重传回来,这条连接上的所有流一起卡住。 于是在有丢包的网络上(移动网络、弱网),HTTP/2 甚至可能比多连接的 HTTP/1.1 更慢。
答案是 QUIC / HTTP/3:把可靠传输搬到用户态、基于 UDP 自己实现,按流做可靠性——丢的是 A 流的包,只卡 A 流,不卡 B 流。
这条演进链(串行 → 流水线 → 多路复用 → 按流可靠)值得你记一辈子,它是一句话的注脚:
性能优化的历史,就是不断发现”哪里还藏着一个人造的顺序依赖”,然后想办法把它拆掉的历史。
面孔三:GPU 的 SIMT——用天文数字的并发去掩盖延迟
GPU 是”吞吐机器”的极致形态,也是理解延迟/吞吐关系最好的反面教材。
单看延迟,GPU 烂得惊人。 一次全局内存访问,GPU 核心要空等成百上千个周期。没有 CPU 那种庞大的多级缓存,也没有精巧的乱序执行来填空档。如果按 CPU 的思路看,GPU 慢得不可理喻。
那它凭什么快?回到利特尔法则:
λ = L / W
W(访存延迟)是个又大又难改的常数。GPU 的选择是:把 L 做到 CPU 想都不敢想的程度。 一个 GPU 上同时可以有成千上万个线程在飞,一个核心上挂着几十个 warp(线程束)。当 warp A 在等内存时,核心立刻切到 warp B 执行,只要”在途的 warp 足够多”,计算单元就永远有活干。
这就是所谓的延迟隐藏(latency hiding):吞吐没被延迟拖垮,因为在途量大到把延迟盖住了。用利特尔法则的话说:GPU 不是在降低 W,它是在把 L 拉高到 W 不再成为约束。
代价是什么?单线程的延迟依然很烂。 所以 GPU 完全不适合”一个任务要尽快跑完”的场景(比如操作系统、数据库事务),只适合”一堆独立任务要尽快全部跑完”的场景(图形渲染、矩阵运算、模型推理)。
那”批”在哪里?GPU 的批在指令里:SIMT——同一条指令,同时作用于几十个 lane 上的不同数据。 一条指令驱动 32 个数据,这就是把”取指译码”的固定开销摊销了 32 倍。它是批处理和流水线的合体:warp 是批(32 个数据的批),warp 调度器是流水线(多个 warp 的访存与计算重叠)。
而 GPU 的”冒险”是分支发散:同一个 warp 里 32 个 lane 走了 if 的两个不同分支,硬件只能串行地把两个分支都执行一遍(不满足条件的 lane 被屏蔽)。32 路并行瞬间退化成 1 路,还要跑两遍。 所以写 GPU 代码的头号纪律就是”让同一个 warp 内的控制流尽量一致”——这跟”避免 CPU 分支预测失败”是同一条原则在不同尺度上的投影。
还有一个 GPU 专属的固定开销摊销:内存合并访问(coalescing)。相邻 lane 访问相邻地址,硬件会合并成少数几次内存事务;如果 32 个 lane 访问的地址散乱,就会变成 32 次独立事务。这跟数据库批量插入、跟磁盘顺序 IO 是同一个道理:连续永远比随机便宜,因为你摊的是”一次寻址”的固定开销。
面孔四:响应式流与背压——把流水线搬到分布式系统
前面三个都在一台机器里。那跨网络的流水线长什么样?
响应式流(Reactive Streams)这类模型的答案是:把处理链拆成若干个阶段(stage),阶段之间用有界队列连接,下游通过 request(n) 向上游申请 n 个元素。
这个设计的精髓在于 request(n):
- 它是信用(credit)制:下游处理完一批,才向上游要下一批。上游永远不会发超过下游申领量的数据。
- 它把流量控制从”上游猜”变成了”下游说”。
- 它让整条链变成一个受控的流水线:阶段之间重叠执行(A 处理第 3 条时,B 在处理第 2 条),而在途量被信用额度精确限制。
对照利特尔法则看,这个设计的意图就非常清楚了:request(n) 里的 n,就是你在给这条管道的每一个环节设定 L。 你不是在定延迟,也不是在定吞吐,你是在定”管道里允许同时躺着多少条数据”。延迟和吞吐的关系由 L = λW 自己算出来。
那 n 该设多大?这就是延迟和吞吐直接对撞的地方:
- n 太小:下游一处理完就得等上游下一批送过来,如果中间有网络往返,下游就会频繁饿死。吞吐被往返延迟掐住——这正是需要批处理/预取的信号。
- n 太大:管道里堆积大量数据,单条端到端延迟上升,内存占用上升,而且一旦下游故障,管道里的数据全丢/全要重来。
- n 适中:刚好够把上游的往返延迟盖住(n ≈ 带宽 × 往返延迟,这就是带宽时延积的思路),既不让下游饿死,也不让管道积太多。
看到没有?带宽时延积(BDP)在这里又冒出来了。 它跟 TCP 窗口该开多大、跟 Kafka 该攒多大批,全都是同一个问题:要让管道填满,在途量必须 ≥ 速率 × 延迟。 这就是 L = λW 的原话。
最后补一条响应式流最核心的价值观:队列必须有界。 无界队列不是”缓冲”,是”延迟炸弹”和”内存泄漏”的合体——关于这一点,下一节专门讲。
子主题十:缓冲的三种代价——延迟、内存、以及”缓冲掩盖问题”
如果说这章只能留一个”防身术”,我选这一节。因为缓冲是工程里最被滥用的东西——它太好用了,加一行配置就能让监控变绿,于是它被到处乱加,直到某天系统以一种没人预料到的方式死掉。
先把话说清楚:缓冲不是坏东西。它是减震器。路上的车流有波动,缓冲把尖峰削平、把低谷填上,让后面的环节看到一个平滑得多的负载。问题是——减震器不生产通行能力,它只是把冲击换个时间释放。
缓冲的代价有三层,一层比一层隐蔽。
代价一:延迟(最直白的一层)
这是最容易算的一层,利特尔法则直接给答案。一个平均排队长度为 Lq 的缓冲,请求在里面待的时间:
W_queue = Lq / λ(对整个缓冲用一次利特尔法则)
队列里的每一条数据,都是被你欠着的延迟。 一个装了 1000 条消息的队列,在每秒 100 条的速率下,就是 10 秒的延迟——这是恒等式,不是估计。
由此得到一条极其重要的诊断直觉:
一个长期不为空的缓冲,等同于一个正在过载的系统。
很多人看监控时只看”队列有没有满""内存够不够”。错。健康的缓冲应该是”大部分时间空、偶尔有尖峰”;如果它稳定地躺着几百条数据,说明入 ≥ 出,系统正在慢性死亡,只是还没死透。
缓冲不是延迟的解药,它就是延迟本身。 你加缓冲,等于宣布”我接受把这些请求延后处理”。如果你连这个都不接受,那你需要的不是更大的缓冲,是更大的处理能力,或者更少的入口流量。
代价二:内存(最容易被算漏的一层)
缓冲占内存,这谁都知道。但工程事故往往出在乘数效应上——你算的是”一个缓冲”,实际存在的是”一万个缓冲”:
- 一个连接一个读写缓冲 × 10 万长连接 = ?
- 一个分区一个生产批 × 5000 个 topic-partition = ?
- 一个流一条管道缓冲 × 800 个并发流 = ?
- 每个请求一个响应体缓冲 × 突发并发 = ?
缓冲泄漏的第一现场,永远是”连接数/分区数/流数”这个乘数没被算进去。
更难缠的是次生代价,它们不体现在内存监控上:
- GC 压力:大缓冲通常意味着大对象、老年代晋升、更长的停顿。而 GC 停顿又会变成延迟 → 队列更长 → 内存更多 → GC 更狠。又一个正反馈。
- 缓存污染:大缓冲把 CPU 缓存挤掉,热数据被驱逐,整体变慢。
- 数据丢失面变大:缓冲里的东西通常是”已确认接收但未持久化”的。进程一崩,这些全没。你用缓冲换来的”高性能”,其实有一部分是从耐久性里借的。
还有一条经常被忽略的:缓冲让”一份数据在内存里存在多久”变得不可控,而这会直接影响你做容量规划的准确性。一个缓冲膨胀的系统,其实际内存占用和流量不成线性关系,而是在流量模式变化时跳变——这就让压测结果失去预测力。
代价三:缓冲掩盖问题(最危险的一层)
来了。这一层杀死的系统最多。
缓冲的第一宗罪:它切断了反馈信号。
健康的系统应该有一条”从下游传回上游”的负反馈:下游慢了 → 上游感知到 → 上游减速。缓冲把这条信号线剪断了:只要缓冲没满,上游就以为一切正常,继续以原速灌入。
于是:
- 下游慢了一点;
- 缓冲吸收了,上游毫无察觉,继续全速发;
- 缓冲慢慢涨;
- 还在阈值内,监控绿的,警报不响;
- 缓冲满了,或者延迟已经累积到不可接受;
- 系统不是”逐渐变差”,而是”一直很好,然后突然不行”。
这就是所谓的缓冲膨胀(bufferbloat)——在网络领域这个词被研究得很透,但它描述的是所有带缓冲系统的通病:深缓冲把”丢包/拒绝”这个诚实的拥塞信号,替换成了”排队等待”这个沉默的信号。 丢包会立刻触发发送方降速(TCP 拥塞控制),排队不会——它只是让所有人都更慢,而且没人知道该减速。
第二宗罪:它制造了”看起来还行”的假象,让人不去修根因。
延迟涨了?加缓冲。超时多了?加缓冲。队列满了?加大缓冲。每一次加缓冲都让监控变绿、让警报安静、让值班的人能去睡觉——然后债务滚到了下一次事故。
这个模式的恶毒之处在于:它让”修好系统”这件事在系统内部失去了推动力。 缓冲把压力从”让工程师难受”变成了”让系统难受”,而系统不会提需求。
第三宗罪:它改变了故障的形状,让故障从可预测变成不可预测。
- 无缓冲/小缓冲:过载立刻表现为失败,快速失败,故障模式简单、可预测、可演练。
- 深缓冲:过载表现为”延迟缓慢累积”,然后在某个你没预料到的地方引爆——可能是 GC 超时、可能是客户端超时引发重试风暴、可能是内存 OOM、可能是 failover 后的数据丢失。
你以为你买了保险,其实你买的是一张”把小事故攒成大事故”的期票。
那怎么办:从”深缓冲”转向”主动队列管理”
网络界早就给出了答案,而且这个答案完全适用于软件系统。核心思想叫主动队列管理(AQM):
不要等缓冲满了才丢包/拒绝,要在队列刚开始形成时就给出信号。
具体落到你的服务上,就是几条可以立刻执行的纪律:
- 所有缓冲必须有界,并且用”时间”而不是”字节/条数”来标定大小。一条 1MB 的缓冲在低速链路上是几秒延迟,在高速链路上是几毫秒——字节数不含延迟信息,时间才含。 正确做法是:缓冲上限 ≈ 目标带宽 × 你能接受的排队延迟。
- 队列深度本身要作为一级监控指标和告警指标,而不是”内存占用”。队列长度的斜率比队列长度的绝对值更早预警。
- 优先在满之前降级,而不是满了之后丢弃。比如队列到 50% 就开始对新请求返回”稍后重试”(带上退避时间),到 80% 就开始 shedding 低优先级流量。早拒绝比晚拒绝便宜得多——因为早拒绝时,请求还没消耗完你的 CPU 和内存。
- 让背压贯穿全链路,而不是只在你这一层加缓冲。下游慢了,就该让我慢,就该让我的上游慢,一路传到流量入口。把信号传回去,比把数据囤起来健康得多。
- 把”排队时间”显式地从”服务时间”里分离出来计量。这是 Tail at Scale 那类实践里最重要的一个可观测性习惯:如果你只统计总延迟,你永远看不出延迟到底是”处理慢”还是”在排队”。 而这两者的解法完全相反(一个要优化代码,一个要减负载/加容量)。
一句话总结这一节,请背下来:
缓冲是用来吸收抖动的,不是用来吸收过载的。抖动是短期的、会自己平复的;过载是持续的、只会恶化的。拿缓冲去扛过载,等于拿信用卡去还房贷。
子主题十一:尾延迟解剖——长尾到底藏在哪
子主题五讲了”平均值撒谎”,这一节来干脏活:把长尾的尸体剖开,看看里面到底是什么。
在动手之前,先建立一个规模直觉,它比任何具体技术都重要:
在小规模下”罕见”的事件,在大规模下是常态。
一个”万分之一概率”的慢事件,听起来可以忽略。但如果你的系统每秒处理 10 万次请求,它每秒会出现 10 次,每分钟 600 次。在大规模系统里,你担心的不是”会不会发生”,而是”多久发生一次”。 这就是为什么大厂对尾延迟的执念不是洁癖——是算术。
好,那这些”万分之一事件”都是些什么?
来源一:GC 与运行时停顿
最经典的一个。托管运行时(JVM、Go、.NET、V8)的自动内存管理,会在某些时刻要求”所有线程停下来”(Stop-The-World),以便安全地移动对象、修正引用。
要理解的关键点:
- STW 停顿的时长通常和”存活对象的规模/堆的复杂度”相关,而不是和”这次要回收多少垃圾”相关。 所以大堆会显著拉长停顿——这是很多”内存给足就万事大吉”的配置翻车的根因。
- 停顿的频率则由”分配速率”决定。 分配越猛,回收越频繁。于是对象池化、复用、减少临时对象这类优化,降低的是尾延迟的频率而不是幅度。
- 分代假说帮了大忙(大部分对象朝生夕死),所以年轻代回收通常很快;但当对象晋升到老年代、老年代需要整理(compaction)时,那个大停顿就来了。
- 还有一堆”非 GC 的 STW”:到达安全点(safepoint)的等待、类卸载、某些运行时元数据操作、以及最容易被忽略的一个——线程必须能到达安全点才能停,如果某个线程正在执行一段长循环且长时间不检查安全点,其他所有线程都得陪着等。
应对思路(按推荐顺序):降低分配速率 → 控制堆规模(宁可多实例小堆,也不要单实例大堆)→ 把大对象挪到堆外 → 换用停顿时间可控/极低的收集器 → 实在不行就做请求级别的隔离(把有 GC 抖动风险的实例临时摘掉)。
注意最后一条:这是典型的用架构解决运行时问题——单实例 32GB 堆的停顿,换成 8 个 4GB 实例后,虽然总停顿次数变多,但每次停顿短了一个量级,而且单次停顿只影响 1/8 的流量。
来源二:磁盘与存储的抖动
磁盘是最不”平均”的组件。它的 P50 和 P999 之间能差出几个数量级。
抖动从哪来:
- 页缓存命中与未命中:命中是内存速度,未命中是设备速度。而缓存是否命中,取决于别人也在读什么。
- 写回(writeback)与 fsync 争抢:脏页积累到阈值会触发集中刷盘,此时所有新的同步写都要排队等。这是”平时很快、偶尔集体卡住”的经典成因。
- SSD 的内部活动:闪存需要先擦除再写入,设备固件要做垃圾回收和磨损均衡。一个写入很猛的 SSD,其读延迟会比空闲时高得多,而且这对操作系统是完全不透明的——你只能看到”它变慢了”。
- LSM 树的 compaction:基于 LSM 的存储(RocksDB、很多分布式 KV、时序库)会后台合并文件。compaction 一跑,IO 和 CPU 都被抢,还可能触发写停顿(write stall)。 你会看到周期性的延迟毛刺,周期和你的写入量高度相关。
- 读放大:LSM 树一次逻辑读可能要查多层文件,层数越多越慢。
- 云环境的”吵闹邻居”:用网络存储/共享块存储时,你买的 IOPS 是配额制,超配额被限流;而物理设备的负载是你看不见的。多租户是尾延迟的制造机。
来源三:热点与倾斜
理论上数据是均匀分布的,实际上不是。数据倾斜是分布式系统的癌症。
- 键热点:一个超级大 V 的用户数据、一个爆款商品的库存行——所有流量打到一个分区、一个分片、甚至一行。那个分片的 CPU 100%,其他分片闲着,整体吞吐上不去,尾延迟爆表。注意这里利特尔法则的残酷:你的总容量明明够,但因为 λ 集中在一点,那一点的 W 发散。
- 缓存击穿与惊群:一个热点 key 过期的瞬间,成千上万个请求同时穿透到数据库。这是”批处理”的反面教材——你精心设计的缓存,在非热点时把 99.9% 的读挡住了,于是数据库完全没为”击穿”做过准备,一击穿就死。
- 主从/副本不均衡:读写分离后,主库承担所有写;或者某个副本所在的机器恰好也在跑别的重活。
- 计算倾斜:在 Spark/Flink 里做
groupBy或join,某个 key 的数据量是其他 key 的一万倍,那一个 subtask 要跑很久,整个作业的完成时间被它拖死。注意这与批处理的关系:你在批处理里把批开得越大,倾斜的绝对影响就越可怕(批里最慢的那一条决定整批)。
来源四:锁竞争与排队
- 锁护送(lock convoy):一把锁被争抢时,等待者排成一队。更糟的是——持有锁的线程如果被抢占(时间片用完、被调度走、发生缺页),所有等待者都要陪着等它被调度回来。 一把锁的持有时间,会被调度抖动放大成几十倍。
- 优先级反转:低优先级线程持锁,高优先级线程等锁,中优先级线程把低优先级线程挤下去——结果高优先级等得最久。
- 伪共享(false sharing):两个毫无关系的变量落在同一条缓存行上,两个核反复互相作废对方的缓存行。代码里没有任何锁,但性能表现得像有锁一样。
- 无锁不代表无争抢:CAS 循环在高度争抢下会大量重试,缓存行在核间来回弹(cache line ping-pong),吞吐可能比一把好的锁还差。
一个反直觉但极其实用的判断:降低锁争抢最有效的手段,往往不是换更聪明的锁,而是减少临界区里做的事、以及减少需要共享的东西(分片计数器、线程本地累加再合并、数据不变性、每核一份)。把共享拆开,比把锁做快更有效——这也是”减少依赖”这个主题在并发层面的版本。
来源五:网络重传与丢包
- 丢包即延迟:一个包丢了,等超时重传,这个等待就是纯粹加在尾延迟上的。而丢包不是均匀分布的——它成簇出现。
- 微突发(microburst):流量在毫秒级尺度上突发,把交换机缓冲打满,于是丢包。你的监控看到”带宽只用了 30%“,但那是秒级平均,毫秒级的突发早就把缓冲打爆了。这跟”平均值撒谎”是同一个陷阱,换了个尺度。
- 队头阻塞:在严格按序的可靠传输里,一个丢包会卡住它后面所有已经到达的数据。
- 多路径哈希碰撞:ECMP 把多条流哈希到不同链路上,如果哈希不均(或者流量本身集中在少数几条流上),某条链路会拥塞而其他链路空闲——又是一次热点问题,只是发生在交换机上。
- 连接建立成本:TCP 三次握手 + TLS 握手,是若干个额外的往返。如果你的客户端频繁建连(没开长连接、连接池太小、连接被中间设备无情回收),这个成本会周期性地出现在尾延迟里。
来源六:一堆”你不会想到”的东西
长尾之所以难治,很大程度是因为来源太多、太杂。补充一些”老兵才会先查”的:
- 冷启动与 JIT 预热:新实例刚起来时,代码还没被编译优化,请求慢得离谱。滚动发布/扩缩容期间,这个现象会被放大成一次线上事故。
- 同步日志:请求线程里直接写日志,而日志库在某个时刻触发了 flush 或文件滚动(rotate),一次同步 fsync 卡住整个线程池。“打日志而已”造成的 P999 事故,多到可以写本书。
- CPU 配额与节流(throttling):容器被 cgroup 限制了 CPU 配额,你的进程在配额用完后被硬停到下一个周期。监控看到 CPU 才 60%,但那是平均值,每个周期末尾你都被停了。 这是云原生时代最隐蔽的尾延迟来源之一。
- 虚拟化开销:宿主机 steal time、动态迁移、宿主上的其他租户。
- NUMA 与跨节点内存访问:内存挂在其他 NUMA 节点上,访问延迟高一截。
- 透明大页(THP)与缺页:大页分配和整理可能造成长时间停顿。
- DNS 解析:用了域名却没缓存解析结果,每次调用都带一次 DNS 查询。
- 全量指标采集/上报的周期性开销。
规律是什么? 把这些列在一起看,你会发现长尾来源有一个共同特征:
它们几乎都是”共享资源的、偶发的、后台的、或周期性的”活动。
- 共享:锁、磁盘、缓存行、网络链路、云宿主;
- 偶发:GC、丢包、缺页、故障切换;
- 后台:compaction、写回、日志滚动、指标采集;
- 周期性:cron、窗口触发、连接回收、租约续期。
所以治理尾延迟的第一步,不是去调参数,而是把你系统里所有”共享、偶发、后台、周期”的东西列一张清单。 那张清单,基本就是你的长尾地图。
子主题十二:对冲请求——“复制一份取先返回”到底划不划算
有了长尾地图,就该聊怎么治了。这里挑一个最有代表性的手段深挖:对冲请求(hedged requests),也叫备份请求(backup requests)。
机制
一句话讲完:
先发一个请求。如果超过某个时间(比如你观测到的 P95 或 P99)还没回来,就再发一个一模一样的请求给另一个副本,哪个先返回用哪个,剩下的取消。
为什么它这么有效:一个统计学上的解释
关键在于理解”取先返回”在数学上意味着什么。
假设单次请求的延迟是一个随机变量 X。不用对冲时,你付出的延迟就是 X 的一次采样。
用了对冲(在对冲窗口后发第二个),你付出的延迟约等于:
min(X₁, X₂ + d),其中 d 是你等待对冲的那点延迟
而 “取两个采样的最小值”这个操作,天然把分布往左压。这不是什么工程技巧,是顺序统计量的基本性质:n 个独立同分布样本的最小值,其分布永远比原分布更集中在左侧。 你不需要改善单次请求的延迟,你只需要多抽一次样。
这就是为什么对冲对尾延迟的效果远好于对中位数:中位数附近,第二个样本本来也快不了多少;但在尾上,“再抽一次”意味着你有极高的概率抽到一个不在尾上的样本。
一句话戳穿本质:
对冲请求不是在”优化延迟”,它是在”买保险”——用一点点额外负载,买断那些由独立偶发事件造成的长尾。
代价核算:一毛不拔是做不到的
现在算账。对冲的代价是什么?
代价一:额外负载,而且是个可以精确估算的量。
如果你把对冲阈值设在 P95,那么大约有 5% 的请求会触发对冲——额外负载约 5%。 设在 P99,大约 1% 的请求触发——额外负载约 1%。
这个数字漂亮得让人想无条件开启,但请立刻警觉:这个 1% 是”正常状态下”的 1%。
代价二:相关性风险——这是对冲最致命的软肋。
上面的估算有一个隐含假设:两个副本的慢是相互独立的。 但现实中,很多慢的原因根本不是独立的:
- 两个副本都要读同一个数据库,数据库慢了 → 两个都慢,对冲完全无效,白白多打了一份流量;
- 两个副本的 GC 因为相同的代码、相同的流量模式而同步发生(这在同构部署里非常常见);
- 慢的是共享的网络路径、共享的存储、共享的缓存集群;
- 最可怕的情形:系统已经在过载了。这时候每个请求都慢,于是每个请求都触发对冲 → 负载直接翻倍 → 更慢 → 更多对冲 → 雪崩。
对冲请求会把”局部抖动”变成”全局放大”——当抖动不独立的时候。 所以对冲请求的第一条工程纪律是:
只在有空闲容量时才对冲。利用率一高,立刻关掉。
这引出一个必须做的实现细节:对冲必须是自适应的、可熔断的。 常见做法包括:
- 统计最近的对冲触发率,超过阈值(比如翻倍到 5%)就自动关闭对冲;
- 只对冲到”当前负载较低/队列较短”的那个副本,而不是随机选一个;
- 限制对冲请求在总请求量中的占比(对冲预算,和重试预算一个思路);
- 只对幂等的操作对冲,或者带上请求 ID 让服务端去重。
代价三:与超时重试的对比——这两者经常被混为一谈,但性质完全不同。
| 超时后重试 | 对冲(备份)请求 | |
|---|---|---|
| 时机 | 等原请求失败/超时后再发 | 原请求还在飞的时候就发 |
| 对延迟的影响 | 增加延迟(要等超时 + 重试全程) | 几乎不增加(只增加了第二个请求的那点偏移) |
| 对负载的影响 | 只在失败时增加 | 只在慢的时候增加 |
| 适合的场景 | 处理错误(真的失败了) | 处理慢尾(没失败,只是慢) |
这张表里最关键的一行是”对延迟的影响”。重试是”加法”,对冲是”取最小值”。 用重试去治尾延迟,你会得到一个荒谬的结果:你的 P99 = 超时时间 + 重试的 P99,你把尾延迟顶到了一个更高的台阶上。
所以一条铁律:重试用来治失败,对冲用来治慢尾。别拿重试当对冲使。
代价四:语义与幂等。
你发了两个一模一样的请求,两个副本可能都执行了。如果这是个扣款操作,你可能会扣两次。所以:
- 要么操作本身是幂等的(读、查询、幂等写);
- 要么带请求 ID 做服务端去重(这也是”至少一次投递 + 幂等消费”那条老规矩的又一次现身);
- 要么就不能对冲。
代价五:命中”冷副本”的反向惩罚。
如果你的负载均衡是”本地优先”或”会话保持”的,那么对冲到的那个”另一个副本”很可能是冷的——没有热缓存、JIT 没预热、连接池没建立。第二个请求可能比第一个慢得多。所以对冲目标的选取策略非常重要:优先选队列短的、最近响应快的、缓存热的,而不是随便挑一个。
一个改进:tied requests(绑定请求)
对冲的一个明显浪费是:第二个请求发出去后,第一个请求可能马上就返回了,第二份工作白做了。
改进方案是让服务端知道这两个请求是”一伙的”:客户端同时(或先后)发给两个副本,并在请求里标注”我是同一个逻辑请求的两个副本,请互相协调”。谁先开始处理,另一个就取消或跳过。
这把”浪费的第二份工作”从”完整执行一遍”降到”只在真的需要时执行”。代价是需要服务端协议支持、增加耦合。它的思想值得记下来:把”取最小值”这个决策从客户端下沉到服务端,能省掉重复劳动。
对冲之外:尾延迟治理的完整工具箱
对冲只是其中一件武器,而且通常不是第一件。按”应该先做”的顺序排:
- 先修根因:找到长尾地图里那些具体原因(GC、同步日志、热点、无止境的重试),修掉。对冲是麻醉剂,不是手术刀——它让你不痛,但不治病。
- 减依赖:减少关键路径上的扇出。少一个依赖,就少一次尾部放大。
- 区分必要与非必要:把非关键调用做成并行的、可降级的、可跳过的。
- 截止时间传播(deadline propagation):每个请求带一个截止时间,每一跳都检查”我还来得及吗”,来不及就立刻放弃。这比静态超时聪明得多——它让整条链路共享同一个时间预算,避免”每一层都设 500ms,结果总延迟 3 秒”。
- 对冲请求 / 绑定请求:治那些”独立、偶发、无法根除”的慢尾。
- 细粒度分区与快速摘除:把工作切得更细,某个分区慢了就快速把流量挪走(而不是等它拖死整个批)。
- 降级与负载卸载(load shedding):在系统扛不住时主动丢弃低价值请求,保住高价值请求。这是唯一在过载时真正有效的手段。
- 重试预算与熔断:给重试设总量上限,避免重试成为压垮骆驼的最后一根稻草。
注意 3、4、7 的共同点:它们都在”降低 L”或者”降低不必要的 λ”。 又回到利特尔法则了——整章其实一直在围着那个式子转。
子主题十三:两种世界观——实时系统与批处理系统的设计哲学
前面讲的所有权衡,在”系统级设计”这个尺度上会凝结成两种截然不同的世界观。理解这两种世界观的差异,比记住任何一个参数都重要,因为大部分”架构选型之争”的本质,都是这两套哲学在打架。
先抛一个问题让你感受一下:
为什么”批处理系统”做容错的方式是”重新算一遍”,而”流处理系统”做容错的方式是”定期存个档”?
这两个选择看起来只是技术细节,其实背后是完全不同的价值排序。
批处理世界观:吞吐至上,延迟是可以拿来换的资源
代表:经典 MapReduce、离线数仓、ETL 作业、训练数据预处理。
它的核心信条:
- 数据是有限的、静止的、全部已知的。 作业启动时,输入数据集是完整的。
- 目标是”把这堆数据尽快全部处理完”。 单条记录的延迟毫无意义——没有人在等它。
- 阶段之间有明确的屏障(barrier)。 上一个阶段全部完成,下一个阶段才能开始。屏障让”重算”变得极其简单:哪个阶段的哪个分片失败了,就重跑那一片。
- 中间结果落盘。 这是关键。每个阶段的输出写到存储里,下游从存储里读。
为什么落盘?不是为了快,是为了容错。 落盘把”计算的进度”变成了”持久化的状态”,于是故障恢复=重新读取+重新计算,不需要任何复杂的状态管理。
代价是什么?每一道屏障和每一次落盘,都是一次巨大的延迟注入。 一个三阶段的作业,延迟是”三段时间 + 两次全量落盘 + 两次调度等待”。批处理系统坦然接受这个代价,因为它优化的目标函数里根本没有”单条延迟”这一项。
再看看它的吞吐是怎么来的:
- 大批次 → 摊销所有固定开销(任务启动、JVM 启动、连接建立、文件打开);
- 顺序 IO → 摊销寻址开销;
- 阶段屏障 → 允许每个阶段用最适合的并行度和数据布局。
这里的”批”是大到极端的:一个 map 任务可能处理 128MB 的一整块数据。因为 F/c 在这里大得离谱——启动一个 JVM、打开一个文件,相对处理一行日志的固定开销,比值可能是几十万。 所以子主题七那个”有效批大小 ≈ F/c”的结论,在批处理系统里被推到了极限。
实时世界观:延迟至上,吞吐是可以拿来换的资源
代表:在线服务、流式计算、实时风控、在线特征计算。
核心信条:
- 数据是无限的、流动的、不可预知的。 你永远看不到”全部数据”。
- 目标是”每条数据进来后尽快产生它的结果”。 端到端延迟是首要指标。
- 不能有全局屏障。 因为屏障意味着等待,等待意味着延迟。数据必须一进来就能被处理、结果一产生就能被发出。
- 状态在内存里。 因为落盘意味着 IO 延迟。
矛盾来了:没有屏障、状态在内存,那怎么做容错?
答案是定期快照(checkpoint)。系统周期性地在某个”逻辑时刻”给所有算子的状态拍一张一致的快照,存到持久存储;故障时就回滚到最近的快照,然后从数据源重放快照之后的那段数据。
这里最精妙的部分是**“一致性”**:在没有全局暂停的前提下,怎么拍到一张”所有算子都对应同一个逻辑时刻”的快照?
答案是在数据流里插入特殊的屏障消息(barrier),让它随数据一起流下去。每个算子收到屏障时,就把自己的当前状态存下来;因为屏障把流切成了”之前”和”之后”两段,所以所有算子各自存下的状态,拼起来就是”屏障这个逻辑时刻”的完整一致状态。这个思路(分布式快照的经典算法)的美妙之处在于:它不需要暂停计算,代价只是屏障在流中传播的那一点点延迟(以及对齐时的等待,下一节会讲)。
注意这里的关键交换:
| 批处理系统 | 实时/流处理系统 | |
|---|---|---|
| 容错的代价付在哪 | 故障时付(重算整片,延迟巨大) | 稳态时付(定期快照,持续小额开销) |
| 稳态开销 | 低(不用做快照) | 高(持续快照 + 状态管理) |
| 故障恢复时间 | 长(重算) | 短(回滚到最近快照 + 重放增量) |
| 延迟 | 高(分钟到小时) | 低(毫秒到秒) |
| 吞吐效率 | 极高(大批、顺序、无状态管理开销) | 较低(小批/单条、状态管理开销) |
这张表就是”延迟与吞吐权衡”在系统设计层面的完整投影。 批处理把容错的代价”推迟”到故障时支付,换取了稳态下的高吞吐;流处理把容错的代价”摊薄”到稳态时持续支付,换取了低延迟和快速恢复。
这不就是本章的主题吗? 批处理是”攒够了再付”(延迟换吞吐),流处理是”边走边付”(吞吐换延迟)。同一个权衡,出现在了容错策略上。
中间路线:微批(micro-batch)
纯流式的单条处理,固定开销摊销不上去,吞吐差;纯批式的大批,延迟不可接受。于是有了中间路线:把流切成很小的、按时间划分的批(比如每 1 秒一批,或每 100 毫秒一批)。
- 它用”小批”换回了大部分批处理收益(摊销任务调度、网络、序列化开销);
- 同时把延迟压到秒级甚至亚秒级;
- 容错可以沿用批处理那套(每个微批是一个可重算的单元)。
微批是批处理与流处理之间的一段连续光谱上的一点。 而理解一个流处理引擎的”批”有多大,基本就理解了它在这个光谱上的位置:
- 微批模型(如 Spark Streaming 的经典模式):批是显式的、按时间切的,延迟下界就是批间隔。
- 真流式模型(如 Flink):批是隐式的、网络层的、可配置的,甚至可以完全关闭批处理变成纯单条。
于是就有了下一节:一个真正的流处理引擎,是怎么把”延迟 vs 吞吐”做成一个可拧的旋钮的。
子主题十四:Flink 的旋钮——一个”延迟/吞吐可调”的真实样本
讲具体引擎容易过时,所以我不是要你背配置项(配置名在不同版本里有调整,请以你使用版本的官方文档为准),而是要你看清楚:一个成熟的引擎,会把我们这章讲的所有权衡,都暴露成可配置的旋钮。 看明白这些旋钮的分布,你就掌握了一套”审查任何系统延迟/吞吐设计”的检查清单。
以 Apache Flink 为例(它是”真流式”的代表,把批处理退化成”流的有界特例”)。
旋钮一:网络缓冲的刷新超时——最纯粹的”批处理三重触发”
流式引擎里,算子 A 通过网络把记录发给算子 B。如果每条记录都单独打包发送,那网络报文头、序列化头这些固定开销就占了绝大部分——跟 Nagle 面对的是一模一样的问题。
Flink 的做法是在发送端设一个”缓冲超时”:数据先写进网络缓冲,缓冲满了就发,或者超时到了就发。
这不就是”大小触发 + 时间触发”吗?而且它比 Kafka producer 更极端——它把这个超时一直可以调到 0(每来一条立刻 flush)和 -1 / 极大值(只在缓冲满时才 flush,纯吞吐模式)。
三个档位的含义:
| 缓冲超时 | 吞吐 | 延迟 | 适用场景 |
|---|---|---|---|
| 0(每条 flush) | 最低 | 最低 | 极低延迟、流量不大 |
| 小值(毫秒级) | 中 | 低 | 默认的均衡点 |
| 大值 / 仅满才发 | 最高 | 高 | 离线、高吞吐、不在乎延迟 |
这一张表就是整章内容的浓缩。 它同时演示了:
- 批处理收益来自固定开销摊销(报文头);
- 时间触发是给延迟设上限的机制;
- 大小触发是防止缓冲爆炸的机制;
- 以及最关键的——它把选择权交给了使用者,因为引擎作者也不知道你的 SLO 是多少。
(诊断提示:如果你发现 Flink 作业的延迟远高于各算子处理时间之和,而吞吐又压不上去,先看看这个缓冲超时是不是被调得太大,或者是不是因为缓冲太大导致在途数据量失控。)
旋钮二:算子链(operator chaining)——“合并阶段以减少固定开销”
Flink 默认会把上下游相邻的、并行度相同的算子”链”进同一个任务里,让它们在同一个线程内直接通过方法调用传递数据,跳过序列化、网络、缓冲这一整套。
这省掉的是什么?正是子主题一说的那些固定开销——序列化、内存拷贝、网络传输、线程切换、缓冲管理。
那为什么不把所有算子都链起来?因为链起来的部分只能用一个并行度、被调度到一个任务槽里。如果你希望某个算子独立扩缩容(它是瓶颈),或者你希望某个算子和另一个算子隔开(避免相互干扰),就要禁用链。
于是就出现了一个非常典型的权衡:
链起来 = 省固定开销 = 低延迟;拆开 = 可独立并行 = 高吞吐。
这根本就是”批处理 vs 并行”这个老问题的一个新变体:合并(批处理思想)摊销固定开销,拆分(并行思想)提升资源利用率。而 Flink 让你逐个算子地选择。
旋钮三:检查点(checkpoint)——用稳态开销买恢复速度
旋钮包括:检查点间隔、一致性语义(至少一次 / 恰好一次)、以及对齐 vs 非对齐检查点。
这个权衡非常精彩:
- 间隔短:故障时丢的数据少、恢复快,但快照开销频繁,稳态吞吐被拉低;
- 间隔长:稳态开销小,但故障时要重放更多数据,恢复时间长。
这是一个纯粹的”稳态性能 vs 故障代价”的交换——跟前面那张”批处理 vs 流处理”的表是同一个结构:你选择把容错的成本放在什么时候付。
更精彩的是对齐 vs 非对齐:
- 对齐检查点(aligned):屏障到达一个算子时,如果它有多个输入流,它必须先暂停处理那些已经收到屏障的流,等其他流的屏障也到齐——这样才能保证快照严格对应同一逻辑时刻。
- 问题来了:在反压下(上游有的流慢),“等屏障”可能等很久。 于是检查点本身的耗时飙升,而在检查点完成期间的延迟也会被推高。这是一个非常隐蔽的”缓冲导致延迟”的实例——屏障在排队。
- 非对齐检查点(unaligned):屏障插队,越过还在缓冲里排队的数据直接往下传,并把”被越过的数据”一并计入快照状态。
- 好处:检查点不再被反压拖住,在高压下能快速完成,延迟尖峰消失。
- 代价:快照变大(要存那些在途数据),存储成本和恢复时的状态载入成本上升。
看清楚这个交换的结构:用”状态大小/存储成本”换”检查点的延迟尖峰”。 它又一次告诉我们:当一个系统在过载时表现出诡异的延迟尖峰,去查那些”周期性的后台活动”是不是在排队。
旋钮四:水印与允许迟到——“正确性 vs 延迟”的权衡
这是流式处理独有的一类权衡,非常值得后端工程师理解,因为它的思想可以迁移到很多地方。
流处理要面对一个残酷现实:事件到达的顺序,和事件发生的顺序,不是一回事。 一个 10:00:03 发生的事件,可能因为手机断网、因为重试、因为跨地域同步,到 10:00:40 才到达系统。
那”10:00:00 到 10:01:00 这个窗口的统计数据”什么时候才算完?你永远等不到”确定没有更晚的数据了”那一刻(除非你愿意无限等下去)。
解决方案是水印(watermark):系统维护一个”水位”声明——“我认为不会再有早于时间 T 的事件来了”。水位往前推进,窗口就在水位越过窗口末尾时触发输出。
于是权衡出现:
- 水印推进得快(允许的无序度小):结果出得快(低延迟),但迟到的事件会被丢弃或需要单独处理(正确性下降);
- 水印推进得慢(允许的无序度大):结果更完整(正确性上升),但每个窗口都要多等一会儿(延迟上升),而且要为每个窗口多保留一段时间的状态(内存上升)。
还有”允许迟到(allowed lateness)“这个旋钮:窗口触发输出后,再额外保留一段时间状态,让迟到事件能修正已发出的结果。保留多久,就是”用内存和延迟换正确性”。
这个权衡的通用形态值得单独拎出来:
任何”需要等待才能确定的事情”,都存在一个”等多久”的旋钮。等得越久越准确,但越晚知道。
这个模式在别处也到处都是:分布式事务要等多久才提交、共识算法要等多久才认为领导者挂了(以及如何区分”慢”和”挂”)、缓存要等多久才失效、批处理要攒多久才发送。“等多久”这三个字,就是延迟与正确性/吞吐之间最原始的那个旋钮。
旋钮五:窗口聚合的执行方式——增量 vs 全量
窗口触发时怎么算结果?
- 全量(把窗口内所有记录攒着,触发时一起算):简单、支持任意计算,但要缓存整个窗口的数据(内存代价),且触发时有一大坨计算(延迟尖峰)。
- 增量(来一条就用聚合函数更新一次状态):内存只占一份状态值,触发时几乎零成本、延迟极低;但只支持可增量化的计算(求和、计数、最大最小,以及可结合/可交换的聚合)。
又是一个熟悉的形状:批处理(攒着一起算)vs 流水线(来一条算一条)。 而且这次它连”批处理的代价”都完整地复现了:内存占用、失败重算代价、以及触发瞬间的延迟尖峰。
(Flink 里还有”提前触发(early firing)“这类机制:在窗口结束前就周期性输出部分结果,后面再修正。这跟”对冲请求”的思路很像——先用不完整的答案满足低延迟,再用完整的结果满足正确性。)
从这些旋钮里提取的通用检查清单
把上面五个旋钮抽象一下,你拿到任何一个系统,都可以照这个清单问一遍:
- 有没有”攒批时间”的旋钮? 它的默认值是多少?我的 SLO 允许它设这么大吗?
- 有没有”合并阶段以减少固定开销”的旋钮? 合并的代价是什么(并行度、隔离性、故障域)?
- 周期性后台活动(快照/压缩/合并/清理)会不会造成延迟尖峰? 它的耗时在过载时会不会失控?
- 有没有”等多久才认为确定”的旋钮? 它对正确性和内存的影响分别是什么?
- 聚合/落库是增量的还是全量的? 触发瞬间的延迟尖峰有多高?
问完这五个问题,你对这个系统的延迟/吞吐特性就已经比 90% 的使用者清楚了。
子主题十五:测量先于优化——平均值、协调遗漏与延迟-吞吐曲线
在讲”怎么定 SLO”之前,必须先解决一个更前置的问题:你的数字是不是真的?
因为绝大多数团队定 SLO 时的第一个错误,不是定错了目标,而是在用一套撒谎的测量系统给自己打分。
陷阱一:平均值——以及为什么它连”参考”都不配
这个子主题五已经讲过,这里补一个更狠的角度:
平均值不仅掩盖长尾,它还会在你做优化时给你错误的反馈。
想象一下:你做了一个优化,让 P50 从 20ms 降到 15ms,但引入了一个每千次触发一次的 500ms 停顿。平均值怎么变?
平均值 = 0.999 × 15 + 0.001 × 500 ≈ 15.5ms。平均值变好了。 你的监控大盘绿了,你的优化”见效了”。
但实际上,每 1000 个用户里有 1 个要多等半秒。而你做的这个优化,恰恰是在牺牲这 1 个用户去讨好平均值。
这就是为什么平均值不只是”不够好”,它是”有害的”——它会系统性地奖励那些牺牲少数用户换取平均数字的决策。
正确的做法是记录分布:直方图(HdrHistogram、DDSketch 这类相对误差可控的实现,或者监控系统的原生直方图)。有了分布,你才能算出任意分位数,而且事后还能重新聚合(平均值一旦算了就再也回不去了)。
陷阱二:协调遗漏(coordinated omission)——压测界最经典的骗局
这个陷阱极其隐蔽,而且几乎所有朴素的压测工具都掉进去了。理解了它,你就再也不会相信任何一个”我们压到了 X QPS、P99 才 Y 毫秒”的说法而不追问一句”你用什么工具测的”。
问题出在压测客户端的行为上。一个典型的”开 N 个线程、每个线程循环发请求”的客户端,它的循环是这样的:
发请求 → 等响应 → 记录延迟 → 发下一个请求
看出问题了吗?它在等响应的时候,是不发新请求的。
现在假设系统卡住了 2 秒(一次 GC、一次锁护送)。在这 2 秒里:
- 客户端的 N 个线程全部卡在”等响应”上,没有一个新请求被发出去;
- 于是这 2 秒里,系统只承受了 N 个请求(而不是正常速率下的几万个);
- 客户端只记录了 N 个 2 秒的慢样本;
- 而那”本该被发出、但因为客户端也在等而没发出”的几万个请求,连样本都没有。
结果是什么?系统最严重的那个停顿,在统计上被稀释成了一个漂亮的 P99。 你的压测报告显示”P99 = 50ms”,而真实用户在停顿期间体验的是 2 秒以上。
这就是协调遗漏:测量系统(客户端)和被测量系统(服务端)串通好了——服务端一卡,客户端就停止施压,于是服务端最糟的表现反而被少记了。这跟”考试时老师不盯的那几排就没人作弊”是一个道理:你测的样本,被你要测的那个现象本身给过滤掉了。
怎么修?核心是:客户端必须以”计划好的节奏”发请求,而不是以”收到响应才发下一个”的节奏。 如果到了该发请求的时间点而上一次还没回来,就照发不误(这叫”闭环模型的反面”——开环/按时间表发压),并把这次超期如实地记为一次超长延迟。
一条判断标准:你的压测工具,能不能在系统完全卡死的时候,依然保持恒定的请求发送速率? 如果不能,你的 P99/P999 数据就是假的。
(现实中还有一个折中:用”闭环 + 固定并发”的模型测吞吐上限是合理的,但用它测尾延迟就是错的。两个指标应该用不同的压测模型来测——这本身就是”延迟和吞吐是两个自由度”的又一次体现。)
陷阱三:测量点选错了
同样一个请求,在三个地方测,得到三个完全不同的数字:
- 服务端内部计时(进 handler 到出 handler):不包含排队、不含网络、不含 TLS 握手、不含连接建立。这是最”好看”的数字,也是最容易自欺欺人的数字。
- 网关/负载均衡侧的计时:包含网络和服务端的排队,但不包含客户端侧的网络和 DNS。
- 客户端侧计时(真实用户监控):包含一切。
SLO 应该定义在哪一层?
答案是:定义在你的用户实际感受到的那一层。 如果你的用户是外部 App,那就是客户端侧(或者至少是离用户最近的那一跳)。如果你的用户是内部服务,那就是调用方侧。
一个常见的低级错误:服务端内部 P99 是 30ms,于是 SLO 定 50ms,结果调用方天天报警——因为调用方看到的 P99 是 200ms,多出来的 170ms 全在排队和网络里。
规则:永远在”边界外”测量,在”边界内”诊断。 用客户端侧的数字定 SLO,用服务端内的分段耗时(排队多久、处理多久、等下游多久)来定位问题。
陷阱四:不看延迟-吞吐曲线的形状
最后一个陷阱:很多人拿一个”在某个负载下测出来的延迟”就当成系统的特性。但延迟不是系统的固有属性,它是”在给定负载和给定并发下”的表现。
正确的做法是画一条曲线:横轴是吞吐(逐步加压),纵轴是延迟(P50/P99 各画一条)。你会得到那条经典的”L 形”或者”曲棍球棒”曲线:
- 前段:吞吐线性上升,延迟基本平坦(系统有余量,请求不用排队);
- 膝盖处:延迟开始上翘(排队开始形成);
- 后段:吞吐平了(撞到瓶颈),延迟竖直上升。
这条曲线的”膝盖”位置,就是你系统真正的工作点上限。 而且请注意:
P99 的膝盖比 P50 的膝盖出现得早得多。
也就是说,从”P50 看起来还很健康”到”P99 已经崩了”之间有很长一段区间,你在那段区间里会误以为自己还有余量。 这就是为什么只看中位数的容量规划总是翻车。
实操建议:把这条曲线测出来,贴在容量规划文档里,工作点定在膝盖之前,并且明确标出”P99 开始上翘的那个负载值”。 这个数字,比任何”我们能扛多少 QPS”的口号都有用。
子主题十六:怎么定延迟 SLO,以及 SLO 如何反向约束批处理与缓冲
终于到了落地环节。前面所有内容,最终都要汇成两个问题:我的延迟目标定多少?定了之后,它怎么反过来管住我的设计?
第一步:用分位数定,而且分位数由”扇出”决定
SLO(服务级别目标)的标准形式是:
在 30 天滚动窗口内,99% 的合法请求的延迟 ≤ 200ms。
三个要素:时间窗口(30 天滚动)、分位数(99%)、阈值(200ms),外加”合法请求”(排除客户端错误、排除被限流的)。
分位数该怎么选?这里有一个很多人没意识到的关键点:
你的服务在调用链上被扇出的次数越多,它对自身尾延迟的要求就越严格。
推导一下。假设一个用户请求要调用 N 个后端服务,只要有一个慢,整个用户请求就慢。如果每个服务”变慢”的概率是 p,且各服务独立,那么用户请求变慢的概率约为:
1 − (1 − p)^N ≈ N × p(当 p 很小时)
现在反过来用:如果你的产品目标是”99% 的用户请求不慢”(也就是允许 1% 的用户受影响),而这 1% 的预算要被 N 个服务分摊,那么每个服务只能分到 1/N 的预算:
每个服务的 p ≈ 1% / N,也就是每个服务的分位数要求 = 1 − 1% / N
- N = 1(用户直接调你):你的 P99 要达标。
- N = 10:你的 P99.9 要达标。
- N = 100:你的 P99.99 要达标。
所以在一个扇出很大的系统里,后端服务的尾延迟要求会严苛到几乎变态。 这不是卷,这是算术。
(严谨性说明:这个估算假设各服务的慢事件相互独立、且任何一个慢就整体慢。现实中慢事件往往正相关(共享数据库、共享网络),实际会稍好一些;但反过来,重试和依赖链会把放大效应推得更狠。所以它不精确,但它给出的量级判断是对的,而且方向不会错:扇出越大,尾部要求越严。)
由此立刻得到两条工程结论:
- 减少关键路径上的依赖数量,是改善尾延迟最有效的手段之一——比优化任何单个依赖都有效。因为它是乘法关系。
- 不要给所有服务定同一个 SLO。 被扇出 100 次的服务和被直接调用的服务,其尾部要求差两个数量级,用一个模板套是错的。
第二步:SLO 不能比你最慢的关键依赖更好
一个经常被忽略的常识:你的延迟 SLO 不可能优于你关键路径上最慢的那个依赖。
如果你的服务必须同步调用一个 P99 = 300ms 的下游,那你的 P99 承诺 100ms 就是纯粹的自我欺骗——你要么改架构(异步化、缓存、降级、预计算),要么改 SLO。
规则:定 SLO 之前,先把关键路径上所有依赖的实测分位数列出来,你的 SLO 必须 >= 它们的组合。 这张表本身就是一份极有价值的架构文档——它会直接告诉你”哪些依赖在拖后腿”。
第三步:把 SLO 变成”延迟预算”,然后逐级分解
这是把 SLO 从”一个监控数字”变成”设计约束”的关键一步。
假设用户侧总目标是 300ms(P99),链路是:网关 → 服务 A → 服务 B → 存储。
现在做一次预算分解:把 300ms 切成几块,每块分给一跳,且每块里必须同时包含”处理时间 + 排队时间 + 网络时间”。比如:
- 网络与网关:50ms
- 服务 A:100ms(其中处理 30ms,排队预算 70ms)
- 服务 B:100ms(其中处理 40ms,排队预算 60ms)
- 存储:50ms
这份预算表就是”截止时间传播”的静态版本:每一跳拿到一个明确的额度,超时就只能放弃(因为继续做下去,结果也没人等了)。
它还有一个巨大的附带价值:当某个环节超标时,你能立刻知道该去改谁,而不是陷入”大家都说不是我的问题”的扯皮。
第四步:SLO 如何反向约束批处理
这才是本章的闭环。现在你有了一个 100ms 的 P99 额度,其中排队预算 70ms、处理 30ms。那么:
约束一:攒批时间必须远小于尾部预算,而不是小于平均预算。
这是最常见的错误。工程师看到”平均延迟 30ms”,于是把 linger 设成 50ms——觉得”总共 80ms,还在 100ms 里”。
错了。 正确的算法是:攒批时间 + 批的处理时间 + 排队时间,三者的组合必须落在 P99 预算内。 而”排队时间”这个家伙,在利用率上升时会非线性膨胀(子主题七推论三)。
所以实际可用的攒批时间,往往比你直觉上以为的小得多:
攒批窗口 ≤ (P99 预算 − 峰值时的处理时间 − 峰值时的排队时间)× 安全系数
安全系数留多少?取决于你对负载的预测能力。负载越不可预测,系数越小(保守),因为你随时可能撞上排队延迟的非线性区。
约束二:批越大,你就越经不起排队——所以高扇出服务几乎不能批处理。
结合第一步:被扇出 100 次的服务,尾部要求是 P99.99。P99.99 意味着你的延迟预算里几乎不能有任何”等待”成分——因为等待是随机的,随机的东西在 99.99 分位上会被放大得最狠。
结论:越是底层、越是扇出大的服务,越应该”来一条处理一条”。 批处理应该放在链路的入口层或者离线层——那里要么没有上层在等,要么延迟预算宽松。
这是一条非常实用的架构原则:批处理的位置,应该由 SLO 反推,而不是由”哪里数据多”决定。
约束三:批大小的方差必须被监控,而不只是批大小本身。
子主题七推论三说过,方差会炸掉排队延迟。所以除了监控”平均批大小”,你至少还要盯:最大批大小、批大小的分布、以及批的处理时间分布。很多”莫名其妙的 P999 毛刺”,根因就是某个时刻来了个超级大批。
第五步:SLO 如何反向约束缓冲
约束一:用时间标定缓冲,而不是用字节或条数。
这条前面提过,这里给出完整理由:字节数不含延迟信息。 一个 8MB 的缓冲:
- 在 100 MB/s 的链路上 = 80ms 的排队延迟;
- 在 10 GB/s 的链路上 = 0.8ms。
同样的配置,在两种环境下的延迟表现差 100 倍。 所以缓冲上限必须这么算:
缓冲上限 = 目标吞吐 × 你能接受的排队延迟
这是利特尔法则(L = λW)的直接应用——你先定 W(能接受多少排队延迟),再定 λ(目标吞吐),乘出来就是 L(允许的缓冲量)。
这个式子的威力在于:它让”缓冲该开多大”从玄学变成了算术。 如果你的 SLO 说排队延迟不能超过 20ms,你的目标吞吐是 5 万 QPS,那缓冲上限就是 1000 条。不用再调参了,算出来就是它。
约束二:缓冲的”时间占用”要进入 SLO 的预算表。
回到第三步那张预算分解表。每一跳的缓冲,都得在表里占一行预算。 如果你的全链路有 5 跳,每跳缓冲给 10ms 排队预算,那就是 50ms——你的 300ms 预算里,有 1/6 花在了”站着不动”上面。
看到这儿你就能理解,为什么高性能的链路都倾向于减少跳数和减小每跳的缓冲:缓冲是延迟预算的固定消耗者,几乎不给你任何回报。
约束三:缓冲有界 + 队列深度告警 + 提前降级。
SLO 定了之后,缓冲的行为就有了清晰的对错标准:
- 队列深度 = 排队延迟 → 队列深度就是 SLO 的直接前置指标,必须作为一级告警;
- 队列深度的斜率 = 系统是否在累积债务 → 比绝对值更早预警;
- 必须在”满”之前降级(比如 50% 就开始 shedding),因为满了再丢,那些请求已经白白消耗了你的资源;
- 所有缓冲必须有界,且这个界是从 SLO 反推出来的(回到约束一)。
约束四:SLO 决定你能不能”用对冲”和”用重试”。
最后收回到尾延迟治理:
- 对冲和重试都会增加负载。而由子主题七推论三,负载增加会让排队延迟非线性上升;
- 所以:你的利用率余量(headroom),就是你用来买”对冲/重试”的货币。 余量越大,你越可以大胆对冲;余量接近零,任何对冲都是在自杀;
- 这就把”要不要开对冲”从拍脑袋,变成了一个可以量化的决策:对冲带来的尾部改善 × 对冲带来的负载增加 × 当前的延迟-吞吐曲线斜率,三者权衡。
收尾:三条可以直接带走的规则
- 先定 SLO,再定架构,最后才调参数。 顺序反了,你调出来的参数没有对错标准——你永远不知道”延迟降了 5ms”是好事还是还不够。
- 所有的”等”(攒批、缓冲、超时、水印、检查点间隔)都要占用延迟预算,都要写进预算表。 没写进预算表的等待,就是没被管理的延迟。
- 延迟、吞吐、并发度三个量,你只能定两个。 你定的是 SLO(延迟)和目标 QPS(吞吐),那么并发度/缓冲量就是算出来的,不是挑出来的。
演进脉络
- 早期:单条处理为主,批处理主要是离线场景(磁带批处理时代)。
- 中期:数据库和网络 IO 的批处理成为标配(批量插入、批量提交、TCP Nagle 算法)。
- 现代:自适应批处理(数量+时间+大小三重触发)成为工业标准;同时流水线思想深入到硬件(CPU 流水线、GPU SIMD)和软件(响应式流、异步 IO)。
- 前沿:延迟分布(P99/P999)成为核心 SLO 指标,对冲请求和尾延迟治理成为大型系统的必备能力。
贯穿其中的三条主线(把这一章串起来的那根线):
- 从”经验”到”恒等式”:利特尔法则(L = λW)把延迟、吞吐、并发度三个量焊在一起,让”延迟 vs 吞吐”从一团感觉变成一道可以算的算术题。吞吐 = 并发 ÷ 延迟,这条式子解释了连接池、并发窗口、背压额度、缓冲大小的全部设计。
- 从”摊销固定开销”到”消除人造依赖”:批处理摊销的是固定开销(往返、寻道、报文头、fsync),流水线消除的是”顺序”这个人造约束。HTTP/1.1 pipelining 死于队头阻塞,HTTP/2 用流 ID 解除它,QUIC 再按流做可靠性——优化的历史就是不断找出还藏在哪里的顺序依赖。
- 从”延迟换吞吐”到”延迟预算管理”:早期是粗糙的二选一;现在的做法是先把 SLO 定成尾部分位数,再把延迟预算逐级分解到每一跳,然后用它反推批窗口、缓冲上限、并发额度、以及能不能开对冲。等待不再是被遗忘的成本,而是预算表上一行行被管理的开支。
本章思考题
【思考题】
-
批处理提升吞吐的本质是什么?为什么它在涉及 IO 的场景收益最大?
-
为什么”固定批大小”在工业系统里很少用?“数量+时间+大小”三重触发解决了什么问题?
-
批处理和流水线都能提升吞吐,它们的核心区别是什么?为什么优先选流水线?
-
什么是”尾部延迟放大”?一个请求依赖 10 个服务、每个服务 P99=100ms,整体 P99 大约是多少?你该怎么应对?
-
写出利特尔法则的公式并解释三个符号。为什么说”延迟、吞吐、并发度”三个量里只有两个自由度?
-
你的服务通过一个大小为 N 的连接池调用下游,下游平均延迟(含排队)为 W。请给出这条链路吞吐上限的表达式。如果下游延迟因为 GC 抖动翻了一倍,而连接池不变,吞吐会怎样?这说明了什么?
-
已知一批 B 条的成本是”固定开销 F + B × 单条开销 c”,请推导吞吐 X(B) 的表达式,并说明:为什么批次的有效量级约等于 F/c?为什么这个结论能解释”不同系统的合理批大小能差几个数量级”?
-
TCP Nagle 算法的规则是什么?它和”延迟确认”合在一起会造成什么问题?什么场景该开 TCP_NODELAY?开了之后又必须注意什么?
-
HTTP/1.1 pipelining 为什么会失败?请至少说出两个原因。HTTP/2 用什么机制解决了它?HTTP/2 又留下了什么新问题,最终是怎么被解决的?
-
缓冲有哪三种代价?其中你认为最危险的是哪一种,为什么?请给出至少三条”把缓冲用对”的工程纪律。
-
请列举至少五类尾延迟的来源,并说明:为什么”平均值”这个指标对这些来源完全不敏感?为什么在大规模系统里,“万分之一概率”的事件不能当作罕见事件处理?
-
对冲请求和”超时后重试”的本质区别是什么?请从”对延迟的影响”和”对负载的影响”两个维度对比。在什么情况下对冲请求不仅无效、反而有害?
-
批处理系统和实时(流)处理系统在”容错的代价付在什么时候”上有什么根本差异?这种差异如何体现了本章的主题?
-
假设你的产品目标是”99% 的用户请求延迟达标”,而一个用户请求要同步调用 N 个后端服务。请估算每个后端服务的尾延迟分位数要求应该是多少(N=1、10、100 三种情况)。这个结论对”批处理应该放在链路的哪个位置”有什么指导意义?
-
你的服务SLO 是 P99 ≤ 100ms,目标吞吐 5 万 QPS,且你决定排队延迟最多只能占 20ms。请算出缓冲上限应该是多少条,并说明这个计算依据的是哪个公式。
【参考答案】
-
本质是把”固定开销”(网络往返、磁盘寻道、系统调用、加锁)从”每条付一次”摊薄成”每批付一次”。批处理收益的大小取决于”固定开销/可变开销”的比值。IO 场景的比值极大(一次网络往返几毫秒,处理一条数据可能几微秒),所以摊薄固定开销的收益最显著。
-
因为固定批大小无法适应负载变化:高负载下批次太小、摊薄不足;低负载下攒不够批、延迟爆炸。三重触发解决了这个矛盾——数量触发保证高负载下的吞吐,时间触发给低负载下的延迟设上限,大小触发防止内存爆炸。它把”吞吐 vs 延迟”从二选一变成了随负载动态平衡。
-
核心区别:批处理把多个任务”打包”共享一次固定开销,优化的是固定开销摊销;流水线把一个任务拆成多阶段、让不同任务的阶段重叠,优化的是资源并行利用率。优先选流水线,因为它提升吞吐但几乎不增加单任务延迟(批处理则明显牺牲延迟)。能用流水线时用流水线,实在拆不开阶段才用批处理。
-
尾部延迟放大:一次用户请求依赖多个后端服务时,整体慢的概率远高于单个服务慢的概率。10 个服务、每个 P99=100ms,整体(至少一个慢)约 1-0.99^10 ≈ 9.5%,即整体 P99 会明显差于 100ms。应对手段:对冲请求(同时发两个、取先返回的)、设置合理的超时和重试、自适应并发控制、以及减少关键路径上的依赖数量。
-
L = λW。L = 系统内平均在途请求数(含正在服务的和正在排队的),λ = 长期平均到达(完成)速率,W = 请求在系统中的平均逗留时间(排队 + 服务)。三个量只有两个自由度,是因为这个恒等式锁死了它们的关系:给定任意两个,第三个就被唯一确定。所以”延迟-吞吐”从来不是二维问题——你画二维曲线时,其实默认了并发度(L)被某个东西(线程池、连接池、TCP 窗口)钉死了。这也意味着工程师真正能主动拧的旋钮往往是并发度,即”愿意让多少请求同时堵在系统里”。此外 λ 必须取实际完成率而非外部到达率(系统限流/丢弃时两者不等),且系统必须在稳态下这个等式才成立。
-
由 λ = L/W,吞吐上限 ≈ N/W(假设池子被打满且每次调用占用一个槽位)。延迟翻倍 → 吞吐腰斩。这是恒等式,不是概率——只要并发上限固定,延迟翻倍必然导致吞吐减半,没有侥幸。它说明两件事:一是”下游很闲但我 QPS 上不去”的常见病因是并发度不够(连接池开小了);二是雪崩的数学骨架——下游慢 → 我吞吐掉 → 上游超时重试 → λ 增大 → 排队更多 → 更慢。打断这个正反馈只能在 λ 侧动刀(限流、熔断、背压、重试预算),在 W 侧死磕(更激进的重试)只会加速崩溃。
-
X(B) = B/(F + B·c)。令纯可变开销决定的天花板 X∞ = 1/c,则 X(B)/X∞ = B/(B + F/c)。可见 B = F/c 时拿到 50% 天花板,B = 3F/c 时 75%,B = 9F/c 时 90%,此后继续增大 B 的边际收益快速缩水而等待时间仍线性增长——这就是收益递减的双曲线形状。因此”批次的有效量级”就是 F/c。它解释了不同系统合理批大小的量级差:跨机房网络写(F 毫秒级、c 微秒级,F/c 成千上万)批次可以开到几十 KB;批量插入(F 含系统调用和 fsync,F/c 几百到几千)几千行一批合理;纯内存函数调用(F、c 都是纳秒级,F/c ≈ 1)批处理毫无意义甚至更慢。所以调批大小的第一步是估算 F/c,而不是盲目试参数。
-
规则:若连接上还有未被确认的小数据段,就把后续小数据攒着,直到攒够一个 MSS 或收到对端 ACK——它以 RTT 为天然的时间窗口做自适应攒批,目的是消灭小包(tinygram)。危害:与”延迟确认”互锁——Nagle 等 ACK,对端延迟确认等更多数据,双方互等,只能等延迟确认定时器超时,造成毫秒到几百毫秒量级的停顿;典型触发场景是 write-write-read(header 和 body 分两次小 write,第二个被按住)。该开 TCP_NODELAY 的场景:交互式、请求-响应式、小消息、对延迟敏感的 RPC/游戏/数据库协议/SSH。开了之后必须注意:TCP_NODELAY 不等于免死金牌,若应用层仍一个字段一次 write,就会亲手制造小包风暴。正确组合是”应用层自己攒批、一次写一个逻辑完整的帧(应用层懂语义),内核层立刻发出去(别替我再等一轮)”。
-
失败原因(任举两个即可):①响应必须按请求顺序返回,一个慢请求堵住后面所有响应,即队头阻塞,这是”顺序依赖”这个流水线的天敌;②连接断开后失败语义模糊(第 2 个请求是不是执行了?非幂等的 POST 敢不敢重发?),因为设计上没有给请求编号以重建对应关系;③需要全链路(客户端、服务器、所有中间代理)都正确支持,在开放互联网上等于不可能;④收益被”多开几条并行连接”这个粗糙但有效的替代方案吃掉了。HTTP/2 用”流 ID + 帧”的多路复用解决:不同流的帧在一条连接上交织,响应可乱序返回,应用层队头阻塞被解除。但它把队头阻塞下推到了 TCP 层——所有流共享一条按序可靠传输,丢一个包全体卡住,弱网下可能比多连接 HTTP/1.1 更慢。最终由 QUIC/HTTP/3 解决:基于 UDP 在用户态实现按流的可靠性,丢 A 流的包只卡 A 流。
-
三种代价:①延迟——队列里的每条数据都是被欠着的延迟,W_queue = Lq/λ 是恒等式;长期不为空的缓冲等同于正在过载的系统。②内存——危险的是乘数效应(每连接/每分区/每流一份 × 十万级连接),以及次生代价:GC 压力、缓存污染、崩溃时丢失面变大(缓冲里的东西通常是”已确认未持久化”的,你有一部分性能是从耐久性借的)。③缓冲掩盖问题——最危险的一种:它切断了下游到上游的负反馈(缓冲没满上游就以为一切正常,继续全速灌入),制造”看起来还行”的假象从而让修复根因失去内部推动力,并把故障形状从”可预测的快速失败”改成”一直很好然后突然不行”的不可预测引爆。用对缓冲的纪律:所有缓冲必须有界,且用”时间”而非字节/条数标定(缓冲上限 = 目标吞吐 × 可接受排队延迟);队列深度作为一级监控和告警指标,斜率比绝对值更早预警;在满之前提前降级/shedding(50% 就开始拒绝低优先级),早拒绝比晚拒绝便宜;背压贯穿全链路,把信号传回去而不是把数据囤起来;把排队时间与服务时间分开计量,否则你永远分不清”处理慢”和”在排队”。
-
来源:GC 与运行时停顿(STW、安全点等待、大堆)、磁盘与存储抖动(页缓存未命中、写回争抢、SSD 内部 GC、LSM compaction 与写停顿、云上吵闹邻居与 IOPS 配额)、热点与倾斜(键热点、缓存击穿惊群、主从不均、计算倾斜)、锁竞争与排队(锁护送、优先级反转、伪共享、CAS 争抢)、网络重传与丢包(丢包、微突发打爆交换机缓冲、队头阻塞、ECMP 哈希碰撞、连接与 TLS 握手)、以及一堆隐蔽项(冷启动与 JIT 预热、请求线程里的同步日志 flush/滚动、容器 CPU 配额节流、虚拟化 steal time、NUMA、THP、DNS)。平均值不敏感,因为它把”少数极慢”和”多数很快”搅拌成一个中间数:优化让 P50 从 20ms 降到 15ms 但引入千分之一的 500ms 停顿,平均值反而从 20 降到约 15.5——平均值会系统性奖励”牺牲少数用户换取平均数字”的决策,所以它不只是不够好,是有害的。规模视角:万分之一概率的事件,在 10 万 QPS 下每秒发生 10 次、每分钟 600 次——大规模系统里你该问的不是”会不会发生”,而是”多久发生一次”,这正是大厂对尾延迟执念的算术基础。共同规律:这些来源几乎都属于”共享的、偶发的、后台的、或周期性的”活动,所以治理第一步是列出系统里所有这类活动的清单,那张清单就是你的长尾地图。
-
本质区别:重试是”等原请求失败/超时后再发”,对冲是”原请求还在飞的时候就发第二份”。对延迟:重试是加法(P99 ≈ 超时 + 重试的 P99,把尾延迟顶到更高台阶),对冲是取最小值(min(X₁, X₂ + d),顺序统计量的最小值天然把分布往左压,几乎不增加延迟)。对负载:重试只在失败时增加,对冲只在慢的时候增加(阈值设 P95 则约 5% 额外负载,P99 则约 1%)。所以:重试治失败,对冲治慢尾,别混用。对冲无效甚至有害的情形:①慢的原因不独立——两个副本共用一个慢数据库、同构部署导致 GC 同步发生、共享网络路径或缓存集群,此时对冲纯属白打一份流量;②系统已经过载——每个请求都慢于是全部触发对冲,负载翻倍,直接把局部抖动放大成雪崩。因此纪律是:只在有空闲容量时对冲,利用率一高立刻关掉;对冲目标要挑队列短/响应快的而非随机;限制对冲占比(对冲预算);只对幂等操作对冲或带请求 ID 去重。补充:对冲是麻醉剂不是手术刀——它让你不痛,但不治病,优先级应该排在”修根因、减依赖、非关键调用并行化/可降级、截止时间传播”之后。
-
批处理系统把容错的代价”推迟到故障时支付”:阶段间有屏障、中间结果落盘,失败就重算那一片,稳态开销极低、吞吐极高,但延迟是分钟到小时级、恢复慢。流处理系统把容错代价”摊薄到稳态持续支付”:无全局屏障、状态在内存、靠在数据流里插入屏障消息做周期性一致快照(不需要暂停计算,代价只是屏障传播的那点延迟和对齐等待),故障回滚到最近快照再重放增量,延迟毫秒到秒级、恢复快。这个差异正是本章主题的投影:批处理是”攒够了再付”(延迟换吞吐),流处理是”边走边付”(吞吐换延迟)——同一个权衡出现在了容错策略上。中间路线是微批(按时间切小批),它位于这段连续光谱上:微批模型(如 Spark Streaming 经典模式)延迟下界就是批间隔,真流式模型(如 Flink)把批退化成网络层可配置甚至可关闭的旋钮。
-
设每个服务变慢的概率为 p、各服务独立、任一慢则整体慢,则用户受影响概率 ≈ 1−(1−p)^N ≈ N·p(p 很小时)。把 1% 的用户预算摊到 N 个服务,每个服务只能占 1%/N:N=1 → P99;N=10 → P99.9;N=100 → P99.99。(这只是量级估算:现实中慢事件往往正相关使结果稍好,但重试和依赖链会把放大推得更狠,方向与量级判断不变。)对批处理位置的指导:分位数要求越严,延迟预算里能留给”随机等待”的份额就越小,因为等待是随机的、在极端分位上被放大最狠。所以——越是底层、扇出越大的服务,越应该来一条处理一条;批处理应放在链路入口层或离线层,那里要么没有上层在等,要么延迟预算宽松。一句话:批处理的位置由 SLO 反推,而不是由”哪里数据多”决定。
-
用利特尔法则 L = λW:W = 20ms = 0.02s,λ = 50000 QPS,则 L = 50000 × 0.02 = 1000(条)。所以缓冲上限应设为 1000 条左右(再按突发和测量误差留点余量,但量级就是这个)。这个计算体现了”用时间而不是条数/字节来标定缓冲”的原则:同样的 8MB 缓冲在 100MB/s 和 10GB/s 的链路上排队延迟差两个数量级,字节数本身不含延迟信息,只有”吞吐 × 时间”才算得出容量。同理,全链路每一跳的缓冲都要在延迟预算表里占一行——有 5 跳、每跳 10ms 排队预算,300ms 的总预算里就有 1/6 花在”站着不动”上,这正是高性能链路倾向于减少跳数、压缩每跳缓冲的原因。