一条队,还是每人一条队
这是全书唯一一个你每周都在亲身做的决策,也是唯一一个「什么都不用买」就能拿到几倍改善的动作。银行、机场、医院早就改了,而软件系统里到处还是各排各的。
银行有 4 个窗口,人流稳定,四个窗口的忙碌程度都是 90%。
方案甲:一条队,谁前面空了就叫谁(现在的叫号机)。
方案乙:四条队,进门时随便挑一条站进去,之后不能换。
两种方案的窗口数一样、人流一样、办事速度一样。平均等待差多少?
一条队为什么快
先说结论,再说为什么:
| 方案 | 平均等待 | 相对 |
|---|---|---|
| 一条队 + 4 个窗口 | 1.9694 | 1.00 |
| 四条队,随便挑一条 | 9.0000 | 4.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 都在做这件事,理由就是这一章。
「一条队 + 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%——这就是云计算真正在卖的东西。