简介

敏捷开发迭代周期短发布快,每周都可能面临版本发版上线,为最大可能的降低对用户的影响提高服务可用率,大部分团队都需要等到半夜做发布和支持。本文就如何基于spring cloud体系做灰度发版改造提供了方案,让我们终于白天也能偷偷摸摸的无感知发版,验证,上线等动作,从此再也不用因为发版要熬夜了。

本文阐述的方案是灰度发版方案的一种实现(各种部署方案可参考文档最后的附录),属于一种比较节约资源的部署方案,通过精准的导流和开关控制实现用户无感知的热部署,比较适合中小企业采纳应用。整体技术架构基于nepxion discovery插件结合项目中各个实践场景做了方案说明和源代码展示,如需要做权重,分组等策略可自行扩展。

术语与配置

名称

说明

灰度节点

被标记为灰度的节点

灰度入口

前端部署的节点被标记为灰度的节点

灰度用户

账号被标记位灰度的用户

灰度流量

路由是需要优先选择灰度节点的请求链

灰度开关

是否开启灰度路由,值为:开启/关闭

灰度流量开关

是否所有流量都是灰度流量,值为开启/关闭

开关与流量关系

灰度流量开关\灰度总开关

适用场景

正常用户

正常入口

灰度用户

灰度入口

(灰度总开关)开

(灰度流量开关)关

灰度节点发版,新版本验证阶段

旧版本体验

新版本体验

(灰度总开关)开

(灰度流量开关)开

正常节点发版,新版本批量部署阶段

新版本体验

新版本体验

(灰度总开关)关

(灰度流量开关)开/关

新版本完成上线

新版本体验

新版本体验

灰度配置 Gray Properties


用户白名单:

  • 节点清单加载可以从eureka获取
public ResultMessage getServices() {
        //本地配置的服务map
        Map<String, Service> servicesLocalMap = getServicesLocalMap();

        //要返回的服务清单
        List<Service> services = new ArrayList();
        discoveryClient.getServices().forEach(service -> {
            final List<Instance> instances = new ArrayList();
            discoveryClient.getInstances(service).forEach(instanceInfo -> {
                instances.add(toInstance(instanceInfo));
            });

            //优先使用本地实例
            if (null != servicesLocalMap.get(service)) {
                final List<Instance> serviceLocalInstances = servicesLocalMap.get(service).getData();
                //更新状态
                List<Instance> serviceLocalInstancesHasLatestStatus = serviceLocalInstances.stream()
                        .map(instanceLocal -> instances.stream()
                                        .filter(instance -> StringUtils.join(instance.getHost(), instance.getPort().toString()).equals(StringUtils.join(instanceLocal.getHost(), instanceLocal.getPort().toString())))
                                        .findFirst().map(m -> {
                                            instanceLocal.setStatus(m.getStatus());
                                            return instanceLocal;
                                        })
//                                        .orElse(null)
                                        .orElseGet(() -> {
                                            instanceLocal.setStatus("OFFLINE");
                                            return instanceLocal;
                                        })
                        ).filter(Objects::nonNull).collect(Collectors.toList());

                //去除eureka中本地已配置的实例
                List<Instance> instancesRemoveLocal = instances.stream().filter(instance -> !serviceLocalInstancesHasLatestStatus.stream()
                        .anyMatch(instanceLocal -> StringUtils.join(instance.getHost(), instance.getPort().toString()).equals(StringUtils.join(instanceLocal.getHost(), instanceLocal.getPort().toString()))))
                        .collect(Collectors.toList());

                //清空并重新添加处理过的本地和远程实例
                instances.clear();
                instances.addAll(instancesRemoveLocal);
                instances.addAll(serviceLocalInstancesHasLatestStatus);

                //本地服务实例排除已经添加的服务
                servicesLocalMap.remove(service);
            }

            //排序
            Collections.sort(instances);

            //单个服务和节点添加
            Service serviceResp = new Service();
            serviceResp.setService(service);
            serviceResp.setData(instances);
            services.add(serviceResp);
        });

        //添加eureka中不存在,本地存在的服务
        servicesLocalMap.values().forEach(service->{
            Collections.sort(service.getData());
            services.add(service);
        });

        Collections.sort(services);
        return ResultCode.SUCCESS.withData(services);
    }

依赖 Gray Dependency

<!-- 服务的灰度依赖 -->
<dependency>
    <groupId>com.sf</groupId>
    <artifactId>cloud-discovery-service-starter</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

<!-- 网关的灰度依赖,注意不能与服务的灰度依赖一起配置 -->
<dependency>
    <groupId>com.sf</groupId>
    <artifactId>cloud-discovery-gateway-starter</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>


<!-- 灰度发版插件 -->
<plugin>
    <groupId>com.sf</groupId>
    <artifactId>maven-plugin</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <executions>
        <execution>
            <phase>compile</phase>
            <goals>
                <goal>gray-plugin</goal>
            </goals>
            <configuration>
                <grayBuildLocationExclude></grayBuildLocationExclude>
            </configuration>
        </execution>
    </executions>
</plugin>

灰度头部 GrayHeader

灰度头部信息信息,编码支持的类有 < GrayHeader,GrayHeaderConstant,GrayUtil, ServiceGrayUtil >

灰度对象存放的上下文有:自定义实现类 < GrayHeaderHolder >,Request内置实现类 < RequestContextHolder >

参数key

参数value

说明

h-gray-is

true/false

是否为灰度流量

