故事
唐开元二十三年,腊月二十九,长安大明宫。
韦膳正在御膳房里已经站了一整天。明天是除夕,皇帝要在麟德殿设大宴,宴请宗室、朝中三品以上的大臣、以及今年朝贺的吐蕃和新罗使节。一共两百三十七位宾客,八十四道菜。御膳房里有一百一十二位厨子。
韦膳正今年五十八岁。他做膳正已经二十一年。大明宫的除夕宴,他经办过二十场。
每一场都出过事。
他记得清清楚楚——先帝神龙元年的除夕,那一年御膳房里出了一桩事:一位切肉的厨子姓李,切最后那道"浑羊殁忽"的羊腿时,一刀下去切到了自己的左手食指。血流得很厉害。另一位厨子接手,重新切。那道菜晚了整整两刻钟才上桌。那两刻钟里,大殿上三百多人就等着这一道菜。皇帝没说什么,但回到御膳房之后韦膳正罚了李厨子三个月的俸。
景龙三年,也是除夕。那年不是有人切到手,是最后一道"糖蟹"——一种用糖蜜腌过再蒸的螃蟹——蒸笼底下的炭火不知怎么灭了一半,螃蟹蒸到一半凉了下来。等重新生火,再上笼,又是一刻多钟。
再后来景云二年,是一位端菜的宫女在廊下绊了一跤,那盘"过门香"整个倒在地上。要重新做一份,又是半个时辰。
开元八年除夕,御膳房的人都特别小心。那一年出事的是一位负责调汤的老厨子,他做了四十年汤,那晚却把一锅"白龙臛"的盐放重了。他自己尝出来了,把整锅倒掉重做。晚了一刻钟。
去年,开元二十二年的除夕,出事的是炉灶——御膳房左边第三个灶的烟道堵了,倒烟,煎炙的肉串全熏黑了。厨子换到另一个灶重新煎。晚了大半个时辰。
韦膳正今天早上坐在自己的值房里,把这二十场除夕宴的记录一页一页翻了一遍。
他发现一件事。
二十场宴席,没有一场不出事。但出事的从来不是同一个厨子,也不是同一道菜,也不是同一个环节。每次都是一个不同的人、在一个不同的位置上、出一个不同的小差错。单看每一个差错都是极小概率的事——一位好厨子做了十年菜切到手的机会有多大?一次宴席的炉灶突然倒烟的机会有多大?一个端菜的宫女绊跤的机会有多大?每件事几乎都不会发生。
但每次除夕宴都会发生一件。
他把笔放下,盯着窗外的雪看了很久。
中午,他把御膳房里四位副手叫到值房。
"我想换一个办法。"韦膳正说。
四位副手互相看了看,没说话。韦膳正做了二十年膳正,这还是第一次说"换一个办法"这几个字。
"往年,每一道菜我都派一位厨子做。有的难菜派的是最好的厨子,简单菜派的是稍次的厨子,这个排法本身没错。"他说。"问题在于——一个人做一道菜,他出事,这道菜就晚。一百一十二位厨子里,哪怕每人出错的机会只有百分之一,我这八十四道菜里,总会有那么几道碰上出错的那个人。就算出错的机会是千分之一也一样——只要菜够多、厨子够多,总有一个会碰上。"
大副手姓郑,在御膳房做了三十年,比韦膳正资历还老。他说:"这是天时。御膳房哪里能做到万无一失?"
"我也不是说要做到万无一失。"韦膳正说。"我说的是——如果一道菜由两位厨子同时做,最后上哪盘,看哪一位先做成,结果会怎么样?"
四位副手沉默。
"两位厨子做一道菜,材料用双份,人工用双份。但出错的概率——两位厨子同时出错的概率,远远小于一位厨子出错的概率。一位厨子切到手的概率是百分之一,两位同时切到手的概率就是万分之一。一个灶倒烟的概率是百分之一,同时两个灶都倒烟的概率就是万分之一。一个宫女绊跤的概率是百分之一,同时两位宫女都绊跤的概率就是万分之一。"
郑副手皱着眉。"食材要用双份。"
"食材用双份。"韦膳正说。
"厨子要用双份。"
"厨子要用双份。"
"但——"郑副手停了一下,"一百一十二位厨子做八十四道菜,本来就有富余。要是每道菜两位厨子,一百六十八位厨子才够。御膳房腾不出这些人。"
"不是所有菜都要这样做。"韦膳正说。"只有那些出错会让整席都等的菜——那些要最后才上桌、或者要热气腾腾上桌、或者做坏了没法补救的菜——才需要两位厨子。简单的凉菜、果品、蜜煎,本来可以早做好、慢慢摆盘的,派一位厨子就够。"
他转向另一位副手,姓崔的。"崔副手,你把八十四道菜挑一遍,分出来哪些是压席的、哪些是热供的、哪些是只能最后做的。我们就拿这些菜做双厨。别的菜照旧。"
崔副手点头。
郑副手还是没松。"双份食材从哪里来?"
"我已经算过了。"韦膳正说。"挑出来的菜应该在二十道左右。每道多一份食材,总的算下来是三成多的额外支出。这个数目我今早已经报到尚食局,要了批文。批文午后就回来。"
他顿了一下。
"我做了二十年膳正。这二十年里,每次除夕宴出事之后,我都罚人——罚切到手的厨子,罚做错汤的厨子,罚绊跤的宫女。罚完了,第二年还是出事。我现在想明白了——出事不是哪一位厨子的错。是这件事的道理本来就是这样——只要人多、菜多,总有一两个位置上会出一点小乱子,而那一两个小乱子就让整桌都等。"
"罚厨子没用。"他说。"得换办法。"
除夕当天下午,麟德殿的大宴开始前两个时辰,御膳房的灶全部点起来了。
按韦膳正新定的规矩——二十一道"要紧菜"由两位厨子同时做。这二十一道菜里有:
- "升平炙"——用鹿里脊切成薄片炙烤,要最后上桌、热气腾腾。两位厨子同时烤,谁先好谁上。
- "浑羊殁忽"——整只羊肚子里塞一只鹅、鹅肚子里塞糯米和调料,慢烤三个时辰。两位厨子同时做两只,谁的羊烤得更好上谁的。
- "白龙臛"——鱼汤,最怕盐放错。两位厨子同时调,各调各的锅,最后两锅都端上来,尝一口取其优。
- "糖蟹"——蒸螃蟹,最怕火候。两位厨子同时蒸两笼,先熟的那笼上。
- "过门香"——油炸的酥点,端上桌前一刻才能炸完。两位厨子同时炸两份,谁炸得好谁上。
……
其他六十三道菜照旧,一位厨子一道。
韦膳正站在御膳房正中的廊柱下。他看着厨子们各就各位。两位厨子做同一道菜,有的在一个灶上分两口锅,有的在两个灶上各做一份。他们之间不用多说话——做熟了的厨子知道彼此在做什么,也知道自己做不出来的时候另一位做得出来。
酉时初刻,麟德殿开宴。
第一道菜,冷盘"五辛盘",单厨,按时上了。
第二道、第三道、第四道……前十几道都是冷盘和小菜,单厨做的,依次上桌,一切顺利。
第二十二道"升平炙",双厨。其中一位厨子烤到一半,炭盆里一块炭突然爆裂,火星溅出来,把一小片鹿肉烧焦了。他愣了一下,正要重来——但另一位厨子的鹿肉已经烤好了,端出去就上。整道菜比预计早了半刻钟上桌。
第三十九道"白龙臛",双厨。一位厨子自己尝了一口,皱眉——盐调得稍重。他把自己那锅搁下,让送菜的宫女端另一位厨子的。那一锅刚好。
第五十四道"糖蟹",双厨。两位厨子做的都好,两笼几乎同时出。膳正从中选了色泽更亮的那笼端上。
第六十七道"过门香",双厨。一位厨子油温没掌握准,炸出来的酥点颜色偏暗。另一位厨子的那份金黄酥脆,端上去。
第八十一道"浑羊殁忽",双厨。两只羊都烤得很好。膳正让厨子把多出来的那只留下来,第二天赏给御膳房所有的厨子分食——这是他早就想好的处置。
……
八十四道菜,到戌时初刻全部上齐。比往年任何一年的除夕宴都准时。
大殿里,皇帝吃到最后一道果品,对身边的高力士说:"今年的御膳房,似乎手脚利索了许多。"
高力士笑了笑:"回陛下,今年韦膳正换了个法子。"
"什么法子?"
"要紧的菜,都派两位厨子同时做。"
皇帝想了想,笑了一下。"妙。"
酒宴散后,韦膳正在御膳房的后院里坐着。
雪又下了。今夜的雪比昨晚更厚。御膳房门口的大红灯笼在雪里晕出一圈暖红。
郑副手端了一碗热姜汤出来,递给他。"膳正,今年这事,办成了。"
韦膳正接过姜汤,没喝。他想的是别的事。
"郑副手,"他说。"你说,我这办法,要是传开去,别家——比如地方上的官府办筵席、比如北衙那边办军中大宴、比如民间办婚礼——他们用不用得上?"
郑副手笑了。"膳正,您这是想写一篇《膳政论》?"
"不。"韦膳正说。"我是想——这办法有它的道理。凡是一件大事要一堆人同时完成的,每个人都做得好的,但总有一两个会不凑巧——这样的事,不都能这么办吗?不罚任何一位具体的人,只是多安排一份余地。"
他终于喝了一口姜汤,呵出一口白气。
"花点双份的材料,换一顿安稳的宴席。值。"
概念解析
这则故事讲的是现代大规模分布式系统里的一个核心性能问题,以及它最优雅的一种解法。两者出自 Google 的两位研究员 Jeffrey Dean 和 Luiz André Barroso 在 2013 年发表于 Communications of the ACM 的文章 The Tail at Scale——这篇文章虽然不长,但它奠定了现代分布式性能工程的一整套思路。
问题:尾部延迟
想象你在用一个搜索引擎。你敲下一个关键词,按下回车。搜索引擎后台会把你的查询发送到成百上千台服务器——每一台负责索引的一小部分——让它们各自返回相关的结果。你的查询页面要等到所有这些服务器都把结果返回之后才能呈现给你。
现在,假设每一台服务器响应你查询的延迟分布是这样的:
- 百分之五十的请求在 10 毫秒内返回(p50)
- 百分之九十五的请求在 50 毫秒内返回(p95)
- 百分之九十九的请求在 200 毫秒内返回(p99)
- 百分之九十九点九的请求在 1 秒内返回(p99.9)
单看一台服务器,这些数字不算糟。大多数请求都很快。但如果你的查询需要从 100 台服务器并行收集结果,会发生什么?
至少有一台响应进入 p99 区间(大于 200 毫秒)的概率,是 1 − 0.99¹⁰⁰ ≈ 63%。
也就是说,你作为用户有 63% 的概率经历到 200 毫秒以上的等待——哪怕每台服务器看起来都"只有 1% 的请求会慢"。如果查询要 fan-out 到 1000 台服务器,这个概率飙升到 99.99%。你每次查询几乎必然会遇到一个"慢节点"。
这就是"尾部延迟"(Tail Latency)问题。在大规模系统里:
用户体验到的延迟,由延迟分布的尾部决定,平均延迟说了不算。
Dean 和 Barroso 在文章里给出了一句概括——"在规模下,几乎必然会发生。"("At scale, it will almost certainly happen.")那些你以为的小概率事件——节点短暂过载、垃圾回收暂停、网络抖动、磁盘偶尔慢一下、CPU 被别的进程抢走、内存缓存未命中——每件事的发生概率或许都在百分之一以下,但只要系统的请求规模足够大,总会有一个请求碰上这些小概率事件中的某一个。
故事里韦膳正翻了二十年的宴席记录之后,发现的就是这件事——每一年都出事,但每一年都是不同的事。切到手、灶堵烟、汤放咸、宫女绊跤、螃蟹凉了——每一样单独看都不该发生,但合起来就是"每年都会发生一件"。这不是哪位厨子的错,是规模带来的必然。
慢节点的来源
现代分布式系统里,慢节点几乎不可避免,原因包括:
- 资源竞争:一台机器上跑着多个进程,某个后台任务突然抢占了 CPU 或 IO。
- 垃圾回收(GC):运行时的 stop-the-world 暂停可能持续几十到几百毫秒。
- 排队效应:请求到达时正好有一批别的请求在前面排队。
- 硬件毛病:磁盘突然变慢(比如 SSD 的 garbage collection、或一块 HDD 即将损坏)。
- 网络抖动:TCP 重传、路由抖动、拥塞控制触发。
- 冷缓存:一个节点刚重启或刚被路由到,本地缓存还没预热。
每种情况出现的概率都低。但 Dean 和 Barroso 观察到:一台规模 Google 数据中心里的服务器,"慢"的时刻相当普遍——即使每毫秒每台机器只有 0.1% 的概率"慢",在几千台服务器上 fan-out 的请求几乎每次都会碰到。
解法:对冲请求
Dean 和 Barroso 在论文里提出了一系列缓解尾部延迟的技术,其中最优雅、也最容易实现的一种叫做 对冲请求(Hedged Requests):
同时向两台(或更多)副本发送同一个请求;哪个先回来,用哪个。
这个做法的数学道理和故事里韦膳正的直觉完全一致。如果一台服务器进入 p99 尾部的概率是 1%,那么两台服务器同时进入 p99 尾部的概率是 0.01 × 0.01 = 0.01%(假设两台的慢相互独立——在实际中大多数慢都是独立的,因为每台服务器的 GC、磁盘、网络状况都不同)。
这就把"几乎必然遇到慢请求"变成了"几乎必然不遇到慢请求"——只要你愿意多花一份资源。
故事里的"双厨"就是这个机制的物理化身:
- 一位厨子切到手的概率是 1%。两位厨子同时切到手的概率是 0.01%。
- 一个灶倒烟的概率是 1%。两个灶同时倒烟的概率是 0.01%。
- 一位宫女绊跤的概率是 1%。两位宫女同时绊跤的概率是 0.01%。
韦膳正甚至想到了实现的细节——只对"要紧的菜"(会让整席都等的菜)做对冲,简单的菜(可以早做好、慢慢摆盘的)不做对冲。这正是 Dean 和 Barroso 论文里提到的选择性对冲——不是所有请求都需要对冲,只有那些会影响整体响应时间的关键请求才需要。
对冲请求的变体
Dean 和 Barroso 在论文里提出了对冲请求的几种更精细的做法:
-
无条件对冲(Unconditional Hedging):对每个请求都同时发两份。简单粗暴,代价是资源翻倍。适合关键路径上极少量的请求。
-
延迟对冲(Delayed Hedging):先发一份,等待一段时间(比如 p95 延迟)如果没回来,再发第二份。大多数请求第一次就回来了,只有少数触发第二份。代价小得多——一般只有 5% 左右的请求真的发了两份——但几乎同样能消除尾部。
-
带取消的对冲(Hedging with Cancellation):发出第二份请求的同时通知第一份去取消自己(如果还在处理)。这能节省被浪费的计算资源。
-
Tied Requests:把两份请求"系"在一起——每份请求都告诉对方自己的伙伴,谁开始处理了就通知对方取消。这是 Google 生产环境里用的主要做法。
现代系统里,选择对冲策略常常要权衡:
- 对冲越激进,尾部延迟越好,但系统总吞吐量下降(因为同样的资源做了重复工作)。
- 对冲越保守,资源效率越高,但尾部延迟越差。
典型的生产系统选择是"对延迟敏感、低流量的关键路径做对冲;对吞吐敏感、高流量的路径不做"——和故事里"要紧的菜双厨,简单菜单厨"完全一致。
其他减尾部延迟的技术
Dean 和 Barroso 的论文里还提到了几种相关技术:
- 微分区 + 负载均衡动态迁移:把每台服务器的工作切得更细,让热点分区能快速迁移走。
- 后台任务隔离:把 GC、压缩、备份等后台任务限制在低负载时段运行。
- 探测 + 转移(Probing + Failover):发现一个节点在慢化,主动把流量迁走。
- 备份任务(Backup Tasks):MapReduce 在作业快结束时启动备份任务(就是对冲请求在批处理场景下的版本),避免被"尾部 mapper"拖慢。
这些技术合起来构成了现代大规模系统处理尾部延迟的工具箱。它们的共同哲学是——
接受尾部的必然性,用冗余绕开它,别指望消除它。
你不可能让每台服务器都变快。但你可以让"至少有一台快"的概率变得足够高。
更深的启示
"The Tail at Scale" 这篇论文的影响远远超出了它字面讨论的技术范畴。它让分布式系统工程师明白了几件事:
- 规模改变了概率的含义。在小系统里"百分之一"是可以忽略的;在大系统里它是"基本上每次都会发生"。
- 平均值是误导。衡量用户体验必须看 p99、p99.9——p50 说了不算。
- 完美主义的陷阱。想把每台服务器都做到"从来不慢"是不可能的,也不必要。应该接受局部的慢,用系统层面的冗余来让整体感受不到。
- 冗余是设计选择,不是浪费。韦膳正"多用一份食材换一顿安稳宴席"的决定,和 Google 为每个搜索请求多发一份对冲请求的决定,背后是同一条道理——花一点点代价,换掉那个几乎必然会出现的最坏情况。
故事里韦膳正最后的那段话——"凡是一件大事要一堆人同时完成的,每个人都做得好的,但总有一两个会不凑巧——这样的事,不都能这么办吗?不罚任何一位具体的人,只是多安排一份余地"——正是这一课的精髓。
罚那位切到手的厨子没用。再认真的厨子也会有失手的一天。真正的解决办法是承认失手必然发生,然后在系统层面留出一份冗余,让失手不至于拖累全局。这是现代系统设计与前现代"找责任人"的思维最根本的分野。系统的可靠性不来自每个部件的完美,而来自整体的韧性。
这也是为什么 Dean 和 Barroso 那篇论文虽然只讲了几项具体技术,却被看作现代分布式系统设计哲学的转折点——它告诉我们:在规模下,我们不再和个别的"慢"作战,我们和统计规律作战。而和统计规律作战的办法,只能是另一种统计规律。