负载均衡器:算法与适用场景

1. 概述

当你的系统后面有多个实例时,问题总是:新请求应该进入哪个实例?本文将解释常见的负载均衡算法、它们如何通过 Java 示例代码模拟工作,以及如何为不同类型的工作负载选择合适的算法。读完后,你将了解何时使用轮询就足够了,何时需要最少连接或一致哈希。

2. 什么是负载均衡器

负载均衡器(LB)是位于客户端和一组服务器(后端池)之间的组件,其职责是分配请求,使得:

  • 没有服务器在过载的同时其他服务器闲置。
  • 系统能够承受后端服务器宕机(故障转移)。
  • 可以水平扩展(添加/移除实例),客户端无需感知。

LB 可以是软件(Nginx、HAProxy、Envoy)、硬件(F5)或托管服务(AWS ALB/NLB、GCP Load Balancer)。按照工作层级,LB 主要分为两类:

  • Layer 4(传输层):基于 IP/端口进行路由,不读取 HTTP 内容。速度快,开销小。
  • Layer 7(应用层):可以读取 header、path、cookie 等,因此路由更智能(例如根据路径 /api/orders 路由),但 CPU 消耗更高。

注意: 选错层级是常见的设计错误。如果你需要按 URL 路径路由或基于 cookie 的会话保持,Layer 4 无法做到——必须使用 Layer 7。

3. 常见的负载均衡算法

3.1. 轮询(Round Robin)

请求依次通过每台服务器,按照固定顺序循环,到达列表末端时从开头重新开始。

public class RoundRobinBalancer {

    private final List<String> servers;
    private final AtomicInteger index = new AtomicInteger(0);

    public RoundRobinBalancer(List<String> servers) {
        this.servers = servers;
    }

    public String nextServer() {
        int i = index.getAndIncrement() % servers.size();
        return servers.get(Math.abs(i));
    }
}

getAndIncrement 在多线程间安全地增加计数器,% servers.size() 将索引保持在有效范围内。对于 3 个服务器 [A, B, C],连续调用 nextServer() 的结果序列是:

A, B, C, A, B, C, ...

轮询很简单,不需要了解服务器状态。缺点是:如果请求的负载差异很大(一个请求处理 1ms,另一个 500ms),处理重请求的服务器会逐渐积累更多的负载。

3.2. 加权轮询(Weighted Round Robin)

每个服务器有一个“权重”反映其处理能力(CPU、RAM)。更强大的服务器会获得更多请求。

public class WeightedRoundRobinBalancer {

    private record WeightedServer(String name, int weight) {}

    private final List<WeightedServer> servers;
    private int currentIndex = -1;
    private int currentWeight = 0;

    public WeightedRoundRobinBalancer(Map<String, Integer> weightedServers) {
        this.servers = weightedServers.entrySet().stream()
            .map(e -> new WeightedServer(e.getKey(), e.getValue()))
            .toList();
    }

    public synchronized String nextServer() {
        int maxWeight = servers.stream().mapToInt(WeightedServer::weight).max().orElse(1);
        int gcdWeight = servers.stream().mapToInt(WeightedServer::weight)
            .reduce((a, b) -> java.math.BigInteger.valueOf(a).gcd(java.math.BigInteger.valueOf(b)).intValue())
            .orElse(1);

        while (true) {
            currentIndex = (currentIndex + 1) % servers.size();
            if (currentIndex == 0) {
                currentWeight -= gcdWeight;
                if (currentWeight <= 0) {
                    currentWeight = maxWeight;
                }
            }
            if (servers.get(currentIndex).weight() >= currentWeight) {
                return servers.get(currentIndex).name();
            }
        }
    }
}

这是 Nginx 中使用的“平滑加权轮询”算法:权重高的服务器被选中的频率更高,但仍分布均匀,不会连续集中。对于配置 {A: 5, B: 1, C: 1},选择序列大约是:

A, A, B, A, C, A, A

3.3. 最少连接(Least Connections)

选择当前活跃连接数最少的服务器。当请求处理时间差异很大时很合适。

public class LeastConnectionsBalancer {

    private final Map<String, AtomicInteger> activeConnections = new ConcurrentHashMap<>();

    public LeastConnectionsBalancer(List<String> servers) {
        servers.forEach(s -> activeConnections.put(s, new AtomicInteger(0)));
    }

    public String nextServer() {
        return activeConnections.entrySet().stream()
            .min(Comparator.comparingInt(e -> e.getValue().get()))
            .map(Map.Entry::getKey)
            .orElseThrow();
    }

    public void onRequestStart(String server) {
        activeConnections.get(server).incrementAndGet();
    }

    public void onRequestEnd(String server) {
        activeConnections.get(server).decrementAndGet();
    }
}

nextServer() 扫描映射并获取连接数最少的服务器。onRequestStart/onRequestEnd 必须成对调用(例如在 try/finally 中),以防止数据偏差。

注意: Least Connections 需要 LB 追踪状态(有状态),如果有多个 LB 实例并行运行,会增加同步成本。Round Robin 则完全无状态。

3.4. 最短响应时间(Least Response Time)

Least Connections 的变体,选择(连接数 × 平均响应时间)最低的服务器。更复杂,但更准确地反映实际负载,因为考虑了处理延迟。

public record ServerStats(String name, int activeConnections, double avgResponseTimeMs) {
    double score() {
        return activeConnections * avgResponseTimeMs;
    }
}

public String pickFastest(List<ServerStats> stats) {
    return stats.stream()
        .min(Comparator.comparingDouble(ServerStats::score))
        .map(ServerStats::name)
        .orElseThrow();
}