h-gray-domain

域名

用户请求的域名

h-gray-userid

xxx

用户请求的账户

技术改造点

灰度改造分三大类:网关改造,服务改造,场景改造。主要目的是实现灰度头部计算、复用、续传,负载均衡的改造。

网关改造 cloud-discovery-gateway-starter

修改pom.xml依赖
<dependency>
    <groupId>com.nepxion</groupId>
    <artifactId>discovery-plugin-starter-eureka</artifactId>
    <version>0.0.2-SNAPSHOT</version>
</dependency>
<dependency>
    <groupId>com.nepxion</groupId>
    <artifactId>discovery-plugin-strategy-starter-gateway</artifactId>
    <version>0.0.2-SNAPSHOT</version>
</dependency>
configure加载项
@Configuration
public class DiscoveryGatewayAutoConfiguration {
    //负载均衡改造注入
    @Bean
    public DiscoveryEnabledAdapter discoveryEnabledAdapter() {
        return new GatewayGrayDiscoveryEnabledAdapter();
    }
    //灰度路由计算注入
    @Bean
    public GrayRouteFlagFilter grayRouteFilter() {
        return new GrayRouteFlagFilter();
    }
    //灰度配置获取注入
    @Bean
    public GrayPropertiesLoader grayPropertiesLoader() {
        return new GrayPropertiesLoader(gatewayRedisson);
    }
}
[路由场景]增加header

在网关新增Filter,将request上下文和灰度配置匹配,算出灰度路由标记

public class GrayRouteFlagFilter implements GlobalFilter, Ordered {
    @Autowired
    private GrayPropertiesLoader grayPropertiesLoader;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        try {
            ServerHttpRequest request = exchange.getRequest();
            Boolean isGray = grayPropertiesLoader.calculateGrayFlag(request.getHeaders().getFirst(GrayHeaderConstant.GRAY_IS),
                    request.getHeaders().getFirst(GrayHeaderConstant.GRAY_NGINX_IP),
                    request.getHeaders().getFirst(GrayHeaderConstant.GRAY_USER_ACCOUNT));
            request = request.mutate().header(GrayHeaderConstant.GRAY_IS, isGray.toString()).build();
            exchange = exchange.mutate().request(request).build();
        }catch (Throwable e){
            log.error("未知错误",e);
        }finally {
            return chain.filter(exchange);
        }
    }
}
[路由场景]改造feign负载均衡实现节点筛选

改造负载均衡,计算灰度路由标记,灰度开关和节点清单的匹配性,筛选出符合条件的节点

public class GatewayGrayDiscoveryEnabledAdapter extends DefaultDiscoveryEnabledAdapter {

        @Autowired
        private GrayPropertiesLoader grayPropertiesLoader;

        public boolean apply(Server server) {
            if (!this.applyVersion(server)) {
                return false;
            } 
        }

        private boolean applyVersion(Server server) {
            //判断灰度总开关
            if(!Boolean.valueOf(grayPropertiesLoader.get().getEnable())){
                return true;
            }
            Boolean isGray = Boolean.valueOf(strategyContextHolder.getHeader(GrayHeaderConstant.GRAY_IS));
            try{
                return GrayLoadBalanceUtil.isServerMatch(this.pluginAdapter.getServerServiceId(server), isGray, server.getHost(), server.getPort());
            }catch(Exception e){
                return true;
            }
        }

    }

服务改造 cloud-discovery-service-starter

修改pom.xml依赖
<dependency>
    <groupId>com.nepxion</groupId>
    <artifactId>discovery-plugin-starter-eureka</artifactId>
    <version>0.0.2-SNAPSHOT</version>
</dependency>
<dependency>
    <groupId>com.nepxion</groupId>
    <artifactId>discovery-plugin-strategy-starter-service</artifactId>
    <version>0.0.2-SNAPSHOT</version>
</dependency>
configure加载项
@Configuration
public class DiscoveryServiceAutoConfiguration {

    @Autowired
    Redisson getRedisson;
 //灰度负载均衡注入
    @Bean
    public DiscoveryEnabledAdapter discoveryEnabledAdapter() {
        return new ServiceGrayDiscoveryEnabledAdapter();
    }    
    //Feign灰度头部续传改造注入
    @Bean
    public FeignStrategyInterceptor feignStrategyInterceptor() {
        return new ServiceFeignStrategyInterceptor(GrayHeaderConstant.STRATEGY_REQUEST_HEADERS);
    }
 //灰度配置获取注入
    @Bean
    public GrayPropertiesLoader grayPropertiesLoader() {
        return new GrayPropertiesLoader(getRedisson);
    }
 //restTemplate灰度头部续传改造注入
    @Bean
    public RestTemplateStrategyInterceptor restTemplateStrategyInterceptor() {
        return new ServiceRestTemplateStrategyInterceptor(GrayHeaderConstant.STRATEGY_REQUEST_HEADERS);
    }
    //线程池灰度头部续传改造注入
    @Bean
    public RequestContextDecorator requestContextDecorator() {
        return new RequestContextDecorator();
    }

}
  • [feign同步调用场景]改造feign负载均衡实现节点筛选 < ServiceGrayDiscoveryEnabledAdapter >
public class ServiceGrayDiscoveryEnabledAdapter extends DefaultDiscoveryEnabledAdapter {

        @Autowired
        private GrayPropertiesLoader grayPropertiesLoader;

