主题
全链路灰度与智能路由
一句话:全链路灰度的关键在「全链路」:把入口解析出的一个灰度标识,沿同步调用、线程池和消息一路带到链路末端,换来整条链路一起验证新版本;代价是每一层都必须参与透传,任何一处丢失都会静默退化成单服务灰度。
适用版本:Spring Boot 3.2.x / Spring Cloud 2023.0.x / Spring Cloud LoadBalancer 4.x / OpenFeign 4.x。文中的 x-gray-tag、GrayContext 与元数据键名均为示例约定,可按团队规范替换。
为什么需要
只把网关或入口服务切成灰度,下游仍按默认负载均衡打到稳定版:用户看到的是「新页面 + 旧逻辑」,测试覆盖的路径和线上真实执行的路径不是同一条。更麻烦的是它通常不报错——入口返回 200,链路上却已经混用了 v1 和 v2。
异步边界会把问题放大。请求线程结束后,@Async、业务线程池、MQ 消费者、定时任务都拿不到请求线程里的标识;如果这些入口没有显式恢复上下文,后半条链路默认回稳定版。日志上看一切正常,只有按 tag 拆指标时才会发现灰度请求只走了一半。
库表和消息格式也有同类问题:灰度版与稳定版共享数据,破坏性变更会让稳定版直接读写失败。灰度不是隔离环境,它只是路由。
核心机制
标识从哪里来,在哪里收敛
来源通常有五类:用户 ID、租户 ID、请求头、白名单、百分比。只在入口解析一次(示例 x-gray-tag),下游一律只透传、不重新计算。原因是百分比规则若在每层各算一次,同一请求可能在不同服务算出不同结果,链路从中间裂开。
优先级建议:显式请求头(联调/测试)> 白名单 > 用户/租户规则 > 百分比。只允许上游覆盖下游,下游不得改写;冲突时以最靠近入口的判定为准。
百分比要基于稳定的业务键(用户 ID、租户 ID)做哈希,不要用随机数或客户端 IP:随机数会让同一用户在版本间跳变,IP 在 NAT 后大量用户共享出口,两者都会让灰度和复现不可控。
传递:同步靠拦截器,异步靠显式携带
- Web 入口:过滤器从请求头取标识写入线程上下文(
ThreadLocal),请求结束在finally里remove()。不清理的后果是 Tomcat 线程复用,下一个请求会“继承”上一个请求的灰度身份。 - 同步调用:Feign 的
RequestInterceptor与 RestTemplate 的ClientHttpRequestInterceptor从上下文取值,写进下游请求头。网关负责生成和校验标识,非法值直接丢弃或拒绝,避免客户端任意指定灰度。 - 线程池:用
TaskDecorator在提交时捕获、在执行线程设置、在finally清理;或包装Runnable。InheritableThreadLocal只在创建线程时复制,线程池复用后失效,不能替代装饰器。 - 消息:生产端把标识写进消息头(message header),消费端从消息头恢复上下文并在处理完后清理。不要用“消费者实例的版本”反推标识。
- 定时任务与异步回调:没有上游请求,必须从任务参数、配置或业务表里显式取出标识;第三方回调则要在发起时把标识放进回调参数或落库。
图中实线是标识被正确携带的路径,虚线是两条常见分叉:一条靠线程装饰器进入异步任务,另一条是任何一处没透传时静默退回稳定版。后者的危险在于不抛异常,只能靠 tag 维度的指标和日志发现。
选实例:先按标签过滤,再谈负载均衡
实例注册时把 version、tag、dc、subenv 写进元数据;客户端负载均衡先按标识过滤出候选子集,再在子集内轮询、随机或加权。灰度路由本质上是“过滤 + 回退”,不是新的负载均衡算法。
没有匹配实例时只有三种选择,必须显式配置:
| 策略 | 行为 | 适用 |
|---|---|---|
| 回退稳定版 | 忽略标识,走全量实例 | 默认;可用优先,但灰度结论会失真,必须记指标 |
| 快速失败 | 返回错误,不降级 | 验证环境;避免“假灰度”掩盖问题 |
| 按比例降级 | 一部分回稳定版 | 灰度实例容量不足时的过渡 |
多标签冲突时先满足硬约束(环境、机房、租户隔离),再满足软约束(版本);硬约束不满足不能降级,否则等于越权访问。同一用户尽量保持同一版本(粘性,stickiness),否则本地缓存、会话和未完成流程会跨版本;粘性应基于业务键哈希,不要绑定具体实例——实例会重启和漂移。
环境隔离与蓝绿:用标签切换,不用改地址
环境也应是标签:实例注册时带 env=dev|test|staging|prod,入口解析后透传,路由层把它当硬约束。硬约束意味着找不到同环境实例就失败,而不是回退全量——多环境共用注册中心时,仅靠服务名发现很容易把测试流量调进生产;回退会让这个事故静默发生,还可能写脏生产库。
蓝绿不靠改 IP 或 DNS,而是两组实例同时在线,用 blue/green 路由标签切子集,回滚只是把标签切回去。代价在切换瞬间:客户端可能仍持有旧实例列表(Spring Cloud LoadBalancer 4.x 的实例缓存由 spring.cloud.loadbalancer.cache.ttl 控制,默认 35s,以官方文档为准),切换后要等缓存过期或主动刷新,并确认没有请求继续落到旧组。
机房亲和是同一套逻辑:先按 dc/zone 过滤,本地实例不足再跨机房兜底。跨机房多一跳网络,延迟从同机房亚毫秒级升到毫秒级甚至更高,取决于专线与距离,所以只适合兜底;跨机房比例要有上限并单独观测。标签的传递边界与环境一致:网关解析、Feign 透传、线程池用 TaskDecorator、MQ 写消息头,缺一段下游就会按“无标签”处理。
规则:匹配、优先级与动态刷新
一条规则至少包含匹配条件、目标标识和权重,并按优先级排序,首条命中即返回。规则放配置中心,推送到本地后用不可变对象整体替换,避免读到更新到一半的列表;规则版本号要写进日志或响应头,排障时才能确认当时生效的是哪一版。
比例决定“多少请求进灰度”,权重决定“灰度实例之间怎么分”,两者相乘。新实例还要预热(warm-up):按启动时长给权重,线性 min(1, uptime/warmup) 或平方曲线 (uptime/warmup)^2,后者前期更保守。预热时长是经验值,取决于 JIT、连接池和缓存加载时间,没有通用最优解。
数据兼容:灰度不是隔离环境
灰度版和稳定版共享库表、接口和消息,契约只能做向后兼容的加法:新增字段带默认值、消费端忽略未知字段、新接口不被旧版本调用。破坏性变更(改类型、删列、改名、新增必填字段)不能放在灰度里做,否则稳定版会立刻读写失败。
必须变更时走扩展与收缩(expand/contract):先加新字段并双写双读,等全量且旧流量归零,再切读、最后删旧字段。顺序是“先兼容代码,再改数据,最后删旧逻辑”,反过来就会在灰度窗口里制造新旧不兼容。
实战场景
一、灰度只走了一半:新版本从未被真正验证
现象:订单改价逻辑做了全链路灰度,入口 1% 流量进 v2,观察 30 分钟错误率与 P99 与基线持平,于是全量。全量后 2 小时出现改价金额错误的线上问题,复盘发现灰度窗口里 v2 订单服务实例的处理量只有入口量的 1/10——库存扣减与优惠券核销两段始终跑在 v1。
排查:错误率持平不等于灰度生效,先看流量到底落在谁身上。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 实例维度 QPS | 按实例(不是按服务)统计请求数,看 v2 实例是否有量 | 入口有量、下游 v2 为 0 → 链路在中间断开 |
gray_tag_missing_total | 下游访问日志里没有 tag 的请求数 | 持续上升 → 透传断了 |
| 断点位置 | 逐跳比对:网关 → A → B → MQ → C 的日志里 tag 值 | 最后一条有 tag 的跳之后即为断点 |
| MQ 消息头 | 消费端日志打印消息属性里的 x-gray-tag | 生产端没写 → 消费端无从恢复 |
| 线程池 | 是否存在未装饰的 @Async/自建线程池 | 异步任务里 GrayContext.get() 为 null |
本例断点在 MQ:改价链路里有一步是发消息给库存服务,生产端没有把标识写进消息头。消费端按“无标识”处理,走稳定版——完全不报错。
对症做法:
java
// 1. 生产端显式携带,消费端显式恢复并清理
// producer
message.putHeader("x-gray-tag", GrayContext.get());
// consumer
String tag = message.getHeader("x-gray-tag");
GrayContext.set(tag == null ? "" : tag);
try { handle(message); } finally { GrayContext.clear(); }
// 2. 线程池统一装饰,杜绝“忘记传”的入口
@Bean TaskDecorator grayDecorator() {
return runnable -> {
String tag = GrayContext.get(); // 提交时捕获
return () -> {
GrayContext.set(tag); // 执行线程设置
try { runnable.run(); } finally { GrayContext.clear(); }
};
};
}
// 3. 验证环境用快速失败,把“静默回退”变成显式错误
// 生产可以回退稳定版,但必须记 gray_fallback_total,且不允许长期非零灰度覆盖率要能算出来:tag 完整的请求数 / 进入灰度的请求数。低于 100% 就说明链路有洞,本次灰度结论不成立。
复盘要点:这次错误的根源是把“没有报警”当成“验证通过”。灰度发布的前置条件是链路自检:入口打标后,链路上每一跳都能在同一 traceId 下查到 tag;这条自检脚本应该做成发布门禁,跑不过就不允许放量。全量前还要单独确认“v2 实例确实处理过关键业务动作”,看实例 QPS 与业务流水,而不是看大盘。
二、普通用户看到了灰度功能:上下文残留
现象:灰度结束后一周,仍有零星非灰度用户看到新界面(约每天 5 例,经验值,需校准),且同一用户刷新两次结果不同。v2 实例早已下线,这些请求最终落到 v1,但响应里带着 v2 的展示开关。
排查:标识来源只有可能是本次请求携带或线程里的残留。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 请求是否自带标识 | 抓网关入口日志:该请求进入时有无 x-gray-tag | 没有 → 标识在服务内部产生 |
| 线程复用 | 打印 Thread.currentThread().getName() 与 tag 的组合 | 同一 exec 线程上一个请求是 v2、下一个是空 → 残留 |
| 清理点 | 过滤器是否在 finally 里 remove();preHandle 返回 false 的路径是否清理 | 只在正常返回路径清理 → 异常/拦截路径漏清 |
| 异步边界 | @Async、线程池任务是否把 tag 留在池线程上 | 池线程长期持有 tag,被后续任务复用 |
| 规则版本 | 响应头里的规则版本号与当时生效配置是否一致 | 规则缓存更新到一半 → 读到中间状态 |
根因是 Tomcat 线程复用叠加清理缺失:过滤器里 GrayContext.set(tag) 之后,异常路径没走到清理;下一个请求在同一个线程上读到了上一个请求的标识。
对症做法:
java
// 1. set 与 clear 必须在同一个 try/finally 结构里,任何出口都清
@Override protected void doFilterInternal(HttpServletRequest r, HttpServletResponse s, FilterChain c)
throws ServletException, IOException {
GrayContext.set(headerOrEmpty(r, "x-gray-tag")); // 空值也要显式写入,覆盖残留
try { c.doFilter(r, s); }
finally { GrayContext.clear(); } // remove 而不是 set(null)
}
// 2. 拦截器 preHandle 返回 false 前自行清理:该拦截器的 afterCompletion 不会执行
// 3. 规则对象用不可变整体替换,避免读到更新到一半的列表
volatile GrayRuleSet rules = GrayRuleSet.parse(configFromNacos); // 推送时整体换引用
// 4. 入口一律“以本次请求为准”,不允许继承:没有 header 就当无标识处理清理口径要统一到一句:上下文的生命周期以“最外层保证执行的 finally”为边界。协议链路里同类问题在 Spring MVC 与 Netty 都有对应写法,见 Spring MVC 请求链路。
复盘要点:残留类问题的用例是“同线程连续两个请求”:把容器线程池设为 1,先发一个带 tag 的请求、再发一个不带的,断言第二个请求的 tag 为空;这条测试 10 秒就能写完,却能挡住整类故障。灰度结束后还要验证“tag 全链路为空”的指标分布,作为收尾证据。
三、灰度期的必填字段:稳定版被新消息格式打挂
现象:灰度放量到 30% 后,稳定版消费者大量报错 Unknown enum value: PRICE_ADJUSTED_V2 与 Required field 'adjustType' is missing,MQ 堆积 40 万条;生产端只有 v2 实例在发新格式。回滚 v2 后堆积才开始消化。
排查:消息与库表是共享资源,灰度只改路由,不改这份共享事实。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 谁生产谁消费 | 列出该 topic 的所有生产实例版本与消费实例版本 | 双版本共存时格式必须兼容,单向不兼容即事故 |
| 格式差异 | v1/v2 消息体逐字段 diff:新增必填、枚举新增值、类型变更 | 三类都是破坏性变更 |
| 消费端容错 | 反序列化配置是否忽略未知字段;枚举是否有默认分支 | 严格模式 + 新枚举值 → 必然抛错 |
| 库表变更 | 灰度版是否执行过 DDL:改类型、删列、加必填列 | DDL 是全局生效,不受灰度路由约束 |
| 堆积影响 | 死信/重试策略是否把失败消息无限重投 | 无限重投会阻塞整个队列 |
对症做法:
java
// 1. 只做加法:新字段可选 + 默认值,消费端忽略未知字段
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrderChangedV1 { /* 原有字段不动 */ }
// 2. 枚举扩展要带未知兜底,禁止 switch 无 default
public AdjustType adjustType() { return AdjustType.parseOr(raw, AdjustType.UNKNOWN); }
// 3. 新逻辑用新 topic/新版本字段双写,稳定版继续读旧格式;全量后再切读
// expand 阶段
producer.send(legacyTopic, oldPayload);
producer.send(newTopic, newPayload); // 双写,读仍以旧为准
// contract 阶段(全量且旧流量归零后)
producer.stopLegacy(); // 再删旧字段与旧 topicsql
-- 4. DDL 走扩展与收缩:先加可空列并双写,再切读,最后收紧约束
ALTER TABLE order_item ADD COLUMN adjust_type VARCHAR(32) NULL; -- 先加,允许 NULL
-- 全量且稳定版不再写旧列之后,才考虑 NOT NULL / 删列顺序只有一种是对的:先兼容代码,再改数据,最后删旧逻辑。反过来会在灰度窗口里同时存在读写不兼容的两个版本。
复盘要点:破坏性变更不该进灰度,这是设计阶段的约束而不是运行时技巧。判据可以前置到流水线:消息 schema 与库表 DDL 的变更单里必须标注“是否向后兼容”,不兼容的变更走独立发布窗口(先全量兼容代码,再改数据);同时给消费端配“死信 + 人工队列”,避免一条格式异常的消息阻塞整个队列。相关一致性方案见 分布式事务。
最小上手
Spring Cloud LoadBalancer 4.x 的自定义选择器,省略 import 与错误处理:
java
final class GrayContext {
private static final ThreadLocal<String> TAG = new ThreadLocal<>();
static String get() { return TAG.get(); }
static void set(String v) { TAG.set(v); }
static void clear() { TAG.remove(); }
}
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private final ObjectProvider<ServiceInstanceListSupplier> suppliers;
private final Random random = new Random();
public GrayLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> suppliers) {
this.suppliers = suppliers;
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
return suppliers.getIfAvailable().get().next().map(list -> {
String tag = GrayContext.get();
List<ServiceInstance> matched = tag == null ? List.of() :
list.stream().filter(i -> tag.equals(i.getMetadata().get("tag"))).toList();
// 无匹配时回退全量(稳定版);严格模式可改为返回空
List<ServiceInstance> pool = matched.isEmpty() ? list : matched;
return new DefaultResponse(pool.get(random.nextInt(pool.size())));
});
}
}前置条件:注册中心里的实例带有 tag 元数据,GrayContext 已在 Web 过滤器里写入。验证时从网关发起,带标识和不带标识各一次:
bash
curl -s -D - -H 'x-gray-tag: v2' http://localhost:8080/api/order/1 -o /dev/null
curl -s -D - http://localhost:8080/api/order/1 -o /dev/null预期:第一条落到 tag=v2 的实例,第二条落到稳定版。判断依据看响应头或服务日志里的 tag、实例地址;若第一条仍是稳定版,先查网关到服务这一跳有没有把 x-gray-tag 丢掉或改写。
生产动作
- 发布顺序:部署灰度实例(低权重)→ 白名单/内部用户 → 观察 15–30 分钟 → 5% → 10% → 50% → 100% → 稳定版缩容。每一步都要有明确的继续/回滚判据,例如 tag 维度错误率不超过基线、P99 不劣化。
- 观测按 tag 拆维度:错误率、P95/P99、超时、MQ 消费延迟都要能按
tag聚合。总错误率持平不代表灰度正常,1% 的流量会被大盘平均掉。 - 两个自定义指标:
gray_fallback_total(无匹配实例、回退稳定版的次数)和gray_tag_missing_total(下游收到请求但没有标识的次数)。后者持续上升通常意味着透传断了。 - 回退开关:配置中心提供
gray.enabled=false,让路由忽略标识、全部回稳定版,要求秒级生效且不依赖重启。切开关后灰度实例仍可能被直连或健康检查访问,必要时同时从注册中心摘除。 - 灰度实例下线与回收:先移除规则或权重归零 → 等在途请求和 MQ 消费完成 → 再注销实例;灰度期的双写与兼容字段要在全量后按计划删除,否则“临时兼容”会永久留下。
排障路径:
- 灰度用户看到旧功能 → 查网关日志的
x-gray-tag、再看下游访问日志的tag→ 入口有、下游无,说明 Header 被网关 strip 或拦截器没生效。 - 部分请求走稳定版 → 看
gray_fallback_total和注册中心里 v2 健康实例数 → 实例不足、健康检查未通过或被机房硬约束过滤。 - 同一用户版本跳变 → 查百分比规则的哈希键 → 用了随机数或 IP 就改为用户/租户 ID。
- 异步之后链路断 → 查线程池是否配置
TaskDecorator、MQ 消息头是否存在 → 消费端日志没有 tag 即是断点。 - 灰度消息让稳定版消费失败 → 查消息是否新增必填字段或枚举 → 回滚生产端格式,改为可忽略的兼容字段。
边界
- 要求每一层都支持:网关、同步 RPC、线程池、MQ、定时任务、第三方回调,缺一层就在那里断。支持程度决定的是灰度覆盖率,不是“有没有灰度”。
- 双版本有持续成本:每次发布都要维护两套实例、两套规则和 tag 维度监控;发布频繁时,规则配置本身会成为新的故障源。
- 不能替代金丝雀分析(canary analysis):灰度只负责把流量送过去,判断新版本是否更好要靠基线、样本量和统计对比;没有这些,灰度只是“让少数用户先试”。
- 无状态服务更容易:有状态服务要考虑粘性、本地缓存和连接迁移;库表变更、消息格式变更必须单独设计。另外,多租户/环境隔离是硬约束,灰度是软路由,混用会让测试流量进生产或让灰度用户越权访问。
- 百分比不是随机抽样:按请求随机会把同一用户拆到两个版本,体验和问题复现都不可控。
讲清楚
全链路灰度解决的是“入口灰了、下游没灰”导致的验证不完整:入口解析的标识写进线程上下文,同步调用靠拦截器透传,异步靠 TaskDecorator 和消息头携带,下游按实例元数据的 tag 过滤实例。最关键的取舍是,它用“每一层都要参与”换“整条链路可验证”,任何一处 ThreadLocal 没清理、Header 被 strip、线程池没装饰,链路都会静默退回稳定版且不报错。回退稳定版必须记指标,否则结论失真。它不能替代金丝雀分析,也不解决库表和消息格式的兼容问题;有状态服务和破坏性 schema 变更要单独设计。