Gateway网关层灰度方案—xx互联网医院系统灰度发布设计与思路详解

一、灰度发布架构概览

1.1 什么是灰度发布?

​ 灰度发布(Gray Release)是一种平滑过渡的发布策略(渐进式发布策略),通过将新功能先开放给部分用户验证,逐步扩大范围,最终全量上线,从而降低新版本发布风险。在医疗系统中,这种方式可有效保障核心业务(如在线问诊、电子处方)的稳定性。通过灰度发布,可以:

  • 降低发布风险:新功能先在少量用户中验证,避免全量发布可能带来的系统性风险
  • 快速回滚:发现问题可立即切换回稳定版本
  • A/B测试:对比新老版本的性能和用户体验
  • 数据收集:收集用户使用新功能的数据,为后续优化提供依据
  • 平滑过渡:让用户逐步适应新功能,减少用户感知的突兀
  • 合规要求:满足医疗行业对系统稳定性的严格要求

1.2 项目整体结构分析

基于工作空间目录树,项目采用多层微服务架构,主要模块划分如下:

复制代码
xxxx-medical-ihm/
├── api/                 # 各服务API接口定义
├── apps/                # 应用服务实现
├── business/            # 业务逻辑层
├── commons/             # 公共组件(含灰度发布模块)
│   └── xxxx-medical-ihm-common-grayrelease/  # 灰度发布核心模块
├── gateway/             # 网关服务
└── mpc/                 # 领域模型与核心服务

核心技术栈:Spring Cloud微服务生态(nacos/Feign/Loadbalancer/gateway)、Spring Boot自动配置、拦截器模式

1.3灰度发布设计思路与方案选型

1.3.1设计理念

​ 该项目基于Spring Cloud生态的网关层灰度发布方案 ,采用请求头驱动的流量路由模式,核心设计思路是通过在网关层拦截请求并注入灰度标识,结合自定义负载均衡策略实现流量分发。并利用拦截器机制确保灰度上下文在微服务调用链中传递。整体架构遵循以下原则:

  • 轻量级集成 :无侵入式集成现有微服务架构,不引入独立服务网格组件,基于Spring Cloud原生能力扩展
  • 请求头驱动 :支持多维度灰度标识(版本号、开发者模式),通过 application_versiondeveloper 请求头标识灰度流量
  • 上下文传递 :使用TTL(TransmittableThreadLocal)存储灰度上下文,确保跨服务调用时标识透传(确保灰度上下文在服务调用链中透传)
  • 安全降级 :当灰度规则匹配失败时自动降级到非灰度实例
1.3.2方案选型

本项目采用网关层灰度方案,属于业界六种主流方案中的第三种,与其他方案对比:

方案类型 实现方式 本项目适配度
代码硬编码 业务代码中嵌入灰度逻辑 ❌ 侵入性高,已排除
配置中心灰度 动态配置推送灰度规则 ⚠️ 未集成,但可扩展
网关层灰度 拦截器+负载均衡器实现 ✅ 当前采用方案
服务网格灰度 Istio/Linkerd等专用组件 ❌ 架构过重,目前未采用 ⚠️ 微服务的下一阶段云原生
K8s Ingress灰度 基于Ingress Controller ❌ 依赖K8s基础设施 ⚠️ 本项目灰度实现后才引入了k8s,后续可以考虑优化
JavaAgent灰度 字节码增强技术 ❌ 运维复杂度高

1.4 灰度架构详解

1.4.1 核心流程图
1.4.2 配置管理层
  • BusinessGrayEnvironmentController: 运营平台管理灰度配置的REST接口
  • GatewayApi: 网关配置管理API,负责配置的CRUD操作
  • Redis: 配置存储中心,支持实时更新和发布订阅
1.4.3 网关层 - GrayscaleGlobalFilter
  • 作用: 网关入口的灰度路由决策引擎
  • Order: 1 (最高优先级)
  • 功能 :
    • 从Redis实时获取灰度配置
    • 实现多维度灰度判断:用户白名单、医院编码、域名匹配
    • 设置Application-Version请求头
    • 支持开发调试模式
1.4.4 负载均衡层 - GrayRoundRobinLoadBalancer
  • 作用: 基于灰度版本的智能负载均衡器
  • 核心算法 :
    • 原子计数器实现线程安全的轮询选择
    • 版本精确匹配:metadata.version与目标版本完全一致
    • 自动降级机制:无灰度实例时回退到正式版本
  • 执行流程 :
    1. 获取当前请求的灰度版本(从GrayReleaseContextHolder)
    2. 筛选匹配版本的服务实例
    3. 使用轮询算法选择最终实例
1.4.5 业务服务层 - GrayReleaseContextInterceptor
  • 作用: 业务服务内部的灰度上下文管理
  • 执行时机: 每个HTTP请求进入业务服务时
  • 功能 :
    • 提取Application-Version请求头
    • 调用GrayReleaseContextHolder,存储到TransmittableThreadLocal,供后续Feign调用使用
    • 请求完成后自动清理,防止内存泄漏
1.4.6 服务间调用 - GrayReleaseFeignRequestInterceptor
  • 作用: 微服务间灰度标识的透传
  • 执行时机: 每次Feign调用发起时
  • 功能 :
    • 调用GrayReleaseContextHolder,从TransmittableThreadLocal获取当前灰度版本
    • 自动注入到Feign请求头
    • 支持开发调试模式的详细日志

1.5关键数据流转路径

  1. 配置更新路径 :

    运营平台 → GatewayApi → Redis → 网关配置缓存 → 实时生效

  2. 请求处理路径 :

    客户端 → 网关灰度判断 → 负载均衡选择 → 业务服务 → 上下文管理 → Feign透传

  3. 版本标识传递 :

    网关设置 → 请求头传递 → ThreadLocal存储 → Feign注入 → 下游服务继承

  4. 异常降级机制 :

    无灰度实例 → 自动降级到正式版本

    实例不可用 → 熔断降级机制

    配置缺失 → 使用默认正式版本

二、核心实现类详解

2.1 配置管理层---gateway网关相关

2.1.1 yml配置

gateway.yml:其中whitelist白名单配置,后续在WhiteListProperties类中获取

yaml 复制代码
## 端口
#server.port: 8888
spring:
 servlet:
  multipart:
    max-file-size: 500MB
    max-request-size: 500MB
## 监控
management:
  endpoint:
    health:
      show-details: always
  endpoints:
    jmx:
      exposure:
        include: '*'
    web:
      exposure:
        include: '*'
    gateway:
      enabled: false
  server:
    port: -1

## 是否生成新的token
sso.isNewToken: true
## 是否打开接口权限校验
api.isOpenPower: false

