Spring Cloud + Nacos + 负载均衡器,实现全链路灰度发布的最佳实战

📅 2026/8/13 19:46:38
Spring Cloud + Nacos + 负载均衡器,实现全链路灰度发布的最佳实战
概念灰度发布, 也叫金丝雀发布。是指在黑与白之间能够平滑过渡的一种发布方式。AB test就是一种灰度发布方式让一部分用户继续用A一部分用户开始用B如果用户对B没有什么反对意见那么逐步扩大范围把所有用户都迁移到B上面来。灰度发布可以保证整体系统的稳定在初始灰度的时候就可以发现、调整问题以保证其影响度而我们平常所说的金丝雀部署也就是灰度发布的一种方式。具体到服务器上实际操作中还可以做更多控制譬如说给最初更新的10台服务器设置较低的权重、控制发送给这10台服务器的请求数然后逐渐提高权重、增加请求数。一种平滑过渡的思路, 这个控制叫做“流量切分”。组件版本说明我们这项目已经练习了两年半了使用的版本不是很新我这里的Demo也会使用这个版本有感情了使用新版本的朋友自己调整一下就行实现思路是一样的只是这些框架源码可能会有变化。spring-boot: 2.3.12.RELEASEspring-cloud-dependencies: Hoxton.SR12spring-cloud-alibaba-dependencies: 2.2.9.RELEASE核心组件说明注册中心 Nacos网关 SpringCloudGateway负载均衡器 Ribbon 使用SpringCloudLoadBalancer实现也是类似的服务间RPC调用 OpenFeign灰度发布代码实现要实现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 // 用户clientFeign和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 ThreadLocalGrayStatusEnum 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设置到GrayRequestContextHolder中存储这一个请求的灰度状态枚举在负载均衡器中会取出灰度状态枚举判断要调用那个版本的服务同时这里还实现了Ordered 接口会对网关的过滤器进行的排序这里我们将这个过滤器的排序设置为Ordered.HIGHEST_PRECEDENCE int的最小值保证这个过滤器最先执行。public class GrayGatewayBeginFilter implements GlobalFilter, Ordered { Autowired private GrayGatewayProperties grayGatewayProperties; Override public MonoVoid 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())) { ListString 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) { ListString 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) { ListString 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) { ListString 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 MonoVoid 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 MonoVoid 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和GrayVersionProperties的版本号进行匹配出对应版本的服务列表。public abstract class AbstractGrayLoadBalancerRule extends AbstractLoadBalancerRule { Autowired private GrayVersionProperties grayVersionProperties; Value(${spring.cloud.nacos.discovery.metadata.version}) private String metaVersion; /** * 只有已启动且可访问的服务器并对灰度标识进行判断 */ public ListServer getReachableServers() { ILoadBalancer lb getLoadBalancer(); if (lb null) { return new ArrayList(); } ListServer reachableServers lb.getReachableServers(); return getGrayServers(reachableServers); } /** * 所有已知的服务器可访问和不可访问并对灰度标识进行判断 */ public ListServer getAllServers() { ILoadBalancer lb getLoadBalancer(); if (lb null) { return new ArrayList(); } ListServer allServers lb.getAllServers(); return getGrayServers(allServers); } /** * 获取灰度版本服务列表 */ protected ListServer getGrayServers(ListServer servers) { ListServer 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; MapString, 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请求拦截器取出自定义SpringMVC请求拦截器中设置到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_HEADERgray; }灰度版本状态枚举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 grayHeadKeygray; /** * 自定义灰度版本请求头匹配值 */ private String grayHeadValuegray-996; /** * 使用灰度版本IP数组 */ private ListString grayIPList new ArrayList(); /** * 使用灰度版本城市数组 */ private ListString grayCityList new ArrayList(); /** * 使用灰度版本用户编号数组我们系统不会在网关获取用户编号这种方法如果需要可以自己实现一下 */ private ListString 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版本来演示灰度发布效果。❝PSNacos的命名空间我这里叫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.yamlkerwin: 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.port7201-Dspring.cloud.nacos.discovery.metadata.versionV1用户服务V2版本配置为-Dserver.port7202-Dspring.cloud.nacos.discovery.metadata.versionV2订单服务配置类似配置好后点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服务版本为V1User服务版本也为V1第二次调用Order服务版本为V2User服务版本也为V2场景二开启灰度开关只调用生产版本修改网关Nacos配置文件中的kerwin.tool.gray.gateway.enabled设置为true其它灰度IP数组和城市数组配置匹配不上就行这样怎么调用都是V1版本因为在GrayVersionProperties版本配置中设置的生产版本就是为V1灰度版本为V2。场景三开启灰度开关通过请求头、ip、城市匹配调用灰度版本这里通过请求头测试携带请求头graygray-996访问网关那么流量就会都进入灰度版本V2。源码❝https://gitee.com/kerwin_code/spring-cloud-gray-example❞存在问题1、如果项目中使用到了分布式任务调度那怎么区分灰度版本这里其实挺好解决的就拿xxl-job来说注册不同的执行器就行在发布灰度版本时注册到灰度版本的执行器即可。2、如果项目中使用的了MQ我们收发消息怎么控制灰度这里和解决分布式任务调度思想是一样的灰度版本的服务发送消息的时候投递到另外一个MQ的服务端就是弄两套MQ服务端生产的服务使用生产的MQ灰度发布使用灰度的MQ3、这里整个实现流程不是很复杂但也是很没必要只是提供一种实现方案可以参考其实通过Nginx Lua脚本方式直接路由网关然后给灰度整套服务都使用一个Nacos灰度的命名空间生产的使用生产的命名空间这样就能将两套服务都隔离了分布式任务调度、MQ等配置都可以独立在自己命名空间的配置文件中岂不美哉