卷 IV · 加人CH 13深度 13/24

一条队,还是每人一条队

这是全书唯一一个你每周都在亲身做的决策,也是唯一一个「什么都不用买」就能拿到几倍改善的动作。银行、机场、医院早就改了,而软件系统里到处还是各排各的。

★ 叫号机M/M/c它卖的是信息

▷ 先估一个数

银行有 4 个窗口,人流稳定,四个窗口的忙碌程度都是 90%

方案甲:一条队,谁前面空了就叫谁(现在的叫号机)。
方案乙:四条队,进门时随便挑一条站进去,之后不能换。

两种方案的窗口数一样、人流一样、办事速度一样。平均等待差多少?

A 几乎不差。同样 4 个窗口,同样的人,总时间是守恒的 B 差 20%–30% C 差 4.6 倍 D 差十倍以上

一条队为什么快

先说结论,再说为什么:

方案平均等待相对
一条队 + 4 个窗口1.96941.00
四条队,随便挑一条9.00004.57

四条队的那一行有个眼熟的数字:9.0000。那是第 9 章 ρ=90% 时的 ρ/(1−ρ)

因为「四条独立的队」在数学上就是四个独立的 M/M/1——每条队只有 1 个窗口,人流是总量的四分之一。所以它的表现和「一个窗口的小银行」完全一样,一点规模优势都没有。

而一条队为什么快?因为它消除了「有人在等,同时有窗口空着」这个状态。

# 四条队:这一幕每天都在发生
   队 1  ▮▮▮▮▮▮  ◉ 忙
   队 2  ▮▮▮▮▮   ◉ 忙
   队 3            ○ 空着!          ← 有窗口闲着
   队 4  ▮▮▮      ◉ 忙
                                    ← 同时有 14 个人在等

# 一条队:这个状态不可能出现
   一条队 ▮▮▮▮▮▮▮▮▮▮▮▮▮▮   ◉◉◉◉ 四个全忙
                                    ← 只要有人在等,就没有窗口空着

第 1 章说过:产能是易腐品。队 3 那个空窗口在那一刻浪费掉的产能,永远拿不回来了——而它旁边队 1 的第六个人正在等。

一条队做的唯一一件事,就是不让这种浪费发生

「挑人最少的那条队」呢

你会说:现实里没人闭着眼睛挑队,大家都会看一眼哪条短。

对,而且这招很管用。demo 里加了第三行:

方案平均等待说明
一条队1.877667基准
挑人最少的那条2.1444比一条队慢 14.2%
闭眼随便挑9.1543慢 4 倍多

「挑人最少的」追回了绝大部分差距。但还差 14.2%。为什么追不平?

因为你数得清人头,数不清每个人要办多久。

你前面站了两个人,可能是两个来取号的(三十秒搞定),也可能是两个来办房贷的(半小时起)。人数不等于工作量。

而这个差距会随着服务时间的不齐放大。把 demo 的 cs² 拧到 10:

方案(ρ=0.8,cs²=10)平均等待比一条队慢
一条队3.993
挑人最少的那条4.722+18.3%
闭眼随便挑21.893+448%
◆ 一个漂亮的恒等式

如果你能看见每条队剩余的工作量(不是人数,是总时长),然后挑最少的那条——那么:

挑「剩余活最少」的队    Wq = 1.877667
一条队 + 4 个窗口       Wq = 1.877667
两者之差               = 0.0e+0        ← 不是「差不多」,是同一个数

这不是数值巧合,是一个可以证明的恒等式:在 FIFO 下,「加入最早空出来的那条队」和「一条队等最早空出来的窗口」会做出完全相同的调度

所以叫号机卖给你的,不是公平,是信息。它替你看见了那个你本来看不见的量:每条队还剩多少活。

这句话有一个直接的工程推论:如果你能测出每个后端的剩余工作量,那么客户端侧的负载均衡可以做到和集中式队列一样好。这正是「最少未完成请求数」(least-outstanding-requests)负载均衡策略比轮询好那么多的原因——它是在近似那个恒等式。

软件里的「各排各的」

银行早就改成一条队了,机场安检也是,医院叫号也是。但软件系统里,「各排各的」到处都是,而且往往是默认配置

  • 轮询负载均衡(round-robin):把请求平均分给后端,然后就不管了。这就是「闭眼随便挑」——分完之后,就算某台机器空着,排在另一台后面的请求也过不去。
  • 一致性哈希:同一个 key 永远打到同一台。它买的是缓存命中率,卖掉的是队列共享。
  • 每个连接一个队列:很多 RPC 框架给每条连接单独排队,请求发出去就绑定了。
  • 每个分区一个消费者:Kafka 这类系统里,分区是绑死的。一个分区堆积的时候,别的消费者帮不上忙。
  • 线程池 per-核心:为了避免锁竞争,给每个核心一个独立队列。这在低负载下很快,高负载下就是四条队。

这些设计都有各自的理由(缓存亲和性、避免共享状态、无锁)。这一章不是说它们错了,而是说:它们的代价可以精确算出来,而那个代价在高利用率下是几倍,不是几个百分点。

而缓解办法通常也不需要推翻设计——工作窃取(work stealing)就是给「各排各的」加一个逃生口:自己队空了,去别人队尾偷一个。它在数学上是「挑人最少那条队」的动态版本。Go 的调度器、Java 的 ForkJoinPool、Rust 的 tokio 都在做这件事,理由就是这一章。