## 网关白名单
gateway.whiteUrl: /sso/tool/getImageCaptcha|/sso/tool/getRandom|/sso/auth/login|/system/auth/login|/system/tool/getImageCaptcha|/system/tool/getRandom|/system/user/v1/security/queryAnonymousRandomSecretKey|/bigdata/hsb/v1/route/api/doc|/bigdata/qc/v1/qcRuleExecuteResult/internal/receive|/bigdata/qc/v1/qcReport/record/content|/system/auth/findPasswordSms|/system/auth/validateSms|/system/auth/forgetPassword|/system/auth/loginSms|/system/auth/loginByMobile|/system/auth/v1/getToken|/system/auth/v1/testAccept|/health-h5/**||^.*/cdm-nbbl-patient/.*$|^.*/region/queryMap.*$|^.*/openApi/auth.*$|^.*/openApi/checkTokenAndEmpiId.*$|^.*/cdrs-doctor/.*$|^.*/cdrs-patient/.*$|^.*/resource/monitor/.*$|^.*/equipment/dict/get.*$|^.*/equipment/event/.*$|^.*/doc.html|^.*/cdm-nb-doctor/.*$|^.*/cdm-nb-patient/.*$|^.*/cdm-screen-api/.*$|^.*/cdm-nb/screen/.*$|/cdm-nb/screen/query
## 身份白名单
identity.whiteUrl: 1
## 接口权限校验白名单
api.whiteUrl: 1
## xss白名单
xss.whiteUrl: 2

## 跨域白名单
cors.white.list: '*'

## 白名单配置
whitelist:
  identity:
    - /external/queryByTicket
  gray:
    - /portal/external/logistics/mrds/route/callback
    - /hospital/dept/queryAllOnlineDeptsByHosId
    - /portal/tenant-callback/commonQuery
    - /portal/tenant-callback/.*
    - /portal/medical/.*
    - /portal/api/.*
    - /operate/.*
    - /patient/consultation/queryOrderDetailByRoomNo
    - /patient/tenantConfig/fetchHospitalGlobalConfig
    - /portal/3-payment/.*
    - /(.*?)/api/hos/.*
    - /(.*?)/api/inter-hos/.*  
    - ^/.*\/v3\/api-docs/.*  
  auditblacks:
    - /portal/tenant-callback/saveApiLog
    - /hospital/doctor/getDoctorLogo/.*
    - /hospital/getHosLogo/.*
    - /portal/operate/saveUserBehaviorLog
    - /patient/queryPatientByCurrentUser
    - /portal/operate/viewBuryingPoint
    - /patient/homePage/myDoctors
    - /portal/heartbeat
    - /hospital/queryAgreeBook
    - /portal/getUserInfoByToken
    - /patient/user/getImParams

WhiteListProperties:获取白名单配置

java 复制代码
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

import java.util.List;

@Data
@Component
@ConfigurationProperties(prefix = "whitelist")
@RefreshScope
public class WhiteListProperties {

    /**
     * 网关白名单
     */
    private List<String> gateway;
//    /**
//     * 获取接口验证白名单
//     */
//    private List<String> api;
    /**
     * 获取身份验证白名单
     */
    private List<String> identity;
    private List<String> gray;
    private List<String> auditblacks;
//    /**
//     * 跨域白名单配置
//     */
//    private List<String> cors;
//    /**
//     * Xss白名单
//     */
//    private List<String> xss;
}

gateway-route.yml:各服务的网关路由配置,简单看一下就可以

