概念
灰度发布,也叫金丝雀发布,指的是在黑与白之间能够平滑过渡的一种发布方式。A/B Test 就是典型的灰度发布手段:让一部分用户继续使用 A 版本,另一部分用户开始使用 B 版本,如果 B 版本没有明显问题,再逐步扩大范围,最终把所有用户都迁移到 B 版本。
灰度发布的核心价值在于保证整体系统的稳定性。在初始灰度阶段就能发现并调整问题,从而控制影响范围。我们常说的金丝雀部署,本质上也是灰度发布的一种实现方式。
落到服务器层面,实际操作还可以做得更细。比如给第一批更新的 10 台服务器设置较低权重,控制发送给它们的请求数,再逐步提高权重、增加请求量。这种平滑过渡的思路,就叫“流量切分”。
组件版本说明
这个项目我已经维护了挺长时间,这里 Demo 使用的版本不算新,但胜在稳定,有感情了。如果你用的是更新版本,自己调整一下就行,核心实现思路是一样的,只是框架源码可能会有变化。
spring-boot: 2.3.12.RELEASE
spring-cloud-dependencies: Hoxton.SR12
spring-cloud-alibaba-dependencies: 2.2.9.RELEASE
Spring Cloud 对应的版本关系可以参考官方文档。
核心组件说明
- 注册中心:Nacos
- 网关:Spring Cloud Gateway
- 负载均衡器:Ribbon(使用 Spring Cloud LoadBalancer 实现也是类似的思路)
- 服务间 RPC 调用:OpenFeign
灰度发布代码实现
实现 Spring Cloud 灰度发布的技术方案有很多,重点在于服务发现——怎么把灰度流量只路由到灰度服务,而不是一股脑全打到生产环境。这个 Demo 使用 Spring Cloud + Nacos,核心思路是利用 Nacos 元数据 Metadata 设置一个 version 值,在调用下游服务时通过这个 version 来区分要调用哪个版本。部分流程这里会省略,文末提供源码地址。

代码设计结构
这是 Demo 项目,结构按最简单的来。
spring-cloud-gray-example // 父工程
kerwin-common // 项目公共模块
kerwin-gateway // 微服务网关
kerwin-order // 订单模块
order-app // 订单业务服务
kerwin-starter // 自定义springboot starter模块
spring-cloud-starter-kerwin-gray // 灰度发布starter包 (核心代码都在这里)
kerwin-user // 用户模块
user-app // 用户业务服务
user-client // 用户client(Feign和DTO)
核心包 spring-cloud-starter-kerwin-gray 结构介绍