        public boolean apply(Server server) {
            if (!this.applyVersion(server)) {
                return false;
            }
            return true;
        }

        private boolean applyVersion(Server server) {
            //灰度开关是否开启
            if(!Boolean.valueOf(grayPropertiesLoader.get().getEnable())){
                return true;
            }
            Boolean isGray = Boolean.valueOf(ServiceGrayUtil.getGrayHeaderFromContext().getIsGray());
            try{
                return GrayLoadBalanceUtil.isServerMatch(this.pluginAdapter.getServerServiceId(server), isGray, server.getHost(), server.getPort());
            }catch(Exception e){
                return true;
            }
        }

}
  • [restTemplate调用场景]支持header续传 < ServiceRestTemplateStrategyInterceptor >
public class ServiceRestTemplateStrategyInterceptor extends RestTemplateStrategyInterceptor {

    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        this.applyOuterHeader(request);
        return execution.execute(request, body);
    }

    private void applyOuterHeader(HttpRequest request) {
        HttpHeaders headers = request.getHeaders();
        GrayUtil.toMap(ServiceGrayUtil.getGrayHeaderFromContext()).forEach((key, value)->{
            if(null==headers.get(key)){
                headers.add(key, value);
            }
        });
    }

}

场景改造

线程池灰度改造 ThreadPoolTaskExecutor

对于线程池的使用请使用封装过的taskExecutor,这样就自动实现了跨线程的头部续传,装饰类为: < RequestContextDecorator >

public class RequestContextDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {

        try{
            RequestAttributes context = RequestContextHolder.currentRequestAttributes();
            GrayHeader grayHeader = ServiceGrayUtil.getGrayHeaderFromContext();
            log.debug(" --- gray debug: THREAD parent:{}", Thread.currentThread().getName());
            return () -> {
                try {
                    try {
                        RequestContextHolder.setRequestAttributes(context);
                        GrayUtil.setGrayHeader2Local(grayHeader);
                        log.debug(" --- gray debug: THREAD child:{}", Thread.currentThread().getName());
                    }catch(Exception e){
                        log.error("跨线程传递变量出错:", e);
                    }
                    runnable.run();
                }catch(Exception e){
                    log.error("定时任务执行出错:", e);
                }finally{
                    RequestContextHolder.resetRequestAttributes();
                    GrayUtil.removeGrayHeader();
                }
            };
        }catch(Exception e){
            log.debug("线程装饰出错:", e);
        }
        return () -> {
                runnable.run();
        };
    }
}
  • 使用场景
#引入bean依赖(此线程池添加了灰度header续传,已把RequestContextHolder从主线程迁移过来):
@Resource
ThreadPoolTaskExecutor taskExecutor;

//场景1,异步注解方式
* @Async("taskExcutor")

//场景2,异步调用
taskExcutor.submit(task);

//场景3,异步回调方式
* CompletableFuture<UserAccount> userAccountCompletableFuture = CompletableFuture.<Void>completedFuture(null)
                .thenApplyAsync(dummy -> {...}, taskExcutor);

自定义executor注意添加setTaskDecorator(new RequestContextDecorator())
    private ThreadPoolTaskExecutor executorService;
    @PostConstruct
    public void init() {
        executorService = new ThreadPoolTaskExecutor();
        ...
        //此处添加了灰度header续传
        executorService.setTaskDecorator(new RequestContextDecorator());
        executorService.initialize();
    }
定时任务改造 Job
  • 需要考虑支持的定时任务调度模式模式

全随机:从所有的节点选一个执行任务 灰度随机:从灰度节点中选一个执行任务 并行随机:从非灰度节点中选一个执行任务,同时从灰度节点中选一个执行任务 非灰度随机:从非灰度节点中选一个执行任务

  • 以Quartz Job Scheduling分布式任务为例,其他xxl-job可参考此方案改造
public class QuartzJobRunner implements Job {
@Override    public final void execute(JobExecutionContext context) throws JobExecutionException { 
    //对不同的调度模式执行不同的负载均衡算法选择节点,可参考附件工具类  
   GrayLoadBalanceUtil.getServerUrl(String serviceName, Boolean isGray, String grayDomain, String grayUserid)    } }
消息队列改造 Queue

统一标准的消息队列结构封装,带入GrayHeader信息。

统一改造封装消费方,如果GrayHeader信息与消费方机器匹配才做消费。

遗留问题

第三方组件未做隔离带来的问题

因此方案从节约成本的考虑仍然共用redis,db等第三方组件,如开发过程中应尽量避免第三方存储的数据结构发生变更,需要评估新旧版本是否会存在差异导致服务不可用来决定是否停服维护。

跨平台的灰度服务改造探讨

  • 对于spring cloud体系发起调用时,统一按灰度规则改造Http Client实现灰度负载均衡,实现GrayHeader传递
  • 对spring cloud体系接收调用时,统一规范第三方填入GrayHeader信息
  • 对于通过消息队列交互的第三方,可定制A,B topic轮换策略来实现灰度发版

附录:

灰度工具类代码

  • GrayUtil,ServiceGrayUtil提供了一系列工具类,如获取灰度上下文,构造灰度上下文,设置灰度上下文到下一个请求等
/** 灰度头部上下文获取方式 */
GrayHeader ServiceGrayUtil.getGrayHeaderFromContext()  
/** 跨线程的灰度头部续传范例 **/
GrayUtil.snapshotGrayHeaderProperties();
...
GrayUtil.recoverGrayHeaderProperties(new HttpHeaders(), grayHeaderMap));