yaml 复制代码
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
        - id: portal
          uri: lb://portal
          predicates:
            - Path=/portal/**
          filters:
            - RewritePath=/portal/(?<segment>.*), /$\{segment}
        - id: portal-his
          uri: lb://portal
          predicates:
            - Path=/{hisOrgCode}/api/inter-hos/**
          filters:
            - StripPrefix=1
            - RewritePath=/api/inter-hos/(?<segment>.*), /inbound/$\{segment}
            - AddRequestHeadersIfNotPresent=orgcode:{hisOrgCode}         
        - id: hospital
          uri: lb://hospital
          predicates:
            - Path=/hospital/**
          filters:
            - RewritePath=/hospital/(?<segment>.*), /$\{segment}
        - id: patient
          uri: lb://patient
          predicates:
            - Path=/patient/**
          filters:
            - RewritePath=/patient/(?<segment>.*), /$\{segment}
        - id: mpc
          uri: lb://mpc-server
          predicates:
            - Path=/rbac/**
          filters:
            - RewritePath=/rbac/(?<segment>.*), /$\{segment} 
        - id: gateway
          uri: lb://gateway
          predicates:
            - Path=/gateway/**
          filters:
            - RewritePath=/gateway/(?<segment>.*), /$\{segment}
        - id: orders
          uri: lb://orders
          predicates:
            - Path=/orders/**
          filters:
            - RewritePath=/orders/(?<segment>.*), /$\{segment}
2.1.2 GatewayApi
java 复制代码
@FeignClient(name = "gateway")
public interface GatewayApi {

    @GetMapping("/infoByUserId")
    Result<JSONObject> infoByUserId(@RequestParam String userId);

    @PostMapping("/setting")
     Result<GatewaySettingDto> save(@RequestBody GatewaySettingDto dto);

    @GetMapping("/syn")
     Result<GatewaySettingDto> syn();

    @GetMapping("/isGrayUser")
    Result<Boolean> isGrayUser(@RequestParam String userId);
    
    @PostMapping("/version/info")
    Result<GatewayVersionDto> info();
    
    @PostMapping("/version/update")
    Result update(@RequestBody GatewayVersionDto dto);
}
2.1.3 GatewayController

Spring Boot网关控制器,主要功能如下:

  • 灰度发布控制:通过queryInfo()方法根据用户ID、医院、域名等条件判断是否启用灰度版本,返回对应的版本号和角色

  • 配置管理:

    • syn()同步网关配置
    • save()保存网关设置
    • /version/info:获取版本配置【运营平台中使用,提供界面配置化】
    • /version/update:更新版本配置【运营平台中使用,提供界面配置化】
  • 用户查询:

    • infoByUserId()通过用户ID查询信息

    • isGrayUser()判断是否为灰度用户

所有接口均封装在GatewayController类中,通过GatewaySettingService操作配置数据。

java 复制代码
@RestController
@RequestMapping("/")
public class GatewayController {

    private Logger log = LoggerFactory.getLogger(this.getClass());

    @Resource
    private GatewaySettingService gatewaySettingService;

    //infoByUserId()通过用户ID查询信息
    @GetMapping("/infoByUserId")
    public Result<JSONObject> infoByUserId(@RequestParam String userId) {

        FeignClientsConfiguration d;

        GatewayDto gatewayDto = new GatewayDto();
        gatewayDto.setCustomerId(userId);
        return Result.success(queryInfo(gatewayDto));
    }

    @GetMapping("info")
    public Result<JSONObject> info(GatewayDto dto,@RequestHeader("Domainsign") String domainsign) {        // 查询域名或者医院的版本号
        try {
            dto.setDomainSign(domainsign);
            JSONObject info = queryInfo(dto);
            return Result.success(info);
        } catch (Exception e) {
            log.error("handle info error", e);
            return Result.error();
        }
    }

	//灰度发布控制:通过queryInfo()方法根据用户ID、医院、域名等条件判断是否启用灰度版本,返回对应的版本号和角色
    private JSONObject queryInfo(GatewayDto dto) {
        // 网关配置
        GatewaySettingDto s = gatewaySettingService.find();

        String domainSign = dto.getDomainSign();
        StringBuffer platFrom = new StringBuffer("");
        if (StringUtils.isNotEmpty(domainSign)) {
            platFrom.append(domainSign.split("_")[0]);
        }else {
            platFrom.append("JTP");
        }

        boolean garyUser4Cust = Optional.ofNullable(s.getGrayUserIds())
                .map(map -> map.get(platFrom.toString()))
                .filter(set -> set.contains(dto.getCustomerId()))
                .isPresent();

        Boolean gray =
                (Objects.nonNull(dto.getHospital()) && s.getGrayHospitals().contains(dto.getHospital()))
                        // 管理平台使用domain确定version
                        || (StrUtil.isNotBlank(dto.getDomain()) && s.getGrayVersionDomains().contains(dto.getDomain()))
                        || (StrUtil.isNotBlank(dto.getCustomerId()) && garyUser4Cust)
                        || (StrUtil.isNotBlank(dto.getCustomerId()) && s.getGrayCustomerIds().contains(dto.getCustomerId()));

        String version = gray ? s.getGrayVersion() : s.getReleaseVersion();

        JSONObject info = new JSONObject();

        info.putOpt(GrayConstant.VERSION, version);
        info.putOpt(GrayConstant.ROLE, gray ? GrayConstant.GRAY_ROLE : GrayConstant.SIMPLE_ROLE);
        return info;
    }
	
    //syn()同步网关配置
    @GetMapping("syn")
    public Result<GatewaySettingDto> syn() {
        try {
            // 网关配置
            GatewaySettingDto s = gatewaySettingService.find();

            return Result.success(s);

        } catch (Exception e) {
            log.error("handle info error", e);
            return Result.error();
        }
    }

    //save()保存网关设置
    @PostMapping("setting")
    public Result<GatewaySettingDto> save(@RequestBody GatewaySettingDto dto) {

        GatewaySettingDto gatewaySettingDto = gatewaySettingService.save(dto);
        dto.setPassword(null);

        return Result.success(gatewaySettingDto);
    }

    //isGrayUser()判断是否为灰度用户
    @GetMapping("/isGrayUser")
    public Result<Boolean> isGrayUser(@RequestParam String userId) {
        // 网关配置
        GatewaySettingDto s = gatewaySettingService.find();
        return Result.success(StrUtil.isNotBlank(userId) && s.getGrayCustomerIds().contains(userId));
    }

    @Operation(summary = "获取版本号")
    @PostMapping("/version/info")
    public Result<GatewayVersionDto> info() {
        GatewaySettingDto gatewaySettingDto = gatewaySettingService.find();
        ValidatorUtil.validateNotEmpty(gatewaySettingDto,"网关配置为空,请先配置");

        GatewayVersionDto gatewayVersionDto = new GatewayVersionDto();
        gatewayVersionDto.setGrayVersion(gatewaySettingDto.getGrayVersion());
        gatewayVersionDto.setReleaseVersion(gatewaySettingDto.getReleaseVersion());

        return Result.success(gatewayVersionDto);
    }
    @Operation(summary = "更新版本号")
    @PostMapping("/version/update")
    public Result update(@RequestBody GatewayVersionDto dto) {
        log.info("更新版本号 start--:{}", JSONUtil.toJsonPrettyStr(dto));
        dto.check();
        GatewaySettingDto gatewaySettingDto = gatewaySettingService.find();
        ValidatorUtil.validateNotEmpty(gatewaySettingDto,"网关配置为空,请先配置");

        gatewaySettingDto.setGrayVersion(dto.getGrayVersion());
        gatewaySettingDto.setReleaseVersion(dto.getReleaseVersion());
        log.info("更新版本号 end--:{}", JSONUtil.toJsonPrettyStr(gatewaySettingDto));
        gatewaySettingService.save(gatewaySettingDto);
        return Result.success();
    }

}
2.1.4 GatewaySettingService
java 复制代码
import cn.hutool.json.JSONUtil;
import com.chinaunicom.medical.ihm.model.GatewaySettingDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

/**
 * 网关配置
 */
@Service
public class GatewaySettingService {

    private Logger log = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private RedisTemplate<String, String> stringRedisTemplate;