入口 Spring Cloud Gateway 实现灰度发布设计
请求进入网关时开始判断是否需要调用灰度版本,通过 Spring Cloud Gateway 的过滤器实现。在调用下游服务时,通过重写 Ribbon 的负载均衡器,对灰度状态进行路由判断。
存储请求灰度标记 Holder(业务服务也使用这个)
使用 ThreadLocal 记录每个请求线程的灰度标记,前置过滤器会把标记设置到 ThreadLocal 中。
public class GrayFlagRequestHolder {
/**
* 标记是否使用灰度版本
* 具体描述请查看 {@link com.kerwin.gray.enums.GrayStatusEnum}
*/
private static final ThreadLocal<GrayStatusEnum> grayFlag = new ThreadLocal<>();
public static void setGrayTag(final GrayStatusEnum tag){
grayFlag.set(tag);
}
public static GrayStatusEnum getGrayTag(){
return grayFlag.get();
}
public static void remove(){
grayFlag.remove();
}
}
前置过滤器
在前置过滤器中,会对请求是否使用灰度版本进行判断,并将灰度状态枚举 GrayStatusEnum 设置到 GrayFlagRequestHolder 中,存储这一个请求的灰度状态。负载均衡器随后会取出这个枚举,判断要调用哪个版本的服务。此外,这个过滤器还实现了 Ordered 接口,排序值设置为 Ordered.HIGHEST_PRECEDENCE 的最小值,保证它最先执行。
public class GrayGatewayBeginFilter implements GlobalFilter, Ordered {
@Autowired
private GrayGatewayProperties grayGatewayProperties;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain){
GrayStatusEnum grayStatusEnum = GrayStatusEnum.ALL;
// 当灰度开关打开时才进行请求头判断
if (grayGatewayProperties.getEnabled()) {
grayStatusEnum = GrayStatusEnum.PROD;
// 判断是否需要调用灰度版本
if (checkGray(exchange.getRequest())) {
grayStatusEnum = GrayStatusEnum.GRAY;
}
}
GrayFlagRequestHolder.setGrayTag(grayStatusEnum);
ServerHttpRequest newRequest = exchange.getRequest().mutate()
.header(GrayConstant.GRAY_HEADER, grayStatusEnum.getVal())
.build();
ServerWebExchange newExchange = exchange.mutate()
.request(newRequest)
.build();
return chain.filter(newExchange);
}
/**
* 校验是否使用灰度版本
*/
private boolean checkGray(ServerHttpRequest request){
if (checkGrayHeadKey(request) || checkGrayIPList(request) || checkGrayCiryList(request) || checkGrayUserNoList(request)) {
return true;
}
return false;
}
/**
* 校验自定义灰度版本请求头判断是否需要调用灰度版本
*/
private boolean checkGrayHeadKey(ServerHttpRequest request){
HttpHeaders headers = request.getHeaders();
if (headers.containsKey(grayGatewayProperties.getGrayHeadKey())) {
List<String> grayValues = headers.get(grayGatewayProperties.getGrayHeadKey());
if (!Objects.isNull(grayValues)
&& grayValues.size() > 0
&& grayGatewayProperties.getGrayHeadValue().equals(grayValues.get(0))) {
return true;
}
}
return false;
}
/**
* 校验自定义灰度版本IP数组判断是否需要调用灰度版本
*/
private boolean checkGrayIPList(ServerHttpRequest request){
List<String> grayIPList = grayGatewayProperties.getGrayIPList();
if (CollectionUtils.isEmpty(grayIPList)) {
return false;
}
String realIP = request.getHeaders().getFirst("X-Real-IP");
if (realIP == null || realIP.isEmpty()) {
realIP = request.getRemoteAddress().getAddress().getHostAddress();
}
if (realIP != null && CollectionUtils.contains(grayIPList.iterator(), realIP)) {
return true;
}
return false;
}
/**
* 校验自定义灰度版本城市数组判断是否需要调用灰度版本
*/
private boolean checkGrayCiryList(ServerHttpRequest request){
List<String> grayCityList = grayGatewayProperties.getGrayCityList();
if (CollectionUtils.isEmpty(grayCityList)) {
return false;
}
String realIP = request.getHeaders().getFirst("X-Real-IP");
if (realIP == null || realIP.isEmpty()) {
realIP = request.getRemoteAddress().getAddress().getHostAddress();
}
// 通过IP获取当前城市名称
// 这里篇幅比较长不具体实现了,想要实现的可以使用ip2region.xdb,这里写死cityName = "本地"
String cityName = "本地";
if (cityName != null && CollectionUtils.contains(grayCityList.iterator(), cityName)) {
return true;
}
return false;
}
/**
* 校验自定义灰度版本用户编号数组(我们系统不会在网关获取用户编号这种方法如果需要可以自己实现一下)
*/
private boolean checkGrayUserNoList(ServerHttpRequest request){
List<String> grayUserNoList = grayGatewayProperties.getGrayUserNoList();
if (CollectionUtils.isEmpty(grayUserNoList)) {
return false;
}
return false;
}
@Override
public int getOrder(){
// 设置过滤器的执行顺序,值越小越先执行
return Ordered.HIGHEST_PRECEDENCE;
}
}
后置过滤器
后置过滤器的作用是在调用完下游业务服务之后、响应返回之前,把 GrayFlagRequestHolder 中的 ThreadLocal 清理掉,避免造成内存泄漏。
public class GrayGatewayAfterFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain){
// 请求执行完必须要remore当前线程的ThreadLocal
GrayFlagRequestHolder.remove();
return chain.filter(exchange);
}
@Override
public int getOrder(){
// 设置过滤器的执行顺序,值越小越先执行
return Ordered.LOWEST_PRECEDENCE;
}
}
全局异常处理器
全局异常处理器负责在异常场景下清理 GrayFlagRequestHolder 中的 ThreadLocal。为什么必须单独处理?因为调用下游业务服务时一旦抛异常,就进不了后置过滤器了。
public class GrayGatewayExceptionHandler implements WebExceptionHandler, Ordered {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex){
// 请求执行完必须要remore当前线程的ThreadLocal
GrayFlagRequestHolder.remove();
ServerHttpResponse response = exchange.getResponse();
if (ex instanceof ResponseStatusException) {
// 处理 ResponseStatusException 异常
ResponseStatusException responseStatusException = (ResponseStatusException) ex;
response.setStatusCode(responseStatusException.getStatus());
// 可以根据需要设置响应头等
return response.setComplete();
} else {
// 处理其他异常
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
// 可以根据需要设置响应头等
return response.setComplete();
}
}
@Override
public int getOrder(){
// 设置过滤器的执行顺序,值越小越先执行
return Ordered.HIGHEST_PRECEDENCE;
}
}
自定义 Ribbon 负载均衡路由(业务服务也使用这个)
「灰度 Ribbon 负载均衡路由抽象类:」 这里提供了两个获取服务列表的方法,会对 GrayFlagRequestHolder 中存储的当前线程灰度状态枚举进行判断。如果枚举值为 GrayStatusEnum.ALL,返回全部服务列表,不区分版本;如果为 GrayStatusEnum.PROD,返回生产版本服务列表;如果为 GrayStatusEnum.GRAY,返回灰度版本服务列表。版本号在 GrayVersionProperties 中配置,通过匹配服务列表中 Nacos Metadata 的 version 与配置版本号,筛选出对应版本的服务。
public abstract class AbstractGrayLoadBalancerRule extends AbstractLoadBalancerRule {
@Autowired
private GrayVersionProperties grayVersionProperties;
@Value("${spring.cloud.nacos.discovery.metadata.version}")
private String metaVersion;
/**
* 只有已启动且可访问的服务器,并对灰度标识进行判断
*/
public List<Server> getReachableServers(){
ILoadBalancer lb = getLoadBalancer();
if (lb == null) {
return new ArrayList<>();
}
List<Server> reachableServers = lb.getReachableServers();
return getGrayServers(reachableServers);
}
/**
* 所有已知的服务器,可访问和不可访问,并对灰度标识进行判断
*/
public List<Server> getAllServers(){
ILoadBalancer lb = getLoadBalancer();
if (lb == null) {
return new ArrayList<>();
}
List<Server> allServers = lb.getAllServers();
return getGrayServers(allServers);
}
/**
* 获取灰度版本服务列表
*/
protected List<Server> getGrayServers(List<Server> servers){
List<Server> result = new ArrayList<>();
if (servers == null) {
return result;
}
String currentVersion = metaVersion;
GrayStatusEnum grayStatusEnum = GrayFlagRequestHolder.getGrayTag();
if (grayStatusEnum != null) {
switch (grayStatusEnum) {
case ALL:
return servers;
case PROD:
currentVersion = grayVersionProperties.getProdVersion();
break;
case GRAY:
currentVersion = grayVersionProperties.getGrayVersion();
break;
}
}
for (Server server : servers) {
NacosServer nacosServer = (NacosServer) server;
Map<String, String> metadata = nacosServer.getMetadata();
String version = metadata.get("version");
// 判断服务metadata下的version是否于设置的请求版本一致
if (version != null && version.equals(currentVersion)) {
result.add(server);
}
}
return result;
}
}
「自定义轮询算法实现 GrayRoundRobinRule:」 代码篇幅太长,这里只截取关键片段。我直接拷贝了 Ribbon 的轮询算法,把其中获取服务列表的部分换成了自定义 AbstractGrayLoadBalancerRule 中的方法。其他算法也可以用类似的方式替换。