GrayUtil

public class GrayUtil1 {

    public static List<String> requestHeaderList = StringUtil.splitToList(GrayHeaderConstant.STRATEGY_REQUEST_HEADERS.toLowerCase(), ";");

    /**
     * 此方法只适用于网关入口处获取灰度头部,不能在续传过程中使用,如续传使用请参见ServiceGrayUtil.getGrayHeaderFromContext()
     * 从头部获取灰度灰度参数对象,指定grayUserid覆盖头部中的参数
     * 此方法不会重用header:is-gray标记,只会重新计算。
     * @param headers
     * @param grayUserid
     */
    public static GrayHeader getGrayHeaderFromHeader(HttpHeaders headers, String grayUserid){
        GrayHeader grayHeader = new GrayHeader();
        if(StringUtils.isEmpty(grayUserid)){
            grayUserid = headers.getFirst(GrayHeaderConstant.GRAY_USER_ACCOUNT);
        }
        GrayPropertiesLoader grayPropertiesLoader = GrayContextUtil.getBean(GrayPropertiesLoader.class);
        /**
         * 无法获取grayUserid和grayDomain的情况下,从缓存灰度配置拿灰度flag:满足多数用户的流量走向
         */
        if(null!= grayPropertiesLoader){
            grayHeader = grayPropertiesLoader.calculateGrayHeader(null, headers.getFirst(GrayHeaderConstant.GRAY_NGINX_IP), grayUserid);
        }
        return grayHeader;
    }
    /**
     * 设置当前header的灰度头部信息
     * @param headers
     * @param grayHeader
     */
    public static HttpHeaders appendGrayHeader(HttpHeaders headers, GrayHeader grayHeader){
        try{
            Iterator<Map.Entry<String,String>> iter = toMap(grayHeader).entrySet().iterator();
            while(iter.hasNext()){
                Map.Entry<String, String> row = iter.next();
                if(!headers.containsKey(row.getKey())){
                    headers.add(row.getKey(), row.getValue());
                }
            }
            return headers;
        }catch(Exception e){
            log.error("恢复灰度头部信息失败", e);
        }
        return headers;
    }

    /**
     *
     * @param headersJson request header json
     * @param grayHeaderString gray  header json
     * @return
     */
    public static String appendGrayHeader(String headersJson, String grayHeaderString){
        try{
            JSONObject headParamObj = new JSONObject();
            if(StringUtils.isNotEmpty(headersJson)){
                headParamObj = JSONObject.parseObject(headersJson);
            }

            JSONObject grayHeaderJson = new JSONObject();
            if(StringUtils.isNotEmpty(grayHeaderString)){
                grayHeaderJson = JSONObject.parseObject(grayHeaderString);
            }

            Iterator<JSONObject.Entry<String,Object>> iter = grayHeaderJson.entrySet().iterator();
            while(iter.hasNext()){
                JSONObject.Entry<String, Object> row = iter.next();
                if(StringUtils.isEmpty(headParamObj.getString(row.getKey()))){
                    headParamObj.put(row.getKey(), row.getValue());
                }
            }
            return  headParamObj.toJSONString();
        }catch(Exception e){
            log.error("设置恢复灰度头部信息失败", e);
        }
        return headersJson;
    }

    /**
     * 把grayHeader写入到当前线程local(适合消息队列等通过消息接收到grayHeader信息后写入threadlocak,提供给restTemplate发起时抓取并塞入头部)
     * @param grayHeader
     */
    public static void setGrayHeader2Local(GrayHeader grayHeader){
        GrayHeaderHolder.setGrayContext(grayHeader);
    }

    /**
     * 清除threadLocal存储的灰度路由信息
     */
    public static void removeGrayHeader(){
        GrayHeaderHolder.removeGrayContext();
    }

    /**
     * Gray头部对象转map
     * @param grayHeader
     * @return
     */
    public static Map<String, String> toMap(GrayHeader grayHeader){
        Map<String, String> res = new HashMap();
        if(null!=grayHeader.getIsGray()){
            res.put(GrayHeaderConstant.GRAY_IS, grayHeader.getIsGray().toString());
        }
        if(null!=grayHeader.getGrayUserid()){
            res.put(GrayHeaderConstant.GRAY_USER_ACCOUNT, grayHeader.getGrayUserid());
        }
        if(null!=grayHeader.getGrayDomain()){
            res.put(GrayHeaderConstant.GRAY_NGINX_IP, grayHeader.getGrayDomain());
        }
       
        return res;
    }

    /**
     * map中获取grayHeader对象
     * @param map
     * @return
     */
    public static GrayHeader toGrayHeader(Map<String, String> map){
        GrayHeader grayHeader = new GrayHeader();
        if(null!=map.get(GrayHeaderConstant.GRAY_IS)){
            grayHeader.setIsGray(Boolean.valueOf(map.get(GrayHeaderConstant.GRAY_IS)));
        }
        if(null!=map.get(GrayHeaderConstant.GRAY_USER_ACCOUNT)){
            grayHeader.setGrayUserid((String)map.get(GrayHeaderConstant.GRAY_USER_ACCOUNT));
        }
        if(null!=map.get(GrayHeaderConstant.GRAY_NGINX_IP)){
            grayHeader.setGrayDomain((String)map.get(GrayHeaderConstant.GRAY_NGINX_IP));
        }
       
        return grayHeader;
    }

