手写负载均衡这件事,听起来像是业务代码写多了自娱自乐,但真把它当回事做一遍的人,对分布式流量的理解完全不一样。很多时候我们一直在用现成组件——Nginx、LVS、各种注册中心自带的负载策略——出了问题只能对着文档调参数,根本不知道底层在发生什么。面试聊到负载均衡,也经常是一问就懂、一写就废。这篇内容就围绕“手写”展开,把轮询、随机、加权、平滑加权、最小连接数、一致性哈希这些常见算法逐个写一遍,结合我实际踩坑的经验,讲清楚它们的原理、适用场景和工程落地时的细节,适合想自己实现一个网关、做面试准备,或者单纯想把负载均衡原理吃透的人。
1. 为什么还要手写负载均衡算法
1.1 现成组件遍地都是,手写到底图什么
现在一个稍微成熟点的技术栈里,负载均衡的轮子少说有三层:最外层是 Nginx 这样的反向代理,中间有网关,微服务架构里还有各种注册中心内置的客户端负载。看起来根本不缺方案,似乎没必要自己造一个。
但真正动手做过的场景其实不少。比如你要写一个轻量级的内部网关,为了一个转发逻辑去部署一套 Nginx 集群,运维成本和资源成本都划不来。又比如公司自研的 RPC 框架,负载均衡是客户端侧的能力,你需要嵌入到服务发现流程里,这时候现成的组件帮不了你,只能自己写。再比如去做一些对路由有强定制需求的场景,例如灰度发布、多环境隔离、按调用来源分配集群,通用组件虽然有策略,但扩展起来往往很别扭。
还有一个很重要的场景是面试和团队能力建设。负载均衡是所有后端工程师都需要理解的分布式基础,手写一遍的过程里你要考虑并发安全、节点状态、权重变化、故障转移,这些思考很难通过背诵八股获得。团队里有人真正手写过,架构评审的时候讨论阈值、重试、倾斜这类话题,才是在同一个层面交流,而不是互相抛术语。
从性价比角度看,手写一套基础版的负载均衡并不难。核心逻辑只需要一个接口、一个选择器、一组节点列表,大概几百行代码就能跑起来一个可用的版本。它没法替代 Nginx 在那么大规模连接数下的表现,但在自己的业务系统里完全够用,而且你会获得对流量调度完全的控制力。
1.2 手写前先拆清楚负载均衡的核心问题
手写之前先把问题域拆清楚,不然写着写着就容易迷失在细节里。一个负载均衡组件,本质上是解决一件事:把一组请求按照某种策略分发到一组服务节点上。这个描述背后有四个必须想清楚的子问题:
节点从哪来。是静态配置还是从注册中心动态拉取?手写最简版本可以先做静态列表,但接口设计上要预留动态更新的能力,否则后面接注册中心时要重构。
节点状态如何判断。一个节点被选中了,但它真的能用吗?这就是健康检查的范畴。手写版至少要区分节点列表和可用节点列表,把健康检查产出的状态单独管理,而不是在算法代码里临时判断。
请求如何分发。这是算法的核心职责,也是最容易被当成全部工作量的部分。实际代码里这一块往往只是一次选择,选出节点后还有连接建立、超时、失败重试这些事,但算法这部分只关心“选谁”。
策略的调度维度。是按请求数平均分,按权重比例分,还是按某种资源水位分?常见的算法对应不同维度,没有银弹方案,选型取决于业务场景和流量特征。
把这些问题在脑子里过一遍之后,再看具体算法时思路会清晰很多。接下来逐个过。
2. 从最简单的轮询与随机开始
2.1 轮询算法:一个索引和一个取模
轮询是负载均衡里最容易理解、也最容易写错的算法。原理就是把节点排成一个列表,每次请求按顺序挑下一个。实现上最朴素的方式是维护一个计数器,每次请求给它加一,然后用计数器对节点总数取模。
public class RoundRobinLoadBalancer implements LoadBalancer { private final AtomicInteger index = new AtomicInteger(0); @Override public Node select(List<Node> nodes, Object context) { int size = nodes.size(); if (size == 0) throw new IllegalStateException("no available node"); int current = index.getAndIncrement(); if (current < 0) index.set(0); // 处理溢出 Node node = nodes.get(Math.abs(current % size)); return node; } }这里有一个细节值得说:计数器类型一定不能用普通的 int,多线程环境下轮询会乱掉,甚至导致数组越界。用AtomicInteger可以让自增操作具备原子性,但AtomicInteger默认是有符号的,请求量大时存在溢出变负数的问题。上面代码里加了溢出保护,虽然实际场景中要打到大几亿次请求才可能触发,但作为一个严谨的实现,这个边界条件值得处理。
轮询的优点是简单、无状态、负载分布最平均。缺点也很突出:它对节点之间的性能差异视而不见。如果一台老机器和一个新机器放在同一个列表里,老机器会收到和新机器相同数量的请求,性能差的那台会先扛不住。日志里轮询算法的表现是请求量完全平均,但节点的 CPU 和延迟完全不平均,这是轮询最大的坑。
所以轮询比较适合节点规格一致、请求处理成本接近的场景。做业务网关时如果后端服务做了容器化标准化,节点规格一致,轮询反而是一个很实用的默认策略。
2.2 随机算法:你以为的随机并不随机
随机算法的实现看起来更简单:生成一个随机数,对新节点列表长度取模,就选出了目标节点。
public class RandomLoadBalancer implements LoadBalancer { private final ThreadLocalRandom random = ThreadLocalRandom.current(); @Override public Node select(List<Node> nodes, Object context) { int size = nodes.size(); return nodes.get(random.nextInt(size)); } }注意不要用Math.random(),它内部是共享的原子变量,在高并发下会有竞争开销。JDK 提供的ThreadLocalRandom是线程隔离的,性能好得多。这个细节以前在压测中非常明显,同样是一亿次随机,用ThreadLocalRandom比Math.random()快了接近一个数量级。
随机算法的基础版有一个很反直觉的特点:当请求量少时,随机分配的负载不均匀。比如两个节点,十次请求,可能会出现 7:3 的分布,这是正常概率波动。要等请求量上到几百上千次,比例才逐渐逼近 5:5。
如果想在小流量下也保持相对均匀,可以在随机基础上加一个“平滑”思路——每次随机选一个起点,再在这个起点附近选择。这本质上变成了随机加局部偏移,但会引入更多复杂度。我的经验是,如果系统 QPS 低于几十,直接用随机问题不大,因为流量本身小,短暂的不均匀不会压垮节点;如果 QPS 很高,随机分布自然就均匀了,也不太需要担心。
随机算法的最大价值是简单,代码量极少,没有任何状态,天然适合多线程。很多 RPC 框架默认就用随机策略,配合权重调整,可以做出一个扩展性很好的基础实现。
3. 生产环境真正在用的加权策略
3.1 朴素加权轮询为什么会有突刺
实际生产环境里节点规格几乎不可能完全一致,这时候轮询和随机都需要一个升级版:给节点设置权重,让性能好的节点多承担一些流量。最直观的加权轮询实现是“展开法”:把每个节点按权重复制成多份,合成一个新列表,然后对展开后的列表做普通轮询。
public class WeightedRoundRobinLoadBalancer implements LoadBalancer { private final AtomicInteger index = new AtomicInteger(0); @Override public Node select(List<Node> nodes, Object context) { List<Node> expanded = expand(nodes); // 按权重展开 int current = index.getAndIncrement(); return expanded.get(Math.abs(current % expanded.size())); } }这种实现思路简单,但问题不少。第一,如果权重数字较大,展开列表会很大,浪费内存;第二,权重变化时要重建整个列表,并发环境下重建成本和内存抖动让人头疼;第三,也是最重要的,它会把流量“按窗口”打向同一个节点。
举个例子,三台节点权重分别是 5、1、1。展开后列表长度是 7,假设排布成 [A-A-A-A-A-B-C],轮询时前连续五次请求全打到 A,然后一次 B、一次 C。这不是“平滑”的分布,而是五连发后歇一会儿,对后端的连接池和线程池都不友好,容易出现流量突刺。
3.2 平滑加权轮询:Nginx 的经典实现
要解决突刺问题,业界有一个非常经典的实现,就是 Nginx 里的平滑加权轮询算法。它的核心不是列表展开,而是维护两个权重:固定权重(weight)和当前权重(currentWeight)。
算法过程是三步:第一步,每个节点当前权重加上自身固定权重;第二步,选出当前权重最大的节点作为本次目标;第三步,被选中的节点当前权重减去所有节点的固定权重之和。
public class SmoothWeightedRoundRobinLoadBalancer implements LoadBalancer { private Map<Node, Integer> currentWeights = new ConcurrentHashMap<>(); @Override public synchronized Node select(List<Node> nodes, Object context) { int totalWeight = 0; Node selected = null; int maxCurrent = Integer.MIN_VALUE; for (Node node : nodes) { int weight = node.getWeight(); int current = currentWeights.getOrDefault(node, 0) + weight; currentWeights.put(node, current); totalWeight += weight; if (current > maxCurrent) { maxCurrent = current; selected = node; } } if (selected != null) { int updated = currentWeights.get(selected) - totalWeight; currentWeights.put(selected, updated); } return selected; } }手推一遍权重 5:1:1 的场景就能看出它为什么平滑:
第 1 次:初始全 0,加权重后 [5,1,1],选 A,A 减总权重 7,得到 [-2,1,1] 第 2 次:加权重后 [3,2,2],选 A,A 减 7,得到 [-4,2,2] 第 3 次:加权重后 [1,3,3],选 B,B 减 7,得到 [1,-4,3] 第 4 次:加权重后 [6,-3,4],选 A,A 减 7,得到 [-1,-3,4] 第 5 次:加权重后 [4,-2,5],选 C,C 减 7,得到 [4,-2,-2] 第 6 次:加权重后 [9,-1,-1],选 A……
最终序列是 A、A、B、A、C、A……权重比 5:1:1 完全保持,但 A 不再连续扎堆,而是被 B、C 隔开。这个算法只使用常量级额外状态,时间和空间复杂度都是 O(n),n 是节点数,非常适合在实际代码中落地。
一个经验:上面代码中为了简洁用了 synchronized,实际上可以对 currentWeights 更新用原子操作或者给每个节点一个单独的 AtomicInteger,避免在极端场景下锁竞争成为瓶颈。如果流量没有达到百万级并发,synchronized 足够用了,但要注意这个实现是有状态的,不能在多线程下直接共享同一个 LoadBalancer 实例而不加锁。
4. 动态负载:最小连接数算法的实战细节
4.1 最小连接数的实现与适用场景
轮询和随机都是“预定策略”,它们不关心节点此刻的真实负载。但在请求处理时间差异很大的场景里,这两种策略会很坑。比如有的接口做的是内存计算,3 毫秒返回,有的接口要查一个慢数据库,300 毫秒返回。如果轮询分发,慢接口那个节点会累积大量堆积请求,而快接口的节点一直空闲。这时候就需要动态感知节点负载的算法,最小连接数就是这么被设计出来的。
最小连接数的核心思路是:统计每个节点当前正在处理的请求数(活跃请求数),每次选择活跃数最少的节点。实现上需要在调用前后对计数器做增减。
public class LeastActiveLoadBalancer implements LoadBalancer { private final ConcurrentHashMap<Node, AtomicInteger> activeCounts = new ConcurrentHashMap<>(); @Override public Node select(List<Node> nodes, Object context) { Node selected = nodes.get(0); int minActive = Integer.MAX_VALUE; for (Node node : nodes) { int active = getActiveCount(node); if (active < minActive) { minActive = active; selected = node; } } AtomicInteger count = getCounter(selected); count.incrementAndGet(); return selected; } public void release(Node node) { getCounter(node).decrementAndGet(); } }这里有一个很重要的细节:统计的“连接数”不是指 TCP 连接数,而是业务层面的活跃请求数。在网关场景里,一个 TCP 连接上会跑很多 HTTP 请求,如果用 TCP 连接数作为指标,那连接复用得越多看起来越空闲,完全失真。所以统计时机应该在请求进入时加一、请求结束时减一,统计的是正在处理的请求。
4.2 最小连接数的坑:并发一致性、计数器泄漏
实现最小连接数踩坑概率最高的地方是计数器不准。并发场景下,多个线程同时读到某个节点活跃数为 0,全都选择它,计数器就会快速上升。实际上在高并发下这种策略天然存在“羊群效应”,因为它只看到过去的状态,不能预判未来。一个相关优化是,节点选择时不只看活跃数绝对值,而是把权重加进去:计算activeCount / weight,比值最小的节点胜出,这样高权重节点在同等活跃数下优先被选。
计数器泄漏也是容易翻车的点。如果请求在处理过程中异常中断,比如客户端断连,release没被调用,对应节点的计数器就永远少不回去,调用多了节点会被彻底“饿死”。解决方法是把计数逻辑放进 try-finally 里,确保释放逻辑一定执行:
try { Node node = balancer.select(nodes, ctx); try { return invoke(node, request); } finally { balancer.release(node); } } catch (Exception e) { // 处理调用异常 }还有一个实际经验是,最小连接数并不适合所有场景,特别是处理时间非常短的请求。短任务下活跃数变化极快,算法频繁切换目标节点,反而增加建立连接的开销。更好的方式是在调用链路上做一次熔断控制:节点活跃数超过某个阈值后直接跳过该节点,而不是简单选最小。这本质上是从“选最小的”升级为“排除过载的,再从剩余的里选”,能显著减少极端场景下的错误率。
5. 一致性哈希:缓存场景下的关键算法
5.1 哈希取模的问题:节点一变,缓存全乱
前面几种算法核心目标是“把负载摊平”,但有些场景的目标不是摊平,而是“把相同 key 的请求稳定打到同一个节点上”。最典型的就是缓存场景:网关把某个用户的请求路由到某台缓存节点,希望后续请求仍然命中同一台。
最直观的实现是 hash(key) % N,但这种朴素取模有致命问题:节点数量一旦从 3 变成 4,绝大多数 key 的映射关系都会变。原来存在 0 号节点的 key,现在算出来的 hash 值可能指向 1 号、2 号,缓存全部失效,请求直接穿透到数据库,这就是缓存雪崩的触发原因之一。
5.2 一致性哈希原理与虚拟节点实现
一致性哈希的思路是:把所有哈希值组织成一个环,节点和请求都通过哈希函数映射到环上,请求顺着环找到离自己最近的节点。这样节点数量变化时,只有环上对应区间的 key 会重新映射,其他 key 的归属不受影响。
实现时用 Java 的话,最方便的数据结构是TreeMap,它天然支持“找大于等于某个 key 的最小值”这种操作。
public class ConsistentHashLoadBalancer implements LoadBalancer { private final TreeMap<Integer, Node> ring = new TreeMap<>(); private final int virtualNodeCount; public ConsistentHashLoadBalancer(List<Node> nodes, int virtualNodeCount) { this.virtualNodeCount = virtualNodeCount; for (Node node : nodes) { addNode(node); } } private void addNode(Node node) { for (int i = 0; i < virtualNodeCount; i++) { int hash = hash(node.getId() + "#" + i); ring.put(hash, node); } } @Override public Node select(List<Node> nodes, Object context) { String key = String.valueOf(context); int hash = hash(key); Map.Entry<Integer, Node> entry = ring.ceilingEntry(hash); if (entry == null) entry = ring.firstEntry(); // 环形回绕 return entry.getValue(); } }一致性哈希刚写出来时有一个容易被忽视的坑:如果不加虚拟节点,节点数量很少时哈希分布极度不均匀。比如只有两个节点,其中一个可能占了环上 80% 的区间,流量倾斜非常明显。加上虚拟节点后,每台真实节点映射多个位置,分布会平滑很多。一个经验值参考:在节点数少于 10 的场景下,虚拟节点数配置在 150~200 左右,流量标准差能降到 5% 以内;节点多时可以适当减少虚拟节点数量以节省内存。
另一个实际问题是,使用一致性哈希时请求的 key 选择要慎重。按用户 ID 路由到某个节点时,如果一个用户本身是大客户,流量全部打到某个节点,仍然会造成热点。热点场景下可能需要两层策略:外层一致性哈希,内层再拆分成更细的 key 粒度,比如用户 ID 加业务线组合。
6. 手写实现中的工程化细节
6.1 策略模式 + 优雅扩展的设计思路
手写负载均衡最容易写坏的是把算法逻辑和调用逻辑耦合在一起。一种合理的设计是定义统一接口,算法之间互不干扰,调用方只需要面向接口编程。
public interface LoadBalancer { Node select(List<Node> nodes, Object context); }这个接口看起来极其简单,但要注意一个扩展点:context参数。轮询和随机不需要用到它,一致性哈希和分组路由需要,所以把它抽象出来非常关键。后续要新增“按地区路由”“按用户灰度”等策略时,不用改接口,只需要从 context 里取信息。
节点的抽象也要做对。Node 至少有 id、地址、权重、状态这几个字段,状态字段要允许动态更新。提前把健康检查的结果集中更新到 Node 的状态上,算法实现里就只用过滤“可用”节点,不需要每个算法都关心健康逻辑。
public class Node { private String id; private String host; private int port; private int weight; private volatile boolean healthy; // getter / setter }顺序上,先过滤不可用节点,再执行策略选择,这个流程应该安排在 LoadBalancer 外层统一处理,不需要每个算法重复实现。这样做的好处是:后续加熔断、限流、动态权重时只改外层编排逻辑,不用动任何具体算法。
6.2 并发安全与节点动态更新的处理
手写负载均衡的并发问题很隐蔽。轮询用AtomicInteger解决了计数器安全,但节点列表是可变的:某个节点挂掉后被移除、新节点上线被添加,这些操作和正在进行的“选节点”操作必须是线程安全的。
我的做法是节点列表用CopyOnWriteArrayList或“不可变快照 + 定时刷新”的方式维护。每次获取列表时取一个不可变引用,算法里对列表进行任何操作都不影响其他线程。这样设计下,节点变更不必和请求选择加同一把锁,性能损耗很小,代码逻辑也更清晰。
private volatile List<Node> nodes = new ArrayList<>(); // 刷新节点(由单独线程定时执行或注册中心回调触发) public void refreshNodes(List<Node> newNodes) { this.nodes = newNodes; // volatile 保证可见性 } @Override public Node select(Object context) { return loadBalancer.select(nodes, context); }权重变更也是同样的处理方式。平滑加权轮询里,某个节点权重调整后,它的 currentWeight 要不要重置?我的经验是不要直接清 0,而是保留当前 currentWeight,让新权重在下一次选择中自然参与计算,这样不会在调整瞬间产生流量突刺。如果直接把 currentWeight 重置,下一轮选择时新获得大权重的节点会被瞬间选中,实际上并不平滑。
6.3 健康检查与故障转移的联动
负载均衡算法选择的只是“当下状态最好的节点”,如果节点实际已经挂了,选择结果就是失效的。所以手写实现里必须配套健康检查和故障转移机制,否则再精妙的算法也只是纸上谈兵。
健康检查可以做成主动探测和被动统计的组合。主动探测是单独的定时线程,每隔几秒对节点发送一个 HTTP 探测请求,连续失败超过阈值就把节点标记为不健康;被动统计则是调用失败时给节点失败次数加一,失败率高时降级节点。
被动统计务必要加上恢复机制,不能一个节点失败几次就永远被移除。我见过的最常见的坑是:一张动态权重表,请求失败就减少权重,但权重减下去之后没有恢复策略,最终导致健康的节点全部莫名变成 0。
故障转移和算法是两层逻辑。算法负责选节点,故障转移负责“算法选的节点调用失败后怎么办”。通常有两种策略:快速失败和重试其他节点。如果业务对可用性要求高,可以允许重试 1~2 次其他节点,但要注意接口的幂等性——不确定是否执行成功的请求只能快速失败,不能盲目重试。
7. 常见问题与排查技巧实录
7.1 线上常见的流量倾斜现象
手写完负载均衡接入真实流量后,最常见的现象是流量明显向某个节点倾斜。排查思路不能只盯着负载均衡算法,要从链路角度来检查。
先看节点状态标记,确认是不是某个节点被健康检查误判。比如某个节点偶尔超时一次就被标记为不健康,流量全被分走,另一个节点压力暴涨。这种情况的修复方式是调大失败阈值,并且把健康检查的连调改为“滑动窗口内失败率超过比例才摘除”。
再看权重是不是被动态修改过。如果数据库里的权重配置和线上实际生效的权重不一致,通常是因为配置缓存没刷新。像平滑加权轮询这类有状态算法,权重变化时机不当也可能导致一段时间的偏斜,所以权重配置最好只做低频更新,避免在高峰期高频调整。
还有一个容易忽略的点:请求 context 的 key 分布。一致性哈希场景下如果 key 是订单号这类业务字段,分布往往和用户行为强相关。热点订单会让某个节点始终在被选中,这不是算法问题,而是 key 的选择粒度问题。
7.2 典型问题速查表
| 现象 | 可能原因 | 检查方式 | 解决方案 |
|---|---|---|---|
| 某个节点 QPS 明显偏高 | 权重配置过大,一致性哈希 key 热点,健康检查误判导致其他节点被摘除 | 查看节点实际权重、健康状态标记和节点处理请求日志 | 核对配置中心,调整 key 拆分粒度,修正健康检查阈值 |
| 轮询模式下节点请求数不一致 | 有状态算法实例没加锁,每个实例各自维护计数器 | 检查 LoadBalancer 实例是否被多线程共享 | 原子类 + 必要的锁保护,确认实例单例 |
| 平滑加权不生效,总是同一节点打头 | currentWeight 被重置或误用了朴素加权轮询 | 检查是否实现了三步骤流程 | 按固定权重先加、选最大、再减总权重的方式实现 |
| 节点从列表中下线后,流量仍持续进入 | DNS 缓存、长连接复用、服务发现列表刷新延迟 | 查看调用方连接池和目标节点访问日志 | 缩短服务发现刷新周期,设置长连接空闲回收 |
| 请求失败但节点计数不减 | 计数器没有放进 finally 释放 | 检查调用链路上的异常分支 | try-finally 或 try-with-resources 释放计数 |
7.3 手写负载均衡的性能观察方向
性能方面,负载均衡算法本身的耗时通常不是瓶颈,最简单的一次选择也就微秒级。真正影响整体性能的是并发设计和连接管理。
用锁实现的算法要重点观察锁竞争情况。压力测试时如果 QPS 上不去,先看一下线程栈是不是大量卡在负载均衡的锁上。平滑加权轮询用 synchronized 在并发超过十万的情况下会有明显锁竞争,可以考虑给每个节点单独维护 AtomicInteger,选择过程改成无锁的原子操作。
节点列表的更新频率也会影响性能。如果节点上下线和请求选择共用同一个锁,那么节点频繁变动会拖慢整体流量。通过 volatile 快照模式或者 CopyOnWrite 机制把节点更新的读开销降到几乎为零,是一个值得投入时间的优化。
先用压测确认负载均衡选择耗时占整体请求耗时的比例,如果低于 1%,就不要做过度的优化,把精力放在连接池、超时设置、重试策略这些真正影响可用性的地方。我见过太多团队在负载均衡选择函数上抠微秒,却忽略了节点故障时整个调用链路的可靠性设计。
8. 手写之外:框架实现里的借鉴思路
自己动手写一遍之后再去看市面上成熟的负载均衡实现,会打开完全不同的视角。以前看官方文档只觉得参数多,真正写过之后才知道每个参数背后都在解决一个真实问题。
比如很多框架的负载均衡都提供了“自适应”或“动态权重”策略,本质上是对最小连接数的升级版。它们不只统计活跃请求数,还会采集节点 CPU 使用率、内存压力、甚至 GC 频率,再综合成一个分数进行调度。手写了基础版之后,你会很自然地想到这样的扩展方向,也能更容易理解这些策略为什么这么设计。
再看权重设计,框架里的权重往往不只是静态配置。多因素加权——比如服务端上报的健康分、调用链路上的错误率、网络延迟——会被合成到一个乘数里,最终得出的才是动态权重。这个思路都可以在手写版本里逐步迭代加进来。
我个人最大的体会是,手写一次负载均衡相当于把分布式系统里“流量调度”这块最核心的知识打了一遍底。之后不管用哪个框架,遇到一些奇特的流量问题,心里都有一个清晰的定位路径:第一反应是先判断是节点状态问题还是算法分配问题,再根据具体情况往对应的策略和参数上排查。这种判断力,单纯看文档真的很难练出来。