业务服务实现灰度发布设计
自定义 SpringMVC 请求拦截器
自定义 SpringMVC 请求拦截器获取上游服务的灰度请求头,如果拿到值就设置到 GrayFlagRequestHolder 中,后续如果有新的 RPC 调用,同样把灰度标记传递下去。
@SuppressWarnings("all")
public class GrayMvcHandlerInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String grayTag = request.getHeader(GrayConstant.GRAY_HEADER);
// 如果HttpHeader中灰度标记存在,则将灰度标记放到holder中,如果需要就传递下去
if (grayTag!= null) {
GrayFlagRequestHolder.setGrayTag(GrayStatusEnum.getByVal(grayTag));
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
GrayFlagRequestHolder.remove();
}
}
自定义 OpenFeign 请求拦截器
自定义 OpenFeign 请求拦截器会取出 GrayFlagRequestHolder 中的灰度标识,并把它放进调用下游服务的请求头中,实现灰度标记的透传。
public class GrayFeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template){
// 如果灰度标记存在,将灰度标记通过HttpHeader传递下去
GrayStatusEnum grayStatusEnum = GrayFlagRequestHolder.getGrayTag();
if (grayStatusEnum != null ) {
template.header(GrayConstant.GRAY_HEADER, Collections.singleton(grayStatusEnum.getVal()));
}
}
}
基础信息设计
这里定义一些基础参数,比如是否开启灰度、哪些请求需要走灰度版本等,为后续业务做准备。
public interface GrayConstant {
/**
* 灰度统一请求头
*/
String GRAY_HEADER="gray";
}
public enum GrayStatusEnum {
ALL("ALL","可以调用全部版本的服务"),
PROD("PROD","只能调用生产版本的服务"),
GRAY("GRAY","只能调用灰度版本的服务");
GrayStatusEnum(String val, String desc) {
this.val = val;
this.desc = desc;
}
private String val;
private String desc;
public String getVal(){
return val;
}
public static GrayStatusEnum getByVal(String val){
if(val == null){
return null;
}
for (GrayStatusEnum value : values()) {
if(value.val.equals(val)){
return value;
}
}
return null;
}
}
@Data
@Configuration
@RefreshScope
@ConfigurationProperties("kerwin.tool.gray.gateway")
public class GrayGatewayProperties {
/**
* 灰度开关(如果开启灰度开关则进行灰度逻辑处理,如果关闭则走正常处理逻辑)
* PS:一般在灰度发布测试完成以后会将线上版本都切换成灰度版本完成全部升级,这时候应该关闭灰度逻辑判断
*/
private Boolean enabled = false;
/**
* 自定义灰度版本请求头 (通过grayHeadValue来匹配请求头中的值如果一致就去调用灰度版本,用于公司测试)
*/
private String grayHeadKey="gray";
/**
* 自定义灰度版本请求头匹配值
*/
private String grayHeadValue="gray-996";
/**
* 使用灰度版本IP数组
*/
private List<String> grayIPList = new ArrayList<>();
/**
* 使用灰度版本城市数组
*/
private List<String> grayCityList = new ArrayList<>();
/**
* 使用灰度版本用户编号数组(我们系统不会在网关获取用户编号这种方法如果需要可以自己实现一下)
*/
private List<String> grayUserNoList = new ArrayList<>();
}
@Data
@Configuration
@RefreshScope
@ConfigurationProperties("kerwin.tool.gray.version")
public class GrayVersionProperties {
/**
* 当前线上版本号
*/
private String prodVersion;
/**
* 灰度版本号
*/
private String grayVersion;
}
@Configuration
// 可以通过@ConditionalOnProperty设置是否开启灰度自动配置 默认是不加载的
@ConditionalOnProperty(value = "kerwin.tool.gray.load",havingValue = "true")
@EnableConfigurationProperties(GrayVersionProperties.class)
public class GrayAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(value = GlobalFilter.class)
@EnableConfigurationProperties(GrayGatewayProperties.class)
static class GrayGatewayFilterAutoConfiguration {
@Bean
public GrayGatewayBeginFilter grayGatewayBeginFilter(){
return new GrayGatewayBeginFilter();
}
@Bean
public GrayGatewayAfterFilter grayGatewayAfterFilter(){
return new GrayGatewayAfterFilter();
}
@Bean
public GrayGatewayExceptionHandler grayGatewayExceptionHandler(){
return new GrayGatewayExceptionHandler();
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(value = WebMvcConfigurer.class)
static class GrayWebMvcAutoConfiguration {
/**
* Spring MVC 请求拦截器
* @return WebMvcConfigurer
*/
@Bean
public WebMvcConfigurer webMvcConfigurer(){
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(new GrayMvcHandlerInterceptor());
}
};
}
}
@Configuration
@ConditionalOnClass(value = RequestInterceptor.class)
static class GrayFeignInterceptorAutoConfiguration {
/**
* Feign拦截器
* @return GrayFeignRequestInterceptor
*/
@Bean
public GrayFeignRequestInterceptor grayFeignRequestInterceptor(){
return new GrayFeignRequestInterceptor();
}
}
}
项目运行配置
这里启动五个服务:一个网关服务、一个用户服务 V1 版本、一个订单服务 V1 版本、一个用户服务 V2 版本、一个订单服务 V2 版本,用来演示灰度发布效果。
PS:Nacos 的命名空间我这里叫 spring-cloud-gray-example,可以自己创建,也可以换成自己的命名空间。源码里的配置都是完整的,遇到问题看源码就行。
配置 Nacos 全局配置文件(common-config.yaml)
所有服务都会使用到这个配置。
kerwin:
tool:
gray:
# 配置是否加载灰度自动配置类,如果不配置那么默认不加载
load: true
# 配置生产版本和灰度版本号
version:
prodVersion: V1
grayVersion: V2
# 配置Ribbon调用user-app和order-app服务时使用我们自定义灰度轮询算法
user-app:
ribbon:
NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule
order-app:
ribbon:
NFLoadBalancerRuleClassName: com.kerwin.gray.loadbalancer.GrayRoundRobinRule

