Spring Cloud Gateway 灰度发布:从 Header 判定到实例级分流的实践
这个不是基于配置的灰度发布,而是基于请求头做的实例层面的灰度发布
# ===== 灰度用户白名单 ===== gray.users=10001,10086,20000,10087,10084
效果图


这里的网关依赖版本和服务的依赖并不是依赖,依旧是没问题的
本项目是基于nacos开发的


下面给出代码部分
order-service服务有两个,gatewat-service服务一个,同时测试使用的代码一个
order-service目录结构和代码部分

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>order-service</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
<skipTests>true</skipTests>
<springcloud.version>2021.0.5</springcloud.version>
<alibaba.version>2021.0.5.0</alibaba.version>
</properties>
<!-- 继承 Spring Boot 父 POM,管理 Spring Boot 依赖版本 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- 从仓库查找父 POM -->
</parent>
<!-- 依赖管理 -->
<dependencyManagement>
<dependencies>
<!--spring cloud依赖管理,引入了Spring Cloud的版本-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${springcloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--SCA -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!--SCA -->
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot 核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--tomcat容器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Nacos 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Spring Boot Maven 插件 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.7.15</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
server.port=10013
spring.config.import=optional:nacos:${spring.application.name}.properties
spring.application.name=order-service
spring.cloud.nacos.discovery.server-addr=192.168.31.60:8850
spring.cloud.nacos.discovery.service=${spring.application.name}
spring.cloud.nacos.discovery.namespace=gateway-test
management.endpoints.web.exposure.include=*
#2024.3.1 13:56 add
spring.cloud.nacos.config.server-addr=192.168.31.60:8850
spring.cloud.nacos.config.namespace=gateway-test
spring.cloud.nacos.config.group=DEFAULT_GROUP
spring.cloud.nacos.config.file-extension=properties
spring.cloud.nacos.discovery.metadata.version=v2
logging.level.com.alibaba.nacos=DEBUG
logging.level.com.java=debug
logging.level.com.alibaba.nacos.shaded.io.perfmark=info
logging.level.web=debug
package com.order;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Description:
* @Author: tutu-qiuxie
* @Create: 2025/12/29 21:45
*/
@RestController
@EnableDiscoveryClient
@SpringBootApplication
@RequestMapping("/order")
public class OrderApplication {
@Value("${server.port}")
private String port;
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class,args);
}
@GetMapping("/{id}")
public String queryById(@PathVariable("id") Long id) {
return "恭喜您成功进入订单系统~"+port;
}
@GetMapping("/")
public String get(){
return null;
}
}

改完后就是新的order-service服务了
gateway-service项目结构和代码部分

pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gate</groupId>
<artifactId>gateway-service-two</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.12.RELEASE</version>
<relativePath/>
</parent>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-cloud.version>Hoxton.SR12</spring-cloud.version>
<spring-cloud-alibaba.version>2.2.7.RELEASE</spring-cloud-alibaba.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Gateway(必须 WebFlux) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- Nacos 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--lombok依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.26</version>
</dependency>
<!--Spring Session使得基于Redis的Session共享-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
</project>
配置文件
server.port=10010
# Nacos
spring.application.name=gateway-service
spring.cloud.nacos.discovery.server-addr=192.168.31.60:8850
spring.cloud.nacos.discovery.service=${spring.application.name}
spring.cloud.nacos.discovery.namespace=gateway-test
management.endpoints.web.exposure.include=*
# ===== 1️⃣ 灰度路由(必须在前)=====
spring.cloud.gateway.routes[0].id=order-gray
spring.cloud.gateway.routes[0].uri=lb://order-service
spring.cloud.gateway.routes[0].predicates[0].name=Path
spring.cloud.gateway.routes[0].predicates[0].args[pattern]=/order/**
spring.cloud.gateway.routes[0].predicates[1].name=Header
spring.cloud.gateway.routes[0].predicates[1].args[header]=X-Gray,true
# ===== 2️⃣ 正常路由(兜底)=====
spring.cloud.gateway.routes[1].id=order-normal
spring.cloud.gateway.routes[1].uri=lb://order-service
spring.cloud.gateway.routes[1].predicates[0].name=Path
spring.cloud.gateway.routes[1].predicates[0].args[pattern]=/order/**
# ===== 灰度用户白名单 =====
gray.users=10001,10086,20000,10087,10084
#测试环境redis配置
spring.redis.database=0
spring.redis.host=192.168.31.60
spring.redis.port=6379
spring.redis.password=13301455191@qiuxie
代码部分
package com.gate;
/**
* @Description:
* @Author: tutu-qiuxie
* @Create: 2026/1/10 21:33
*/
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
package com.gate.bean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
/**
* @Description: 灰度上下文(调试增强版)
* @Author: tutu-qiuxie
* @Create: 2026/1/10 22:30
*/
@Slf4j
public class GrayContext {
private static final String PREFIX = "gray:thread:";
private static final Duration TTL = Duration.ofSeconds(120);
private static StringRedisTemplate redisTemplate;
/* ========================= 初始化 ========================= */
public static void init(StringRedisTemplate template) {
redisTemplate = template;
log.info("[GrayContext] Redis initialized");
}
/* ========================= SET ========================= */
public static void set(boolean isGray) {
String threadName = Thread.currentThread().getName();
String key = PREFIX + threadName;
redisTemplate.opsForValue()
.set(key, String.valueOf(isGray), TTL);
log.info("[GrayContext][SET][Redis] thread={}, isGray={}",
threadName, isGray);
}
/* ========================= GET ========================= */
public static boolean get() {
String threadName = Thread.currentThread().getName();
log.info("get()--threadName :{}",threadName);
String key = PREFIX + threadName;
String value = redisTemplate.opsForValue().get(key);
boolean result = Boolean.TRUE.toString().equals(value);
log.info("[GrayContext][GET][Redis] thread={}, result={}",
threadName, result);
return result;
}
/* ========================= CLEAR ========================= */
public static void clear() {
String threadName = Thread.currentThread().getName();
String key = PREFIX + threadName;
redisTemplate.delete(key);
log.info("[GrayContext][CLEAR][Redis] thread={}", threadName);
}
public static void printAll() {
if (redisTemplate == null) {
log.warn("[GrayContext][ALL] redisTemplate not initialized");
return;
}
ScanOptions options = ScanOptions.scanOptions()
.match(PREFIX + "*")
.count(100)
.build();
Cursor<byte[]> cursor = redisTemplate
.getConnectionFactory()
.getConnection()
.scan(options);
boolean hasData = false;
log.info("========== [GrayContext][ALL][Redis] BEGIN ==========");
while (cursor.hasNext()) {
hasData = true;
String key = new String(cursor.next());
String value = redisTemplate.opsForValue().get(key);
log.info("key={}, isGray={}", key, value);
}
if (!hasData) {
log.info("[GrayContext][ALL] 当前没有灰度数据");
}
log.info("========== [GrayContext][ALL][Redis] END ==========");
}
}
package com.gate.config;
import com.gate.bean.GrayContext;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* @Description:
* @Author: tutu-qiuxie
* @Create: 2026/1/11 20:39
*/
@Configuration
public class GrayContextConfig {
@Bean
public CommandLineRunner initGrayContext(StringRedisTemplate redisTemplate) {
return args -> GrayContext.init(redisTemplate);
}
}
package com.gate.config;
import com.gate.filter.DebugRule;
import com.netflix.loadbalancer.IRule;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @Description: 不是靠配置文件,而是靠 Spring Bean 覆盖默认 Rule
* @Author: tutu-qiuxie
* @Create: 2026/1/11 21:38
*/
@Configuration
public class RibbonRuleConfig {
@Bean
@ConditionalOnMissingBean
public IRule ribbonRule() {
return new DebugRule();
}
}
识别是否需要灰度做标记
package com.gate.filter;
import com.gate.bean.GrayContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* @Description:
* @Author: tutu-qiuxie
* @Create: 2026/1/10 21:34
*/
@Component
@Slf4j
public class UserGrayFilter implements GlobalFilter, Ordered {
@Autowired
private GrayProperties grayProperties;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String userId = exchange.getRequest()
.getHeaders()
.getFirst("X-User-Id");
log.info("UserGrayFilter--userId:{}",userId);
log.info("UserGrayFilter--gray users:{}",grayProperties.getUsers());
boolean isGray = isGrayUser(userId);
// ⭐ 不管是不是灰度,都 set
GrayContext.set(isGray);
return chain.filter(exchange)
.doFinally(signalType -> {
GrayContext.clear();
});
}
/**
* 是否灰度用户(稳定,不跳)
*/
private boolean isGrayUser(String userId) {
// ① 白名单优先
if (grayProperties.getUsers().contains(userId)) {
return true;
}
return false;
}
@Override
public int getOrder() {
// 一定要在路由之前
return -1;
}
}
拿到灰度版本指定走哪个版本
package com.gate.filter;
import com.alibaba.cloud.nacos.ribbon.NacosServer;
import com.gate.bean.GrayContext;
import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.Server;
import java.util.ArrayList;
import java.util.List;
/**
* @Description:
* @Author: tutu-qiuxie
* @Create: 2026/1/10 22:08
*/
public class DebugRule extends AbstractLoadBalancerRule {
@Override
public Server choose(Object key) {
System.out.println("🔥 DebugRule.choose() 被调用了");
GrayContext.printAll();
ILoadBalancer lb = getLoadBalancer();
List<Server> servers = lb.getReachableServers();
System.out.println("🔍 servers size = " + servers.size());
boolean isGray = GrayContext.get();
System.out.println("🚦 isGray = " + isGray);
List<Server> target = new ArrayList<>();
for (Server server : servers) {
if (!(server instanceof NacosServer)) {
continue;
}
NacosServer nacosServer = (NacosServer) server;
String version = nacosServer.getMetadata().get("version");
if (isGray) {
// 灰度用户 → 走 v2
if ("v2".equals(version)) {
target.add(server);
}
} else {
// 非灰度用户 → 走 v1
if ("v1".equals(version)) {
target.add(server);
}
}
}
// 灰度兜底(防止某一侧实例挂了)
if (target.isEmpty()) {
target = servers;
}
return target.get(0);
}
@Override
public void initWithNiwsConfig(IClientConfig clientConfig) {
}
}
配置代码
package com.gate.filter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.Set;
/**
* @Description:
* @Author: tutu-qiuxie
* @Create: 2026/1/10 21:39
*/
@Component
@ConfigurationProperties(prefix = "gray")
public class GrayProperties {
/**
* 灰度用户白名单
*/
private Set<String> users = new HashSet<>();
/**
* 灰度比例(0~100)
*/
private int percent = 0;
public Set<String> getUsers() {
return users;
}
public void setUsers(Set<String> users) {
this.users = users;
}
public int getPercent() {
return percent;
}
public void setPercent(int percent) {
this.percent = percent;
}
}
测试代码
package com.java.thread;
/**
* @Description:
* @Author: tutu-qiuxie
* @Create: 2026/1/11 20:03
*/
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
public class GrayUsersOnlyTest {
private static final String URL_STR = "http://127.0.0.1:10010/order/1";
public static void main(String[] args) throws Exception {
List<Integer> grayUsers = Arrays.asList(10001, 10086, 20000,30000,30001,10087,10084);
int repeatPerUser = 30; // 每个 userId 请求 10 次
int total = grayUsers.size() * repeatPerUser;
ExecutorService pool = Executors.newFixedThreadPool(10);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(total);
for (Integer userId : grayUsers) {
for (int i = 0; i < repeatPerUser; i++) {
pool.submit(() -> {
try {
startLatch.await();
Thread.sleep(ThreadLocalRandom.current().nextInt(0, 300));
long start = System.currentTimeMillis();
HttpURLConnection conn =
(HttpURLConnection) new URL(URL_STR).openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("X-User-Id", String.valueOf(userId));
int status = conn.getResponseCode();
BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream())
);
StringBuilder body = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
long cost = System.currentTimeMillis() - start;
System.out.println(
"GRAY userId=" + userId +
" | status=" + status +
" | cost=" + cost + "ms" +
" | response=" + body
);
} catch (Exception e) {
System.err.println("GRAY userId=" + userId + " ERROR " + e.getMessage());
} finally {
endLatch.countDown();
}
});
}
}
startLatch.countDown();
endLatch.await();
pool.shutdown();
System.out.println("====== 灰度用户并发测试完成 ======");
}
}
最后说下本项目的使用场景
少量灰度用户
假设你公司刚开发了一个新版本,想给 100 个用户做灰度测试,完全用你现在的方案:
-
通过 Gateway 的 Header 判断这些用户是灰度用户,这些用户 100% 走 v2。
-
其他用户依然走 v1。
以上就是本次教程的全部内容了
浙公网安备 33010602011771号