    public GatewaySettingDto save(GatewaySettingDto dto) {
        try {
            // update
            stringRedisTemplate.boundValueOps(GatewaySettingDto.GATEWAY_SETTING_REDIS).set(JSONUtil.toJsonPrettyStr(dto));
            // notify
            stringRedisTemplate.convertAndSend(GatewaySettingDto.GATEWAY_SETTING_TOPIC, String.valueOf(System.currentTimeMillis()));
            return dto;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    //本地缓存
    private GatewaySettingDto cache = null;

    public GatewaySettingDto find() {
        if (cache == null) {
            syn();
        }
        return cache;
    }

    //主动同步
    public void syn() {
        try {
            String json = stringRedisTemplate.boundValueOps(GatewaySettingDto.GATEWAY_SETTING_REDIS).get();
            log.info("gateway syn result " + json);
            if (null != json) {
                cache = JSONUtil.toBean(json,GatewaySettingDto.class) ;
                return;
            }
            log.warn("gateway has no config ,setting [" + GatewaySettingDto.GATEWAY_SETTING_REDIS + "] !");
        } catch (Exception e) {
            log.error("gateway syn error", e);
        }
    }


}

2.1 灰度常量定义(GrayConstant.java)

java 复制代码
public class GrayConstant {
    public static final String VERSION = "application_version";           // 版本标识
    public static final String HOSIPITAL = "Application-Hospital-Source-Code"; // 医院编码
    public static final String USER = "application_user_mobile";         // 用户手机号
    public static final String CUST_ID = "Application-Cust-Id";          // 客户ID
    public static final String ROLE = "application_role";                // 角色标识
    public static final String SIMPLE_ROLE = "1";                        // 简单角色
    public static final String GRAY_ROLE = "2";                          // 灰度角色
    public static final String DEVELOPER = "developer";                    // 开发者标识
}

2.2 网关层灰度流量标记(GrayscaleGlobalFilter)

  • 网关层的GrayscaleGlobalFilter是整个灰度系统的入口,负责识别和标记灰度流量。

  • 该过滤器实现Spring Cloud Gateway的GlobalFilter接口(order=1,优先级最高),核心逻辑如下:

java 复制代码
@Component
@RefreshScope
@Slf4j
public class GrayscaleGlobalFilter implements GlobalFilter, Ordered {
    @Resource
    private GatewaySettingService gatewaySettingService;

    @Resource
    private WhiteListProperties whiteListProperties;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        try {
            ServerHttpRequest request = exchange.getRequest();

            MultiValueMap querystring = request.getQueryParams();

            var headers = request.getHeaders();

            var path = request.getURI().getPath();

            var setting = gatewaySettingService.find();

            if (null == setting) {
                log.error("setting is null .");
                return chain.filter(exchange);
            }

            var version = setting.getReleaseVersion();// 默认正式版本

            var metadata = new Metadata();

            // 1. 基于客户ID的灰度判断,从请求头获取灰度标识
            String custId = headers.getFirst(GrayConstant.CUST_ID);
            String domainSign = headers.getFirst(Constants.REQUEST_HEADER_DOMAIN_SIGN);

            List<String> list = Optional.ofNullable(setting.getGrayUserIds().get("JTP")).orElse(List.of());

            //解决b端请求头中的 Application-Cust-Id 问题
            if (StrUtil.isNotEmpty(custId) && CollUtil.isNotEmpty(list) && list.contains(custId)
                    && StrUtil.isNotEmpty(domainSign) && domainSign.endsWith("CUST")) {
                version = setting.getGrayVersion(); // 切换到灰度版本
            }
            log.info(">>>>>>path:{},customerId:{},version:{}>>>>>>", path, custId, version);

            if (StrUtil.isNotEmpty(custId) && setting.getGrayCustomerIds().contains(custId)
                    && StrUtil.isNotEmpty(domainSign) && domainSign.endsWith("CUST")) {
                version = setting.getGrayVersion(); // 切换到灰度版本
            }
            log.info("path:{},cust:{},version:{}", path, custId, version);

              // 2. 基于B端用户ID的灰度判断
            String applicationBusiId = headers.getFirst(Constants.APPLICATION_BUSI_ID);
            if (StrUtil.isNotEmpty(applicationBusiId)) {
                String appCode = domainSign.split("_")[0];
                boolean gray = Optional.ofNullable(setting.getGrayUserIds())
                        .map(grayUserMap -> grayUserMap.get(appCode))
                        .map(userIds -> {
                            if (CollUtil.isEmpty(userIds)) {
                                return false;
                            }
                            return userIds.contains(applicationBusiId);
                        })
                        .orElse(false);

                version = gray ? setting.getGrayVersion() : setting.getReleaseVersion();
            }
            log.info("path:{},applicationBusiId:{},version:{}", path, applicationBusiId, version);

             // 3. 基于医院编码的灰度判断
            String hospitalCode = headers.getFirst(GrayConstant.HOSIPITAL);

            if (StrUtil.isNotEmpty(hospitalCode) && setting.getGrayHospitals().contains(hospitalCode)) {
                version = setting.getGrayVersion();
            }

            log.info("path:{},hospital:{},version:{}", path, hospitalCode, version);

            // 4. 基于路径匹配的灰度判断
            if (isMatchGrayPath(request)) {
                version = setting.getGrayVersion();
            }

            if (isMatchReleasePath(path, setting)) {
                version = setting.getReleaseVersion();
                log.info("path:{} -> 配置为Release版本", path);
            }

            // 
        // 设置灰度上下文,设置线程是为了传到后面的filter
            metadata.setVersion(version + "");
            GrayReleaseContextHolder.set(metadata);

            // 添加灰度请求头,传递灰度标记到下游服务,用于 httpClient.request 传递到实例api
            ServerHttpRequest.Builder mutate = exchange.getRequest().mutate();
            // 设置头有两个作用,1.loadbalancer的时候使用.2.传递到微服务
            mutate.header(GrayConstant.VERSION, version + "");// 系统版本

            return chain.filter(exchange.mutate().request(request).build());
        } catch (Exception exception) {
            log.error("GrayscaleGlobalFilter Error.", exception);
            return chain.filter(exchange);
        }
    }


    @Override
    public int getOrder() {
        return 1;
    }

    private boolean isMatchGrayPath(ServerHttpRequest request) {
        List<String> grayPaths = whiteListProperties.getGray();
        // grayPaths空指针处理
        if (CollUtil.isEmpty(grayPaths)) {
            return false;
        }
        for (String identity : grayPaths) {
            if (request.getPath().toString().matches(identity)) {
                return true;
            }
        }
        return false;
    }

    private boolean isMatchReleasePath(String path, GatewaySettingDto setting) {
        Map<String, String> releasePaths = setting.getReleasePaths();
        if (CollUtil.isEmpty(releasePaths)) {
            return false;
        }
        if (releasePaths.containsKey(path)) {
            return true;
        }
        return false;
    }

}

关键功能

  • 支持用户ID、医院编码、路径匹配等多维度灰度判断
  • 通过GrayReleaseContextHolder维护线程上下文
  • 为下游服务添加VERSION头用于灰度路由

2.3 运营平台灰度用户管理(BusinessGrayEnvironmentController)

运营平台提供了完整的灰度用户管理接口,接口示例:

关键功能

  • 新增灰度用户
  • 删除灰度用户
  • 选择用户
  • 查询用户
java 复制代码
@Tag(name = "运营平台-B端灰度环境管理")
@RestController
@Slf4j
@RequestMapping("/businessGrayEnvironment")
public class BusinessGrayEnvironmentController {

    @Resource
    private UserInfoService userInfoService;
    @Resource
    private GatewayApi gatewayApi;
    @Resource
    private UserApi userApi;

    @Operation(summary = "新增灰度用户")
    @PostMapping("/save")
    public Result save(@RequestBody GrayUserDTO dto) {
        //新增时,必须选择应用
        Validator.validateNotNull(dto.getAppCode(), "应用编码不能为空");
        Validator.validateNotNull(dto.getUserIds(), "userIds不能为空");
        // 查询灰度用户
        GatewaySettingDto gatewaySettingDto = gatewayApi.syn().assertData();

        if (Objects.nonNull(gatewaySettingDto)) {
            Map<String, List<String>> grayUserMap = Optional.ofNullable(gatewaySettingDto.getGrayUserIds())
                    .orElseGet(HashMap::new);

            Set<String> set = new HashSet<>(dto.getUserIds());

            Optional.ofNullable(grayUserMap.get(dto.getAppCode()))
                    .ifPresent(set::addAll);

            grayUserMap.put(dto.getAppCode(), new ArrayList<>(set));
            gatewaySettingDto.setGrayUserIds(grayUserMap);
        }
        return Result.success(gatewayApi.save(gatewaySettingDto).assertData());
    }

