Skip to content

微服务负载均衡:L4/L7、P2C、EWMA、Maglev与长连接治理

负载均衡不是简单轮询 IP。一次选择通常先经过服务发现、健康过滤、局部性、权重、慢启动、算法和连接池;请求失败后还可能触发被动剔除与重试。选择发生在连接级、请求级还是 HTTP/2 Stream 级,决定了扩容是否立即生效和流量是否均匀。

一、学习目标

学完后应能:

  1. 区分 DNS、L4、L7、客户端和服务端负载均衡。
  2. 画出一次微服务实例选择和请求执行链。
  3. 比较 Random、Round Robin、Smooth Weighted RR、Least、P2C、EWMA、Hash 与 Maglev。
  4. 解释连接级均衡、HTTP Keep-Alive、HTTP/2/gRPC 长连接倾斜。
  5. 设计局部性、慢启动、Outlier Detection、重试和连接排空。
  6. 定位扩容不生效、单实例过载、权重不准和流量跨区问题。

二、DNS、L4 与 L7

依据能看到什么典型能力
DNS域名解析域名和记录多IP、地域解析、TTL
L4IP、端口、协议连接TCP/UDP,不理解HTTP语义高性能连接转发、NAT/DSR
L7HTTP/gRPC等应用协议Host、Path、Header、状态码路由、鉴权、重试、灰度、请求级LB
Client-side客户端本地实例快照服务名、实例元数据、调用结果少一跳、本地算法、语言SDK治理

DNS 返回多个地址不表示客户端每次请求都轮换:解析结果会被操作系统、JVM、代理和连接池缓存;一个 Keep-Alive 连接还能持续复用旧地址。

L4 通常在新连接建立时选择后端;一个连接上的全部请求仍到同一后端。L7 可以在请求级重新选择,但 HTTP/2/gRPC 代理是否按 Stream 均衡取决于代理终止连接的位置和实现。

三、一次完整实例选择链

mermaid
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:平滑加权轮询

java
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、延迟或综合负载,选择更优者。它只读取常数个节点状态,避免全量扫描,并显著降低随机分配的最大负载,适合大规模节点集合。

mermaid
flowchart TD
    A["从健康候选随机抽两个"] --> B["读取两者Active、EWMA或权重"]
    B --> C{"哪个综合分数更低"}
    C -- "候选A" --> D["选择A并增加本地Active"]
    C -- "候选B" --> E["选择B并增加本地Active"]
    D --> F["请求完成后减少Active并更新延迟"]
    E --> F

样本必须来自同一口径。若失败请求很快返回,单纯延迟可能误把持续失败实例当成“最快”,需要同时加入成功率、熔断和异常剔除。

九、EWMA 延迟

指数加权移动平均让新样本权重大、旧样本逐渐衰减:

text
ewma = α × sample + (1 - α) × old

α 越大越敏感,越小越平滑。EWMA 可以与 Active、权重组合成 Load Score,让高延迟实例少接请求。

边界:

  • 冷启动没有足够样本。
  • 少量异常值可能抬高分数过久。
  • 完全不给慢实例流量后无法知道它是否恢复,需要探测流量。
  • 客户端局部 EWMA 不代表实例全局延迟。
  • 排队已经发生时,反馈有滞后,仍需并发上限和过载保护。

9.1 JDK 8 Demo:P2C + Active + EWMA怎样避开慢实例

下面Demo模拟三个实例:A正常,B延迟变高,C有较多在途请求。选择时随机抽两个候选,用active * 100 + ewmaMillis作为负载分数,分数低者胜出。

java
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 剔除,形成“刚启动就被打死”。

慢启动在一段时间内把有效权重从小值逐步增加:

mermaid
flowchart TD
    A["实例Ready"] --> B["以低有效权重接少量流量"]
    B --> C["完成JIT、缓存和连接预热"]
    C --> D{"延迟和错误率是否正常"}
    D -- "正常" --> E["逐步增加到目标权重"]
    D -- "异常" --> F["暂停增权、隔离并排查"]

预热请求不能产生真实支付、发券等副作用;可使用只读路径、合成请求或明确的 Warmup API。

十四、Outlier Detection

