灰度发布为什么必须改负载均衡:从灰度标到正确节点
前三篇文章已经把灰度发布的前半段讲完了。
第一篇讲整体架构:灰度发布不是入口分流,而是一套全链路流量控制机制。
第二篇讲入口识别:Gateway 怎么补全请求特征、执行规则匹配,并产出 X-LANE-CODE。
第三篇讲链路透传:灰度标怎么在 HTTP、RPC、MQ、异步线程里继续往下传。
这篇继续往后走一步:灰度标已经打上了,流量怎么真正走到正确的服务节点?
这是灰度发布里非常容易被低估的一层。很多方案做到 Gateway 打标就停了,觉得只要请求里有:
X-LANE-CODE: gray-a
灰度就算完成了。
但线上真正决定请求打到哪台机器的,不是这个 Header 本身,而是负载均衡。
如果负载均衡不理解这个灰度标,那么它还是会按普通方式从服务实例列表里选一个节点。这样就会出现一个很典型的问题:
Gateway 命中 gray-a
-> 第一跳进入 order-service 的 gray-a 节点
-> order-service 通过 OpenFeign 调 member-service
-> 普通负载均衡随机打到 member-service 的 DEFAULT 节点
从入口看,这条请求已经进了灰度。
从完整链路看,它已经串流了。
所以灰度发布要真正落地,不能只解决“谁该进灰度”,还要解决“每一次服务调用该选哪个节点”。
一、为什么灰度必须接管负载均衡
普通负载均衡通常只关心几个问题:
- 这个服务有哪些可用实例?
- 这些实例是否健康?
- 当前应该轮询到哪一个?
- 有没有权重或者区域偏好?
但灰度场景多了一个更前置的问题:
这个实例和当前请求是不是同一个灰度环境?
如果不先回答这个问题,后面的轮询、随机、权重都可能把请求送错地方。
比如当前请求的灰度标是:
X-LANE-CODE: gray-a
目标服务有三类实例:
member-service-1 DEFAULT
member-service-2 gray-a
member-service-3 gray-b
普通负载均衡看到的是三个可用实例。
灰度负载均衡看到的是三种不同环境。
它不能在这三个实例里直接轮询,而应该先把候选集缩小成:
member-service-2 gray-a
然后再在 gray-a 的实例池里做轮询。
这就是灰度负载均衡和普通负载均衡的本质区别:
普通负载均衡是在所有可用节点里选一个;灰度负载均衡是先按环境隔离,再在同环境节点里选一个。
所以只打灰度标不够。
灰度标必须被负载均衡消费,才能真正影响路由结果。
二、负载均衡怎么知道哪个节点是灰度节点
这里自然会引出第二个问题:负载均衡怎么知道一个服务实例属于哪个环境?
答案是服务注册 Metadata。
服务实例启动并注册到 Nacos 时,需要把自己所属的环境写进 Metadata。比如灰度实例可以这样注册:
spring:
cloud:
nacos:
discovery:
metadata:
LANE_CODE: gray-a
基线实例可以显式标记为:
spring:
cloud:
nacos:
discovery:
metadata:
LANE_CODE: DEFAULT
也可以不配置 LANE_CODE,在当前实现里,没有 LANE_CODE 的实例也会被视为基线实例。
这样一来,灰度路由就有了两个可以匹配的对象。
请求侧有灰度标:
X-LANE-CODE: gray-a
实例侧有环境标:
metadata.LANE_CODE = gray-a
负载均衡要做的核心事情,就是把这两个标识匹配起来。
可以把通用筛选算法写成下面这段伪代码:
List<ServiceInstance> chooseInstances(List<ServiceInstance> instances, String laneCode) {
if (isBlank(laneCode)) {
return baselineInstances(instances);
}
List<ServiceInstance> sameLaneInstances = instances.stream()
.filter(instance -> laneCode.equalsIgnoreCase(instance.metadata["LANE_CODE"]))
.collect(toList());
if (!sameLaneInstances.isEmpty()) {
return sameLaneInstances;
}
return baselineInstances(instances);
}
List<ServiceInstance> baselineInstances(List<ServiceInstance> instances) {
return instances.stream()
.filter(instance -> isBlank(instance.metadata["LANE_CODE"])
|| "DEFAULT".equalsIgnoreCase(instance.metadata["LANE_CODE"]))
.collect(toList());
}
这段逻辑不是直接在所有实例里做负载均衡,而是先完成环境过滤:
- 有 laneCode:优先选同泳道实例
- 无 laneCode:只选基线实例
- 同泳道为空:fallback 到基线实例
这里有一个重要边界:不要跨泳道。
如果当前请求是 gray-a,但目标服务没有 gray-a 实例,默认应该回基线,而不是随机进入 gray-b。
因为不同泳道可能代表不同版本、不同实验、不同人群。跨泳道比回基线更危险。
三、Gateway 到下游服务:入口负载如何重写
Gateway 是灰度流量进入系统后的第一层负载均衡。
在前一篇文章里,Gateway 已经完成了两件事:
HeaderEnhancementGatewayFilter 负责补全规则特征
LaneRoutingGatewayFilter 负责匹配泳道并写入 X-LANE-CODE
但这里要注意,LaneRoutingGatewayFilter 不只是把灰度标写进 Header,还会写进 ServerWebExchange 的 attributes。
Header 是给下游服务继续透传用的。
Attributes 是给 Gateway 当前过滤链后面的负载均衡 Filter 用的。
当前实现里,Gateway 版本较低,使用的是阻塞式负载均衡重写方式:自定义 LaneLoadBalancerClientFilter,继承 Spring Cloud Gateway 默认的 LoadBalancerClientFilter,然后覆盖 choose(ServerWebExchange exchange)。
伪代码可以简化成这样:
class LaneLoadBalancerClientFilter extends LoadBalancerClientFilter {
ServiceInstance choose(ServerWebExchange exchange) {
String laneCode = exchange.getAttribute("X-HDL-LANE-CODE");
String serviceId = parseServiceId(exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR));
if (serviceId == null) {
return defaultChoose(exchange);
}
List<ServiceInstance> instances = discoveryClient.getInstances(serviceId);
List<ServiceInstance> candidates = chooseInstances(instances, laneCode);
if (candidates.isEmpty()) {
return defaultChoose(exchange);
}
return roundRobin.choose(serviceId, laneCode, candidates);
}
}
这里有两个点比较关键。
第一,Gateway 这一跳读取的是 ServerWebExchange attributes,不是从全链路 Tracer 上下文里取。
因为当前请求刚在 Gateway 内部完成规则匹配,结果已经保存在 exchange 里,直接从 attributes 取最准确。
第二,当前实现用了短 TTL 缓存。
LaneLoadBalancerClientFilter 会按下面这个维度缓存过滤后的实例列表:
serviceId:laneCode
例如:
member-service:gray-a
member-service:DEFAULT
这样做是为了避免 Gateway 每次请求都从注册中心拉实例、过滤 Metadata。缓存时间不能太长,否则服务上下线感知会变慢;也不能完全不缓存,否则入口链路开销会偏高。
如果是高版本 Spring Cloud Gateway,思路会不一样。
高版本更推荐走 Spring Cloud LoadBalancer 的响应式体系,比如实现或替换 ReactorServiceInstanceLoadBalancer,从 ServiceInstanceListSupplier 获取实例列表,再返回 Mono<Response<ServiceInstance>>。
响应式版本可以抽象成下面这段伪代码:
class LaneReactorLoadBalancer implements ReactorServiceInstanceLoadBalancer {
Mono<Response<ServiceInstance>> choose(Request request) {
String laneCode = extractLaneCode(request.context);
return serviceInstanceListSupplier.get()
.next()
.map(instances -> {
List<ServiceInstance> candidates = chooseInstances(instances, laneCode);
if (candidates.isEmpty()) {
return new EmptyResponse();
}
ServiceInstance selected = roundRobin.choose(serviceId, laneCode, candidates);
return new DefaultResponse(selected);
});
}
}
区别只是技术入口从阻塞式 LoadBalancerClientFilter 变成响应式 ReactorServiceInstanceLoadBalancer。
灰度逻辑本身没有变:
先匹配请求 laneCode 和实例 Metadata,再做负载选择。
四、OpenFeign:低版本改 Ribbon,高版本改 Spring Cloud LoadBalancer
Gateway 只能控制入口第一跳。
请求进入服务后,还会继续发生服务间 HTTP 调用。最常见的就是 OpenFeign。
这里要先明确一点:OpenFeign 自己不是最终的服务实例选择者。
在不同 Spring Cloud 版本里,它会委托不同的负载均衡组件。
低版本 OpenFeign 通常走 Ribbon。
高版本 OpenFeign 通常走 Spring Cloud LoadBalancer。
所以灰度能力应该落在对应的负载均衡层,而不是散落到每一个 Feign 接口里。
在低版本 OpenFeign + Ribbon 场景下,当前实现是通过自定义 Ribbon IRule 完成的。
LaneRibbonRule 的核心流程可以写成下面这段伪代码:
class LaneRibbonRule extends AbstractLoadBalancerRule {
Server choose(Object key) {
List<Server> servers = loadBalancer.getReachableServers();
Map<ServiceInstance, Server> mapping = new HashMap<>();
List<ServiceInstance> instances = new ArrayList<>();
for (Server server : servers) {
if (server instanceof NacosServer) {
ServiceInstance instance = toServiceInstance((NacosServer) server);
instances.add(instance);
mapping.put(instance, server);
}
}
String laneCode = LaneContextHolder.getLaneCode();
List<ServiceInstance> candidates = chooseInstances(instances, laneCode);
if (candidates.isEmpty()) {
return null;
}
ServiceInstance selected = roundRobin.choose(serviceName, laneCode, candidates);
return mapping.get(selected);
}
}
这里和 Gateway 最大的区别是 laneCode 的来源。
Gateway 还在入口请求上下文里,所以可以从 ServerWebExchange attributes 取。
OpenFeign/Ribbon 已经进入普通服务内部,没有 Gateway exchange,所以要从全链路上下文里取。
当前实现里是:
String getLaneCode() {
List<String> values = HdlTracerContext.getAttachmentValues("X-LANE-CODE");
if (values == null || values.isEmpty()) {
return getLocalLaneCode();
}
return values.get(0);
}
这就把第三篇文章的“全链路透传”接上了。
如果 X-LANE-CODE 没有被透传到当前服务,Ribbon 侧就拿不到请求真实泳道,自然无法继续选择正确的下游节点。
如果是高版本 OpenFeign,底层不再走 Ribbon,而是走 Spring Cloud LoadBalancer,那么重写点也要迁移。
这时可以通过定制 ReactorServiceInstanceLoadBalancer、ServiceInstanceListSupplier 或相关 LoadBalancer 配置,把同样的筛选逻辑放进去:
class LaneFeignLoadBalancer implements ReactorServiceInstanceLoadBalancer {
Mono<Response<ServiceInstance>> choose(Request request) {
String laneCode = LaneContextHolder.getLaneCode();
return supplier.get()
.next()
.map(instances -> {
List<ServiceInstance> candidates = chooseInstances(instances, laneCode);
ServiceInstance selected = roundRobin.choose(serviceId, laneCode, candidates);
return new DefaultResponse(selected);
});
}
}
换句话说,版本升级会改变接入点,但不会改变灰度负载的核心算法:
- 从上下文读取 laneCode
- 拿到目标服务 ServiceInstance 列表
- 根据 metadata.LANE_CODE 筛选
- 在候选实例里选择
五、Dubbo:不要硬改负载均衡,优先走标签路由
Dubbo 场景和 Spring Cloud/OpenFeign 不太一样。
要支持 Dubbo 灰度,优先考虑的不是直接重写普通 LoadBalance,而是走标签路由。
Dubbo 的调用链路大致是:
Consumer 发起调用
-> Router 过滤 Invoker
-> Cluster LoadBalance 在候选 Invoker 中选择
-> 发起 RPC
所以灰度在 Dubbo 里更适合放到 Router 阶段。
Provider 注册时带标签:
tag = gray-a
或者带上类似的环境标识:
LANE_CODE = gray-a
Consumer 发起调用前,从全链路上下文读取:
X-LANE-CODE = gray-a
然后把它映射到 Dubbo 的 invocation attachment 或路由上下文中,让标签路由先筛出同标签 Provider。
伪代码可以这样表达:
class LaneDubboConsumerFilter implements Filter {
Result invoke(Invoker invoker, Invocation invocation) {
String laneCode = LaneContextHolder.getLaneCode();
if (isNotBlank(laneCode)) {
invocation.setAttachment("tag", laneCode);
invocation.setAttachment("X-LANE-CODE", laneCode);
}
return invoker.invoke(invocation);
}
}
路由阶段可以抽象成:
class LaneTagRouter implements Router {
List<Invoker> route(List<Invoker> invokers, Invocation invocation) {
String laneCode = invocation.getAttachment("tag");
if (isBlank(laneCode)) {
return baselineInvokers(invokers);
}
List<Invoker> sameLaneInvokers = invokers.stream()
.filter(invoker -> laneCode.equals(invoker.getUrl().getParameter("tag")))
.collect(toList());
if (!sameLaneInvokers.isEmpty()) {
return sameLaneInvokers;
}
return baselineInvokers(invokers);
}
}
这和 Spring Cloud 的做法本质一致。
Spring Cloud/OpenFeign 筛的是 ServiceInstance。
Dubbo 筛的是 Invoker。
Spring Cloud/Nacos 看的是 instance metadata。
Dubbo 标签路由看的是 provider tag 或 invocation attachment。
但核心都一样:
- 请求标识匹配节点标识
- 先做环境隔离
- 再做负载均衡
不要把灰度逻辑放到最后的随机、轮询、最少活跃这些普通负载算法里。
更合理的分层是:
Router 负责筛选正确环境 LoadBalance 负责在正确环境内选一个节点
六、最后回到最核心的问题:灰度标必须全链路传递
讲到这里,可以把这篇文章收束成一条完整链路。
入口阶段:
- Gateway 补全请求特征
- Gateway 匹配灰度规则
- Gateway 写入 X-HDL-LANE-CODE
- Gateway 负载均衡根据 laneCode 选择下游实例
服务注册阶段:
- 服务实例注册自己的 LANE_CODE metadata
- 基线实例为 DEFAULT 或不配置
- 灰度实例配置具体泳道编码
服务间 HTTP 调用阶段:
- OpenFeign 发起调用
- 低版本进入 Ribbon
- 高版本进入 Spring Cloud LoadBalancer
- 负载均衡从上下文读取 laneCode
- 根据实例 metadata.LANE_CODE 过滤候选节点
Dubbo 调用阶段:
- Consumer 从上下文读取 laneCode
- 映射为 tag / attachment
- Dubbo 标签路由筛选同标签 Provider
- LoadBalance 在筛选后的 Provider 内选择
这里面最核心的东西只有一个:
X-LANE-CODE 必须全链路传递
如果灰度标只停在 Gateway,后面的 OpenFeign、Dubbo、MQ、异步线程都拿不到它,负载均衡就没有依据选择正确节点。
如果灰度标传下去了,但服务实例没有注册 LANE_CODE Metadata,负载均衡也不知道哪些节点属于对应环境。
如果灰度标和 Metadata 都有,但负载均衡没有重写,最终还是普通轮询,灰度仍然会串。
所以灰度发布真正不串流,靠的是这条闭环:
请求侧灰度标
-> 全链路透传
-> 实例侧 Metadata / Tag
-> 负载均衡或路由器消费
-> 在正确环境内选择节点
写在最后,灰度发布里最容易误判的一点是:打标不是路由,透传也不是路由。
打标只是产出决策。
透传只是把决策带到后面。
真正执行这个决策的是每一次服务调用时的负载均衡和路由选择。
所以验证一套灰度方案时,不要只看:
Header 有没有打上?
还要继续追问:
- 这个 Header 有没有传到下一跳?
- 下一跳负载均衡有没有读取它?
- 目标服务实例有没有注册对应 Metadata?
- 最终选中的节点是不是同一个泳道?
这些问题都能回答清楚,灰度发布才从“入口分流”走到了“全链路控制”。