当前位置 博文首页 > 文章内容

    han949417140的博客:Spring Cloud灰度发布方案----自定义路由规则

    作者:21344 栏目:未分类 时间:2021-11-26 13:33:32

    Spring Cloud灰度发布方案----自定义路由规则

    一、简介

    1.1 不停机部署服务策略介绍

    • 蓝绿部署
      蓝绿部署的模型中包含两个集群A和B
      1、在没有上线的正常情况下,集群A和集群B的代码版本是一致的,并且同时对外提供服务。
      2、在系统升级的时候下,我们首先把一个集群(比如集群A)从负载列表中摘除,进行新版本的部署。集群B仍然继续提供服务。
      3、当集群A升级完毕,我们把负载均衡重新指向集群A,再把集群B从负载列表中摘除,进行新版本的部署。集群A重新提供服务。
      4、最后,当集群B也升级完成,我们把集群B也恢复到负载列表当中。这个时候,两个集群的版本都已经升级,并且对外的服务几乎没有间断过。
      详细介绍请参考:https://www.cnblogs.com/aaron911/p/11299422.html

    • 滚动部署
      和蓝绿部署不同的是,滚动部署对外提供服务的版本并不是非此即彼,而是在更细的粒度下平滑完成版本的升级。
      滚动部署只需要一个集群,集群下的不同节点可以独立进行版本升级。比如在一个16节点的集群中,我们选择每次升级4个节点,过程如下图:
      在这里插入图片描述

    • 灰度发布(金丝雀发布)
      金丝雀发布,与蓝绿部署不同的是,它不是非黑即白的部署方式,所以又称为灰度发布。它能够缓慢的将修改推广到一小部分用户,验证没有问题后,再推广到全部用户,以降低生产环境引入新功能带来的风险。
      灰度发布的重点就是制定引流策略,将请求分发到不同版本服务中。比如内部测试人员的请求分发到金丝雀服务,其他用户分发到旧服务中。测试通过之后在推广到全部用户。

    部署方式优势劣势描述
    蓝绿部署同一时间对外服务的只有一个版本,容易定位问题。升级和回滚一集群为粒度,操作相对简单需要维护两个集群,机器成本要求高两套环境交替升级,旧版本保留一定时间便于回滚。
    滚动部署只需维护一个集群,成本低上线过程中,两个版本同时对外服务,不易定位问题,且容易造成数据错乱。升级和回滚操作相对复杂按批次停止老版本实例,启动新版本实例。
    灰度发布新版本出现问题影响范围很小,允许失败,风险较小只能适用于兼容迭代的方式,如果是大版本不兼容的场景,就没办法使用这种方式了根据比例将老版本升级,例如80%用户访问是老版本,20%用户访问是新版本。

    1.2 eureka RestFul接口

    请求名称请求方式HTTP地址请求描述
    注册新服务POST/eureka/apps/{appID}传递JSON或者XML格式参数内容,HTTP code为204时表示成功
    删除注册服务DELETE/eureka/apps/{appID}/{instanceID}
    发送服务心跳PUT/eureka/apps/{appID}/{instanceID}
    查询所有服务GET/eureka/apps
    查询指定appID的服务列表GET/eureka/apps/{appID}
    查询指定appID&instanceIDGET/eureka/apps/{appID}/{instanceID}获取指定appID以及InstanceId的服务信息
    查询指定instanceID服务列表GET/eureka/apps/instances/{instanceID}获取指定instanceID的服务列表
    变更服务状态PUT/eureka/apps/{appID}/{instanceID}/status?value=DOWN服务上线、服务下线等状态变动
    变更元数据PUT/eureka/apps/{appID}/{instanceID}/metadata?key=value更新eurekametadata元数据

    二、灰度发布流程及实现思路

    2.1 调用链分析

    • 用户请求==>zuul网关==>服务a==>服务b
      1、首先用户发送请求
      2、经过网关分发请求到具体服务a
      3、服务a 调用服务b接口
      在这里插入图片描述

    灰度发布的核心就是路由转发,如果我们能够自定义网关==>服务a、服务a==>服务b中间的路由策略,就可以实现用户引流,灰度发布。

    2.2 实现思路、流程

    在这里插入图片描述

    • 网关层设计思路
    1. 用户请求首先到达Nginx然后转发到网关zuul,此时zuul拦截器会根据用户携带请求token解析出对应的userId,然后从路由规则表中获取路由转发规则。
    
    2. 如果该用户配置了路由策略,则该用户是灰度用户,转发用户请求到配置的灰度服务。否则转发到正常服务。
    
    • 服务间调用设计思路
    3. zuul网关将请求转发到服务a后,可能还会通过fegin调用其他服务。所以需要拦截请求,将请求头version=xxx给带上,然后存入线程变量。
    此处不能用Threadlocal存储线程变量,因为SpringCloud用hystrix做线程池隔离,而线程池是无法获取到ThreadLocal中的信息的! 
    所以这个时候我们可以参考Sleuth做分布式链路追踪的思路或者使用阿里开源的TransmittableThreadLocal方案。
    此处使用HystrixRequestVariableDefault实现跨线程池传递线程变量。
    
    4. 服务间调用时会经过ribbon组件从服务实例列表中获取一个实例选择转发。Ribbon默认的IRule规则为ZoneAvoidanceRule`。而此处我们继承该类,重写了其父类选择服务实例的方法。
    
    5. 根据自定义IRule规则将灰度用户请求路由到灰度服务,非灰度用户请求路由到正常服务。
    
    

    2.3 资源准备

    • spring cloud微服务准备
      调用链路:用户==>zuul-server==>abTest==> provider-server
    服务名端口eureka元数据描述
    zuul-server9000网关服务
    abTest8083version: v1新版本金丝雀服务
    abTest8084老版本服务
    abTest8085老版本旧服务
    provider-server8093version: v1新版本金丝雀服务
    provider-server8094老版本服务
    provider-server8095老版本旧服务
    • 路由规则库表
    # 用户表
    CREATE TABLE `t_user`  (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `nickname` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '用户昵称',
      `head_image` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT 'head_image',
      `city` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '城市',
      `gender` int(2) DEFAULT NULL COMMENT '性别  0:男 1:女',
      `user_type` int(2) DEFAULT 0 COMMENT '用户类型(0:普通用户 1:vip)',
      `mobile` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '用户手机号',
      `status` int(2) DEFAULT 1 COMMENT '用户状态 0:冻结  1:正常',
      `token` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '登录token',
      `token_expires_time` datetime(0) DEFAULT NULL COMMENT 'token过期时间',
      `create_time` datetime(0) DEFAULT NULL COMMENT '创建时间',
      `update_time` datetime(0) DEFAULT NULL COMMENT '更新时间',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;
    
    INSERT INTO `t_user` VALUES (1, 'hld', NULL, NULL, 1, 0, 'xxxx', 1, 'nm4p2ouy9ckl20bnnd62acev3bnasdmb', '2021-12-01 15:31:09', '2021-08-31 15:31:18', '2021-09-01 16:15:25');
    INSERT INTO `t_user` VALUES (2, 'xxx', NULL, NULL, 1, 0, 'xxxxx', 1, 'lskeu9s8df7sdsue7re890er343rtolzospw', '2021-12-01 15:31:09', '2021-08-31 15:31:18', '2021-09-01 16:15:25');
    INSERT INTO `t_user` VALUES (3, 'www', NULL, NULL, 1, 0, 'wwww', 1, 'pamsnxs917823skshwienmal2m3n45mz', '2021-12-01 15:31:09', '2021-08-31 15:31:18', '2021-09-01 16:15:25');
    
    # 灰度路由规则配置表
    CREATE TABLE `ab_test`  (
      `id` int(11) NOT NULL,
      `application_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '服务名',
      `version` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '版本',
      `userId` int(11) DEFAULT NULL COMMENT '用户id',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
    
    INSERT INTO `ab_test` VALUES (1, 'abTest', 'v1', 1);
    INSERT INTO `ab_test` VALUES (2, 'abTest', 'v2', 3);
    

    三、 代码实现

    灰度服务eureka.instance.metadata-map元数据信息添加version: v1。 正常服务设置元数据信息
    自定义路由规则IRule时可以根据version来区分是否灰度服务,从而实现不同用户路由到不同的服务中。

    3.1 网关路由(zuul-server服务)

    本demo使用zuul作为网关层,自定义网关层IRule路由规则实现网关层灰度。

    • 自定义IRule规则
    package com.hanergy.out.config;
    
    import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
    import com.hanergy.out.entity.AbTest;
    import com.hanergy.out.entity.TUser;
    import com.hanergy.out.service.AbTestService;
    import com.hanergy.out.service.TUserService;
    import com.netflix.client.config.IClientConfig;
    import com.netflix.loadbalancer.ILoadBalancer;
    import com.netflix.loadbalancer.Server;
    import com.netflix.loadbalancer.ZoneAvoidanceRule;
    import com.netflix.niws.loadbalancer.DiscoveryEnabledServer;
    import com.netflix.zuul.context.RequestContext;
    import io.jmnarloch.spring.cloud.ribbon.rule.MetadataAwareRule;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.http.HttpStatus;
    import org.springframework.stereotype.Component;
    
    import javax.servlet.http.HttpServletRequest;
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Map;
    import java.util.Random;
    import java.util.concurrent.atomic.AtomicInteger;
    
    /**
     * @description: 此处轮询调用对应服务
     * @author: Han LiDong
     * @create: 2021/11/18 16:12
     * @update: 2021/11/18 16:12
     */
    // ZoneAvoidanceRule   AbstractLoadBalancerRule
    @Component
    public class GrayRule extends MetadataAwareRule {
    
        private AtomicInteger nextServerCyclicCounter;
        private static final boolean AVAILABLE_ONLY_SERVERS = true;
        private static final boolean ALL_SERVERS = false;
    
        private static Logger log = LoggerFactory.getLogger(GrayRule.class);
    
        public GrayRule() {
            nextServerCyclicCounter = new AtomicInteger(0);
        }
        private Random random = new Random();
    
        @Autowired
        private AbTestService abTestService;	//灰度规则配置表
        @Autowired
        private TUserService userService;		//用户表
    
        @Override
        public void initWithNiwsConfig(IClientConfig iClientConfig) {
    
        }
    
        /**
         * 根据请求头token获取用户信息,然后去ab_test表获取灰度规则。
         * @param lb
         * @param o
         * @return
         */
        @Override
        public Server choose(Object o) {
            return choose(getLoadBalancer(),o);
        }
    
        public Server choose(ILoadBalancer lb, Object o){
            if (lb == null) {
                log.warn("no load balancer");
                return null;
            }
            RequestContext requestContext =  RequestContext.getCurrentContext();
            HttpServletRequest request = requestContext.getRequest();
            //请求请求头token信息
            String token = request.getHeader("token");
            // 根据token获取用户信息
            TUser user = userService.getOne(new QueryWrapper<TUser>()
                    .lambda()
                    .eq(TUser::getToken, token));
            // token异常
            if (user == null){
                requestContext.setSendZuulResponse(false);
                requestContext.setResponseStatusCode(HttpStatus.INTERNAL_SERVER_ERROR.value());
            }
            // 查询灰度发布配置表,判断此用户是否灰度用户
            AbTest abTest = abTestService.getOne(new QueryWrapper<AbTest>()
                    .lambda()
                    .eq(AbTest::getUserid, user.getId()));
            String version = null;
            if(abTest != null){
                version = abTest.getVersion();
            }
            //该用户可选择的服务列表(灰度用户:灰度服务列表   非灰度用户:非灰度服务列表)
            List<Server> allServers = new ArrayList<>();
    
    
            //1.从线程变量获取version信息
            //String version = GrayHolder.getGray();
            //获取所有可达服务
            List<Server> reachableServers = lb.getReachableServers();
            for (Server server : reachableServers){
                Map<String, String> metadata = ((DiscoveryEnabledServer) server).getInstanceInfo().getMetadata();
                String metaVersion = metadata.get("version");
                if (version != null && !version.isEmpty() && version.equals(metaVersion)){   //是灰度用户并且当前server是灰度服务
                    allServers.add(server);
                } else if ((version == null || version.isEmpty()) && metaVersion == null){    //非灰度用户并且当前server非灰度服务
                    allServers.add(server);
                }
            }
            // 轮询选择其中一个服务
            Server choosedServer = choose(lb, o, allServers);
    
            return choosedServer;
        }
        /**
         * 轮询策略选择一个服务
         * @param lb
         * @param o
         * @param allServers
         * @return
         */
        public Server choose(ILoadBalancer lb, Object o, List<Server> allServers){
            Server server = null;
            int count = 0;
            while (server == null && count++ < 10) {
                int upCount = allServers.size();
    
                if (upCount == 0) {
                    log.warn("No up servers available from load balancer: " + lb);
                    return null;
                }
                // 轮询服务下标
                int nextServerIndex = incrementAndGetModulo(upCount);
                server = allServers.get(nextServerIndex);
    
                if (server == null) {
                    /* Transient. */
                    Thread.yield();
                    continue;
                }
    
                if (server.isAlive() && (server.isReadyToServe())) {
                    return (server);
                }
    
                // Next.
                server = null;
            }
    
            if (count >= 10) {
                log.warn("No available alive servers after 10 tries from load balancer: "
                        + lb);
            }
            return server;
        }
    
        /**
         * Inspired by the implementation of {@link AtomicInteger#incrementAndGet()}.
         *
         * @param modulo The modulo to bound the value of the counter.
         * @return The next value.
         */
        private int incrementAndGetModulo(int modulo) {
            for (;;) {
                int current = nextServerCyclicCounter.get();
                int next = (current + 1) % modulo;
                if (nextServerCyclicCounter.compareAndSet(current, next))
                    return next;
            }
        }
    }
    
    • 自定义规则加入Spring容器(zuul-server服务)
      1、编写config配置类
    package com.hanergy.out.config;
    
    import com.netflix.loadbalancer.IRule;
    import org.springframework.context.annotation.Bean;
    
    /**
     * @description: 此处无需@Configuration注解,启动类增加@RibbonClient注解注入配置类
     * @author: Han LiDong
     * @create: 2021/11/18 16:53
     * @update: 2021/11/18 16:53
     */
    //@Configuration
    public class GrayRibbonConfiguration {
    
        @Bean
        public IRule ribbonRule(){
            return new GrayRule();
        }
    }
    

    2、启动类增加@RibbonClient注解,扫描IRule配置

    package com.hanergy.out;
    
    import com.hanergy.out.config.GrayRibbonConfiguration;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
    import org.springframework.cloud.netflix.ribbon.RibbonClient;
    import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
    
    // 网关
    @SpringBootApplication
    @EnableZuulProxy
    // name为微服务名称,必须和服务提供者的微服务名称一致,configuration配置自定义的负载均衡规则
    @RibbonClient(name = "zuul-server",configuration = GrayRibbonConfiguration.class)
    public class ZuulServiceApplication {