∑ 算一遍:Erlang C

「一条队 + c 个窗口」的精确公式叫 Erlang C,1917 年由丹麦电话工程师 Agner Erlang 推导出来(他要算的是「电话交换机需要几条线路」)。

# 先算 Erlang B:c 条线路、没有等待区,来了满员就挂断
# 用递推算,避免阶乘溢出
B(0) = 1
B(k) = a·B(k−1) ÷ (k + a·B(k−1))        a = λ/μ(提供的负载,单位叫「爱尔兰」)

# 再算 Erlang C:「来了得等」的概率
C = B(c) ÷ (1 − ρ(1 − B(c)))            ρ = a/c

# 然后
Wq = C ÷ (cμ − λ)

# 代进 c=4, μ=1, λ=3.6(ρ=0.9):
B(4) = 0.270685
C    = 0.787753        ← 78.8% 的人来了得等
Wq   = 0.787753 ÷ 0.4 = 1.9694

那个 C 单独看很有用:它就是「来了得等」的概率。一条队 4 个窗口时是 78.8%,而四条独立队时是 90%(等于 ρ)。差的这 11 个百分点,就是共享队列买到的东西。

✎ 术语正名:「公平」

叫号机通常被解释成「更公平」——先来的先办,不会因为挑错队而吃亏。

但这一章说明,它的主要价值不是公平,是效率:它把平均等待降到了 1/4.57。公平是副产品。

这个区分有实际意义,因为它决定了你该在什么时候用它。如果有人说「我们不需要绝对公平,允许一些请求运气差一点」,那不构成放弃共享队列的理由——你放弃的是四倍的平均性能,不是一点公平感。

反过来,第 17 章会讲一个真正只关于公平、不关于效率的选择:先来后到 vs 后来先服务。那两个的平均等待完全相同,区别纯粹在于把方差分给谁。

▸ 在现实里

超市的收银台没改。银行改了、机场改了,超市基本没改。原因不是超市不懂,是物理空间:一条队需要一条长通道,而超市的出口是并排的。Costco 和一些便利店试过「蛇形单队」,效果很好但占地方。这是一个约束把最优解挡在门外的好例子。

为什么高速公路收费站要设 ETC 专用道。把「办得快的」和「办得慢的」分开,看起来是在制造更多的独立队列(坏事)。但它同时把每条队里的 cs² 压到接近 0(好事)。两个方向相反的效应,净收益取决于哪一边更大——当服务时间差异极大时(ETC 三秒 vs 人工缴费一分钟),分开是划算的。第 19 章会给这类决策一个更一般的框架。

为什么「连接池」比「每个请求一个连接」好。连接池就是一条队:请求排队等一个空闲连接,谁先空出来给谁。而给每个请求单独建连接,本质上是把队列切碎了。

✗ 这个直觉是错的
四个窗口就是四个窗口,总服务能力是定死的。怎么排队只影响公平,不影响总体效率——总时间是守恒的。 总的服务时间守恒,但等待时间完全不守恒。差别在于有没有出现「有人在等,同时有窗口空着」这个状态——每出现一次,就浪费掉一份产能,而产能是存不住的。

这个直觉背后是一个更深的错误:把系统想成一个总量守恒的容器。在这个模型里,四个窗口的总产能是 4 个单位/小时,来了 3.6 个,剩下的 0.4 个是「富余」,怎么排都一样。

但产能不是水,不能存。某个窗口在某一秒空着,那一秒的产能就永久消失了。四条独立队会制造大量这样的「空窗时刻」,因为它们的忙闲是不同步的。

顺带一提,这也解释了第 17 章那个乍看矛盾的结果:先来后到和后来先服务的平均等待确实相同——因为它俩都是单一队列,都不会产生「有人等着而服务台空着」的状态。守恒的是「工作量」,不守恒的是「产能有没有被浪费」。

◇ 结算

C差 4.57 倍。一条队 1.9694,四条队 9.0000

而且这个倍数随利用率变化:ρ=70% 时差 6.53 倍,ρ=80% 时差 5.37 倍,ρ=90% 时差 4.57 倍。有意思的是负载越轻,差距越大——因为负载轻的时候「有窗口空着」的机会更多,浪费的空间也更大。

A 「总时间守恒」——守恒的是服务时间,不是等待时间。这是这一章要破的核心直觉。 B 「差 20%–30%」——这恰好是「挑人最少那条队」和一条队的差距(14.2%–20.2%)。如果题目里说的是「大家都会看一眼哪条短」,这个答案就对了。 D 「十倍以上」——高估了。不过如果服务时间很不齐(cs²=10),闭眼挑队会慢 483%,接近六倍,往这个方向走了不少。

这一章的一句话

「有人在等,同时有服务台空着」是排队系统里唯一纯粹的浪费,而一条队的全部作用就是让它不可能发生。

下一章把「一条队」这个思路推到底:既然共享队列有好处,那池子越大越好吗?答案是的,而且好得超出预期。同样忙到 90%,1 个台子要等 9.0000 个服务时长,128 个台子只要 0.0132。反过来说,同样是「最多等半个服务时长」,1 个台子只能跑到 33.3%,128 个台子能跑到 98.7%——这就是云计算真正在卖的东西。