    @Operation(summary = "删除(移除)灰度用户")
    @PostMapping("/delete")
    public Result delete(@RequestBody GrayUserDTO dto) {
        Validator.validateNotNull(dto.getAppCode(), "应用编码不能为空");
        Validator.validateNotNull(dto.getUserIds(), "userIds不能为空");

        // 查询灰度用户
        GatewaySettingDto gatewaySettingDto = gatewayApi.syn().assertData();
        Map<String, List<String>> grayUserMap = gatewaySettingDto.getGrayUserIds();
        if (Objects.nonNull(gatewaySettingDto) && CollUtil.isNotEmpty(grayUserMap)) {
            List<String> list = grayUserMap.get(dto.getAppCode());
            Set set = new HashSet<>();
            set.addAll(list);
            set.removeAll(dto.getUserIds());
            grayUserMap.put(dto.getAppCode(), new ArrayList<>(set));
            gatewaySettingDto.setGrayUserIds(grayUserMap);
        }
        return Result.success(gatewayApi.save(gatewaySettingDto).assertData());
    }


    @PostMapping("/selectUser")
    @Operation(summary = "选择用户")
    public Result<PageData<UserVo>> selectUser(@RequestBody GrayUserPageDTO dto) {

        GatewaySettingDto gatewaySettingDto = gatewayApi.syn().assertData();
        Map<String, List<String>> grayUserMap = gatewaySettingDto.getGrayUserIds();
        List<String> grayUserIds;
        if (Objects.nonNull(gatewaySettingDto) && CollUtil.isNotEmpty(grayUserMap)) {
            grayUserIds = grayUserMap.get(dto.getAppCode());
        } else {
            grayUserIds = null;
        }

        //患者端 单独处理
        if (dto.getAppCode().equals("JTP")) {
            //查询ihm_user_info表
            IPage<UserInfo> page = userInfoService.lambdaQuery()
                    .select(UserInfo::getId, UserInfo::getName, UserInfo::getPhone)
                    .notIn(CollUtil.isNotEmpty(grayUserIds), UserInfo::getId, grayUserIds)
                    .like(StrUtil.isNotEmpty(dto.getName()), UserInfo::getName, dto.getName())
                    .eq(StrUtil.isNotEmpty(dto.getPhone()), UserInfo::getPhone, DesensitizedUtils.encryption(dto.getPhone()))
                    .page(new Page<>(dto.getCurrent(), dto.getSize()));

            List<UserVo> userVos = page.getRecords()
                    .stream()
                    .map(userInfo -> {
                        UserVo userVo = BeanUtil.copyProperties(userInfo, UserVo.class);
                        userVo.setMobile(DesensitizedUtils.decrypt(userInfo.getPhone()));
                        return userVo;
                    })
                    .collect(Collectors.toList());
            Page<UserVo> returnPage = new Page<>(dto.getCurrent(), dto.getSize());
            BeanUtil.copyProperties(page, returnPage);
            returnPage.setRecords(userVos);

            return Result.success(new PageData<>(returnPage));
        }

        //查询u_user表下的所有用户
        UserQueryParam userQueryParam = new UserQueryParam();
        userQueryParam.setName(dto.getName());
        userQueryParam.setMobile(dto.getPhone());
        userQueryParam.setCurrent(dto.getCurrent());
        userQueryParam.setSize(dto.getSize());
        userQueryParam.setUserIds(grayUserIds);

        Page<UserDto> userDtoPage = userApi.page(userQueryParam).assertData();

        List<UserVo> userVos = userDtoPage.getRecords()
                .stream()
                .map(userDto -> BeanUtil.copyProperties(userDto, UserVo.class))
                .collect(Collectors.toList());

        Page<UserVo> returnPage = new Page<>(dto.getCurrent(), dto.getSize());
        BeanUtil.copyProperties(userDtoPage, returnPage);
        returnPage.setRecords(userVos);

        return Result.success(new PageData<>(returnPage));
    }

    @Operation(summary = "分页查询")
    @PostMapping("/page")
    public Result<PageData<GrayUserVo>> page(@RequestBody GrayUserPageDTO dto) {
        Integer current = dto.getCurrent();
        Integer size = dto.getSize();
        String name = dto.getName();
        String phone = dto.getPhone();

        GatewaySettingDto gatewaySettingDto = gatewayApi.syn().assertData();

        Map<String, List<String>> grayUserMap = Optional.ofNullable(gatewaySettingDto)
                .map(GatewaySettingDto::getGrayUserIds)
                .orElse(Collections.emptyMap());

        //患者端 单独处理
        if (StrUtil.equals("JTP", dto.getAppCode())) {
            List<Long> userIds = grayUserMap.get(dto.getAppCode()).stream().map(Long::valueOf).collect(Collectors.toList());
            if (CollUtil.isEmpty(userIds)) {
                return Result.success(new PageData<>());
            } else {
                IPage<UserInfo> page = userInfoService.lambdaQuery()
                        .select(UserInfo::getId, UserInfo::getName, UserInfo::getPhone)
                        .in(Objects.nonNull(userIds), UserInfo::getId, userIds)
                        .like(StrUtil.isNotEmpty(name), UserInfo::getName, dto.getName())
                        .eq(StrUtil.isNotEmpty(phone), UserInfo::getPhone, DesensitizedUtils.encryption(dto.getPhone()))
                        .page(new Page<>(dto.getCurrent(), dto.getSize()));
                if (page.getRecords().size() > 0) {
                    for (UserInfo userInfo : page.getRecords()) {
                        userInfo.setPhone(DesensitizedUtils.decrypt(userInfo.getPhone()));
                    }
                }
                List<GrayUserVo> grayUserVos = page.getRecords()
                        .stream()
                        .map(userInfo -> {
                            GrayUserVo grayUserVo = BeanUtil.copyProperties(userInfo, GrayUserVo.class);
                            grayUserVo.setUserId(String.valueOf(userInfo.getId()));
                            grayUserVo.setAppCode(dto.getAppCode());
                            return grayUserVo;
                        }).collect(Collectors.toList());
                Page<GrayUserVo> returnPage = new Page<>(dto.getCurrent(), dto.getSize());
                BeanUtil.copyProperties(page, returnPage);
                returnPage.setRecords(grayUserVos);
                return Result.success(new PageData<>(returnPage));
            }
        }
        List<GrayUserVo> grayUserVos = CollUtil.newArrayList();

        List<Long> userIds = grayUserMap.get(dto.getAppCode()).stream().map(Long::valueOf).collect(Collectors.toList());
        if (CollUtil.isNotEmpty(userIds)) {
            IdsDto idsDto = new IdsDto();
            idsDto.setIds(userIds);
            List<UserDto> userDtos = userApi.queryByIds(idsDto).assertData();
            grayUserVos = userDtos.stream().map(userDto -> {
                GrayUserVo grayUserVo = new GrayUserVo();
                grayUserVo.setUserId(String.valueOf(userDto.getId()));
                grayUserVo.setName(userDto.getName());
                grayUserVo.setPhone(userDto.getMobile());
                grayUserVo.setAppCode(dto.getAppCode());
                return grayUserVo;
            }).collect(Collectors.toList());
        }

        List<GrayUserVo> subList = grayUserVos.stream()
                .filter(x ->
//                        (StrUtil.isEmpty(dto.getAppCode()) || Objects.equals(x.getAppCode(), dto.getAppCode())) &&
                                (StrUtil.isEmpty(name) || StrUtil.contains(x.getName(), name)) &&
                                        (StrUtil.isEmpty(phone) || StrUtil.contains(x.getPhone(), phone))
                )
                .skip((current - 1) * size)
                .limit(size)
                .collect(Collectors.toList());

        int totalPages = (int) Math.ceil((double) grayUserVos.size() / size);
        return Result.success(new PageData<>(subList, current, subList.size(), size, totalPages));
    }
}

2.4 负载均衡层过滤服务实例(GrayRoundRobinLoadBalancer)

自定义负载均衡器,基于灰度版本选择服务实例:

java 复制代码
/**
 * 灰度发布增强版轮询负载均衡器
 * 实现基于版本号和开发者标识的服务实例筛选与路由
 * 继承Spring Cloud ReactorServiceInstanceLoadBalancer接口,支持响应式负载均衡
 */
public class GrayRoundRobinLoadBalancer implements ReactorServiceInstanceLoadBalancer {