    public static boolean isHeaderContains(String headerName) {
        return headerName.startsWith("n-d-") || requestHeaderList.contains(headerName);
    }

    public static boolean isHeaderContainsExcludeInner(String headerName) {
        return isHeaderContains(headerName) ;
    }
}

ServiceGrayUtil

public class ServiceGrayUtil {
    /**
     * 获取当前header的灰度头部信息,优先从discoveryRequest上下文获取,如果抓不到则从threadLocal获取
     * @return
     */
    public static GrayHeader getGrayHeaderFromContext(){
        String grayDomain = null;
        String grayUserid = null;
        GrayHeader grayHeader = new GrayHeader();
        try{
            /**
             * 从request上下文获取
             */
            Map<String, String> grayHeaderMap = new HashMap();
            ServiceStrategyContextHolder serviceStrategyContextHolder = GrayContextUtil.getBean(ServiceStrategyContextHolder.class);
            ServiceStrategyRouteFilter serviceStrategyRouteFilter = GrayContextUtil.getBean(ServiceStrategyRouteFilter.class);

            ServletRequestAttributes attributes = serviceStrategyContextHolder.getRestAttributes();
            if (attributes != null) {
                HttpServletRequest previousRequest = attributes.getRequest();
                Enumeration<String> headerNames = previousRequest.getHeaderNames();
                if (headerNames != null) {
                    String routeRegionWeight;
                    while (headerNames.hasMoreElements()) {
                        routeRegionWeight = (String) headerNames.nextElement();
                        String headerValue = previousRequest.getHeader(routeRegionWeight);
                        boolean isHeaderContains = GrayUtil.isHeaderContainsExcludeInner(routeRegionWeight.toLowerCase());
                        if (isHeaderContains) {
                            grayHeaderMap.put(routeRegionWeight, headerValue);
                        }
                    }

                }
            }
            grayHeader = GrayUtil.toGrayHeader(grayHeaderMap);
            grayDomain = null==grayDomain?grayHeader.getgrayDomain():grayDomain;
            grayUserid = null==grayUserid?grayHeader.getgrayUserid():grayUserid;

            if(null!=grayHeader.getIsGray()){
                log.debug(" --- gray debug:从request头部获取灰度信息成功:{}", JsonUtil.toJson(grayHeader));
                return grayHeader;
            }
        }catch(Exception e){
            log.debug("从request头部获取灰度信息失败", e);
        }

        try{
            /**
             * 从threadlocal获取:如果request上下文未获取到isGray标记则进入
             */
            if(null!= GrayHeaderHolder.getGrayContext()){
                grayHeader = GrayHeaderHolder.getGrayContext();
                grayDomain = null==grayDomain?grayHeader.getgrayDomain():grayDomain;
                grayUserid = null==grayUserid?grayHeader.getgrayUserid():grayUserid;
            }
            if(null!=grayHeader.getIsGray()){
                log.debug(" --- gray debug:从threadlocal获取灰度信息成功:{}", JsonUtil.toJson(grayHeader));
                return grayHeader;
            }
        }catch(Exception e){
            log.debug("从threadlocal获取灰度信息失败", e);
        }
        try{
            GrayPropertiesLoader grayPropertiesLoader = GrayContextUtil.getBean(GrayPropertiesLoader.class);
            /**
             * 无法获取grayUserid和grayDomain的情况下,从缓存灰度配置拿灰度flag:满足多数用户的流量走向
             */
            if(null!= grayPropertiesLoader){
                grayHeader = grayPropertiesLoader.calculateGrayHeader(null, grayDomain, grayUserid);
            }
            if(null!=grayHeader.getIsGray()){
                log.debug(" --- gray debug:本地上下文获取灰度信息失败,从配置计算灰度信息信息成功:{} , grayDomain:{}, grayUserid:{}", JsonUtil.toJson(grayHeader),grayDomain,grayUserid );
                return grayHeader;
            }
        }catch(Exception e){
            log.debug("从配置获取灰度标志失败", e);
        }

        return grayHeader;
    }
    public static String getGrayHeaderStrFromContext(){
        return  JsonUtil.toJson(GrayUtil.toMap(getGrayHeaderFromContext()));
    }
}

GrayLoadBalanceUtil

public class GrayLoadBalanceUtil {
    public static GrayLoadBalanceUtil1 grayLoadBalanceUtil;

    @Autowired
    private DiscoveryClient discoveryClient;

    @Autowired
    private Registration registration;
    @Autowired
    private GrayPropertiesLoader grayPropertiesLoader;

    private static AntPathMatcher matcher = new AntPathMatcher();

    @PostConstruct
    public void init() {
        grayLoadBalanceUtil = this;
    }

    public static String getServerUrl(String serviceName, Boolean isGray, String grayDomain, String grayUserid){

        List<ServiceInstance> serviceInstancesList = grayLoadBalanceUtil.discoveryClient.getInstances(serviceName);
        if(null!=serviceInstancesList){
            for(ServiceInstance serviceInstance: serviceInstancesList){
                try{
                    //计算isGray的过滤器在此入口之后,所以这里自己计算一遍isGray
                    isGray = grayLoadBalanceUtil.grayPropertiesLoader.calculateGrayFlag(null==isGray?null:isGray.toString(), grayDomain, grayUserid);

                    if(isServerMatch(serviceInstance.getServiceId(), isGray, serviceInstance.getHost(), serviceInstance.getPort())){
                        return serviceInstance.getUri().toString();
                    }
                }catch(Exception e){
                    log.info(" --- gray inf: 获取版本或者服务名失败 > {} ", JsonUtil.toJson(serviceInstance));
                    return serviceInstance.getUri().toString();
                }

            }
        }
        log.error("自定义负载均衡匹配失败,无法继续请求服务:{}, {}", serviceName, isGray);
        return "";

    }

