手写负载均衡这件事听起来像是业务代码写多了自娱自乐但真把它当回事做一遍的人对分布式流量的理解完全不一样。很多时候我们一直在用现成组件——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(ListNode 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(ListNode 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(ListNode nodes, Object context) { ListNode 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 MapNode, Integer currentWeights new ConcurrentHashMap(); Override public synchronized Node select(ListNode 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]选 AA 减总权重 7得到 [-2,1,1] 第 2 次加权重后 [3,2,2]选 AA 减 7得到 [-4,2,2] 第 3 次加权重后 [1,3,3]选 BB 减 7得到 [1,-4,3] 第 4 次加权重后 [6,-3,4]选 AA 减 7得到 [-1,-3,4] 第 5 次加权重后 [4,-2,5]选 CC 减 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 ConcurrentHashMapNode, AtomicInteger activeCounts new ConcurrentHashMap(); Override public Node select(ListNode 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 TreeMapInteger, Node ring new TreeMap(); private final int virtualNodeCount; public ConsistentHashLoadBalancer(ListNode 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(ListNode nodes, Object context) { String key String.valueOf(context); int hash hash(key); Map.EntryInteger, 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(ListNode 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 ListNode nodes new ArrayList(); // 刷新节点由单独线程定时执行或注册中心回调触发 public void refreshNodes(ListNode 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 频率再综合成一个分数进行调度。手写了基础版之后你会很自然地想到这样的扩展方向也能更容易理解这些策略为什么这么设计。再看权重设计框架里的权重往往不只是静态配置。多因素加权——比如服务端上报的健康分、调用链路上的错误率、网络延迟——会被合成到一个乘数里最终得出的才是动态权重。这个思路都可以在手写版本里逐步迭代加进来。我个人最大的体会是手写一次负载均衡相当于把分布式系统里“流量调度”这块最核心的知识打了一遍底。之后不管用哪个框架遇到一些奇特的流量问题心里都有一个清晰的定位路径第一反应是先判断是节点状态问题还是算法分配问题再根据具体情况往对应的策略和参数上排查。这种判断力单纯看文档真的很难练出来。