分布式系统(35):两次选择的负载均衡复现实验
“随机挑两台,选更空的一台”看起来只比随机分配多一次观察,却会显著降低最大负载。经典 Balanced Allocations 论文给出的结论更强:在明确的静态模型里,单选最大负载的量级约为 log n / log log n,固定 d>=2 次选择后降为 log log n / log d + O(1),概率随规模增大趋近 1。
这项结论经常被压缩成“Power of Two Choices 总是更好”。本篇用独立研究项目检验这句话丢掉了哪些条件:先精确枚举小状态空间,再做固定种子的配对模拟,最后加入相关候选、整批陈旧快照和反向选择。结果同时包含支持主张的分布证据和推翻逐轨迹强断言的反例。
分布式系统(34):复制 KV、分片迁移与故障恢复研究问题先固定
实验只研究静态 balls-into-bins。m 个单位大小的 ball 依次到达,放入 n 个不会卸载的 bin。它可以代表一次性任务分配的抽象,却不包含服务完成、队列等待、节点权重或请求时延。
基线 one-choice 为每个 ball 均匀随机抽一个 bin。候选 two-choice 独立、有放回地抽两个 bin,读取当前负载,把 ball 立即放入较轻者;平局选第一个。两种策略复用同一条候选对序列,因此差异来自选择规则,而非换了一批随机数。
flowchart LR
B[一个ball到达] --> R1[随机候选first]
B --> R2[随机候选second]
R1 --> C{比较当前负载}
R2 --> C
C -->|first较轻或平局| F[放入first]
C -->|second较轻| S[放入second]
预注册有限假设 H:在 n=m=4096、200 个固定 seed 的配对试验中,fresh two-choice 的最大负载不高于 one-choice,且至少 95% 的试验严格更低。它不是论文的渐近定理;源码里的门槛只判断这组有限输入。
为什么平均值看不出差异
每种策略都放置 4096 个 ball,所有 bin 的平均负载必然是 1。只报告均值会得到“算法没有差别”的错误印象。分配是否均衡要看最大负载、bin 负载分位数、空 bin 数与标准差。
flowchart TB
T[总ball数固定] --> M[平均负载固定为m/n]
M --> X[不能区分均衡程度]
X --> A[maximum]
X --> P[p99 bin load]
X --> E[empty bins]
X --> D[standard deviation]
经典论文关心最大负载的高概率渐近界。有限实验报告样本统计,是为了检查趋势和实现,不用 200 次运行替代数学证明。
65,536 条小轨迹的精确结果
取 n=m=4,每个 ball 有一个有序候选对,共 4^(2*4)=65,536 条完整输入。穷举结果中,one-choice 最大负载的精确期望为 2.125,fresh two-choice 为 1.714844;完全相关的 two-choice 与 one-choice 在每条轨迹上逐字相同,反向选择的期望最大负载升到 2.558594。
分布改善不意味着逐轨迹支配。65,536 条输入中,fresh two-choice 有 24,240 条更好、41,200 条相同,还有 96 条更差。首个反例的候选对为:
1 | |
one-choice 始终取 first,得到 [2,2,0,0],最大负载为 2。two-choice 逐步避开当时较重的候选,最终却得到 [1,3,0,0],最大负载为 3。局部贪心在这条对抗性固定轨迹上更差,不与“随机输入分布下期望和高概率界更好”矛盾。
flowchart LR
D[分布命题<br/>期望/高概率更低] --> OK[允许少量固定轨迹更差]
P[逐轨迹支配<br/>每条输入都不差] --> BAD[被96条反例否定]
200 个配对 seed 的结果
默认规模为 4096 个 bin 和 4096 个 ball,seed 从 20260927 连续取 200 个。每个 seed 生成一条候选对序列,五种策略都在这条序列上运行。
| 策略 | 最大负载均值 | 最大负载中位数 | 最大负载p95 | 解释 |
|---|---|---|---|---|
| one-choice | 6.275 | 6 | 8 | 单次随机基线 |
| fresh two-choice | 3.030 | 3 | 3 | 当前负载、独立候选 |
| correlated-two | 6.275 | 6 | 8 | 两次都观察first |
| stale-two | 6.275 | 6 | 8 | 整批只看初始快照 |
| anti-two | 8.270 | 8 | 10 | 故意选较重者 |
fresh two-choice 在 200 次中全部严格低于 one-choice,最大负载差的中位数为 3,有限假设 H 通过。它的 p99 bin load 中位数为 2,one-choice 为 4;标准差均值分别约 0.703 和 0.999。这里的 p99 指“bin 负载的第 99 百分位”,不是请求延迟。
选择次数不是独立信息的替代品
correlated-two 把第二候选强制设为第一候选。代码仍执行“两次观察”的接口,但有效信息只有一个 bin,精确枚举与 200 次模拟都与 one-choice 完全相同。
flowchart TD
Q[两次采样] --> I{候选独立?}
I -->|是| L{负载信息新鲜?}
I -->|否| O[退化为一次选择]
L -->|是| F[fresh P2C]
L -->|否| S[可能退化或形成herd]
F --> C{比较方向正确?}
C -->|选轻者| G[分布更集中]
C -->|选重者| H[放大热点]
独立均匀采样也是负载分散的前提。机架亲和、缓存命中、热点键或路由规则可能让两个候选高度相关;此时增加一次探测不等于增加一次独立机会。
陈旧负载把 two-choice 退化为 one-choice
stale-two 每隔固定数量的 ball 才刷新一次可见负载。默认刷新间隔等于整个批次 4096,因此所有决策都看到全零快照;平局规则始终选择 first。结果在每个 seed 上与 one-choice 完全相同。
这不是“旧信息永远无用”的证明。Mitzenmacher 对 old information 的分析说明,信息价值取决于刷新模型与批量大小。本地反例只说明:同步批次共享一份完全陈旧的快照时,fresh two-choice 的结论不能原样套用。真实系统还可能通过随机打破平局、缩短刷新间隔、late binding 或服务器端反馈减弱 herd。
第一次运行曾把刷新间隔设为 64,并预期多数试验会比 fresh two-choice 更差。实际只有 14/200 个试验的最大负载更高,中位差为 0,预注册门槛失败。这个 pilot 不能支持“轻度陈旧必然明显恶化”;失败日志保留在仓库内 .build/research35/,正式端点实验只回答整批不刷新时是否精确退化。
反向选择验证判定器不是装饰
anti-two 使用相同候选和实时负载,却故意把 ball 放入较重者。200 次中有 194 次最大负载高于 one-choice,中位差为 2。比较方向确实参与了结果;“多看一个候选”本身没有保证。
这个负例也检查实验是否只会打印成功。如果实现意外忽略负载比较,fresh、correlated、stale 和 anti 四种策略会趋同,预注册门槛或反例断言就会失败。
正确性与故障模型
静态分配器的安全性很窄:每个 ball 恰好放入一个合法 bin,最终负载总和等于 ball 数,没有负数。活性同样是构造性的,每个输入在一次策略调用内完成,不等待消息或多数派。
网络超时、节点崩溃、请求重试和任务执行失败都不在模型中。若一次分配响应丢失,重试可能重复放置;若节点在选择后失效,任务需要重新调度;若观测延迟,fresh 假设被破坏。这些问题需要请求身份、租约或调度协议,不能由 P2C 的负载界自动解决。
实验命令
1 | |
Python 3.12.3 正式运行退出 0;--help 退出 0,--trials 0 退出 2。另用 n=m=2、单次试验触发预注册门槛失败,程序先写出完整结构化结果再退出 1,没有用 traceback 代替失败报告。每个正式 trial 都保留 seed、五种策略的最大负载和三个配对差值,便于复算 200/200。源码 SHA-256 见随文证据。完整结果见观察结果,来源和运行记录见实验证据,验证范围见验证说明。
从定理到工程系统
NGINX 的 random two least_conn 明确了两个工程参数:候选如何随机取,比较量是连接数还是权重。Sparrow 把小规模随机探测用于分布式调度,还需要 late binding 和批任务处理。产品名字里出现“two choices”并不表示它自动继承静态 balls-into-bins 的全部结论。
| 模型差异 | 可能改变什么 | 需要额外验证 |
|---|---|---|
| ball 有不同工作量 | 计数相同但剩余工作不同 | 加权负载或剩余服务时间 |
| bin 会完成任务 | occupancy 变成动态队列 | 到达率、服务分布、稳定性 |
| 负载信息有延迟 | 多个分配器看到旧状态 | 刷新间隔、批量并发、tie-break |
| 候选不独立 | 第二次采样信息减少 | 拓扑、亲和规则、热点分布 |
| 节点会失败 | 已选目标可能不可用 | 重试身份、重调度与容量余量 |
| 探测跨网络 | 两次观察产生额外成本 | 探测流量、超时和缓存一致性 |
标准库模型没有吞吐、p99 请求延迟、调度公平性或网络成本数据,也没有验证 NGINX、Sparrow 或云调度器。它只给静态占用分布与几种明确反例。
两个推演练习
为什么允许两个候选相同不会破坏经典模型?
独立均匀、有放回抽样本来就允许重复,概率为 1/n。重复时该 ball 退化为单选,但随 n 增大只占小部分。若实现要求两个候选必异,模型变成无放回抽样,需要在代码与结论中明确。
为什么 96 条更差轨迹不能否定论文主定理?
论文比较的是随机输入分布下的高概率最大负载界,不是对每条候选序列的点态不等式。有限穷举显示 two-choice 的精确期望更低,同时给出少量更差轨迹;两项观察可以同时成立。
结课结论
独立研究项目不应只寻找支持主张的图。问题先限定为静态模型,判定门槛在运行前固定,基线与候选复用随机输入,精确枚举保留反例,模拟结果与论文定理分开陈述。这样的报告可以复跑,也能说明结论在哪些条件下停止成立。
主线到这里结束。选修篇从 CRDT 与多主写入开始,讨论不依赖单一 leader 时,合并规则如何代替全局顺序。
参考资料
- Azar et al., 1999, Balanced Allocations:静态模型与经典最大负载定理。
- Mitzenmacher, 2001, The Power of Two Choices in Randomized Load Balancing:静态与动态模型综述。
- Mitzenmacher, 2000, How Useful Is Old Information?:陈旧负载信息的模型边界。
- Vvedenskaya et al., 1996, Shortest of Two Queues:动态 supermarket queue 模型。
- Berenbrink et al., 2006, Balanced Allocations: The Heavily Loaded Case:重载模型的 maximum-average gap。
- Ousterhout et al., 2013, Sparrow:随机采样在分布式调度中的工程化。
- NGINX, HTTP upstream random directive:
random two的产品 API 语义。
