权重分配算法全解析:从加权随机到平滑加权轮询的工程实践

📅 发布时间:2026/10/15 4:38:15
权重分配算法全解析:从加权随机到平滑加权轮询的工程实践
去年我负责一个线上活动的流量分发需求最初版的权重分配算法只做了三行按权重百分比取随机数。压测阶段一切正常可活动一开其中一台接收节点瞬时QPS比别的节点高出三倍CPU直接打满请求大面积超时。排查到最后问题不在机器也不在数据库就出在那个看起来人畜无害的权重分配算法实现上。那次之后我花了两周时间把基于权重分配算法的组件从原理到上线重新捋了一遍加权随机、加权轮询、平滑加权轮询、动态权重、一致性保障、验证调优、降级兜底。文档里一句话带过的地方实际全是坑。这篇文章就当作一次完整复盘也给正准备自己动手实现权重分配的同行一点参考。这篇内容适合谁后端开发、做流量调度的朋友以及抽奖、推荐、任务分配场景里需要按比例选一个的开发者。如果你也遇到过权重明明配了5:3:2流量却忽高忽低的怪现象读完之后基本能定位到根因。1. 先想清楚权重分配算法在业务里到底解决什么问题1.1 一个典型的分配错位场景以负载均衡为例。你有三台后端节点配置权重是5:3:2期望流量是50%、30%、20%。但实际观测到的比例经常是45%、31%、24%而且波动幅度不小。很多人第一反应是某台机器性能差其实更可能是权重分配算法本身在小样本窗口内就不满足比例关系。权重分配算法最基本的语义其实非常简单给定一组元素每个元素带一个非负权重按照权重占总权重的比例来决定每次选择哪个元素。这个语义能套用到很多场景后端节点选路、抽奖奖品概率、广告流量切分、数据采样、任务下发。但一个关键点在于不同场景对算法性质的要求完全不同不能一套代码通吃。1.2 三类典型场景分别要什么我习惯把权重分配的诉求分成三类。第一类是单次决策型抽奖、广告探索、推荐召回。每次选择互不相关上一次选中谁对下一次没有影响波动是天然可以接受的。这种场景用加权随机就行简单高效。第二类是长期持续型负载均衡、队列消费、任务调度。这类场景要求任意一个时间窗口内的分配比例都稳定不能有突刺否则某个节点会周期性被打满。这种场景适合用平滑加权轮询。第三类是会话保持型登录态、购物车、本地缓存命中。同一个用户必须始终落到同一个节点。这种情况下单靠概率分配是不够的必须叠加哈希槽位或一致性哈希这类有状态映射。明白了这一点再去选算法而不是拿着加权随机一把梭。1.3 不要忽略比例以外的隐性指标做权重分配时眼睛不能只盯着最终比例。我后来发现有三个隐性指标比比例本身更值得关注。第一个是短时间窗口内的连续命中次数。它决定某一个节点会不会在几秒内被连续打满。第二个是分配结果在不同时间段的方差。方差大意味着节点一会儿闲得发慌、一会儿忙到冒烟。第三个是节点故障或权重变化之后重新分配的影响范围。影响范围越小系统越稳。这三个指标后面的章节会逐一展开。如果只看长期比例对不对基本等于只验证了算法的最浅层。2. 三种最常用的权重分配实现代码与缺陷对比2.1 加权随机实现最简单但要留意分布质量先看最直接的加权随机。把所有节点按权重累加成前缀和区间每次生成一个随机数看落在哪个区间就选哪个节点。public int weightedRandom(int[] weights) { int total 0; for (int w : weights) { total w; } int r ThreadLocalRandom.current().nextInt(total); int cumulative 0; for (int i 0; i weights.length; i) { cumulative weights[i]; if (r cumulative) { return i; } } return weights.length - 1; }这段代码复杂度是O(n)。节点少时无所谓如果有几百上千个节点可以提前构建前缀和数组再用二分查找优化到O(log n)。不过加权随机有个天然特性随机数在短样本内波动非常明显。权重5:3:2连续选100次A完全可能只被选到40次而不是50次甚至连续7次都命中A。抽奖场景觉得没问题但用在流量转发上就可能把一个节点打出尖峰。这也是当年那起线上事故的直接原因。2.2 加权轮询看似公平实则存在突刺为了消除随机波动很多人会改成加权轮询把权重展开成一个序列按顺序循环。权重5:3:2序列就是AAAABBBCC每10次一轮。public int weightedRoundRobin(int[] weights, int[] counter) { int totalWeight 0; for (int w : weights) { totalWeight w; } int index counter[0] % totalWeight; int cumulative 0; for (int i 0; i weights.length; i) { cumulative weights[i]; if (index cumulative) { counter[0]; return i; } } return -1; }长期看加权轮询的比例确实非常准因为它是穷尽式枚举每个周期内的分布都会完整复现一遍权重比例。但问题同样明显看序列AAAABBBCC就知道前5次请求全部打到AA的瞬时压力直接被拉满接着B又被连续打3次。这其实不是平滑只是把随机波动换成了周期波动。权重差异越悬殊突刺越严重。比如9:1就是连续9个请求全打到大权重节点小权重节点长时间空闲偶尔来一个请求又快速进入空闲。这种忙闲不匀的状态对服务端连接池、线程池都不友好。2.3 平滑加权轮询生产环境里最实用的修正方案平滑加权轮询能同时解决随机波动和周期突刺。它的思路是每个节点维护两个数值weight是固定权重current是动态累计值。每次选择时先把所有节点的current加上各自的weight挑current最大的节点返回再把该节点的current减去总权重。public class SmoothWeightedRoundRobin { private static class Node { private final String name; private final int weight; private int current; Node(String name, int weight) { this.name name; this.weight weight; } } private final ListNode nodes; private final Object lock new Object(); public SmoothWeightedRoundRobin(MapString, Integer weightMap) { this.nodes new ArrayList(); for (Map.EntryString, Integer e : weightMap.entrySet()) { if (e.getValue() 0) { nodes.add(new Node(e.getKey(), e.getValue())); } } } public String select() { synchronized (lock) { Node selected null; int total 0; for (Node n : nodes) { n.current n.weight; total n.weight; if (selected null || n.current selected.current) { selected n; } } selected.current - total; return selected.name; } } }核心不变量是任意时刻所有节点的current之和永远保持为0。选中一个节点并减掉总权重等于把它的领先优势一次性削减避免它连续被选中。以权重5:3:2为例SWRR的典型选择序列大致是A、B、A、C、A、B、A、A、B、C这种形态。A不会连续出现5次分布被明显抹平了。原因是A每次被选中后它的优先级会大幅下降其他节点有机会追上来。有人可能会问那A的总次数是不是就变少了不会。长期统计下来依然是50%、30%、20%。因为weight决定的是current的增长速度增长快的节点被选中的次数自然多。平滑只是改变了被选中次序的排列不改变长期比例。这个概念一定要吃透。提示SWRR不是万能的。如果需要带权重的会话保持它依然做不到第3节会讲怎么补。3. 动态权重与一致性保障从单机算法到分布式可用3.1 运行时调整权重的三个注意点真实线上系统的权重很少是一成不变。容量评估、故障隔离、促销扩容都会动态调整节点权重。这里我先后踩过三个坑。第一个坑是直接重置current会破坏平滑性。最初更新权重时我把节点的current也归零结果更新后的那几轮current较高的节点失去缓冲连续命中率飙升。后来改成更新weight时current要么按比例缩放要么干脆保留继续累积只改weight。后一种实现更省事短期会有一点点扰动但整体平滑性好很多。第二个坑是并发安全不要只靠锁硬扛。select本身是读多写少的逻辑用synchronized简单但节点数量上千、QPS很高时锁竞争会很扎眼。我最终使用的方案是不可变快照所有节点的weight和current封装进一个不可变数组写入时构建新数组并更新原子引用读取时拿到当前引用直接遍历。写操作虽然需要拷贝但读操作完全无锁线上稳定。第三个坑是更新权重必须保持原子性。如果配置中心下发的是整体快照例如{A:5, B:3, C:2}直接整个替换。千万不要一条一条地更新否则中间状态会出现总权重和各节点权重之和对不上选路结果短暂失真。分布式环境下这个失真可能造成流量抖动。3.2 权重分配和一致性哈希如何配合前面说的SWRR都是无状态选择意味着同一个用户两次请求可能落到不同节点。如果只是普通接口型服务问题不大但涉及本地缓存、登录态、分布式事务时就必须保证同一个key永远选到同一个节点。常见的做法是哈希槽位法把一串连续的整数编号按权重切成多个区间节点A权重5就占5个槽位。请求进来时对key做哈希哈希值对总槽位数取模落到哪个槽位就选哪个节点。public class WeightedHashAllocator { private final int[] nodeOfSlot; public WeightedHashAllocator(MapString, Integer weightMap) { int total 0; for (int w : weightMap.values()) { total w; } nodeOfSlot new int[total]; int idx 0; int nodeIdx 0; for (Map.EntryString, Integer e : weightMap.entrySet()) { for (int i 0; i e.getValue(); i) { nodeOfSlot[idx] nodeIdx; } nodeIdx; } } public int select(String key) { int h key.hashCode() Integer.MAX_VALUE; return nodeOfSlot[h % nodeOfSlot.length]; } }这段代码会引出新问题节点或权重变化时槽位总数变化所有key的取模结果全部改变相当于全量迁移。为了减少迁移范围可以引入一致性哈希虚拟节点的思路每个节点在哈希环上放若干个虚拟节点虚拟节点数量与权重成正比。选路时对key做哈希在环上顺时针找最近的虚拟节点。节点增减时只影响相邻的一小段数据迁移比例可控。3.3 精度问题浮点权重与整数化陷阱配置里写0.5、0.3、0.2很常见但我强烈建议后端一律转换成整数权重再参与计算理由有两个。第一浮点累加会引入误差。累加1000个浮点权重后total可能和实际总和差出一点点边界判断就会变得不可控。第二浮点比较在边界场景容易出bug比如r cumulative用float或double时精度问题可能让选中结果偏离预期。一个稳妥的整数化方案是把配置里的小数统一乘以10的n次幂转成整数。比如0.5、0.3、0.2同时乘以10得到5、3、2。抽奖类的百分比配置建议用通分思路先把百分比转成分母相同的分数再取分子作为权重。0.550/1000.330/1000.220/100化简后的分子比例正好是5:3:2。注意整数化之后权重总和可能变得很大。比如100个节点、每个权重1000总权重是10万用int没问题。但如果再给每个节点放大100倍生成虚拟槽位可能溢出int。计算total时建议用long承接中间结果。4. 验证与调参如何证明算法真的分配正确4.1 用统计指标代替直觉实现完之后不能只看几组输出觉得差不多那样很容易被假象迷惑。我一般会写一个蒙特卡洛验证程序模拟几十万次分配统计三个指标。第一个是期望比例与实际比例的相对偏差判断长期正确性。第二个是滑动窗口内的最大连续命中次数判断短时突刺。第三个是分配序列中相邻切换的次数判断平滑度。举个例子权重5:3:2模拟10万次之后简单加权轮询的长期比例几乎精确到50%而加权随机可能落在49.4%附近。如果只看这个指标会误以为加权随机不够准但它的真正问题在于短窗口方差大。所以验证时一定要把窗口拆小比如统计每连续100次分配中各节点的占比画出最大最小值区间才能暴露真实行为。4.2 不同算法在小样本下的表现差异我整理了一份对照表权重设定为5:3:2模拟10000次分配。实现方式长期比例误差短窗口最大连续命中A次数短窗口比例波动实现复杂度加权随机约±1%7到11大低加权轮询几乎为0等于权重值5周期突刺明显低平滑加权轮询几乎为02到3小中哈希槽位法取决于key散列质量取决于key序列与key分布相关中这里面最大连续命中A次数是统计意义上的经验值。平滑加权轮询在节点权重不超过总和一半的情况下连续命中次数通常很小但如果是9:1这种极端权重大权重节点依然可能出现连续两次以上命中这是算法特性决定的不算bug。这组对比说明的事很简单目标是长期比例准确短时平滑直接选平滑加权轮询需要同一个key稳定落到同一节点再叠加哈希槽位法只有明确接受随机波动比如抽奖场景才用加权随机。4.3 根据真实流量反馈离线调参权重初始值大多是拍脑袋定的上线后要靠反馈数据回调。最朴素但有效的方法是指数加权移动平均式自适应调整responseScore_i 近期平均RT或错误率的归一化值越小越好 newWeight_i oldWeight_i * (1 - alpha * (responseScore_i - avgScore))alpha取0.1到0.3每次更新步长小一点避免震荡。注意这里调的是配置里的总权重不是运行时那个current。写完一版后用历史流量日志离线回放看调整后的权重能不能让高负载机器释放压力。回放验证有一个容易忽略的点日志里的流量是已经发生的结果包含很多和权重无关的噪声比如突发活动、客户端重试。所以回放结论只能作参考真正上线后还要靠实时监控曲线二次校验。5. 生产环境落地兜底、监控与边界处理5.1 权重为0、总和溢出、空节点这些边界权重分配的代码主体不难难的是边界条件。这些都是线上事故的高发区。我整理了一个自查清单权重为0的节点必须跳过否则它会占据槽位却永远不该被选中所有节点权重都为0时直接报配置错误并走降级逻辑由调用方决定随机选一个还是返回空节点列表为空时select方法要抛出明确的异常不能返回null后一路触发空指针权重为负数时初始化阶段就要拦截避免current累加后总和不为0的不变量被破坏计算total时用long中间量防止int溢出。这些边界里我踩过最狠的是权重为0的节点没被过滤。当时故障节点被标记成权重0但过滤逻辑少写了一个判断总权重累加后比例全乱故障节点还持续分到流量排查了很久才发现是边界过滤的问题。5.2 故障剔除与权重自动衰减权重分配只负责按比例选它不负责选到的节点是否健康。实际落地时我在业务侧加了一层健康检查配合权重衰减机制一起用。健康检查失败的节点第一次不直接摘除而是把权重乘以一个衰减系数比如0.5。这样做的原因是如果失败只是瞬时抖动节点还能接少量流量逐步恢复如果持续失败权重会指数衰减到阈值以下然后自动摘除。恢复健康后再按原权重或者平滑爬坡方式回填。衰减系数的选择要小心。我记得有一次把系数设成0.1结果一个节点只是慢了两秒权重很快就跌没了流量瞬间全部压到其他节点反而把正常节点打挂。后来统一改成0.5并且每次衰减之间加一次观察冷却期整体稳定了很多。5.3 最小可用的监控指标集组件上线后我长期只盯四个指标。第一个是每个节点的实际分配占比与期望占比的偏差粒度取1分钟。第二个是select方法的P99时延在不可变快照的实现下这个值应该稳定在微秒级。第三个是滑动窗口内的最大连续命中次数超过阈值就告警。第四个是权重变更次数和变更来源用于复盘。这四个指标不需要额外引入复杂的监控系统用日志打点就能覆盖。重点不是指标数量而是每一条都能和业务现象对上号流量飘了、节点挂了、配置改错了都能从这些指标里看出是谁先变化的。最后分享一点个人体会。我在实际做这套组件时印象最深的是不要在权重分配代码里堆设计模式也不要一上来就搞自适应调权这种进阶能力。先把平滑加权轮询的主流程跑通把边界和监控补齐再根据真实数据决定要不要继续加复杂度。很多线上问题的根源其实出在配置管理、监控和运维习惯上而不是算法本身。如果你现在只有一台测试机可以先用脚本把权重分配打上个几千次统计分布和平滑度这些脚本跑完收获比翻十篇文档都大。