Spring Cloud Gateway 灰度发布:从 Header 判定到实例级分流的实践

这个不是基于配置的灰度发布,而是基于请求头做的实例层面的灰度发布

# ===== 灰度用户白名单 =====
gray.users=10001,10086,20000,10087,10084

  效果图

image

image

 这里的网关依赖版本和服务的依赖并不是依赖,依旧是没问题的

本项目是基于nacos开发的

image

 

image

 下面给出代码部分

order-service服务有两个,gatewat-service服务一个,同时测试使用的代码一个

order-service目录结构和代码部分

image

 

<?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;
    }
}

  

image

 改完后就是新的order-service服务了

gateway-service项目结构和代码部分

image

 

 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。

以上就是本次教程的全部内容了

posted @ 2026-01-18 21:55  板凳哲学家  阅读(0)  评论(0)    收藏  举报