    private static final Logger logger = LoggerFactory.getLogger(GrayRoundRobinLoadBalancer.class);

    /** 轮询计数器,使用原子整数保证线程安全 */
    final AtomicInteger position;
    /** 目标服务ID */
    final String serviceId;
    /** 服务实例列表提供者,用于获取可用服务实例 */
    ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;

    /**
     * 构造函数:使用随机种子初始化轮询位置。
     * 为什么这样做?
     * 原因:在高并发的分布式系统中,可能会同时创建多个 GrayRoundRobinLoadBalancer 实例。如果这些实例都从 0 开始轮询服务实例,
     * 就可能出现多个请求同时访问同一个服务实例的情况,无法充分利用所有可用的服务实例,造成负载不均衡。
     * 通过设置随机的初始轮询位置,不同的负载均衡器实例会从不同的位置开始轮询,使得服务实例的请求分布更加均匀,提高系统的负载均衡效果。
     * @param serviceId 服务ID
     * @param serviceInstanceListSupplierProvider 服务实例列表提供者
     */
    public GrayRoundRobinLoadBalancer(String serviceId, ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider) {
        this(new Random().nextInt(1000), serviceId, serviceInstanceListSupplierProvider);
    }

    /**
     * 构造函数:指定初始轮询位置
     * @param seedPosition 初始轮询位置
     * @param serviceId 服务ID
     * @param serviceInstanceListSupplierProvider 服务实例列表提供者
     */
    public GrayRoundRobinLoadBalancer(int seedPosition, String serviceId, ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider) {
        this.position = new AtomicInteger(seedPosition);
        this.serviceId = serviceId;
        this.serviceInstanceListSupplierProvider = serviceInstanceListSupplierProvider;
    }

    /**
     * 核心负载均衡方法:选择合适的服务实例
     * @param request 负载均衡请求对象,包含请求上下文信息
     * @return 封装服务实例的响应对象
     */
    public Mono<Response<ServiceInstance>> choose(Request request) {

        ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new);
        return supplier.get(request).next().map(serviceInstances -> processInstanceResponse(supplier, serviceInstances, request));
    }

    /**
     * 处理服务实例响应
     * @param supplier 服务实例列表提供者
     * @param serviceInstances 服务实例列表
     * @param request 请求对象
     * @return 封装服务实例的响应对象
     */
    private Response<ServiceInstance> processInstanceResponse(ServiceInstanceListSupplier supplier, List<ServiceInstance> serviceInstances, Request request) {
        Response<ServiceInstance> serviceInstanceResponse = this.getInstanceResponse(serviceInstances, request);
        if (supplier instanceof SelectedInstanceCallback && serviceInstanceResponse.hasServer()) {
            ((SelectedInstanceCallback) supplier).selectedServiceInstance((ServiceInstance) serviceInstanceResponse.getServer());
        }

        return serviceInstanceResponse;
    }

    /**
     * 获取实例响应:实现灰度筛选和轮询选择
     * @param instances 原始服务实例列表
     * @param request 请求对象
     * @return 封装服务实例的响应对象
     */
    private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances, Request request) {
        if (instances.isEmpty()) {
            if (logger.isWarnEnabled()) {
                logger.warn("No servers available for service: " + this.serviceId);
            }

            return new EmptyResponse();
        }

        instances = getInstances(instances, request);// Do not move position when there is only 1 instance, especially some suppliers// have already filtered instances


        if (instances.size() == 1) {
            return new DefaultResponse(instances.get(0));
        }// Ignore the sign bit, this allows pos to loop sequentially from 0 to Integer.MAX_VALUE
        int pos = this.position.incrementAndGet() & Integer.MAX_VALUE;
        ServiceInstance instance = instances.get(pos % instances.size());
        return new DefaultResponse(instance);

    }

    /**
     * 灰度实例筛选核心逻辑
     * @param instances 原始服务实例列表
     * @param request 请求对象
     * @return 筛选后的服务实例列表
     */
    private List<ServiceInstance> getInstances(List<ServiceInstance> instances, Request request) {
        DefaultRequest<RequestDataContext> defaultRequest = Convert.convert(new TypeReference<DefaultRequest<RequestDataContext>>() {
        }, request);
        RequestDataContext dataContext = defaultRequest.getContext();
        RequestData requestData = dataContext.getClientRequest();
        HttpHeaders headers = requestData.getHeaders();

        String[] version = new String[] {""} ;


        if(StrUtil.isEmpty(version[0])){
            version[0] = headers.getFirst(GrayConstant.VERSION) ; // 网关由于是Nio架构,所以input和output不是同一个线程, context是拿不到version的
        }

        GrayReleaseContextHolder.clear();

        if(StrUtil.isEmpty(version[0])){
            List<ServiceInstance> list = instances.stream()
                    .filter(i-> StrUtil.isBlank(i.getMetadata().get(GrayConstant.DEVELOPER)))
                    .collect(Collectors.toList()) ;
            return list;
        }

        List<ServiceInstance> list = instances.stream()
                .filter(i->{
                    String instanceVersion = i.getMetadata().get(GrayConstant.VERSION) ;

                    if(StrUtil.isEmpty(instanceVersion)){
                        return false ;
                    }
                    if(StrUtil.isNotEmpty(headers.getFirst(GrayConstant.DEVELOPER))){
                        logger.info("本地开发调试:{}",headers.getFirst(GrayConstant.DEVELOPER));
                        return StrUtil.equals(instanceVersion, version[0])
                                &&
                                StrUtil.equals(i.getMetadata().get(GrayConstant.DEVELOPER) , headers.getFirst(GrayConstant.DEVELOPER));
                    }else {
                        return StrUtil.equals(instanceVersion, version[0])
                                &&
                               StrUtil.isBlank(i.getMetadata().get(GrayConstant.DEVELOPER)) ;
                    }

                })
                .collect(Collectors.toList()) ;


        logger.info("version:{} ,instances url:{} , list:{}",version[0], requestData.getUrl() , JSONUtil.toJsonStr(list));

        if(CollectionUtil.isEmpty(list)){
            list = instances.stream()
                    .filter(i-> StrUtil.isBlank(i.getMetadata().get(GrayConstant.DEVELOPER)))
                    .collect(Collectors.toList()) ;
            return list;
        }
        else return list ;

    }
}

