队列不是缓冲区,是债
前二十章都假设队是无限长的,每个人最终都会被服务。现实里没有无限长的队,而「该让它多长」是一个比看起来重要得多的决策——因为队列的本质不是弹性,是杠杆。
一个服务,产能 1000 个请求/秒。某天流量涨到 1100 个/秒——只超载 10%,持续 10 分钟,然后回落到 800/秒。
队列是无界的(没有拒绝,全部排着)。
从超载开始,到队列彻底排空,一共要多久?
ρ ≥ 1 不是「很慢」,是另一种东西
这本书前面所有的公式都有一个前提:ρ < 1。越过它,公式不是变得不准,而是不再有意义——稳态平均等待时间不存在。
取而代之的是一个非常简单、非常无情的算术:
# 超载时,队列**线性增长** 积压(t) = (到达率 − 产能) × t # 例子:产能 1000/秒,来了 1100/秒 积压(10 分钟) = 100 × 600 = 60000 个请求 # 高峰末尾那个人要等多久? 等待 = 积压 ÷ 产能 = 60000 ÷ 1000 = 60 秒 # 高峰过后降到 800/秒,排空速度 = 1000 − 800 = 200/秒 排空时间 = 60000 ÷ 200 = 300 秒 = 5 分钟
所以答案是 10 + 5 = 15 分钟。
一次 10% 的、持续 10 分钟的过载,制造了一场 15 分钟的事故。而且在那 15 分钟里,高峰早就过去了,用户还在为半小时前的事情买单。
「加个队列缓冲一下」这个说法,把队列想成了一个弹簧:压一下,回弹,没事。
队列不是弹簧,是信用卡。它让你在没有产能的时候先「刷」出去,但账要还,而且还款速度取决于余量——余量越小,还得越慢。
欠债速度 = 到达率 − 产能 (超载时) 还债速度 = 产能 − 到达率 (恢复后,也就是余量) # 这两个数通常**不对称**: # 超载时可能超 10%,恢复后余量可能只有 20% # → 还债时间 = 欠债时间 × (超载幅度 ÷ 余量) = 10 × (10% ÷ 20%) = 5 分钟
而如果恢复后的余量更小(比如只回落到 950/秒,余量 5%),还债时间就变成 20 分钟——比高峰本身还长一倍。
那把队列设个上限?
看第一个标签页。同样是 ρ=0.95 的系统,队列上限从 1 到 500:
| 队列上限 K | 被拒绝的比例 | 实际吞吐 | 平均逗留 |
|---|---|---|---|
| 5 | 14.60% | 0.8113 | 2.90 |
| 10 | 6.94% | 0.8840 | 5.08 |
| 50 | 0.42% | 0.9461 | 15.83 |
| 500 | 0.00% | 0.9500 | 20.00 |
从 K=10 放宽到 K=500:
- 吞吐只多了 7.5%(0.8840 → 0.9500);
- 平均逗留从 5.1 涨到 20.0——四倍。
大缓冲区几乎不增加吞吐,它只是把「拒绝」换成了「等到超时」。
而这两种失败方式,成本差别很大:
| 立刻拒绝 | 等到超时 | |
|---|---|---|
| 用户体验 | 快速失败,可以立刻重试或降级 | 转圈 30 秒然后失败 |
| 服务器成本 | 几乎为零 | 整整一份处理成本(做完了才发现没人要) |
| 连锁反应 | 上游立刻知道,可以熔断 | 上游也在等,连接池被占满,故障向上传播 |
第二行是关键。超时的请求,服务器往往已经把活干完了——结果发到网络上,客户端早就断开了。这份产能是纯粹的浪费,而且它发生在系统最缺产能的时候。
有一个非常好用的经验法则,它是 Little 定律(第 2 章)的直接应用:
队列上限 K = 产能 × 你能容忍的最长等待 # 例子:产能 1000/秒,用户最多愿意等 200 毫秒 K = 1000 × 0.2 = 200 # 意思是: # 队里排到第 200 个的时候,这个请求即使被处理也已经超时了。 # ★ 所以让它排进来毫无意义 —— 直接拒绝。
这条规则把「队列上限」这个看起来很技术的参数,变成了一个产品决策:用户愿意等多久。
而更好的做法是按时间而不是按长度限队:给每个请求打一个到达时间戳,取出来时如果已经超过预算,直接丢掉不处理。这样自动适应产能变化,不需要调参。这就是 CoDel 算法的核心思想(controlled delay,最早用在网络队列上,现在被广泛移植到应用层)。
重试:把过载变成雪崩的那一步
上面的算术还是乐观的,因为它假设到达率是外生的。而真实系统里,变慢会产生更多流量:
# 正反馈回路 延迟涨 → 客户端超时 → 重试 → 到达率涨 → ρ 涨 → 延迟更涨 → … # 如果重试策略是"失败就立刻重试 3 次",那么在完全故障时 # 到达率会变成正常值的 4 倍。 # 一个本来只超载 10% 的系统,瞬间变成超载 300%。
这就是为什么重试策略是分布式系统里最危险的默认配置之一。三条对策,按重要性排:
- 指数退避 + 抖动。不要固定间隔重试(那会让所有客户端同步,第 6 章的成团问题),要指数增长并加随机偏移。
- 重试预算。限制「重试请求占总请求的比例」(比如不超过 10%),而不是限制「每个请求重试几次」。后者在大面积故障时完全失效,因为每个请求都在重试。
- 熔断。连续失败到一定程度就停止发送,给下游留出还债的时间。熔断的本质是:主动制造余量。
注意第 3 条和这本书的主线是同一件事。第 9 章说余量是队列唯一的负反馈来源;而当系统已经越过 ρ=1,它自己没有任何机制能恢复——余量必须从外面强加进去。熔断、限流、降级,全都是在做这件事。
第 5 章埋了一个伏笔:帕累托分布 α ≤ 2 时方差是无穷的,这本书的公式在那里失效。
现在可以说清楚它的实际后果了。无穷方差意味着:你测出来的平均等待,会随着观察时间越来越长而越来越大,永远不收敛。
# 有限方差(比如 cs²=10) 观察 1 分钟 → 平均等待 20ms 观察 1 小时 → 平均等待 22ms 观察 1 天 → 平均等待 22ms ← 收敛了 # 无穷方差(帕累托 α=1.5) 观察 1 分钟 → 平均等待 20ms 观察 1 小时 → 平均等待 45ms 观察 1 天 → 平均等待 130ms 观察 1 周 → 平均等待 400ms ← 永远在涨
这在实践中的样子是:「我们的系统在慢慢退化,但找不到任何原因」。没有内存泄漏,没有数据增长,代码没变,可 p99 就是在往上飘。
而超时正是那个把它救回来的东西。给请求设一个硬超时,等于给服务时间分布加了一个硬上界——这在数学上把一个方差无穷的分布截断成了方差有限的分布,让这本书所有的公式重新成立。
所以超时不只是防御性编程。它是让你的系统重新变成一个有解的系统。
「用消息队列削峰填谷」是一个非常流行的说法,它暗示队列能把高峰的流量「存起来」,等低谷时慢慢消化。
这个说法只在一个条件下成立:高峰过后确实有足够长的低谷。
而这个条件必须被显式检查:
高峰欠下的债 = (峰值 − 产能) × 高峰时长 低谷能还的量 = (产能 − 低谷流量) × 低谷时长 # 必须 低谷能还的 ≥ 高峰欠下的 # 否则每天的债都还不完,会一天天累积 —— 那不是削峰,那是慢性死亡。
而且即使能还完,高峰期间的用户仍然经历了很长的延迟。削峰填谷保护的是系统(不崩),不是用户体验(不慢)。这两件事经常被混为一谈。
如果你的业务不能接受高峰期用户等一分钟,那么队列救不了你,你需要的是产能或者降级。
为什么线程池的队列不该设成无界。Java 的 Executors.newFixedThreadPool() 用的是无界队列,这是一个众所周知的坑:过载时任务无限堆积,最后 OOM,而且在 OOM 之前所有任务都已经超时了。正确做法是有界队列 + 明确的拒绝策略。
为什么「加大 backlog」通常没用。TCP 的 accept 队列满了会拒绝连接。很多人的第一反应是调大 somaxconn。但如果你的应用层处理不过来,加大 backlog 只是让连接排在更深的地方等死。队列深度不是产能。
春运抢票。12306 的排队机制是这一章的教科书案例:明确告诉你「前面还有 N 人」,并且拒绝超出容量的请求,而不是让所有人一起转圈。这个设计在体验上不友好,但它是对的——诚实的拒绝优于虚假的希望。
餐厅的等位。好的餐厅会告诉你「大概等 40 分钟」,让你自己决定走不走。差的餐厅只说「马上就好」。前者是快速失败(给你信息去做决策),后者是无界队列(把你困在里面)。
这个直觉背后有一个很有道理的担心:拒绝请求感觉像是「主动失败」,而排队感觉像是「尽力而为」。前者需要有人签字,后者是默认行为。组织的激励结构在系统性地偏向无界队列。
但从数学上说,ρ ≥ 1 时,你已经在丢请求了,只是丢的方式不同:
- 有界队列:立刻丢掉超出的部分,被服务的那些体验良好;
- 无界队列:全部请求一起变慢,最后一起超时——你不但丢了同样多的请求,还搭上了所有人的体验。
这是这本书最反直觉、也最有实践价值的一条:过载时,让一部分人快速失败,是对剩下那些人负责。而「大家一起慢」不是公平,是把所有人一起牺牲掉。
C大约 15 分钟。10 分钟的高峰欠下 60000 个请求,高峰末尾进来的人要等 60 秒;然后以 200/秒 的速度还债,要 5 分钟。
而这还是没有重试的乐观估计。加上重试放大,同样的输入可以变成一次一小时的完全不可用。
A 「高峰过去就恢复了」——把队列当成了弹簧。队列没有回弹力,它只有被余量慢慢排空这一条路。 B 「大约 12 分钟」——直觉方向对(知道要还债),但低估了还债速度和欠债速度的不对称:欠的时候超载 10%,还的时候余量 20%,看起来还得快一倍,但欠了 10 分钟只需还 5 分钟,总共 15 分钟。 D 「大约 1 小时」——高估了,除非恢复后的余量很小。如果流量只回落到 950/秒(余量 5%),那么还债要 20 分钟,总共半小时——这个选项在余量更紧的现实系统里是对的。这一章的一句话
队列不是弹性是杠杆:它把一次 10% 的短暂过载,放大成一场比过载本身长得多的事故。
下一章讲这本书里唯一一个会自己找天花板的系统。它每时每刻都在试探「还能不能再快一点」,撞到墙就退回来,然后再试——一条锯齿。而它顺带解释了一件几乎所有人都误解的事:把网络设备的缓冲区从 10 个包加到 1000 个包,吞吐只涨 4.2%,而延迟涨了 32.6 倍。