
前阵子在给一个小型内部请求网关做压测时发现接入层的机器配置并不均匀两台老机器在处理某些复杂查询时明显比新机器慢了一截。当时的服务端负载均衡策略用的是最简单的“轮询”Round Robin结果低配机器被流量拖得频繁告警而高配机器反而一直有空闲。这个问题让我重新把“轮询”和“加权轮询”这两类负载均衡算法从底层过了一遍也顺手用Java把两种经典实现完整写了出来。这篇文章就把这次整理沉淀下来核心内容包括负载均衡算法在解决什么问题、纯轮询的实现细节与并发注意点、加权轮询从“朴素版”到“平滑版”的演进以及在实际业务中如何选型、如何接入动态权重和健康检查。无论你是刚开始接触分布式系统、需要自己写RPC客户端选择策略还是单纯想弄懂负载均衡算法原理这篇文章都值得看完。1. 负载均衡算法在解决什么问题什么时候该自己写1.1 从一次“Nginx兜底但后端依旧打满”的场景说起先说那次压测的实际情况。上游接入层确实做了负载均衡配置也指向了后端集群但后端集群里的机器规格差距比较大。流量进来时负载均衡器默认按轮询分发每台后端平均接收请求。问题在于“平均接收请求”不等于“平均消化请求”——老机器处理慢新的请求不断堆积而新机器处理快很快就空闲下来等待下一个请求。压测跑到一半老机器CPU持续飙高新机器却只有不到一半的负载。这种场景在技术圈其实很常见很多人以为“配了Nginx就万事大吉”实际上Nginx只是按照上游服务器列表进行转发默认的轮询策略并不感知后端机器的处理能力差异。真正的负载均衡既要考虑“每个后端分到多少流量”也要考虑“后端能不能接得住这些流量”。1.2 按场景决定是自己实现还是交给网关这里需要先明确一件事自己实现负载均衡算法不是为了替代Nginx这类成熟网关。Nginx、云负载均衡服务在处理流量分发、健康检查、会话保持、故障转移方面已经非常成熟大多数场景下直接配置就够了。真正需要自己写算法的地方是那些“网关覆盖不到”的角落。最典型的两个场景RPC客户端本地负载均衡服务发现拿到一批可用节点后客户端要自己决定每次请求发到哪个节点。这时候没法依赖中心化的负载均衡器只能在客户端代码里选择一个轮询策略。自定义网关或代理服务当你自己在Java里实现一个轻量转发层、消息队列消费端的worker分配器、甚至任务调度器时也需要在代码里实现分派逻辑。另外还有一种常见情况是学习源码或准备面试。很多人在看Dubbo、gRPC等框架源码时最先遇到的负载均衡接口就是轮询和加权轮询。理解它们其实是理解各种RPC框架路由策略的敲门砖。1.3 轮询和加权轮询在整个算法版图里的位置负载均衡算法可以粗略分为两类静态算法和动态算法。静态算法包括纯轮询、加权轮询、随机、加权随机、一致性哈希等。它们不依赖实时的后端状态决策逻辑简单、性能开销小在节点配置固定且响应能力相对稳定的场景下完全够用。动态算法包括最小连接数、最短响应时间、基于CPU/内存使用率的自适应策略等。它们需要收集后端节点的实时指标并根据指标动态调整分发权重复杂度明显更高通常要配套一套监控数据采集链路。纯轮询和加权轮询属于静态算法里最基础、也最容易被聊透的两个。但“基础”不代表“简单”尤其加权轮询的平滑实现里面有一个非常巧妙的数学设计值得深入拆解。2. 纯轮询Round Robin一行取模背后藏着哪些细节2.1 最小可复现的实现纯轮询的思路很直白维护一个递增的计数器每次请求到来时计数器加一再对服务器列表长度取模得到本次选中的节点下标。这样请求就会按照服务器的排列顺序依次分发。一个最直接的Java实现长这样public class RoundRobin { private final ListString servers; private final AtomicInteger index new AtomicInteger(0); public RoundRobin(ListString servers) { if (servers null || servers.isEmpty()) { throw new IllegalArgumentException(servers must not be empty); } this.servers servers; } public String next() { int i index.getAndIncrement(); return servers.get(Math.floorMod(i, servers.size())); } }这里有两个看似不起眼、但实际很关键的细节。第一为什么用AtomicInteger而不是synchronized因为getAndIncrement是CAS操作在高并发下只会出现少量自旋冲突整体吞吐比直接加锁高很多。当然如果请求量小到可以忽略用synchronized也没有问题但既然标准库里就有无锁方案没有理由不用。第二取模时我用了Math.floorMod而不是%。原因是最小负数除以正数的%结果仍然可能是负数。AtomicInteger虽然初始值是0但它是一个32位有符号整数经过几十亿次递增后会溢出为负数。一旦溢出servers.get(i)就会抛出数组下标越界异常。Math.floorMod(i, servers.size())能正确处理负数把下标归一化到[0, size)区间。2.2 并发视角下的轮询计数器再聊聊并发安全。第一个版本通过AtomicInteger保证了“每次取到的下标都不重复”在单线程和多线程环境下都能严格轮转。但要注意使用getAndIncrement时如果同一时刻有N个线程并发调用它们拿到的下标并不是严格“1、2、3、4”这种顺序而是CAS成功顺序。不过对请求分发来说这个顺序不重要重要的是每个节点被选中的比例是均等的。还有一个容易被忽略的问题服务器列表变化。比如后端节点扩容或缩容servers列表变了但计数器还在按旧列表游走。最粗糙的版本会在列表变小时出现越界异常即使不越界也可能在切换的一瞬间分布不均。比较稳妥的做法是把服务器列表整体封装为不可变对象当列表变化时直接替换引用同时重置计数器。类似这样public class RoundRobinV2 { private volatile ListString servers; private AtomicInteger index new AtomicInteger(0); public RoundRobinV2(ListString servers) { this.servers List.copyOf(servers); } public synchronized void refresh(ListString servers) { this.servers List.copyOf(servers); this.index.set(0); } public String next() { ListString snapshot this.servers; int i index.getAndIncrement(); return snapshot.get(Math.floorMod(i, snapshot.size())); } }使用volatile引用保证其他线程能立刻看到新列表refresh时重置计数器避免新列表刚上线时跳过前几个节点。这种“快照引用 原子计数器”的组合在很多RPC框架的本地负载均衡实现里都能看到影子。2.3 轮询真正的局限不在算法本身很多人把轮询实现到上面这个程度就停了但我一直觉得轮询真正的局限不是实现细节而是它的基础假设太理想化。纯轮询隐含了三个假设所有后端节点的处理能力相同每个请求的代价相同后端节点的状态始终正常。现实中这三条几乎都不成立。机器配置有差异、请求参数导致处理时间方差很大、某个节点可能在GC停顿或网络抖动。轮询不会区分这些它只知道机械地“每人一次”。所以纯轮询最适合的场景是后端节点规格一致、接口处理时间相对稳定、节点健康状态由外部组件保障。只要场景偏离这三点就该考虑加权轮询或者至少给轮询器增加健康检查逻辑。3. 加权轮询从“按比例”到“平滑”的演进3.1 朴素的加权实现数组展开和概率区间加权轮询解决的是“后端能力不均”的问题。给每台机器一个权重权重高就多分流量权重低就少分流量。最直觉的思路有两种。一种是按权重把节点重复放进数组。比如A权重是3、B权重是2、C权重是1那就创建数组[A, A, A, B, B, C]然后对这个数组做普通轮询。实现简单但问题很直接如果权重很大比如A的权重是1000数组会非常浪费内存而且请求会扎堆出现前3个请求全打到A上后面两个全打到B上对后端节点很不友好。另一种是按权重区间做随机选择。把总权重算出来生成一个[0, total)的随机数落在哪个节点的区间就选哪个节点。这种实现能保证权重比例但因为是随机的短时间内的分布可能很不均匀而且没有“确定性”。这两种方案其实都停留在“按比例”层面。它们保证了长期比例却没有保证短期分布也尽量平滑。实际生产环境中我们当然不希望某个节点连续收到一堆请求另一个节点却一直闲着。这就需要引入真正平滑的加权轮询算法。3.2 Nginx平滑加权轮询的核心思想Nginx官方的平滑加权轮询算法通常被称为SWRRSmooth Weighted Round Robin给出了一个很优雅的答案。这个算法的核心思想是每个节点有两个权重值——一个是固定权重weight配置不变一个是动态权重current每次选择时都会变化。算法的流程可以概括为四步每次请求到来时先把所有节点的current都加上各自的weight找出current最大的节点把这个节点的current减去总权重返回这个节点。只看文字可能不容易理解我们用一个具体例子走一遍。假设有三台节点A、B、C固定权重分别是4、2、1总权重是7。请求序号加权重后的current值选中的节点操作后的current值1[4, 2, 1]A[-3, 2, 1]2[1, 4, 2]B[1, -3, 2]3[5, -1, 3]A[-2, -1, 3]4[2, 1, 4]C[2, 1, -3]5[6, 3, -2]A[-1, 3, -2]6[3, 5, -1]B[3, -2, -1]7[7, 0, 0]A[0, 0, 0]这一轮7个请求的选择顺序是A、B、A、C、A、B、A。A出现了4次B出现2次C出现1次严格符合权重比4:2:1。更关键的是A的4次请求并没有连续扎堆而是被B和C穿插着切开整体分布非常平滑。为什么这个算法能同时做到“比例正确”和“分布平滑”我来说说直觉理解。每一轮每个节点都会“积攒”自己的固定权重因此高权重节点攒得快但当它被选中后它会一次性减去总权重相当于“吐出”一大截短时间内很难再被选中。而低权重节点虽然攒得慢但慢慢积累后在某一步会成为最大值于是获得一次被选中的机会。这个“高权重节点攒得快、选中后吐得多低权重节点攒得慢、但终有机会”的机制就是平滑的来源。连走7步之后所有节点的current都归0整轮清零下一轮又重新开始。所以我通常喜欢把这个算法理解为每个节点在整轮内被选中的次数由固定权重决定但每两次选中间隔的步数由动态权重调节。3.3 平滑加权轮询的Java实现基于上面这个思想Java实现很简洁。public class SmoothWeightedRoundRobin { private final ListNode nodes; private final int totalWeight; public SmoothWeightedRoundRobin(MapString, Integer weightedNodes) { if (weightedNodes null || weightedNodes.isEmpty()) { throw new IllegalArgumentException(weightedNodes must not be empty); } this.nodes weightedNodes.entrySet().stream() .map(e - new Node(e.getKey(), e.getValue())) .toList(); this.totalWeight this.nodes.stream().mapToInt(Node::weight).sum(); } public synchronized String next() { Node selected null; for (Node node : nodes) { node.current node.weight; if (selected null || node.current selected.current) { selected node; } } if (selected ! null) { selected.current - totalWeight; return selected.id; } return null; } private static class Node { private final String id; private final int weight; private int current; private Node(String id, int weight) { this.id id; this.weight weight; this.current 0; } } }使用方式也很简单SmoothWeightedRoundRobin lb new SmoothWeightedRoundRobin(Map.of( 192.168.1.10, 4, 192.168.1.11, 2, 192.168.1.12, 1 )); for (int i 0; i 14; i) { System.out.println(lb.next()); }输出会是两轮完整的A B A C A B A A B A C A B A周期权重比和分布平滑度都能验证。实现里有两个容易被看漏的细节需要提一下。第一next()方法我直接用了synchronized。因为current的更新不是单步操作它需要“先全部加权重再选最大再减总权重”这个完整流程。如果不用锁并发场景下会出现两个线程同时读到旧current值导致选出的节点重复。这里不能像纯轮询一样把计数器独立出来因为current的更新依赖上一次的完整状态必须保证原子性。好在节点数量通常不大每次遍历的开销极小加锁完全不影响性能。第二权重的类型。示例里用的是int我建议在无特殊需求时尽量用整数。浮点权重虽然看起来更灵活但经过多轮累加后会有浮点误差可能破坏“整轮归零”的性质。如果确实需要小数权重比如配置中心下发的权重是0.5、1.2这种可以先统一放大成整数再参与计算。3.4 为什么平滑是必要的普通加权轮询的“扎堆现象”讲完平滑加权轮询回头对比一下朴素的数组展开法就能看出问题在哪里。假设A权重4、B权重2、C权重1数组展开法是[A, A, A, A, B, B, C]挨个轮下去。前4个请求全部落在A上A瞬间被打满B要等到第5、第6个请求才有流量C更惨要等第7个请求才有机会。这种“流量脉冲”对后端是非常不友好的——它没有把压力平均摊到时间轴上只是把总量按权重分配了。平滑加权轮询的价值恰恰在于它在不改变总比例的前提下把请求尽量均匀地散布到时间线上。对于后端节点来说这意味着更平稳的CPU占用、更低的峰值压力、更少因为瞬时流量过高而触发的告警。我在实际项目中对比过这两种效果。同样权重配置普通加权轮询下高权重节点经常出现连续请求排队低权重节点则间歇性空闲换用平滑加权轮询后各节点的请求曲线明显更“碎”但每个节点的吞吐总和与权重比例完全一致。对于追求稳定性的线上服务这个差异有时候比提升一点吞吐量更重要。4. 放进真实业务选型、动态权重与常见坑4.1 直接给选型结论什么时候用哪个很多人看完算法会问那我到底应该用哪种我按实际场景给出一个比较直接的选型参考。场景推荐策略原因后端节点配置一致、请求处理时长稳定纯轮询实现最简平均分发即可后端节点配置有差异但权重基本固定平滑加权轮询按能力分配流量且分布平滑后端处理时长波动大需要实时感知最小连接数/自适应策略轮询类算法无法感知实时负载有缓存类状态尽可能命中同一节点一致性哈希轮询会把同一类请求打散到不同节点客户端RPC本地路由轮询/平滑加权轮询本地无需中心化网关实现轻量这里特别说一下“最小连接数”和“加权轮询”的区别。加权轮询的权重是预知的静态信息比如运维根据机器规格手动配置最小连接数则是实时反馈每次请求前统计各节点当前连接数找最少的发过去。后者看起来更智能但需要一个实时统计模块复杂度会高一个量级。很多系统最终的方案其实是两者结合用加权轮询保证基础比例再用健康检查和失败计数动态调整权重。4.2 如何加入健康检查和动态权重静态权重在节点长期健康的前提下是好用的但线上节点总有抖动。比较务实的做法是在加权轮询的基础上增加两个机制失败熔断和权重自动衰减。失败熔断的思路非常简单为每个节点维护一个连续失败计数器每次调用成功后清零连续失败超过阈值就把该节点临时摘除。摘除不一定要真的从列表里移除直接把它的动态权重临时置为0让它暂时选不中即可。权重自动衰减则更进一步不仅是“0或1”这种二值状态还可以根据节点的平均响应时间、CPU使用率等指标把配置权重动态映射为一个“实际可用权重”。比如某节点配置权重是5但最近一分钟平均RT比其他节点高了一倍那就临时把实际权重降到3或2等指标恢复后再回到5。实现时可以把节点的weight字段从final改成volatile由后台定时任务统一更新public class DynamicWeightedNode { private final String id; private final int configuredWeight; private volatile int currentWeight 0; private volatile int healthyWeight; private volatile int consecutiveFailures 0; public DynamicWeightedNode(String id, int configuredWeight) { this.id id; this.configuredWeight configuredWeight; this.healthyWeight configuredWeight; } public void markSuccess() { this.consecutiveFailures 0; } public void markFailure() { this.consecutiveFailures; if (this.consecutiveFailures 3) { this.healthyWeight 0; } } public void recover() { this.healthyWeight this.configuredWeight; this.consecutiveFailures 0; this.currentWeight 0; } }这里有一个我踩过的坑要重点提醒修改权重后平滑加权轮询里的totalWeight必须同步更新。因为算法在选中节点后是用总权重去减的如果总权重还是旧的新加入的高权重节点在后期会得到一个不正的补偿值导致比例失衡。所以在设计时要么让totalWeight在每次选中时动态累加计算要么维护一个线程安全的原子整数在所有节点权重变更后统一更新。4.3 常见坑权重漂移、计数器溢出、拿错了锁最后把我在实现和落地过程中遇到的几个坑集中说一下这些都是文档里很少写到的。第一个坑是权重漂移。平滑加权轮询依赖“整轮清零”的特性但在动态调整权重的场景里某一轮的中间某个时刻权重变了旧的current值可能让某个节点在新一轮里长期霸榜。我的处理方式是当节点的可用权重从0恢复为正数时把它的current重置为0如果只是数值上的小幅调整则保留现有current让它自然收敛一般经过最多totalWeight次选择后就能回到正确比例。第二个坑是计数器溢出。纯轮询里我用了AtomicInteger配合Math.floorMod来防溢出但如果你偷懒用了%溢出后就是线上P0事故。别问我为什么知道问就是见过凌晨三点的告警群。第三个坑是拿错了锁。有些同学在实现平滑加权轮询时会把current的更新放到AtomicInteger里逐次累加然后不加锁直接使用结果并发下出现同一个节点被连续选择多次。前面说过这个算法的状态是一个整体必须用同一把锁保护整个算法流程。节点数量很少锁开销完全可以忽略这里千万不要为了炫技去搞无锁化。还有一个不是技术坑、但同样重要的坑新节点加入后太容易被选中。如果某个新节点上线时current默认是0按照算法逻辑它会在第一次选择时获得完整权重而老节点可能已经积累了很多负值于是新节点会被连续选中几次。这其实不是bug而是算法特性但如果你希望新节点“温柔”地接入流量可以把它初始current设置为一个负的偏移量让它慢慢追上来。回到开头的压测场景。了解了加权轮询后我把那个内部网关的默认策略换成了平滑加权轮询单机权重按机器规格配置成4:2:1压测曲线立刻平缓了很多。如果你也打算在自己的客户端组件里落地这套算法我会建议你先想清楚终态节点上下线时权重怎么变、恢复时current要不要重置、totalWeight怎么维护。把这些边界条件想明白实现本身反而是最简单的那一步。