该类实现了灰度发布与轮询策略结合的负载均衡器,核心功能如下:

  • 灰度筛选
    • 从请求头获取灰度版本号(GrayConstant.VERSION)
    • 根据版本号和开发者ID(GrayConstant.DEVELOPER)过滤服务实例
    • 优先匹配相同版本且无开发者标签的实例
  • 轮询策略
    • 使用原子整数position保证线程安全
    • 通过取模运算实现均匀轮询
    • 使用随机种子初始化轮询位置,避免请求集中
  • 动态路由
    • 支持开发环境调试(通过开发者ID直连特定实例)
    • 无灰度实例时自动降级到普通轮询
    • 记录日志监控路由决策
  • 响应式编程
    • 实现Spring Cloud ReactorServiceInstanceLoadBalancer接口
    • 支持非阻塞异步处理

完整处理流程:请求→获取/解析灰度参数→筛选实例列表→轮询选择实例→返回负载均衡结果。

2.5 灰度上下文管理(GrayReleaseContextHolder)

采用**TransmittableThreadLocal**实现跨线程上下文传递,确保灰度标记在异步调用中正确传递:

为什么用TransmittableThreadLocal?
普通ThreadLocal在异步线程中会丢失上下文,而医疗系统存在大量异步处理(如处方审核通知),使用阿里开源的TTL(TransmittableThreadLocal)可确保上下文在线程池环境中正确传递。

java 复制代码
public class GrayReleaseContextHolder {

    private static final Logger logger = LoggerFactory.getLogger(GrayReleaseContextHolder.class);

    private static final TransmittableThreadLocal<Metadata> CONTEXT = new TransmittableThreadLocal<>();

    private static TransmittableThreadLocal<Metadata> currentRequestContext() {
        if (Objects.isNull(CONTEXT.get())) {
            Metadata systemDto = new Metadata();

            CONTEXT.set(systemDto);
        }
        return CONTEXT;
    }
    public static void clear() {

        currentRequestContext().remove();
    }

    
    public static void set(Metadata metadata) {
        currentRequestContext().set(metadata);
    }

    public static Metadata get() {
        return currentRequestContext().get();
    }
}
java 复制代码
public class Metadata {

    private String version ;

    public String getVersion() {
        return version;
    }

    public void setVersion(String version) {
        this.version = version;
    }
}

该类实现了灰度发布上下文管理功能,核心逻辑如下:

  • 线程上下文管理:使用TransmittableThreadLocal存储Metadata对象,确保线程池/异步场景下上下文传递

  • 上下文初始化:currentRequestContext()方法确保首次访问时自动创建默认Metadata实例

  • 核心操作方法

  • set():设置当前线程上下文

  • get():获取当前上下文

  • clear():清除上下文防止内存泄漏

三、灰度发布完整流程

3.1 配置阶段

  1. 运营平台配置

    • 管理员登录运营平台
    • 选择应用和灰度用户
    • 配置灰度规则(用户ID、医院编码、路径等)
  2. 服务注册

    • 灰度服务实例启动时携带version=gray元数据
    • 正式服务实例携带version=release元数据
  3. 配置下发

    • 网关配置实时更新(支持@RefreshScope热刷新)

3.2 请求处理阶段

请求头传递示例
用户/前端 API网关 负载均衡器 微服务实例 发送请求(携带标识) GrayscaleGlobalFilter判断 设置灰度标记到上下文 传递灰度标记 GrayRoundRobinLoadBalancer筛选 路由到对应版本实例 处理业务逻辑 用户/前端 API网关 负载均衡器 微服务实例

流量识别:用户发起请求,携带用户ID、医院编码等标识信息

http 复制代码
GET /api/patient/list HTTP/1.1
Host: medical.chinaunicom.com
Application-Cust-Id: 123456789
Application-Hospital-Source-Code: 110101
application_version: gray

网关标记:GrayscaleGlobalFilter根据请求信息和配置的灰度规则,判断是否为灰度用户

上下文设置:如果是灰度用户,在请求上下文中设置相应的灰度标识

复制代码
GrayReleaseContextHolder.get().setVersion("gray");
// 向下游传递版本头
request.mutate().header("VERSION", "gray").build();

负载均衡:GrayRoundRobinLoadBalancer根据上下文中的灰度标识,选择合适的灰度服务实例

java 复制代码
服务实例列表:
- instance-1: metadata={version=release}
- instance-2: metadata={version=gray}  ✅ 被选中
- instance-3: metadata={version=gray}

服务处理与上下文清理:请求被路由到对应的灰度服务实例进行处理,完成后通过拦截器清除上下文:

java 复制代码
@Component
public class GrayReleaseContextInterceptor implements HandlerInterceptor {
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        GrayReleaseContextHolder.clear(); // 防止线程复用导致上下文污染
    }
}

四、关键技术点解析

4.1 多维度灰度判断

系统支持多种维度的灰度判断:

  1. 用户维度:根据用户ID判断是否为灰度用户
  2. 医院维度:根据医院编码判断是否为灰度医院
  3. 路径维度:根据请求路径判断是否进入灰度流程
  4. 角色维度:根据用户角色判断是否为灰度用户

4.2 动态配置更新

通过GatewaySettingService实现版本配置动态更新:

java 复制代码
@RefreshScope // Spring Cloud配置自动刷新注解
@Component
public class GatewaySettingService {
    @Value("${gray.version:release}")
    private String grayVersion;
    
    // 实时获取最新灰度版本配置
    public String getGrayVersion() {
        return grayVersion;
    }
}

修改Nacos配置中心的gray.version参数,网关会自动感知并更新路由策略,无需重启服务。

  • 实时生效:使用Spring Cloud Config和@RefreshScope
  • 零停机:配置修改无需重启服务
  • 版本回退:随时切换回正式版本

4.3 线程安全设计(上下文传递TransmittableThreadLocal)

医疗系统存在大量异步场景(如消息推送、报表生成),通过三层保障确保线程安全:

  1. TransmittableThreadLocal:上下文跨线程传递
  2. 拦截器自动清除:请求结束时调用clear()
  3. AtomicInteger计数器:负载均衡轮询无锁实现

在分布式系统中,特别是在使用异步处理(如线程池、CompletableFuture等)时,普通的ThreadLocal无法正确传递上下文信息。系统采用阿里巴巴开源的TransmittableThreadLocal来解决这个问题:

java 复制代码
// 使用TransmittableThreadLocal保证线程安全
private static final TransmittableThreadLocal<Metadata> CONTEXT = new TransmittableThreadLocal<>();

