做微服务的人,迟早要面对灰度发布这件事。我自己的经历是从一次大促前夜的发布事故开始的:当时某个核心服务升级了底层缓存逻辑,为了稳妥只灰度了入口网关的20%流量,结果线上还是出了问题——订单服务调了新版用户服务,用户服务又调了新版优惠券服务,一条链路上新旧版本混着跑,数据格式对不上,直接导致一批订单异常。事后复盘,问题不在某个服务,而在于灰度只做了入口,链路中间全是"盲区"。
那次事故之后我花了两周时间,把整个灰度方案推翻重做,核心思路就是"全链路流量染色"。这套方案基于 Spring Cloud 生态落地,原理不算复杂:在网关层给请求打上一个标记(染色),这个标记随着调用链一路传递到各个微服务,每个服务在负载均衡时根据标记把流量路由到对应的灰度实例或稳定实例。本文就把这套方案从设计、编码到踩坑完整拆解一遍,适合那些已经上了微服务、正在被灰度发布折磨的团队参考。
1. 灰度发布为什么这么难:先看清服务拆分的连锁反应
1.1 单体时代的灰度,在微服务时代失效了
先说说单体时代怎么做灰度。一个应用打包成WAR或者JAR,前面挂Nginx,灰度就是按IP、按Cookie或者按百分比,把进入Nginx的流量分到新旧两个集群。请求进到应用内部之后,所有逻辑都在同一个进程里,不存在"链路中间版本不匹配"的问题。
微服务拆开之后,情况完全不同。一个用户请求从网关进来,可能要经过四五个服务:网关 → 订单服务 → 库存服务 → 优惠券服务 → 用户服务。如果我只在网关层做了灰度,网关把20%流量分给"新版订单服务",但是"新版订单服务"内部调用的还是"旧版库存服务",然后"旧版库存服务"回调"旧版优惠券服务"……新旧版本的协议、字段、SQL逻辑一旦有差异,整条链路上的数据就开始"打架"。
更麻烦的是,微服务场景下每个服务都是独立部署的,可以有自己的发布节奏。A团队想灰度订单服务,B团队想灰度库存服务,C团队暂时不动。如果没有统一的标记传递机制,灰度范围没法精确控制,"灰度发布"就退化成"部分节点更新",出问题了根本定位不到是哪一段引起的。
我见过不少团队的"伪灰度"做法:新版本服务先启动几个实例,注册中心里新旧实例并存,但负载均衡完全随机,流量到了哪个实例全凭运气。这种做法的本质是"多实例部署",不是灰度发布——因为它没有办法按用户、按请求、按规则去精准控制流量走向。
1.2 "全链路灰度"到底要解决什么问题
全链路灰度要解决的问题,用一句话说就是:让一个请求从入口到出口,始终只走同一套版本的服务。
如果请求被标记为"灰度用户",那么不管这条调用链经过多少个服务,每个服务在选实例的时候都要优先选灰度版本;反之,如果请求没有标记,或者标记为"正式用户",那所有服务都走正式版本。
这样做的意义在于:
- 版本一致性:一条链路内,所有服务的版本是配套的,不会出现新旧混跑。
- 精细控制:你可以按用户维度、请求维度、百分比维度决定哪些流量走灰度,而且这个决定在入口做一次,整条链路自动生效。
- 快速验证和回滚:新版本有问题,把入口标记关掉就行,灰度流量瞬间归零,不用一台台重启服务。
1.3 流量染色在其中的位置
流量染色是实现全链路灰度的关键技术手段。"染色"这个词很形象,想象一下给白色水流中滴入一滴红色颜料,整条水管里的水都变成红色。具体到技术上,就是通过HTTP Header把标记信息从网关传下去,每个服务在发起下一个远程调用时,把这个Header原样透传,下游服务拿到Header之后,结合注册中心的实例元数据做路由决策。
这个方案有几个前提条件:服务之间的调用框架要能支持自定义Header透传(Spring Cloud生态里的OpenFeign、RestTemplate都能做到);注册中心要能保存实例的自定义元数据;负载均衡逻辑要支持自定义策略。这三件事,Spring Cloud体系内都有成熟解法,这也是为什么标题里说"Spring Cloud全链路"——因为这套技术栈天然具备实现条件。
2. 流量染色方案设计:从标记规范到路由决策
2.1 染色标记规范设计
做方案设计的时候,第一个决策就是标记长什么样。我的建议是:一个请求头,KV结构,值用K1=V1&K2=V2这种格式,不要搞得太复杂。
我实际在项目里用的Header名是X-Gray-Tag,取值大概是这样的:
X-Gray-Tag: uid=10086&channel=app&version=v2.1.0几个字段分别代表:
uid:用户ID,用于按用户维度的灰度,比如某个内部测试账号永远走灰度。channel:渠道标识,比如app、web、mini-program,某些渠道可以先切灰度。version:目标版本号,表示这个请求应该路由到哪个版本的服务实例。
为什么不直接用单个值比如X-Gray-Tag: gray?因为真实场景里灰度策略往往需要多维度的组合。只有gray/normal二值标记,意味着每个服务只能无脑分流,不够灵活。uid和channel让网关可以按规则动态决策灰度范围,而version则是给下游服务路由用的,比如订单服务同时存在v2.1.0和v2.0.9两套实例,请求头里带了version=v2.1.0,负载均衡就优先选标注了v2.1.0的实例。
这个设计还有一个好处:规则下沉。网关不需要知道每个服务有哪些版本,它只负责在入口处根据规则给请求"染色",具体的版本匹配逻辑由各服务的负载均衡组件自己处理。职责分离,好维护。
2.2 全链路传递机制设计
Header在网关层生成之后,要做的就是随着调用链一路传递下去。这里有个特别容易踩的坑:Spring Cloud里的远程调用默认不会透传Header。
以OpenFeign为例,默认情况下它只会传递自己生成的一些必要Header,自定义的X-Gray-Tag根本不会被带到下游。更隐蔽的问题是Hystrix线程池隔离(如果还在用)会把ThreadLocal上下文清掉,所以标记不仅要解决"HTTP层面传递",还要解决"线程层面传递"。
我的做法是引入TransmittableThreadLocal配合Feign的RequestInterceptor来实现。思路是:
- 网关染色后,把
X-Gray-Tag存到当前线程的上下文里; - OpenFeign发送请求前,通过
RequestInterceptor把上下文中的X-Gray-Tag写到即将发出的请求Header里; - 下游服务收到请求,先把
X-Gray-Tag从Header解析出来,存到自己的上下文,然后继续向下传递。
这个机制本质上是"请求头提取 → 上下文保存 → 请求时注入",环环相扣。
2.3 路由决策模型
染色标记传递到了每个服务之后,每个服务怎么决定把请求发给哪个实例?
答案在负载均衡那一层。Spring Cloud LoadBalancer(新版本已经用这个替代了Ribbon)允许通过ServiceInstanceListSupplier自定义实例列表的获取逻辑。我们可以写一个灰度感知的Supplier:拿到注册中心返回的所有实例后,读取每个实例的元数据(比如version字段),再结合请求头里的X-Gray-Tag,过滤出目标版本实例。
如果下游只有灰度实例没有稳定实例,或者反过来,路由策略需要有一个兜底逻辑。我当时的策略是:
- 请求带了明确版本号,优先匹配同版本实例;
- 没有匹配到,降级到"稳定版实例"(即不带任何版本标记的实例),保证请求不能因为没有灰度实例而失败;
- 如果稳定版也没有,直接抛异常,宁可快速失败也不要把请求发给错误版本。
这个兜底逻辑非常重要,很多灰度方案做了一半就"上线崩"或者"灰度不生效",都是因为漏了兜底处理。
3. 基于Spring Cloud的实操落地:从注册中心到网关到调用链
3.1 注册中心元数据:让实例自带版本标签
全链路灰度的基础是实例能被区分。我用的是Nacos作为注册中心(Consul也行,原理一样),在服务启动的时候给实例配置元数据。具体做法是给Spring Boot的配置文件加上自定义属性:
spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848 metadata: version: v2.1.0 env: gray deploy-time: "2024-11-20 10:00:00"稳定版本的服务不要加version字段,或者统一写成version: stable。这样在注册中心里,同一个服务名下就有两类实例:带版本号的灰度实例和不带版本号的稳定实例。这一步是整个方案能落地的前提,如果实例本身没有版本标识,负载均衡就算拿到了X-Gray-Tag也不知道该往哪里转。
我强烈建议部署的时候用CI/CD自动注入这个metadata,而不是写在代码里。因为同一个代码分支可能同时部署到稳定环境和灰度环境,写死在配置文件里会带来很大的维护成本。用流水线参数动态生成配置文件片段,或者启动时通过环境变量注入,更符合实际操作习惯。
3.2 网关染色:全局Filter实现
网关是整个染色链路的起点,所有外部的HTTP请求都要从这里经过,在这里决定"这个请求要不要染色、染什么色"。
我用的是Spring Cloud Gateway,实现一个GlobalFilter,负责读取请求信息,结合灰度规则库,给请求打上X-Gray-Tag头:
@Component public class GrayTagFilter implements GlobalFilter, Ordered { private final GrayRuleService grayRuleService; public GrayTagFilter(GrayRuleService grayRuleService) { this.grayRuleService = grayRuleService; } @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String uid = request.getQueryParams().getFirst("uid"); String channel = request.getHeaders().getFirst("X-Channel"); // 如果上游已经传了灰度标记(多级网关场景),直接透传 String grayTag = request.getHeaders().getFirst("X-Gray-Tag"); if (grayTag == null) { grayTag = grayRuleService.resolveGrayTag(uid, channel); } ServerWebExchange mutatedExchange = exchange.mutate() .request(builder -> builder.header("X-Gray-Tag", grayTag)) .build(); return chain.filter(mutatedExchange); } @Override public int getOrder() { // 这个Filter要在路由之前执行,但也要注意和鉴权、限流的顺序 return -100; } }这里注意几个设计细节:
- Filter顺序:灰度染色一定要放在路由转发之前,但具体放在鉴权前还是后,取决于你的团队怎么发布规则。如果灰度规则里包含"某些内部用户直接走灰度",那染色要在鉴权之前,因为鉴权可能消耗额外资源。如果灰度规则依赖登录后的用户信息,那就在鉴权之后。
- 上游透传:如果整条链路有二级网关(比如内网网关 + 外网网关),外层网关已经染过色,内层网关就不要重新染色,否则可能把规则覆盖掉。
- 默认值兜底:
resolveGrayTag一定要保证永远返回一个字符串,哪怕是不带任何标记的空串,而不是返回null。因为builder.header()如果写入一个null值在后续处理中会出问题。
3.3 OpenFeign调用链传递:三行代码解决Header透传
网关染好色之后,请求会落到第一个微服务。接下来就是最关键的:请求从这个服务往下一个服务发起OpenFeign调用时,X-Gray-Tag不能被丢掉。
在Spring Boot 2.x + Spring Cloud Hoxton之后,实现很简单,写一个RequestInterceptor的Bean即可:
@Configuration public class FeignGrayTagInterceptor implements RequestInterceptor { private static final String GRAY_TAG_HEADER = "X-Gray-Tag"; @Override public void apply(RequestTemplate template) { // 从当前请求上下文里提取灰度标记 String grayTag = GrayTagContextHolder.get(); if (grayTag != null && !grayTag.isEmpty()) { template.header(GRAY_TAG_HEADER, grayTag); } } }这里的GrayTagContextHolder是我做的一个ThreadLocal封装。为什么不用RequestContextHolder直接拿?因为RequestContextHolder是基于RequestAttributes的,在某些异步场景下不好使。而且它保存的是整个Request对象,我们只需要一个字符串,没必要背那么重的上下文。
每一个被调用的服务,入口处都要做这样一件事:
@Component public class GrayTagMvcInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String grayTag = request.getHeader("X-Gray-Tag"); if (grayTag != null && !grayTag.isEmpty()) { GrayTagContextHolder.set(grayTag); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束一定要清理,不然线程池复用时灰度标记会串 GrayTagContextHolder.clear(); } }这个拦截器需要注册到Spring MVC的拦截器链中。"用完清理"这一点非常重要,如果你的服务用到了线程池,请求处理完不清理,下一个请求从这个线程拿到的就是上一个请求的灰度标记,产生"串色",故障很难排查。
3.4 自定义负载均衡:实现版本感知路由
标记已经传到每个服务了,最后一步是让负载均衡"看懂"这个标记。
Spring Cloud LoadBalancer的扩展点是对ServiceInstanceListSupplier做二次封装。核心逻辑是:从注册中心获取全部实例 → 解析当前请求的X-Gray-Tag→ 按版本过滤实例 → 如果过滤结果为空,按兜底策略处理。
public class GrayVersionInstanceListSupplier extends DelegatingServiceInstanceListSupplier { private static final String VERSION_METADATA_KEY = "version"; public GrayVersionInstanceListSupplier(ServiceInstanceListSupplier delegate) { super(delegate); } @Override public Flux<List<ServiceInstance>> get() { return getDelegate().get().map(this::filterByVersion); } private List<ServiceInstance> filterByVersion(List<ServiceInstance> instances) { String grayTag = GrayTagContextHolder.get(); // 没有灰度标记,直接返回稳定实例 if (grayTag == null || grayTag.isEmpty()) { return filterStableInstances(instances); } Map<String, String> tagMap = parseGrayTag(grayTag); String targetVersion = tagMap.get("version"); if (targetVersion == null || targetVersion.isEmpty()) { return filterStableInstances(instances); } List<ServiceInstance> matchedInstances = instances.stream() .filter(instance -> { String version = instance.getMetadata().get(VERSION_METADATA_KEY); return targetVersion.equals(version); }) .collect(Collectors.toList()); if (!matchedInstances.isEmpty()) { return matchedInstances; } // 兜底:没有匹配的灰度实例,回退到稳定实例 return filterStableInstances(instances); } private List<ServiceInstance> filterStableInstances(List<ServiceInstance> instances) { List<ServiceInstance> stables = instances.stream() .filter(instance -> instance.getMetadata().get(VERSION_METADATA_KEY) == null) .collect(Collectors.toList()); return stables.isEmpty() ? instances : stables; } }需要说明的是负载均衡看的是GrayTagContextHolder,不是请求头本身——因为灰度标记在服务入口已经存进上下文了,负载均衡在调用链的哪个位置执行不受控制,用上下文比用Request更可靠。
把自定义Supplier挂到配置里:
@Configuration public class GrayLoadBalancerConfiguration { @Bean public ServiceInstanceListSupplier grayTagSupplier( DiscoveryClient discoveryClient, Environment environment) { ServiceInstanceListSupplier delegate = ServiceInstanceListSupplier.builder() .withDiscoveryClient(discoveryClient) .build(environment); return new GrayVersionInstanceListSupplier(delegate); } }然后在调用方服务上标注使用这套负载均衡配置。
4. 灰度策略管理:从规则配置到发布节奏
4.1 规则库设计与动态刷新
染色不是把灰色标记写死在代码里,而是要有独立的规则库。我在网关服务里做了一个简单的灰度规则表,数据源用的是配置中心(Nacos Config),规则本身是个JSON,这样修改规则不用重新发布代码:
{ "rules": [ { "name": "内测用户全量灰度", "match": { "type": "uid_suffix", "value": "9999" }, "grayTag": "channels=internal&version=v2.1.0" }, { "name": "app渠道10%灰度", "match": { "type": "percentage", "channel": "app", "value": "10" }, "grayTag": "version=v2.1.0" } ], "defaultGrayTag": "version=stable" }规则引擎的执行顺序很重要。我的习惯是:精确匹配优先,百分比策略其次,默认兜底最后。因为精确匹配(比如指定某个测试账号)是高风险操作,必须优先保障;百分比灰度是基于概率的,默认策略无关紧要。
这里分享一个实操心得:百分比灰度不要用Math.random(),因为随机数不可控,同一个用户两次请求可能被分到不同版本,体验割裂。正确做法是对用户ID做哈希取模,比如uid.hashCode() % 100 < 10,这样同一个用户始终在灰度名单里,到了"全量发布"阶段也不会有切换的感觉。
4.2 发布节奏控制:从内测到全量的四步走
灰度发布本质上是一个"风险逐步释放"的过程,我总结为四个阶段:
第一阶段:内部验证期。灰度实例部署好之后,先不要对外放量。用内部测试账号、测试手机号作为固定灰度名单,走一遍核心链路。这个阶段发现的Bug要立刻修,因为影响范围只有一个或几个测试账号。我一般会让核心开发人员的个人账号都在灰度名单里,让他们日常开发过程中就"被动"使用灰度环境,发现问题往往比专门测试更快。
第二阶段:小流量观察期。内部验证没问题后,放1%到5%的真实流量。这个阶段的目的是暴露真实环境才有的问题——数据量、并发、第三方依赖的差异。监控指标要盯死:错误率、响应时间、GC频率、线程池活跃度。这个阶段持续时间建议至少一个完整业务周期(比如一天),让流量自然覆盖各种场景。
第三阶段:百分比放量期。从5%逐步放到10%、30%、50%。每放一次,观察20到30分钟,确认指标平稳再继续。如果过程中有任何指标异常,立即把百分比调回上一个安全值。不要追求一步到位,灰度系统存在的意义就是让你有机会停下来调整。
第四阶段:全量发布。百分比放到100%,把稳定实例从注册中心摘除,或者直接升级稳定实例到新版本。全量发布后灰度规则清空,链路恢复"无标记"状态。全量不等于结束,接下来一个小时内还要持续观察,因为全量后流量分布变化,可能触发新的瓶颈。
4.3 灰度期间的链路可观测性
灰度发布期间,"看不到链路状态"和"没有灰度"一样危险。我给这套方案配套了三个基础观测工具:
- 日志染色:日志框架的输出格式里加上灰度标记字段。这样排查问题的时候,看到一条报错日志,立刻知道它来自灰度流量还是稳定流量,判断要不要优先处理。
- TraceId串联:确保全链路有唯一TraceId(Spring Cloud Sleuth或者Micrometer Tracing都能做),灰度标记关联到Trace上,在链路追踪系统里可以直接筛选灰度请求的表现。
- 业务指标对比:灰度期间我习惯在关键服务里埋点,输出"灰度版本处理耗时""稳定版本处理耗时"两个指标,对比曲线。版本升级后性能变差是最常见的问题,有了对比指标就能第一时间发现。
5. 常见问题与排查技巧实录
5.1 问题一:灰度标记传到一半就丢了
现象:网关染了色,第一个服务也拿到了X-Gray-Tag,但第二次OpenFeign调用时下游服务收到的Header里没有这个标记。
排查过程:先确认RequestInterceptor有没有生效——在Interceptor的apply方法里打印日志,看看有没有执行;如果执行了,再确认GrayTagContextHolder里有没有值——极有可能是上游服务的入口拦截器没有把Header解析到上下文,或者解析之后被异步线程丢掉了。
经验:除了Feign,还要检查两类调用方式。第一类是RestTemplate,需要单独配置RestTemplateInterceptor;第二类是异步线程池,比如你用了@Async或者自己提交线程池执行任务,线程切换后ThreadLocal里的标记直接丢失。这个问题我用TransmittableThreadLocal解决,它在创建子线程时能自动复制父线程的上下文,比原生ThreadLocal适合这种场景。
5.2 问题二:负载均衡规则不生效,灰度流量打到了稳定实例
现象:灰度标记正常传递,ServiceInstanceListSupplier的代码也写了,但是被调用的服务实例依然是稳定版。
排查过程:Spring Cloud LoadBalancer的ServiceInstanceListSupplier有两种加载方式,一种是全局配置,一种是针对某个服务的配置。如果你的配置写在了@LoadBalancerClients(defaultConfiguration = GrayLoadBalancerConfiguration.class),但它管不到另一个包下的FeignClient,就会出现"规则没挂上"的情况。逐个服务、逐个FeignClient检查配置生效情况是排查这类问题的快速路径。
另一个坑:注册中心的实例元数据缓存。Nacos客户端默认会缓存实例列表,服务实例刚启动或元数据更新后,客户端不一定立即感知到。可以通过Nacos控制台看实例详情里有没有version字段,没有的话大概率是启动脚本没注入元数据。
5.3 问题三:异步线程内灰度标记串了
现象:一个请求的灰度标记莫名其妙出现在另一个请求的调用链里。
排查过程:看日志里的TraceId和灰度标记时间线,基本能定位到是哪个线程复用造成的。核心原因就是线程池的工作线程被多个请求复用,前一个请求处理完没有清理GrayTagContextHolder。
解决:除了入口Interceptor里做clean up之外,在RequestInterceptor里也要做一次安全清理——发送请求前先判断当前线程有没有标记,有就写入Header,无论有没有都建议先clear再set。绝对不要裸用ThreadLocal而忽略清理,这是所有链路透传方案最容易翻车的地方。
5.4 问题四:大版本升级时数据格式不兼容
现象:新版服务接口字段改了,灰度链路内新老服务之间调用时,老服务解析不了新字段。
排查过程:这类问题其实不是"链路路由"的问题,而是兼容设计问题。做全链路灰度之前,一定要对接口级别的字段变更做约束:新增字段用默认值兜底,删除字段要提前一个版本过期。如果实在无法兼容,那灰度就不能只是"路由到不同实例",还必须配合数据库双写、缓存双读这类方案,让新旧版本各用各的数据,灰度结束后再做数据迁移。
经验:全链路灰度解决的是"流量路由"问题,不是"数据兼容"问题。它降低的是发布风险,但前提是新旧版本之间对外的协议要基本兼容。协议不兼容的升级,再怎么灰度都救不了。
5.5 快速问题排查速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 标记传递中断 | Feign/RestTemplate未配置透传 | 检查Interceptor日志、Context内容 |
| 灰度流量打到稳定实例 | LoadBalancer配置未生效 | 检查@LoadBalancerClients配置、注册中心元数据 |
| 异步调用丢失标记 | 线程池清空了ThreadLocal | 改用TransmittableThreadLocal |
| 标记串到其他请求 | 线程复用未清理上下文 | 请求结束后强制clear |
| 百分比灰度用户跳变 | 用了随机数而非哈希取模 | 改为uid取模算法 |
| 灰度实例无流量 | Nacos元数据未及时同步 | 检查实例详情version字段 |
| 请求失败且无兜底 | 目标版本实例离线 | 检查兜底逻辑、实例健康检查 |
6. 写在最后:为了这套方案,我做了哪些取舍
回到开头那次事故。如果当时我把方案做成"全链路流量染色",即使订单服务升级了缓存逻辑,因为入口染色的原因,整个链路都会自动走新版本服务,不会出现新旧版本混跑。灰度发布不是"发布工具",它是风险控制工具,这个定位想清楚之后,整个方案设计就会围绕"可控"而不是"快"。
最后说几个层面的心得。
第一,染色标记一定要简单。能用一个Header解决的事情不要设计成三个Header,能用一个字符串搞定就不要搞成对象序列化。全链路透传本身就是复杂度,标记规范越复杂,传递链路越容易出问题。
第二,先做基础监控再做灰度。灰度方案上线之前,先把链路追踪、日志集中、指标监控搭好。没有可观测性,灰度规则做得再好也只是"盲人摸象"。我现在公司的灰度发布流程里有一条硬性要求:新服务接入灰度之前,必须先接入链路追踪和日志标准。
第三,灰度规则越晚决策越好。网关染色规则不要写死在Filter里,而是要做成配置中心动态下发。这样运营同学要开灰度、要切比例,一行配置改下去立刻生效,不用等开发改代码重新部署。
第四,兜底永远比规则优先。每个服务的负载均衡逻辑必须有"没有匹配实例怎么办"的答案。宁可让请求快速失败到稳定实例,也不能让请求在链路里不断重试最后超时。灰度期间"灰度版本实例突然挂掉"这种情况太常见了,没有兜底就是发布事故。
这套思路不只适用于Spring Cloud,其他微服务技术栈(比如Dubbo、Service Mesh)同样有对应的实现方式。核心不是某个框架的API怎么调,而是"给流量打标、标记全链路透传、基于标记做路由"这个思路本身。框架可以变,这套逻辑是通用的。