配置网关 Nacos 配置文件(gateway-app.yaml)
kerwin:
tool:
gray:
gateway:
# 是否开启灰度发布功能
enabled: true
# 自定义灰度版本请求头
grayHeadKey: gray
# 自定义灰度版本请求头匹配值
grayHeadValue: gray-996
# 使用灰度版本IP数组
grayIPList:
- '127.0.0.1'
# 使用灰度版本城市数组
grayCityList:
- 本地

启动网关服务
网关服务启动一个就行,直接 Debug 启动即可,方便调试源码。
启动业务服务 V1 和 V2 版本(用户服务和订单服务都用这种方式启动)
先直接 Debug 启动,在 IDEA 这个位置会看到对应启动类名称。

点击 Edit 编辑这个启动配置。

复制一个对应启动配置作为 V2 版本,把 Name 改成自己能区分的即可。

配置启动参数:第一步点击 Modify options,第二步勾选 Add VM options,第三步填写对应服务端口和 Nacos 的 metadata.version。我这里用户服务 V1 版本配置为 -Dserver.port=7201 和 -Dspring.cloud.nacos.discovery.metadata.version=V1;用户服务 V2 版本配置为 -Dserver.port=7202 和 -Dspring.cloud.nacos.discovery.metadata.version=V2。订单服务类似,配置好后点 Apply。