    /**
     * 当前节点是否被选中
     * @param isGray 路由模式,true为灰度路由
     * @param serviceName 服务名称
     * @return
     */
    public static Boolean isServerMatch(String serviceName, Boolean isGray, String host, Integer port){
        if(!grayLoadBalanceUtil.grayPropertiesLoader.get().getGrayServiceMap().containsKey(serviceName)){
            //该服务未配置灰度节点则匹配第一个
            return true;
        }else{
            //该服务配置了灰度节点则需要匹配
            return (isGray && isGrayNode(host,port)) || (!isGray && !isGrayNode(host,port));
        }
    }


    /**
     * 匹配版本规则
     * @param pattern
     * @param value
     * @return
     */
    private static boolean match(String pattern, String value) {
        return GrayLoadBalanceUtil1.matcher.match(pattern, value);
    }

    /**
     * 获取当前服务
     * @return
     */
    public static ServiceInstance getLocalServiceInstance() {
        return grayLoadBalanceUtil.registration;
    }


    /**
     * 当前节点是否为金丝雀节点
     * @return
     */
    public static Boolean isCurrentGrayNode(){
        return isGrayNode(grayLoadBalanceUtil.registration.getHost(), grayLoadBalanceUtil.registration.getPort());
    }
    public static Boolean isGrayNode(String host, Integer port){
        try{
            return grayLoadBalanceUtil.grayPropertiesLoader.get().getGrayInstanceMap().containsKey(StringUtils.join(host, ":",port));
        }catch(Exception e){
            log.error(" --- gray debug:  node judge  获取当前节点是否为灰度节点失败", e);
        }
        return false;
    }
}

GrayHeaderHolder

public class GrayHeaderHolder {
    private static final InheritableThreadLocal<GrayHeader> grayContextLocal = new InheritableThreadLocal<>();
    public static GrayHeader getGrayContext(){
        return grayContextLocal.get();
    }
    public static void setGrayContext(GrayHeader grayHeader){
        grayContextLocal.set(grayHeader);
    }
    public static void removeGrayContext(){
        grayContextLocal.remove();
    }
}

GrayProperties

public class GrayProperties implements Serializable {

    public GrayProperties(){

    }
    public GrayProperties(Boolean enable,Boolean online, String userWhitelist,String frontNginxipWhitelist, String userWhitelistRemark){
        this.enable =  enable;
        this.online =  online;
        this.userWhitelist =  userWhitelist;
        this.frontNginxipWhitelist =  frontNginxipWhitelist;
        this.userWhitelistRemark =  userWhitelistRemark;
    }


    //金丝雀路由启用总开关
    private Boolean enable = false;
    //金丝雀节点匹配流量控制开关true为导流全部流量,false按service金丝雀节点匹配规则导流量
    private Boolean online = false;
    //用户金丝雀路由条件----客户端用户白名单
    private String userWhitelist;
    //用户金丝雀路由条件----客户端用户白名单备注
    private String userWhitelistRemark;
    //用户金丝雀路由条件----前端nginxip白名单(具体参数为最新前端代码统一部署的负载均衡ip)
    private String frontNginxipWhitelist;

    private Map userWhitelistMap = null;
    private Map frontNginxipWhitelistMap = null;

    private GrayProductVersion productVersion;
    /**
     * 金丝雀服务清单
     */
    private List<Service> servicesLocal;
    //金丝雀节点清单
    private Map grayInstanceMap = null;
    //存在金丝雀节点的服务清单
    private Map grayServiceMap = null;

    @PostConstruct
    public void initMap() {
        userWhitelistMap = transfer2Map(userWhitelist);
        frontNginxipWhitelistMap = transfer2Map(frontNginxipWhitelist);
        grayInstanceMap = new HashMap();
        grayServiceMap = new HashMap();
        if(null!=servicesLocal && servicesLocal.size()>0){
            servicesLocal.forEach(serviceLocal->{
                if(null!=serviceLocal.getData() && serviceLocal.getData().size()>0){
                    grayServiceMap.put(serviceLocal.getService(), "");
                    serviceLocal.getData().forEach(instanceLocal->{
                        grayInstanceMap.put(StringUtils.join(instanceLocal.getHost(),":", instanceLocal.getPort()), "");
                    });
                }
            });
        }

    }



    /**
     * 转换逗号分隔的字符串到map, value为null
     * @param str
     * @return
     */
    Map transfer2Map(String str){
        Map map = new HashMap<String, String>();
        if(StringUtils.isNotEmpty(str)){
            for(String key: str.trim().split(",")){
                map.put(key.toLowerCase(), null);
            }
        }
        return map;
    }


}

GrayPropertiesLoader

public class GrayPropertiesLoader {
    public static String GRAY_PROPERTIES_SYNC_TOPIC =  "gray-properties-topic";
    public static String GRAY_PROPERTIES_KEY =  "GLOBAL:CONFIG:GRAY_PROPERTIES";

    Redisson redisson;

    GrayProperties grayProperties = null;

