- 通過之前技術的積累,終于開始了本文的編寫,如果對灰度、負載均衡、上下文傳遞、網關不太理解,可以先學習博主的以下博客內容。共勉:
- 企業級 Java 應用灰度發布設計方案與實踐全解析
- 《Spring 中上下文傳遞的那些事兒》 Part 1:ThreadLocal、MDC、TTL 原理與實踐
- Spring Cloud LoadBalancer 詳解-負載均衡
- 【Spring Cloud Gateway 實戰系列】基礎篇:路由、斷言、過濾器、負載均衡深度解析
- 本系統采用Spring Cloud微服務架構,通過網關層+負載均衡器實現了靈活可控的灰度發布方案。
- 本系統是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_version
和developer
請求頭標識灰度流量 - 上下文傳遞 :使用
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與目標版本完全一致
- 自動降級機制:無灰度實例時回退到正式版本
- 執行流程:
- 獲取當前請求的灰度版本(從GrayReleaseContextHolder)
- 篩選匹配版本的服務實例
- 使用輪詢算法選擇最終實例
1.4.5 業務服務層 - GrayReleaseContextInterceptor
- 作用: 業務服務內部的灰度上下文管理
- 執行時機: 每個HTTP請求進入業務服務時
- 功能:
- 提取Application-Version請求頭
- 調用GrayReleaseContextHolder,存儲到TransmittableThreadLocal,供后續Feign調用使用
- 請求完成后自動清理,防止內存泄漏
1.4.6 服務間調用 - GrayReleaseFeignRequestInterceptor
- 作用: 微服務間灰度標識的透傳
- 執行時機: 每次Feign調用發起時
- 功能:
- 調用GrayReleaseContextHolder,從TransmittableThreadLocal獲取當前灰度版本
- 自動注入到Feign請求頭
- 支持開發調試模式的詳細日志
1.5關鍵數據流轉路徑
-
配置更新路徑:
運營平臺 → GatewayApi → Redis → 網關配置緩存 → 實時生效 -
請求處理路徑:
客戶端 → 網關灰度判斷 → 負載均衡選擇 → 業務服務 → 上下文管理 → Feign透傳 -
版本標識傳遞:
網關設置 → 請求頭傳遞 → ThreadLocal存儲 → Feign注入 → 下游服務繼承 -
異常降級機制:
無灰度實例 → 自動降級到正式版本
實例不可用 → 熔斷降級機制
配置缺失 → 使用默認正式版本
二、核心實現類詳解
2.1 配置管理層—gateway網關相關
2.1.1 yml配置
gateway.yml:其中whitelist白名單配置,后續在WhiteListProperties類中獲取
## 端口
#server.port: 8888
spring:servlet:multipart:max-file-size: 500MBmax-request-size: 500MB
## 監控
management:endpoint:health:show-details: alwaysendpoints:jmx:exposure:include: '*'web:exposure:include: '*'gateway:enabled: falseserver: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/queryByTicketgray:- /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:獲取白名單配置
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:各服務的網關路由配置,簡單看一下就可以
spring:cloud:gateway:discovery:locator:enabled: trueroutes:- id: portaluri: lb://portalpredicates:- Path=/portal/**filters:- RewritePath=/portal/(?<segment>.*), /$\{segment}- id: portal-hisuri: lb://portalpredicates:- Path=/{hisOrgCode}/api/inter-hos/**filters:- StripPrefix=1- RewritePath=/api/inter-hos/(?<segment>.*), /inbound/$\{segment}- AddRequestHeadersIfNotPresent=orgcode:{hisOrgCode} - id: hospitaluri: lb://hospitalpredicates:- Path=/hospital/**filters:- RewritePath=/hospital/(?<segment>.*), /$\{segment}- id: patienturi: lb://patientpredicates:- Path=/patient/**filters:- RewritePath=/patient/(?<segment>.*), /$\{segment}- id: mpcuri: lb://mpc-serverpredicates:- Path=/rbac/**filters:- RewritePath=/rbac/(?<segment>.*), /$\{segment} - id: gatewayuri: lb://gatewaypredicates:- Path=/gateway/**filters:- RewritePath=/gateway/(?<segment>.*), /$\{segment}- id: ordersuri: lb://orderspredicates:- Path=/orders/**filters:- RewritePath=/orders/(?<segment>.*), /$\{segment}
2.1.2 GatewayApi
@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操作配置數據。
@RestController
@RequestMapping("/")
public class GatewayController {private Logger log = LoggerFactory.getLogger(this.getClass());@Resourceprivate 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
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());@Autowiredprivate RedisTemplate<String, String> stringRedisTemplate;public GatewaySettingDto save(GatewaySettingDto dto) {try {// updatestringRedisTemplate.boundValueOps(GatewaySettingDto.GATEWAY_SETTING_REDIS).set(JSONUtil.toJsonPrettyStr(dto));// notifystringRedisTemplate.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)
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"; // 客戶IDpublic 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,優先級最高),核心邏輯如下:
@Component
@RefreshScope
@Slf4j
public class GrayscaleGlobalFilter implements GlobalFilter, Ordered {@Resourceprivate GatewaySettingService gatewaySettingService;@Resourceprivate WhiteListProperties whiteListProperties;@Overridepublic 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);}// // 設置灰度上下文,設置線程是為了傳到后面的filtermetadata.setVersion(version + "");GrayReleaseContextHolder.set(metadata);// 添加灰度請求頭,傳遞灰度標記到下游服務,用于 httpClient.request 傳遞到實例apiServerHttpRequest.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);}}@Overridepublic 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)
運營平臺提供了完整的灰度用戶管理接口,接口示例:
關鍵功能:
- 新增灰度用戶
- 刪除灰度用戶
- 選擇用戶
- 查詢用戶
@Tag(name = "運營平臺-B端灰度環境管理")
@RestController
@Slf4j
@RequestMapping("/businessGrayEnvironment")
public class BusinessGrayEnvironmentController {@Resourceprivate UserInfoService userInfoService;@Resourceprivate GatewayApi gatewayApi;@Resourceprivate 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)
自定義負載均衡器,基于灰度版本選擇服務實例:
/*** 灰度發布增強版輪詢負載均衡器* 實現基于版本號和開發者標識的服務實例篩選與路由* 繼承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 instancesif (instances.size() == 1) {return new DefaultResponse(instances.get(0));}// Ignore the sign bit, this allows pos to loop sequentially from 0 to Integer.MAX_VALUEint 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)可確保上下文在線程池環境中正確傳遞。
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();}
}
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 配置階段
-
運營平臺配置
- 管理員登錄運營平臺
- 選擇應用和灰度用戶
- 配置灰度規則(用戶ID、醫院編碼、路徑等)
-
服務注冊
- 灰度服務實例啟動時攜帶
version=gray
元數據 - 正式服務實例攜帶
version=release
元數據
- 灰度服務實例啟動時攜帶
-
配置下發
- 網關配置實時更新(支持@RefreshScope熱刷新)
3.2 請求處理階段
請求頭傳遞示例:
流量識別:用戶發起請求,攜帶用戶ID、醫院編碼等標識信息
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根據上下文中的灰度標識,選擇合適的灰度服務實例
服務實例列表:
- instance-1: metadata={version=release}
- instance-2: metadata={version=gray} ? 被選中
- instance-3: metadata={version=gray}
服務處理與上下文清理:請求被路由到對應的灰度服務實例進行處理,完成后通過攔截器清除上下文:
@Component
public class GrayReleaseContextInterceptor implements HandlerInterceptor {@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {GrayReleaseContextHolder.clear(); // 防止線程復用導致上下文污染}
}
四、關鍵技術點解析
4.1 多維度灰度判斷
系統支持多種維度的灰度判斷:
- 用戶維度:根據用戶ID判斷是否為灰度用戶
- 醫院維度:根據醫院編碼判斷是否為灰度醫院
- 路徑維度:根據請求路徑判斷是否進入灰度流程
- 角色維度:根據用戶角色判斷是否為灰度用戶
4.2 動態配置更新
通過GatewaySettingService實現版本配置動態更新:
@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)
醫療系統存在大量異步場景(如消息推送、報表生成),通過三層保障確保線程安全:
- TransmittableThreadLocal:上下文跨線程傳遞
- 攔截器自動清除:請求結束時調用clear()
- AtomicInteger計數器:負載均衡輪詢無鎖實現
在分布式系統中,特別是在使用異步處理(如線程池、CompletableFuture等)時,普通的ThreadLocal無法正確傳遞上下文信息。系統采用阿里巴巴開源的TransmittableThreadLocal來解決這個問題:
// 使用TransmittableThreadLocal保證線程安全
private static final TransmittableThreadLocal<Metadata> CONTEXT = new TransmittableThreadLocal<>();
TransmittableThreadLocal能夠自動傳遞線程上下文,確保在異步處理過程中也能正確獲取到灰度標識。
TransmittableThreadLocal的作用:
- 解決異步線程上下文傳遞問題
- 支持Hystrix、CompletableFuture等異步場景
- 避免內存泄漏:每次請求結束后清理上下文
五、前端傳值與后端判斷示例
5.1 前端傳值示例
場景1:B端用戶灰度測試
// 前端axios配置
axios.get('/api/business/data', {headers: {'Application-Busi-Id': 'user123456', // B端用戶ID'Application-Hospital-Source-Code': '110101', // 醫院編碼'Domain-Sign': 'JTP_CUST' // 域名標識}
})
場景2:醫院維度灰度
// 醫院維度灰度
axios.post('/api/medical/record', data, {headers: {'Application-Hospital-Source-Code': '310104' // 北京某醫院}
})
5.2 后端判斷邏輯
灰度判斷流程:
// 網關層判斷邏輯簡化版
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中實現了降級機制:
// GrayRoundRobinLoadBalancer中的降級邏輯
if (CollectionUtil.isEmpty(filteredInstances)) {// 降級到正式版本實例return instances.stream().filter(i -> StrUtil.isBlank(i.getMetadata().get(GrayConstant.DEVELOPER))).collect(Collectors.toList());
}
6.2 上下文傳遞問題
問題描述:在異步處理場景中,如何保證灰度上下文的正確傳遞?
解決方案:使用TransmittableThreadLocal替代普通的ThreadLocal,并在請求結束時清理上下文:
// 在攔截器中清理上下文
@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測試
為不同用戶群展示不同問診流程:
GET /api/consultation/process
CUST_ID: 123456 // A流程(新界面)
CUST_ID: 654321 // B流程(舊界面)
場景:新掛號功能上線
- 灰度用戶:內部員工+試點醫院
- 灰度比例:10% → 30% → 100%
- 監控指標:接口響應時間、錯誤率、用戶滿意度
7.2 重大版本升級
場景:醫保接口升級
- 灰度策略:按醫院逐步切換
- 回滾策略:一鍵切換回老版本
- 驗證周期:2周觀察期
7.3 性能壓測
場景:雙十一前性能測試
- 灰度用戶:壓測機器人賬號
- 灰度實例:獨立的壓測環境
- 數據隔離:壓測數據寫入影子庫
八、灰度設計亮點
- 低侵入性:通過過濾器和攔截器實現,不侵入業務代碼
- 無業務侵入:業務代碼無需修改
- 配置驅動:通過配置實現灰度控制
- 插件化:可插拔的灰度組件
- 多維度控制:支持用戶/醫院/路徑等多場景灰度
- 用戶維度:根據用戶ID判斷是否為灰度用戶
- 醫院維度:根據醫院編碼判斷是否為灰度醫院
- 路徑維度:根據請求路徑判斷是否進入灰度流程
- 角色維度:根據用戶角色判斷是否為灰度用戶
- 完善的監控體系-全鏈路追蹤:VERSION頭貫穿整個調用鏈,便于問題定位
- 實時日志:每個灰度決策都有日志記錄
- 指標監控:灰度流量占比、錯誤率監控
- 告警機制:灰度異常自動告警
- 動態調整:配置中心實時更新,無需重啟服務
- 降級機制-安全兜底:無灰度實例時自動降級到生產環境
- 易于擴展:模塊化設計,便于添加新的灰度判斷維度
- 線程安全:使用TransmittableThreadLocal解決異步場景下的上下文傳遞問題
九、總結與擴展建議
? 本項目實現的灰度發布系統,通過網關層標記、負載均衡層路由、上下文層傳遞的三層架構,結合多維度灰度判斷和動態配置能力,有效支撐了醫療系統的平穩發布需求。特別是TransmittableThreadLocal的應用,解決了異步場景下的上下文傳遞難題。
9.1 核心優勢
- 技術架構先進:基于Spring Cloud原生實現
- 運維友好:可視化配置,支持熱更新
- 安全可靠:多重降級機制,確保系統穩定
- 擴展性強:支持多種灰度策略
9.2 未來擴展方向
- 智能灰度:基于機器學習預測灰度效果
- 監控告警:添加灰度流量占比、響應時間監控,異常時自動熔斷,增加灰度流量的監控指標,實時觀察灰度發布的效果
- 灰度報告:自動生成灰度發布報告
- 權限細化:支持按功能模塊,支持設備類型、地理位置等更多維度的灰度控制
- 規則引擎:引入開源規則引擎(如Drools),支持更復雜的灰度策略
- 流量比例控制:支持按百分比分配灰度流量,而非僅通過用戶列表控制
- 跨語言支持:支持Dubbo、gRPC等協議
- 自動化擴縮容:結合Kubernetes等容器編排平臺,實現灰度實例的自動擴縮容
9.3 最佳實踐建議
- 灰度比例控制:建議從5%開始,逐步擴大
- 監控覆蓋:灰度期間加強監控密度
- 回滾預案:制定詳細的回滾方案
- 用戶溝通:提前告知灰度用戶可能的影響