代理或客户端根据连续 5xx、连接失败、成功率或延迟暂时剔除实例。它通常是本地判断:不同代理可能剔除不同实例,不是全局一致健康状态。

需要设置:最少请求量、检测窗口、剔除时长、最大剔除比例、指数延长和恢复探测。最大剔除比例防止一次下游整体故障时把所有实例都剔除;但保留坏实例也会失败,所以还需熔断、降级和总容量判断。

14.1 异常实例摘除状态机

异常实例治理不是“失败一次就永久删除”。更合理的是状态机:

mermaid
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适合实例级摘除;整个库存服务数据库故障时,只摘除某几个实例没有意义,应触发依赖级熔断、降级和限流。

商业项目里建议同时保留三个事实:

  1. 选址日志:本次选择了哪个实例,为什么。
  2. 调用结果:连接失败、5xx、业务错误、超时还是解码失败。
  3. 实例状态:当前是否被本客户端、本Gateway或Mesh临时摘除。

没有这些证据时,“负载均衡有问题”会变成一个黑箱锅,最后只能靠重启和改权重碰运气。

十五、负载均衡与重试

重试换实例只适合可安全重放且剩余 Deadline 足够的故障。若请求已到下游并提交,换实例重试仍可能重复副作用。Gateway、客户端和 Mesh 多层重试会相乘。

每次尝试要记录逻辑请求 ID、Attempt、选择实例、连接复用、失败阶段和剩余预算。重试不能把异常实例的失败统计完全隐藏,否则健康治理无法看到真实问题。

十六、商业场景:订单服务扩容后的流量倾斜

订单服务从 4 个 Pod 扩到 8 个,新增 Pod QPS 很低:

  1. EndpointSlice 已包含新 Pod,证明发现层完成。
  2. 网关到旧 Pod 有大量 Keep-Alive/HTTP2 连接,L4 只在建连接时选择后端。
  3. 旧连接持续承载请求,新连接数量不足,新 Pod 接不到流量。
  4. 配置最大连接年龄和优雅排空,让连接逐步重建。
  5. 新 Pod 使用慢启动逐步加权,避免冷缓存被瞬时打满。
  6. 按 Pod 查看连接数、Active Stream、QPS、P99,而不是只看平均值。

这类问题增加轮询频率无效,因为真正的选择单位是连接。

十七、负载均衡失败窗口

窗口风险应对
新实例已发现但未建连接扩容短期无流量连接年龄、重连和预连接
实例摘除传播中旧缓存仍选择被动失败、连接排空、有限重试
慢实例未被识别队列持续增长Active/EWMA、并发限制、Outlier
所有客户端同时选最空闲羊群效应P2C、随机化和局部统计
Outlier误剔除容量骤降最小样本、最大剔除比例、探测恢复
跨区故障转移剩余区域过载预留容量、Spillover限速、降级
重试换实例重复写和请求放大幂等、单层重试、Retry Budget

十八、流量不均与单实例过载 Runbook

  1. 确认选择单位是连接、请求还是 HTTP/2 Stream。
  2. 按实例查看 QPS、连接数、Active Request/Stream、P95/P99、错误率和CPU。
  3. 检查服务发现快照、Readiness、标签、权重和路由版本。
  4. 检查客户端连接池、最大连接年龄、空闲回收、DNS缓存和预连接。
  5. 检查算法实际配置和统计口径:本地Active还是全局指标。
  6. 检查热 Key、租户亲和、Hash 策略和局部性路由。
  7. 检查慢启动、Outlier、熔断和最大剔除比例是否互相放大。
  8. 检查多层重试是否让少数健康实例承受倍增流量。
  9. 变更后用相同流量模型观察分布方差、尾延迟和跨区比例。

十九、常见错误

错误正确理解
Round Robin必然请求均匀Keep-Alive和请求成本会破坏均匀
连接数少就是负载低HTTP/2单连接可有大量Stream
Least Request看到全局压力客户端通常只有局部Active视图
一致性哈希保证数据一致只提供Key亲和和较小重映射
扩容后立即均衡旧连接和缓存需要再平衡窗口
新实例Ready即可满流量JIT、缓存和连接池可能仍冷
Outlier是全局摘除通常每个代理独立判断
重试一定提升成功率可能放大过载和重复副作用

二十、关联知识点