    public GrayPropertiesLoader(Redisson redisson){
        this.redisson = redisson;
    }
    /**
     * 本地获取配置
     */
    public GrayProperties get(){
        if(null!=grayProperties){
            return grayProperties;
        }else{
            log.debug(" --- gray debug: PROPERTIES > {}", "未加载到配置本地缓存,开始从远程获取");
            return load();
        }
    }

    /**
     * 从缓存或db加载配置
     * @return
     */
    private synchronized GrayProperties load(){
        //缓存获取
        GrayProperties cacheGrayProperties = null;
        try{
            RBucket<GrayProperties> bucket = redisson.getBucket(GrayPropertiesLoader.GRAY_PROPERTIES_KEY);
            cacheGrayProperties = bucket.get();
        }catch(Exception e){
            log.error("从redis获取金丝雀配置信息失败", e);
        }

        if(null!=cacheGrayProperties){
            cacheGrayProperties.initMap();
            grayProperties = cacheGrayProperties;
            log.debug(" --- gray debug: PROPERTIES > {}, {}", "从缓存加载:", JsonUtil.toJson(grayProperties));
            return grayProperties;
        }else{
            //数据库获取(暂不实现,redis重启后需要修改配置来加载)
            log.debug(" --- gray debug: PROPERTIES > {}", "未加载到配置缓存,跳过数据库加载");
            //都没有则,返回一个空的
            return new GrayProperties();
        }
    }
    /**
     * 金丝雀灰度设置修改监听
     */
    @PostConstruct
    public void listener(){
        RTopic topic = redisson.getTopic(GRAY_PROPERTIES_SYNC_TOPIC, new JsonJacksonCodec());
        topic.addListener(GrayProperties.class, new MessageListener<GrayProperties>() {
            @Override
            public void onMessage(CharSequence charSequence, GrayProperties cacheGrayProperties) {
                if(null!=cacheGrayProperties){
                    cacheGrayProperties.initMap();
                    grayProperties = cacheGrayProperties;
                    log.debug(" --- gray debug: PROPERTIES > {}, {}", "订阅的配置更新了:", JsonUtil.toJson(grayProperties));
                }
            }
        });
    }

    /**
     * 发布配置修改
     * @param grayProperties
     */
    public void publish(GrayProperties grayProperties){
        RTopic topic = redisson.getTopic(GRAY_PROPERTIES_SYNC_TOPIC, new JsonJacksonCodec());
        topic.publish(grayProperties);
        log.debug(" --- gray debug: PROPERTIES > {}, {}", "修改了配置并发布订阅:", JsonUtil.toJson(grayProperties));
    }

    /**
     * 根据头部传参判断请求是否走灰度
     * @param isGray
     * @param nginxIp
     * @param userAccount
     * @return
     */
    public Boolean calculateGrayFlag(String isGray, String nginxIp, String userAccount){
        Boolean flag = false;
        /**
         * 保底方案1: nginxIp,userAccount为空会根据开关状态来返回灰度标记
         */
        if(null!=isGray){
            flag = Boolean.valueOf(isGray);
            log.debug(" --- gray debug -  【金丝雀标记】  - 优先使用已有的金丝雀标记:{}", flag);
            return flag;
        }else{
            if(this.get().getEnable()){   //金丝雀全局开关开启--金丝雀发版到上线阶段
                if(this.get().getOnline()){   //金丝雀上线开关开启--金丝雀密集上线阶段
                    flag = true;
                    log.debug(" --- gray debug -  【金丝雀标记】 - 总开关开【开】,金丝雀开关【开】:{}", flag);
                    return flag;
                }else{//金丝雀上线开关关闭--金丝雀验证阶段
                    log.debug(" --- gray debug: USER > {} {}", nginxIp, userAccount);
                    if((null!=userAccount && this.get().getUserWhitelistMap().containsKey(userAccount)) || (null!=nginxIp && this.get().getFrontNginxipWhitelistMap().containsKey(nginxIp))){
                        flag = true;
                        log.debug(" --- gray debug -  【金丝雀标记】 - 总开关开【开】,金丝雀开关【关】, 用户和nginxip【匹配成功】:{}", flag);
                        return flag;
                    }
                    /**
                     * 保底方案2: 根据当前节点是否为金丝雀节点来计算灰度
                     */
                    flag =  GrayLoadBalanceUtil.isCurrentGrayNode();
                    log.debug(" --- gray debug -  【金丝雀标记】 - 总开关开【开】,金丝雀开关【关】, 用户和nginxip【匹配失败】,判断当前CLOUD节点是否为金丝雀节点: {}", flag);
                    return flag;
                }
            }else{
                log.debug(" --- gray debug -  【金丝雀标记】 - 总开关开【关】:{}", flag);
            }
        }
        return flag;
    }

    public GrayHeader calculateGrayHeader(String isGray, String nginxIp, String userAccount){
        GrayHeader grayHeader = new GrayHeader();
        grayHeader.setIsGray(calculateGrayFlag(isGray, nginxIp, userAccount));
        if(null!=nginxIp){
            grayHeader.setNginxIp(nginxIp);
        }
        if(null!=userAccount){
            grayHeader.setUserAccount(userAccount);
        }
        return grayHeader;
    }

}

GrayHeaderConstant

public class GrayHeaderConstant {

    public static String GRAY_IS = "cloudp-is-gray";
    public static String GRAY_NGINX_IP= "nginxip";
    public static String GRAY_USER_ACCOUNT= "user-account";
    