TransmittableThreadLocal能够自动传递线程上下文,确保在异步处理过程中也能正确获取到灰度标识。

TransmittableThreadLocal的作用

  • 解决异步线程上下文传递问题
  • 支持Hystrix、CompletableFuture等异步场景
  • 避免内存泄漏:每次请求结束后清理上下文

五、前端传值与后端判断示例

5.1 前端传值示例

场景1:B端用户灰度测试

javascript 复制代码
// 前端axios配置
axios.get('/api/business/data', {
  headers: {
    'Application-Busi-Id': 'user123456',  // B端用户ID
    'Application-Hospital-Source-Code': '110101',  // 医院编码
    'Domain-Sign': 'JTP_CUST'  // 域名标识
  }
})

场景2:医院维度灰度

javascript 复制代码
// 医院维度灰度
axios.post('/api/medical/record', data, {
  headers: {
    'Application-Hospital-Source-Code': '310104'  // 北京某医院
  }
})

5.2 后端判断逻辑

灰度判断流程

java 复制代码
// 网关层判断逻辑简化版
private boolean isGrayUser(ServerHttpRequest request) {
    String custId = request.getHeaders().getFirst("Application-Cust-Id");
    String hospitalCode = request.getHeaders().getFirst("Application-Hospital-Source-Code");
    String busiId = request.getHeaders().getFirst("Application-Busi-Id");
    
    // 1. 检查客户ID是否在灰度列表
    if (grayCustomerIds.contains(custId)) {
        return true;
    }
    
    // 2. 检查医院是否在灰度列表
    if (grayHospitals.contains(hospitalCode)) {
        return true;
    }
    
    // 3. 检查B端用户是否在灰度列表
    return grayUserIds.get(appCode).contains(busiId);
}

六、常见问题与解决方案

6.1 灰度实例不可用的降级策略

问题描述:当灰度实例出现故障时,如何保证服务的可用性?

解决方案:在GrayRoundRobinLoadBalancer中实现了降级机制:

java 复制代码
// GrayRoundRobinLoadBalancer中的降级逻辑
if (CollectionUtil.isEmpty(filteredInstances)) {
    // 降级到正式版本实例
    return instances.stream()
        .filter(i -> StrUtil.isBlank(i.getMetadata().get(GrayConstant.DEVELOPER)))
        .collect(Collectors.toList());
}

6.2 上下文传递问题

问题描述:在异步处理场景中,如何保证灰度上下文的正确传递?

解决方案:使用TransmittableThreadLocal替代普通的ThreadLocal,并在请求结束时清理上下文:

java 复制代码
// 在拦截器中清理上下文
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
    GrayReleaseContextHolder.clear();
}

6.3 灰度实例不匹配

问题 :服务实例元数据未正确设置version
解决 :在服务启动参数中添加-Dspring.cloud.nacos.discovery.metadata.version=gray

6.4 动态配置不生效

问题 :@RefreshScope未生效
解决:确保配置类被Spring容器管理,且配置中心监听正确

6.5 配置同步延迟问题

  • 问题:配置更新后,网关实例可能延迟感知
  • 解决:使用Spring Cloud Bus实现配置变更广播

6.6 灰度用户列表过大

  • 问题:用户列表过大导致内存占用高
  • 解决:使用Redis缓存+本地缓存的二级缓存策略

七、实际应用场景

7.1 新功能AB测试

为不同用户群展示不同问诊流程:

typescript 复制代码
GET /api/consultation/process
CUST_ID: 123456  // A流程(新界面)
CUST_ID: 654321  // B流程(旧界面)

场景:新挂号功能上线

  • 灰度用户:内部员工+试点医院
  • 灰度比例:10% → 30% → 100%
  • 监控指标:接口响应时间、错误率、用户满意度

7.2 重大版本升级

场景:医保接口升级

  • 灰度策略:按医院逐步切换
  • 回滚策略:一键切换回老版本
  • 验证周期:2周观察期

7.3 性能压测

场景:双十一前性能测试

  • 灰度用户:压测机器人账号
  • 灰度实例:独立的压测环境
  • 数据隔离:压测数据写入影子库

八、灰度设计亮点

  1. 低侵入性 :通过过滤器和拦截器实现,不侵入业务代码
    • 无业务侵入:业务代码无需修改
    • 配置驱动:通过配置实现灰度控制
    • 插件化:可插拔的灰度组件
  2. 多维度控制 :支持用户/医院/路径等多场景灰度
    • 用户维度:根据用户ID判断是否为灰度用户
    • 医院维度:根据医院编码判断是否为灰度医院
    • 路径维度:根据请求路径判断是否进入灰度流程
    • 角色维度:根据用户角色判断是否为灰度用户
  3. 完善的监控体系-全链路追踪 :VERSION头贯穿整个调用链,便于问题定位
    • 实时日志:每个灰度决策都有日志记录
    • 指标监控:灰度流量占比、错误率监控
    • 告警机制:灰度异常自动告警
  4. 动态调整:配置中心实时更新,无需重启服务
  5. 降级机制-安全兜底:无灰度实例时自动降级到生产环境
  6. 易于扩展:模块化设计,便于添加新的灰度判断维度
  7. 线程安全:使用TransmittableThreadLocal解决异步场景下的上下文传递问题

九、总结与扩展建议

本项目实现的灰度发布系统,通过网关层标记、负载均衡层路由、上下文层传递的三层架构,结合多维度灰度判断和动态配置能力,有效支撑了医疗系统的平稳发布需求。特别是TransmittableThreadLocal的应用,解决了异步场景下的上下文传递难题。

9.1 核心优势

  1. 技术架构先进:基于Spring Cloud原生实现
  2. 运维友好:可视化配置,支持热更新
  3. 安全可靠:多重降级机制,确保系统稳定
  4. 扩展性强:支持多种灰度策略

9.2 未来扩展方向

  1. 智能灰度:基于机器学习预测灰度效果
  2. 监控告警:添加灰度流量占比、响应时间监控,异常时自动熔断,增加灰度流量的监控指标,实时观察灰度发布的效果
  3. 灰度报告:自动生成灰度发布报告
  4. 权限细化:支持按功能模块,支持设备类型、地理位置等更多维度的灰度控制
  5. 规则引擎:引入开源规则引擎(如Drools),支持更复杂的灰度策略
  6. 流量比例控制:支持按百分比分配灰度流量,而非仅通过用户列表控制
  7. 跨语言支持:支持Dubbo、gRPC等协议
  8. 自动化扩缩容:结合Kubernetes等容器编排平台,实现灰度实例的自动扩缩容

9.3 最佳实践建议

  1. 灰度比例控制:建议从5%开始,逐步扩大
  2. 监控覆盖:灰度期间加强监控密度
  3. 回滚预案:制定详细的回滚方案
  4. 用户沟通:提前告知灰度用户可能的影响