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