    public static  String STRATEGY_REQUEST_HEADERS="sf-domain;cloudp-is-gray;user-account;nginxip;access_token;authorization;refresh_token;redirectUrl;cloudType;Referer;back-pass";


}

GrayHeader

public class GrayHeader implements Serializable {
    public GrayHeader(){

    }
    public GrayHeader(Boolean isGray, String nginxIp, String userAccount){
        this.isGray = isGray;
        this.nginxIp = nginxIp;
        this.userAccount = userAccount;
    }

    //是否为灰度
    Boolean isGray;
    //前端路由ip
    String nginxIp;
    //用户账号
    String userAccount;

}

自定义负载均衡算法参考

/**
     * 根据灰度规则在服务器节点中选择节点
     * @return
     */
    private NodeCacheVO selectServer(ProviderCacheVO providerCacheVO, String isGray, String userAccount, String nginxIp){
        NodeCacheVO nodeCacheVO = null;
        GrayProperties grayProperties = grayPropertiesLoader.get();
        /* 第①部分 */
        //总开关关闭,则所有节点随机跳转
        if(!grayProperties.getEnable()){
            return providerCacheVO.randomNodeByWeightInAll();
        }
        
        /* 第②部分*/
        Boolean gray = grayPropertiesLoader.calculateGrayFlag(isGray, nginxIp, userAccount);
        if(gray){//走灰度
            nodeCacheVO = providerCacheVO.randomNodeByWeightInCanary();
        }else{//走正常节点
            nodeCacheVO = providerCacheVO.randomNodeByWeightInStandard();
        }
        
		/* 第③部分*/
        if(null==nodeCacheVO){//兜底:如果总开关开,灰度开关关,找不到节点时在所有节点随机选择
            nodeCacheVO = providerCacheVO.randomNodeByWeightInAll();
        }
        return nodeCacheVO;
    }

灰度路由算法保底方案

  • getGrayHeaderFromContext()实现原理
ServiceGrayUtil.getGrayHeaderFromContext() 实现了保底方案
//① GrayHeader从request上下文获取(isGray有就直接取,userAccount/nginxIp有就传递下去)
//② GrayHeader从GrayHeaderHolder的ThreadLocal中获取(isGray有就直接取,userAccount/nginxIp有就传递下去)
//③ GrayHeader从GrayPropertiesLoader进入重新计算灰度标记模式
  • 灰度标记算法实现
    GrayPropertiesLoader.calculateGrayFlag

实现了重新计算灰度标记,其中采用了保底方案:保底方案1:计算时userAccout,nginxIp不存在则根据开关规则来判断 保底方案2:则根据当前节点是不是灰度节点来设置后续节点是不是走灰度

部署方案对比(此小节来自网络)

蓝绿部署



SpringCloud全链路灰色发布方完美方案 springcloud灰度发布方案_负载均衡


图片来源知乎@稻草先生

所谓蓝绿部署,是指同时运行两个版本的应用,如上图所示,蓝绿部署的时候,并不停止掉老版本,而是直接部署一套新版本,等新版本运行起来后,再将流量切换到新版本上。但是蓝绿部署要求在升级过程中,同时运行两套程序,对硬件的要求就是日常所需的二倍,比如日常运行时,需要10台服务器支撑业务,那么使用蓝绿部署,你就需要购置二十台服务器。

「滚动发布」

滚动发布能够解决掉蓝绿部署时对硬件要求增倍的问题。



SpringCloud全链路灰色发布方完美方案 springcloud灰度发布方案_灰度_02


图片来源知乎@稻草先生

所谓滚动升级,就是在升级过程中,并不一下子启动所有新版本,是先启动一台新版本,再停止一台老版本,然后再启动一台新版本,再停止一台老版本,直到升级完成,这样的话,如果日常需要10台服务器,那么升级过程中也就只需要11台就行了。

但是滚动升级有一个问题,在开始滚动升级后,流量会直接流向已经启动起来的新版本,但是这个时候,新版本是不一定可用的,比如需要进一步的测试才能确认。那么在滚动升级期间,整个系统就处于非常不稳定的状态,如果发现了问题,也比较难以确定是新版本还是老版本造成的问题。

为了解决这个问题,我们需要为滚动升级实现流量控制能力。

「灰度发布」

灰度发布也叫金丝雀发布,起源是,矿井工人发现,金丝雀对瓦斯气体很敏感,矿工会在下井之前,先放一只金丝雀到井中,如果金丝雀不叫了,就代表瓦斯浓度高。



SpringCloud全链路灰色发布方完美方案 springcloud灰度发布方案_灰度_03


图片来源知乎@稻草先生

在灰度发布开始后,先启动一个新版本应用,但是并不直接将流量切过来,而是测试人员对新版本进行线上测试,启动的这个新版本应用,就是我们的金丝雀。如果没有问题,那么可以将少量的用户流量导入到新版本上,然后再对新版本做运行状态观察,收集各种运行时数据,如果此时对新旧版本做各种数据对比,就是所谓的A/B测试。

当确认新版本运行良好后,再逐步将更多的流量导入到新版本上,在此期间,还可以不断地调整新旧两个版本的运行的服务器副本数量,以使得新版本能够承受越来越大的流量压力。直到将100%的流量都切换到新版本上,最后关闭剩下的老版本服务,完成灰度发布。

如果在灰度发布过程中(灰度期)发现了新版本有问题,就应该立即将流量切回老版本上,这样,就会将负面影响控制在最小范围内。