avgResponseTimeMs 通常使用指数移动平均线计算,在每次响应返回后更新。此算法适合混合快速 API(缓存查询)和慢速 API(报表、繁重计算)的系统。

3.5. IP 哈希 / 一致性哈希(IP Hash / Consistent Hashing)

在需要会话保持时使用——同一客户端始终被路由到同一服务器(例如服务器保持内存中的会话,或本地缓存)。

public class ConsistentHashBalancer {

    private final SortedMap<Long, String> ring = new TreeMap<>();
    private static final int VIRTUAL_NODES = 100;

    public ConsistentHashBalancer(List<String> servers) {
        for (String server : servers) {
            for (int i = 0; i < VIRTUAL_NODES; i++) {
                ring.put(hash(server + "#" + i), server);
            }
        }
    }

    public String serverFor(String clientKey) {
        long hash = hash(clientKey);
        SortedMap<Long, String> tail = ring.tailMap(hash);
        Long nodeKey = tail.isEmpty() ? ring.firstKey() : tail.firstKey();
        return ring.get(nodeKey);
    }

    private long hash(String input) {
        return input.hashCode() & 0xFFFFFFFFL;
    }
}

每个服务器被分散成多个“虚拟节点”在哈希环(ring)上,以实现更均匀的分布。serverFor 沿顺时针方向找到环上最近的点,对应于客户端的键(通常是 IP 或用户 ID)。最大的优势是:当添加/移除服务器时,只有少量键需要重新映射——不像普通哈希(hash(key) % n)那样完全重新分配。

快速测试以确认相同的键始终返回相同的服务器:

@Test
void sameClientKey_alwaysRoutesToSameServer() {
    ConsistentHashBalancer balancer =
        new ConsistentHashBalancer(List.of("server-1", "server-2", "server-3"));

    String first = balancer.serverFor("user-42");
    String second = balancer.serverFor("user-42");

    assertThat(first).isEqualTo(second);
}

3.6. 随机与两选(Random and Power of Two Choices)

Random 简单地随机选择——对于大量请求效率接近 Round Robin,但不需要保持状态。两选(Power of Two Choices)(P2C)是改进版本:随机选择 2 个服务器,然后选择其中连接数较少的那个。该算法已被证明能显著降低“尾延迟”相比纯随机,且无需像 Least Connections 那样追踪整个集群。

public String pickWithPowerOfTwo(List<String> servers, Map<String, Integer> loadMap, Random random) {
    String candidate1 = servers.get(random.nextInt(servers.size()));
    String candidate2 = servers.get(random.nextInt(servers.size()));
    return loadMap.get(candidate1) <= loadMap.get(candidate2) ? candidate1 : candidate2;
}

4. 健康检查与故障转移

选择服务器的算法只有在 LB 知道哪些服务器是“活着的”时才有意义。健康检查通常有两种类型:

  • 主动检查:LB 定期向 /health 等端点发送请求。
  • 被动检查:LB 在连续收到来自实际请求的错误/超时时将服务器标记为“宕机”。

Nginx 中的主动健康检查配置示例:

upstream backend_pool {
  server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
  server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
}

max_fails=3 表示在连续 3 次失败后,服务器在 fail_timeout 秒内被从池中移除,然后重新尝试。没有健康检查,LB 会继续向已宕机的服务器发送请求,直到客户端超时——体验非常糟糕。

5. 对比表:何时使用哪种算法

算法 何时适合 何时应避免
轮询 服务器配置相同,请求负载相当(简单的 REST API) 服务器配置不同,请求负载差异大
加权轮询 集群中服务器性能不同(逐步迁移到新实例) 需要根据实时负载进行响应
最少连接 请求处理时间差异很大(文件上传、长轮询) 多个 LB 实例,状态同步成本高
最短响应时间 混合快速/慢速 API,需要优化总体延迟 系统较小,缺乏足够的稳定响应时间数据
IP 哈希 / 一致性哈希 需要会话保持、缓存局部性、按客户端分片 服务器频繁快速扩缩容(虽然重新映射影响已减少,但仍有开销)
随机 / 两选 超大规模系统,想避免追踪集中状态的开销 需要在短时间内保证绝对均匀分布

6. 负载均衡设计中的常见错误

  • 为不同质的工作负载使用轮询:几个服务器收到所有重请求,而其他服务器闲置——导致隐性的负载不均,很难通过总体监控检测到(平均 CPU 仍然“正常”)。
  • 当客户端在 NAT/共享代理后面时使用 IP 哈希进行会话保持:多个实际客户端被合并为一个 IP,导致一个服务器异常地承受大量负载。在这种情况下应该使用 Layer 7 中基于 cookie 的会话保持,而不是 IP 哈希。
  • 缺少健康检查或检查错误的端点:/health 返回 200 OK 但不检查数据库连接——服务器按 LB 的说法是“活着的”,但实际上无法处理请求。
  • 缩容时忽视连接耗尽:立即关闭服务器会导致正在处理的请求丢失。需要等待当前连接完成(优雅关闭),才能将服务器从池中移除。
  • LB 本身是单点故障:如果只有一个 LB 实例,它就成为整个系统的单点故障。需要至少 2 个 LB 实例,配合 DNS 故障转移或虚拟 IP(keepalived)。

7. 结论

负载均衡器不仅仅是“均匀分配请求”——选择正确的算法取决于工作负载的特性:是否同质、是否需要会话保持、系统是否有足够的能力进行集中状态追踪。轮询和加权轮询适合大多数简单场景;最少连接和最短响应时间很好地处理变动的工作负载;一致性哈希在集群变化时稳健地解决会话保持和分片问题。比起算法本身,正确的健康检查和故障转移才是决定系统真正可靠性的因素。

system-designload-balancerscalabilitydistributed-systemsbackend