微服务负载均衡:L4/L7、P2C、EWMA、Maglev与长连接治理
负载均衡不是简单轮询 IP。一次选择通常先经过服务发现、健康过滤、局部性、权重、慢启动、算法和连接池;请求失败后还可能触发被动剔除与重试。选择发生在连接级、请求级还是 HTTP/2 Stream 级,决定了扩容是否立即生效和流量是否均匀。
一、学习目标
学完后应能:
- 区分 DNS、L4、L7、客户端和服务端负载均衡。
- 画出一次微服务实例选择和请求执行链。
- 比较 Random、Round Robin、Smooth Weighted RR、Least、P2C、EWMA、Hash 与 Maglev。
- 解释连接级均衡、HTTP Keep-Alive、HTTP/2/gRPC 长连接倾斜。
- 设计局部性、慢启动、Outlier Detection、重试和连接排空。
- 定位扩容不生效、单实例过载、权重不准和流量跨区问题。
二、DNS、L4 与 L7
| 层 | 依据 | 能看到什么 | 典型能力 |
|---|---|---|---|
| DNS | 域名解析 | 域名和记录 | 多IP、地域解析、TTL |
| L4 | IP、端口、协议连接 | TCP/UDP,不理解HTTP语义 | 高性能连接转发、NAT/DSR |
| L7 | HTTP/gRPC等应用协议 | Host、Path、Header、状态码 | 路由、鉴权、重试、灰度、请求级LB |
| Client-side | 客户端本地实例快照 | 服务名、实例元数据、调用结果 | 少一跳、本地算法、语言SDK治理 |
DNS 返回多个地址不表示客户端每次请求都轮换:解析结果会被操作系统、JVM、代理和连接池缓存;一个 Keep-Alive 连接还能持续复用旧地址。
L4 通常在新连接建立时选择后端;一个连接上的全部请求仍到同一后端。L7 可以在请求级重新选择,但 HTTP/2/gRPC 代理是否按 Stream 均衡取决于代理终止连接的位置和实现。
三、一次完整实例选择链
flowchart TD
A["获得服务实例或Endpoint快照"] --> B["过滤未Ready、隔离和熔断实例"]
B --> C["按地域、可用区和版本筛选"]
C --> D["计算静态权重和慢启动权重"]
D --> E["负载均衡算法选实例"]
E --> F["连接池获取或建立连接"]
F --> G["发送请求并记录Active、延迟和结果"]
G --> H{"成功或可重试失败"}
H -- "成功" --> I["更新统计并返回"]
H -- "失败" --> J["被动异常统计、预算内换实例"]候选集若已经错误,再好的算法也无效。负载均衡依赖服务发现快照、Readiness、路由标签和版本;统计又通常是每个客户端/代理的局部视图,不是全局实时真相。
四、到底在均衡连接、请求还是Stream
4.1 连接级
Kubernetes Service、很多四层负载均衡器和 NAT 数据面在新 TCP 连接时选择 Pod。HTTP/1.1 Keep-Alive 在同一连接复用多个请求,所以请求数不会随 Pod 数自动均匀。
4.2 请求级
客户端连接池可为每次请求选择不同连接/目标,L7 代理终止上游协议后也可逐请求选后端。请求级更灵活,但需要解析协议并维护更多统计。
4.3 HTTP/2/gRPC Stream
一个 HTTP/2 连接承载多个并发 Stream。如果客户端只建立一个 gRPC Channel/Subchannel,所有 RPC 可能长期落在少数实例。扩容后需要 Resolver 更新、创建新 Subchannel、连接年龄/排空或代理 Stream 级选择,才能逐步再平衡。
五、Random 与 Round Robin
Random
候选足够多且请求成本接近时,随机长期近似均匀,实现简单、状态少。短时间窗口仍会波动;权重随机要构建累计权重区间,不能简单 random % weight。
Round Robin
轮询按顺序选择实例,适合能力和请求成本接近的节点。它不看实例当前未完成请求:一个慢请求可能让某实例积压,而轮询仍继续发流量。
Weighted Round Robin
按权重比例分配。朴素连续发送 A,A,A,A,A,B 会形成突发;平滑加权轮询每次给所有节点累加有效权重,选择当前权重最大者,再减去总权重,使高权重请求分散在序列中。
六、JDK 8 Demo:平滑加权轮询
import java.util.Arrays;
import java.util.List;
public final class SmoothWeightedRoundRobin {
static final class Node {
final String name;
final int weight;
int current;
Node(String name, int weight) {
if (weight <= 0) throw new IllegalArgumentException("weight必须大于0");
this.name = name;
this.weight = weight;
}
}
static synchronized Node select(List<Node> nodes) {
if (nodes.isEmpty()) throw new IllegalArgumentException("节点不能为空");
Node best = null;
int total = 0;
for (Node node : nodes) {
node.current += node.weight;
total += node.weight;
if (best == null || node.current > best.current) best = node;
}
best.current -= total;
return best;
}
public static void main(String[] args) {
List<Node> nodes = Arrays.asList(new Node("A", 5), new Node("B", 1));
for (int i = 0; i < 12; i++) {
System.out.print(select(nodes).name);
}
}
}输出应在 12 次中约包含 10 个 A、2 个 B,并比简单连续权重更平滑。Demo 的全局 synchronized 适合教学;生产实现要考虑实例快照变化、并发性能、权重恢复和无锁/分段状态。
七、Least Connections、Least Active 与 Least Request
- Least Connections:选择连接数少的实例,适合连接代表主要成本的场景;HTTP/2 一个连接可承载大量 Stream,连接数不再等于负载。
- Least Active/Request:选择当前未完成请求少的实例,更接近请求压力;不同请求成本差异仍会误导。
- 加权 Least Request:结合实例容量权重,避免高配和低配实例被同等对待。
客户端看到的 Active 通常只是“本客户端发出的未完成请求”,不是实例全局 Active。多个客户端可能同时认为同一个实例最空闲并形成羊群效应。
八、P2C:Power of Two Choices
P2C 随机抽两个候选,比较 Active、延迟或综合负载,选择更优者。它只读取常数个节点状态,避免全量扫描,并显著降低随机分配的最大负载,适合大规模节点集合。
flowchart TD
A["从健康候选随机抽两个"] --> B["读取两者Active、EWMA或权重"]
B --> C{"哪个综合分数更低"}
C -- "候选A" --> D["选择A并增加本地Active"]
C -- "候选B" --> E["选择B并增加本地Active"]
D --> F["请求完成后减少Active并更新延迟"]
E --> F样本必须来自同一口径。若失败请求很快返回,单纯延迟可能误把持续失败实例当成“最快”,需要同时加入成功率、熔断和异常剔除。
九、EWMA 延迟
指数加权移动平均让新样本权重大、旧样本逐渐衰减:
ewma = α × sample + (1 - α) × oldα 越大越敏感,越小越平滑。EWMA 可以与 Active、权重组合成 Load Score,让高延迟实例少接请求。
边界:
- 冷启动没有足够样本。
- 少量异常值可能抬高分数过久。
- 完全不给慢实例流量后无法知道它是否恢复,需要探测流量。
- 客户端局部 EWMA 不代表实例全局延迟。
- 排队已经发生时,反馈有滞后,仍需并发上限和过载保护。
9.1 JDK 8 Demo:P2C + Active + EWMA怎样避开慢实例
下面Demo模拟三个实例:A正常,B延迟变高,C有较多在途请求。选择时随机抽两个候选,用active * 100 + ewmaMillis作为负载分数,分数低者胜出。
import java.util.Arrays;
import java.util.List;
import java.util.Random;
public class P2cEwmaLoadBalancerDemo {
static class Instance {
final String id;
int active;
double ewmaMillis;
Instance(String id, int active, double ewmaMillis) {
this.id = id;
this.active = active;
this.ewmaMillis = ewmaMillis;
}
double score() {
return active * 100.0 + ewmaMillis;
}
void onComplete(long latencyMillis) {
double alpha = 0.3;
ewmaMillis = alpha * latencyMillis + (1 - alpha) * ewmaMillis;
active--;
}
}
static class LoadBalancer {
private final Random random = new Random(7);
Instance choose(List<Instance> instances) {
Instance first = instances.get(random.nextInt(instances.size()));
Instance second = instances.get(random.nextInt(instances.size()));
if (first == second) {
return first;
}
Instance selected = first.score() <= second.score() ? first : second;
selected.active++;
return selected;
}
}
public static void main(String[] args) {
List<Instance> instances = Arrays.asList(
new Instance("A", 1, 80),
new Instance("B", 0, 800),
new Instance("C", 5, 120));
LoadBalancer lb = new LoadBalancer();
for (int i = 0; i < 10; i++) {
Instance selected = lb.choose(instances);
System.out.println(selected.id + " score=" + selected.score());
selected.onComplete("B".equals(selected.id) ? 900 : 90);
}
}
}这个Demo故意简单,但能说明几个生产原则:
| 设计点 | 为什么 |
|---|---|
| P2C只抽两个 | 不全量扫描,避免实例很多时每次选择成本过高 |
| Active参与分数 | 避免所有请求都打到“历史延迟低但当前已排队”的实例 |
| EWMA参与分数 | 慢实例延迟升高后逐步少接流量 |
| 请求完成后更新 | 统计必须和真实Attempt绑定,否则失败阶段看不清 |
| 保留探测流量 | 完全不访问慢实例,就不知道它是否恢复 |
不要把这个公式直接当成生产算法。真实系统还要处理实例上下线、权重、冷启动、错误率、熔断状态、并发安全、指标滑动窗口、连接池和多客户端局部视图。
十、Hash、Ring Hash 与 Rendezvous
按用户、租户或会话 Key 选择实例可以提高缓存命中和会话亲和,但会把热 Key 固定到一个节点。成员变化时普通 %N 大量重映射;一致性哈希/Rendezvous 减少变化。
这里的“一致性哈希”表示映射稳定,不表示数据强一致。若实例本地状态不可重建,仍需外部状态存储、复制或迁移协议。
十一、Maglev Hash
Maglev 为每个后端生成确定性排列,再填充一个固定大小查找表;数据面根据 Flow/Key 哈希直接索引表,选择速度接近 O(1)。后端集合变化时重新生成表,目标是在高性能与较小扰动间平衡。
查找表大小通常选择较大的质数以改善分布。Maglev 适合高性能 L4/L7 数据面,但它仍不解决连接存量、后端状态迁移和热 Key。多个代理若后端集合或排序不同,生成的表也会不同,配置快照必须一致。
十二、局部性与故障域
常见策略:同可用区优先、同地域优先,容量不足或全部故障时再跨区。收益是降低延迟和跨区费用;风险是某区过载时仍坚持本地,或故障转移把全部流量瞬间打到剩余区。
应按故障域预留容量,定义 Spillover 阈值和最大跨区比例。恢复时逐步回切,避免流量来回摆动。局部性不能破坏数据主权和单元化路由约束。
十三、慢启动与预热
新实例虽然 Readiness 成功,但 JIT、缓存、连接池和类加载仍可能未热。若立即按满权重分流,会出现高延迟并被 Outlier 剔除,形成“刚启动就被打死”。
慢启动在一段时间内把有效权重从小值逐步增加:
flowchart TD
A["实例Ready"] --> B["以低有效权重接少量流量"]
B --> C["完成JIT、缓存和连接预热"]
C --> D{"延迟和错误率是否正常"}
D -- "正常" --> E["逐步增加到目标权重"]
D -- "异常" --> F["暂停增权、隔离并排查"]预热请求不能产生真实支付、发券等副作用;可使用只读路径、合成请求或明确的 Warmup API。
十四、Outlier Detection
代理或客户端根据连续 5xx、连接失败、成功率或延迟暂时剔除实例。它通常是本地判断:不同代理可能剔除不同实例,不是全局一致健康状态。
需要设置:最少请求量、检测窗口、剔除时长、最大剔除比例、指数延长和恢复探测。最大剔除比例防止一次下游整体故障时把所有实例都剔除;但保留坏实例也会失败,所以还需熔断、降级和总容量判断。
14.1 异常实例摘除状态机
异常实例治理不是“失败一次就永久删除”。更合理的是状态机:
flowchart TD
A["正常接流量"] --> B["窗口内错误或慢调用超阈值"]
B --> C["临时剔除"]
C --> D["等待剔除时间"]
D --> E["放少量探测请求"]
E --> F{"探测是否成功"}
F -- "成功" --> G["逐步恢复权重"]
F -- "失败" --> H["延长剔除时间"]
G --> A
H --> C关键参数不能随便拍脑袋:
| 参数 | 作用 | 配错后果 |
|---|---|---|
| 最小请求量 | 样本太少时不判断 | 低QPS服务误摘除 |
| 错误阈值 | 连续错误或成功率低时摘除 | 阈值太低导致抖动 |
| 慢调用阈值 | P99或单次RT过高时降权 | 正常大请求被误伤 |
| 剔除时长 | 给实例恢复时间 | 太短反复探测,太长浪费容量 |
| 最大剔除比例 | 防止全摘空 | 太低保留坏实例,太高容量骤降 |
| 恢复探测量 | 判断是否恢复 | 探测过多可能再次压垮 |
Outlier Detection和熔断器相似,但视角不同:Outlier更关注“某个实例是否不健康”,熔断器更关注“某个下游依赖是否整体不可用”。一台Provider Full GC适合实例级摘除;整个库存服务数据库故障时,只摘除某几个实例没有意义,应触发依赖级熔断、降级和限流。
商业项目里建议同时保留三个事实:
- 选址日志:本次选择了哪个实例,为什么。
- 调用结果:连接失败、5xx、业务错误、超时还是解码失败。
- 实例状态:当前是否被本客户端、本Gateway或Mesh临时摘除。
没有这些证据时,“负载均衡有问题”会变成一个黑箱锅,最后只能靠重启和改权重碰运气。
十五、负载均衡与重试
重试换实例只适合可安全重放且剩余 Deadline 足够的故障。若请求已到下游并提交,换实例重试仍可能重复副作用。Gateway、客户端和 Mesh 多层重试会相乘。
每次尝试要记录逻辑请求 ID、Attempt、选择实例、连接复用、失败阶段和剩余预算。重试不能把异常实例的失败统计完全隐藏,否则健康治理无法看到真实问题。
十六、商业场景:订单服务扩容后的流量倾斜
订单服务从 4 个 Pod 扩到 8 个,新增 Pod QPS 很低:
- EndpointSlice 已包含新 Pod,证明发现层完成。
- 网关到旧 Pod 有大量 Keep-Alive/HTTP2 连接,L4 只在建连接时选择后端。
- 旧连接持续承载请求,新连接数量不足,新 Pod 接不到流量。
- 配置最大连接年龄和优雅排空,让连接逐步重建。
- 新 Pod 使用慢启动逐步加权,避免冷缓存被瞬时打满。
- 按 Pod 查看连接数、Active Stream、QPS、P99,而不是只看平均值。
这类问题增加轮询频率无效,因为真正的选择单位是连接。
十七、负载均衡失败窗口
| 窗口 | 风险 | 应对 |
|---|---|---|
| 新实例已发现但未建连接 | 扩容短期无流量 | 连接年龄、重连和预连接 |
| 实例摘除传播中 | 旧缓存仍选择 | 被动失败、连接排空、有限重试 |
| 慢实例未被识别 | 队列持续增长 | Active/EWMA、并发限制、Outlier |
| 所有客户端同时选最空闲 | 羊群效应 | P2C、随机化和局部统计 |
| Outlier误剔除 | 容量骤降 | 最小样本、最大剔除比例、探测恢复 |
| 跨区故障转移 | 剩余区域过载 | 预留容量、Spillover限速、降级 |
| 重试换实例 | 重复写和请求放大 | 幂等、单层重试、Retry Budget |
十八、流量不均与单实例过载 Runbook
- 确认选择单位是连接、请求还是 HTTP/2 Stream。
- 按实例查看 QPS、连接数、Active Request/Stream、P95/P99、错误率和CPU。
- 检查服务发现快照、Readiness、标签、权重和路由版本。
- 检查客户端连接池、最大连接年龄、空闲回收、DNS缓存和预连接。
- 检查算法实际配置和统计口径:本地Active还是全局指标。
- 检查热 Key、租户亲和、Hash 策略和局部性路由。
- 检查慢启动、Outlier、熔断和最大剔除比例是否互相放大。
- 检查多层重试是否让少数健康实例承受倍增流量。
- 变更后用相同流量模型观察分布方差、尾延迟和跨区比例。
十九、常见错误
| 错误 | 正确理解 |
|---|---|
| Round Robin必然请求均匀 | Keep-Alive和请求成本会破坏均匀 |
| 连接数少就是负载低 | HTTP/2单连接可有大量Stream |
| Least Request看到全局压力 | 客户端通常只有局部Active视图 |
| 一致性哈希保证数据一致 | 只提供Key亲和和较小重映射 |
| 扩容后立即均衡 | 旧连接和缓存需要再平衡窗口 |
| 新实例Ready即可满流量 | JIT、缓存和连接池可能仍冷 |
| Outlier是全局摘除 | 通常每个代理独立判断 |
| 重试一定提升成功率 | 可能放大过载和重复副作用 |