最后启动好的服务信息如下。

灰度效果演示
源码中的 user-app 提供了一个获取用户信息的接口,会携带当前服务的端口和版本信息;order-app 提供了一个获取订单信息的接口,会远程调用 user-app 获取订单关联的用户信息,同时也会携带当前服务的端口和版本信息。
场景一(关闭灰度开关:不区分调用服务版本)
关闭灰度开关有两种配置方式:
1、在项目启动之前修改 Nacos 全局配置文件中的 kerwin.tool.gray.load,控制是否加载灰度自动配置类。只要配置不为 true,就不会加载整个灰度相关类。

2、关闭网关灰度开关,修改网关 Nacos 配置文件中的 kerwin.tool.gray.gateway.enabled。只要配置不为 true,就不会进行灰度判断。
调用演示
这里调用结果不一定就是 Order 服务 V1、User 服务 V1。反过来 Order 服务 V1、User 服务 V2 也完全可能。
- 第一次调用,Order 服务版本为 V1,User 服务版本也为 V1

- 第二次调用,Order 服务版本为 V2,User 服务版本也为 V2

场景二(开启灰度开关:只调用生产版本)
修改网关 Nacos 配置文件中的 kerwin.tool.gray.gateway.enabled 设置为 true,其他灰度 IP 数组和城市数组配置都匹配不上即可。这样不管怎么调用都是 V1 版本,因为 GrayVersionProperties 里配置的生产版本是 V1,灰度版本是 V2。


场景三(开启灰度开关:通过请求头、IP、城市匹配调用灰度版本)
这里用请求头来测试。携带请求头 gray=gray-996 访问网关,流量就会全部进入灰度版本 V2。


源码
https://gitee.com/kerwin_code/spring-cloud-gray-example
存在问题
1、如果项目中使用了分布式任务调度怎么区分灰度版本?
这里其实很好解决。以 xxl-job 为例,注册不同的执行器就行——发布灰度版本时注册到灰度版本的执行器即可。
2、如果项目中使用了 MQ,收发消息怎么控制灰度?
这和解决分布式任务调度的思路一致。灰度版本的服务发送消息时投递到另一套 MQ 服务端,也就是准备两套 MQ:生产服务使用生产的 MQ,灰度服务使用灰度的 MQ。
3、整个实现流程其实不复杂,但也确实有点绕,只是提供一种参考方案
更简单的做法是:通过 Nginx + Lua 直接路由网关,给灰度整套服务使用一个独立的 Nacos 灰度命名空间,生产使用生产的命名空间。这样两套服务完全隔离,分布式任务调度、MQ 等配置都能独立放在各自命名空间的配置文件中,岂不更省心?当然,方案选择还是得看团队实际情况,这里权当多一种思路。