微服务:Zuul+Eureka —— 版本发布简述、灰度发布、A/B测试

  • Post author:
  • Post category:其他




1. 版本发布简述

一个项目的发展,肯定是伴随着版本的更替中前进的,我们要版本升级来个通知,然后停机维护就可以了。但是,在现在大型项目中,用户的体量是非常大的,停机更新已经变得非常不实际了。后面,针对版本升级的发布,我们衍生出了一些发布的方案:蓝绿发布、滚动发布、灰度发布。



1.1 蓝绿发布

蓝绿发布

原本整个都是A组服务(绿色)处于生产模式。B组服务(蓝色)整个测试没问题后,利用负载均衡器把整个负载切换到B组服务器上。如果B组服务还是出现问题,可以重新切回A组服务。


要求:

硬件是平时的2倍(就是费钱)


更多知识>>



1.2 滚动发布

滚动发布

多准备1台服务器(A),A服务器部署好后,将原本负载到B的请求全部替换到A上面,同时B下线处理重新刷成和A一样的东西,然后去替换C,一次替换。最后留下D。如果再进一步,把原来A替换上去的服务再用D替换上去,这样就把原来的服务全部都更新完成,A就回来了。这样,A就可以购买临时服务了,比如购买1个月的云服务啥的,成本就很低了。


要求:

如果出现新服务出问题,要切回老服务,那就很难受了。



1.3 灰度发布(金丝雀发布)

灰度发布

老版本做为主要的服务,将部分用户切换到新的版本上做试运行,没问题了,然后再慢慢将原来的用户切换到新的版本上。一般都是先把内部测试人员切到新版本上用,然后再逐步扩大外部人员。这个就是A/B测试。


该发布是允许新版本失败,如果新版本效果不好,可以直接彻掉该版本。

另外他可以允许适度浪费、内部竞争:好几个团队做不同的版本,最后竞争出一个最优的版本(微信就是典型)。


灰度发布、A/B测试其实就是一个东西


金丝雀发布名字的由来:


金丝雀会叫,所以以前煤矿啥的能不能下去,就赶一直金丝雀进去,如果跑进去的金丝雀一直叫,那就没问题了。如果没叫,就有问题。就是典型的先上线看看效果,不行我们就撤,毕竟实际的市场才能检验东西的好坏。



2. 灰度发布(A/B测试)的原理和实现(Zuul+Eureka)

这里的灰度发布记录的是基于eureka注册中心,然后采用利用Zuul的方式实现的方式。后期这个方式可以用到中间服务的灰度发布。



2.1 灰度发布原理



2.1.1 简单原理

灰度发布简单原理

  1. 所有服务向Eureka注册的时候,eureka中保存的对应的服务信息都有一个metadata(自定义信息)。
  2. 用户请求的时候带上对应的版本(或者根据用户ID通过数据库比对找到对应服务要请求的版本标识)。
  3. 要调用的服务B拿到这个请求服务A的版本标识后,拿着这个标识去注册中心比对到对应版本标识的服务,然后返回调用的地址端口等信息。
  4. 发起对服务A的调用,获取结果,并返回给对应的用户。



2.2 灰度发布实现

上面的原理已经解释的很清楚了,所以接下来我们要进行实现了。



2.2.1 业务逻辑设计实现思想

业务逻辑设计思想

针对上方的逻辑,在实际项目中其实我们的对应某用户或者用客户端产生的标识是可以提早定义规则的;比如:我们可以根据用户的ID,做一个数据表,对USER_ID和服务版本做一个关联。用户请求的时候,就可以根据USER_ID获取到了对应的服务版本,然后请求到对应版本的服务上了。



2.2.2 利用Zuul进行灰度发布



2.2.2.1 准备环境

  • 准备eureka(对应映射配置为http://eureka-7900:7900)
  • 准备zuul(对应配置cloud-zuul:9100)
  • 准备测试用的service-sms(service-sms:8083,service-sms:8084)
  • 这些配置基本上的应该都会吧,就不详细说明了。


相关资料



相关官方最新文档:

https://github.com/Netflix/eureka/wiki


eureka配置修改操作接口地址信息:

https://github.com/Netflix/eureka/wiki/Eureka-REST-operations


在线更新metadata

=> PUT /eureka/v2/apps/appID/instanceID/metadata?key=value


查看当前eureka服务的配置信息

=>/eureka/apps(http://eureka-7900:7900/eureka/apps)



2.2.2.2 配置service-sms,配置不同版本的application.yml,启动和修改测试

#server:
#  port: 8082
spring:
  application:
    name: service-sms
  #数据库连接配置
  datasource:
    #配置当前使用的数据源的操作类型
    type: com.alibaba.druid.pool.DruidDataSource
    #配置MySQL的驱动程序类
    driver-class-name: com.mysql.cj.jdbc.Driver
    #数据库连接地址
    url: jdbc:mysql://192.168.25.222:3306/oneline-taxi?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    #数据库连接用户名
    username: root
    #数据库连接密码
    password: adminadmin123
    #进行数据库连接池的配置
    dbcp2:
      #初始化提供的连接数
      initial-size: 5
      #数据库连接池的最小维持连接数
      min-idle: 5
      #最大的连接数
      max-total: 5
      #等待连接获取的最大超时时间
      max-wait-millis: 200
      validation-query: SELECT 1
      test-while-idle: true
      test-on-borrow: false
      test-on-return: false

#mybatis配置
mybatis:
  mapper-locations:
    - classpath:mapper/*.xml


eureka:
  client:
    service-url:
      defaultZone: http://eureka-7900:7900/eureka/
    registry-fetch-interval-seconds: 30
    enabled: true
    #,http://localhost:7901/eureka/,http://localhost:7902/eureka/
  instance:
    lease-renewal-interval-in-seconds: 30
---

spring:
  profiles: v1
server:
  #服务端口
  port: 8083
eureka:
  instance:
    metadata-map:
      # 这个区域是自定义元素区域,可以自定义很多元素
      # localhost:7900/eureka/apps/可以在metadata中找到这些自定义的数据
      version: v1
      a: a1
---
spring:
  profiles: v2

eureka:
  instance:
    metadata-map:
      version: v2
server:
  #服务端口
  port: 8084
  • 分别启用配置v1和v2,主要这里配置了metadata-map。
  • 启动后,在

    http://eureka-7900:7900/eureka/apps

    中可以看到对应的

    <metadata>

    标签中的数据和metadata-map的数据一样。
  • 使用postman调用接口

    [PUT]http://eureka-7900:7900/eureka/apps/service-sms/localhost:service-sms:8083/metadata?a=yu

    。service-sms来自于

    http://eureka-7900:7900/

    下的

    Application,

    localhost:service-sms:8083

    来自于Status
  • 刷新/打开

    http://eureka-7900:7900/eureka/apps

    就能看到对应的

    <metadata>

    标签下的

    <a>

    标签的值变为

    yu



2.2.2.3 在网关中添加对应的规则

数据库中添加灰度发布的规则表,对应的maven数据库连接组合别忘了加

CREATE TABLE `common_gray_rule` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) DEFAULT NULL,
  `service_name` varchar(32) DEFAULT NULL,
  `meta_version` varchar(32) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='灰度发布规则';

INSERT INTO `common_gray_rule` (`id`, `user_id`, `service_name`, `meta_version`) VALUES ('1', '1', 'service-sms', 'v1');

cloud-zuul中添加该表对应的dao和mapper


添加对应的过滤器和过滤器用到的maven组件:



组件

<!-- 实现通过 metadata 进行灰度路由 -->
<dependency>
    <groupId>io.jmnarloch</groupId>
    <artifactId>ribbon-discovery-filter-spring-cloud-starter</artifactId>
    <version>2.1.0</version>
</dependency>
package com.haokeed.cloudzuul.filter;

import com.haokeed.cloudzuul.dao.CommonGrayRuleDaoCustom;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import io.jmnarloch.spring.cloud.ribbon.support.RibbonFilterContextHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.context.annotation.FilterType;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

/**
 * @date 2020/06/29
 */
@Component
public class GrayFilter extends ZuulFilter {


    @Override
    public String filterType() {
        // 使用路由规则
        return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return 0;
    }

    @Override
    public boolean shouldFilter() {
        // 打开路由
        return true;
    }

    @Autowired
    private CommonGrayRuleDaoCustom commonGrayRuleDaoCustom;

    @Override
    public Object run() throws ZuulException {


        RequestContext currentContext = RequestContext.getCurrentContext();
        HttpServletRequest request = currentContext.getRequest();

        int userId = Integer.parseInt(request.getHeader("userId"));
        // 根据用户id 查 规则  查库 v1,meata
        String userVersion="v1";

        // 根据userId查询出来的version字段的值
//        if("v1".equals(userVersion)){
//
//        }

        // 伪代码用userId直接来判断版本好了
        // 金丝雀
        if (userId == 1){
            RibbonFilterContextHolder.getCurrentContext().add("version","v1");
        // 普通用户
        }else if (userId == 2){
            RibbonFilterContextHolder.getCurrentContext().add("version","v2");
        }


        return null;
    }
}



2.2.2.4 灰度测试

分别调用http://service-sms:8083/test/sms-test和http://service-sms:8084/test/sms-test 进行测试,发现能调用通。

启动网关cloud-zuul,端口号为:9100;测试 http://cloud-zuul:9100/ 成功

采用浏览器输入地址http://cloud-zuul:9100/service-sms/test/sms-test 刷新多次,发现都能正确和随机路由到

service-sms:8083



service-sms:8084

采用Postman测试:访问http://cloud-zuul:9100/service-sms/test/sms-test 然后header中分别带上userId=1和2测试,发现userId=1和userId=2访问的都是固定端口的service-sms



2.2.3 利用ribbon灰度发布

在这里插入图片描述



2.2.3.1 准备环境

这个记录的是非网关直接的调用,实现的是服务A调用服务B的情况。我们这里使用的是api-massanger调用service-sms来测试。

  • 在前面

    利用Zuul进行灰度发布

    的前提下添加api-massanger这个普通的服务。



2.2.3.2 在api-massanger中添加对应的规则和相关测试代码

类的执行顺序:

  • GrayRibbonConfiguration 注册灰度功能
  • RequestAspect 进行AOP反射处理,将request中加入对应的头部信息。
  • Controller 对应的中的方法执行
  • GrayRule 有服务调用restTemplate,就执行灰度规则匹配注册中心中的metadata
  • 执行返回结果给Controller中。

GrayRibbonConfiguration.java

package com.haokeed.apipassenger.gray;

import com.netflix.loadbalancer.IRule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @date
 */
public class GrayRibbonConfiguration {

    @Bean
    public IRule ribbonRule(){
        return new GrayRule();
    }

}

入口文件头部添加配置

@RibbonClient(name = "service-sms", configuration = GrayRibbonConfiguration.class)

RequestAspect.java

package com.haokeed.apipassenger.gray;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;

/**
 * @date
 */
@Aspect
@Component
public class RequestAspect {

    /**
     * => * com.haokeed.apipassenger.controller..*Controller*.*(..)
     * => * 表示返回值
     * => com.haokeed.apipassenger.controller 表示包名
     * => .*Controller* 表示类名
     * => *(..) 表示方法
     */
    @Pointcut("execution(* com.mashibing.apipassenger.controller..*Controller*.*(..))")
    private void anyMethod(){
        // 这里有注解映射了,就无需添加代码
    }

    @Before(value = "anyMethod()")
    public void before(JoinPoint joinPoint){
        // 先执行的before 再执行 anyMethod
        // 主要目的就是把request请求头中的数据赋值到RibbonParameters中去

        System.out.println("RequestAspect->before");
        HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
        String version = request.getHeader("version");

        Map<String,String> map = new HashMap<>();
        map.put("version",version);

        RibbonParameters.set(map);

    }



    /**
     * 1行解决灰度问题
     * 这个方法覆盖上面的before方法,删除该目录包下的所有类(GrayRibbonConfiguration,GrayRule,RibbonParameters)
     * 删除入口文件(ApiPassengerApplication)中注解:@RibbonClient(name = "service-sms",configuration = GrayRibbonConfiguration.class)
     * 然后pom.xml添加包
     * <dependency>
     *  <groupId>io.jmnarloch</groupId>
     *  <artifactId>ribbon-discovery-filter-spring-cloud-starter</artifactId>
     *  <version>2.1.0</version>
     * </dependency>
     * 就完事了
     * @param joinPoint
     */
//    @Before(value = "anyMethod()")
//    public void before(JoinPoint joinPoint){
//
//        HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
//        String version = request.getHeader("version");
//
//        // 灰度规则 匹配的地方 查db,redis ====
//        if (version.trim().equals("v2")){
//            RibbonFilterContextHolder.getCurrentContext().add("version","v2");
//        }
//    }
}

GrayRule.java

package com.haokeed.apipassenger.gray;

import com.netflix.client.config.IClientConfig;
import com.netflix.loadbalancer.AbstractLoadBalancerRule;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.Server;
import com.netflix.niws.loadbalancer.DiscoveryEnabledServer;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @date 2020/07/02
 */
public class GrayRule extends AbstractLoadBalancerRule {

    /**
     * 根据用户选出一个服务
     * @param iClientConfig
     * @return
     */
    @Override
    public void initWithNiwsConfig(IClientConfig iClientConfig) {


    }

    @Override
    public Server choose(Object key) {

        return choose(getLoadBalancer(),key);
    }

    public Server choose(ILoadBalancer lb, Object key){


        System.out.println("灰度  rule");
        Server server = null;
        while (server == null){
            // 获取所有 可达的服务

            // 获取所有 可达的服务
            List<Server> reachableServers = lb.getReachableServers();

            // 获取 当前线程的参数 用户id verion=1
            Map<String,String> map = RibbonParameters.get();
            String version = "";
            if (map != null && map.containsKey("version")){
                version = map.get("version");
            }
            System.out.println("当前rule version:"+version);

            // 根据用户选服务
            for (int i = 0; i < reachableServers.size(); i++) {
                server = reachableServers.get(i);
                // 用户的version我知道了,服务的自定义meta我不知道。


                // eureka:
                //  instance:
                //    metadata-map:
                //      version: v2
                // 不能调另外 方法实现 当前 类 应该实现的功能,尽量不要乱尝试

                Map<String, String> metadata = ((DiscoveryEnabledServer) server).getInstanceInfo().getMetadata();

                String version1 = metadata.get("version");

                // 服务的meta也有了,用户的version也有了。
                if (version.trim().equals(version1)){
                    return server;

                }

            }

        }

        // 怎么让server 取到合适的值。
        return server;

    }

}

RibbonParameters.java

package com.haokeed.apipassenger.gray;

import com.haokeed.internalcommon.dto.servicepassengeruser.T;
import org.springframework.stereotype.Component;

/**
 * @date 2020/07/02
 */
@Component
public class RibbonParameters {

    /**
     * 多线程访问同一个共享变量的时候容易出现并发问题,特别是多个线程对一个变量进行写入的时候,
     * 为了保证线程安全,一般使用者在访问共享变量的时候需要进行额外的同步措施才能保证线程安全性。
     * ThreadLocal是除了加锁这种同步方式之外的一种保证一种规避多线程访问出现线程不安全的方法,
     * 当我们在创建一个变量后,如果每个线程对其进行访问的时候访问的都是线程自己的变量这样就不会存
     * 在线程不安全问题。
     */
    private static final ThreadLocal local = new ThreadLocal();

    // get
    public static <T> T get(){
        return  (T)local.get();
    }

    // set
    public static <T> void set(T t){
        local.set(t);
    }
}

测试Controller

package com.haokeed.apipassenger.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

@RestController
@RequestMapping("/test")
public class TestCallServiceSmsController {

    @Autowired
    private RestTemplate restTemplate;

    @GetMapping("/call")
    public String testCall(){
        System.out.println("TestCallServiceSmsController->call-1");
        String forObject = restTemplate.getForObject("http://service-sms/test/sms-test", String.class);
        System.out.println("TestCallServiceSmsController->call-2");
        return forObject;
    }

    @GetMapping("/test")
    public String testCall1(){

        return "api-passenger";
    }
}



2.2.3.3 测试进行

  • [GET] http://service-sms:8083/test/sms-test 和 [GET] http://service-sms:8084/test/sms-test 都正常,表面service-sms正常。
  • postman [GET] http://api-passenger:8900/test/call Header添加version=v1,发起请求,发现请求的都是8083,把header中的version改为v2,发现请求的是8084.所以整个灰度发步都正确了。



2.2.4 快速利用ribbon集包处理



2.2.4.1 maven引入包

<!--ribbon灰度发布-->
        <dependency>
            <groupId>io.jmnarloch</groupId>
            <artifactId>ribbon-discovery-filter-spring-cloud-starter</artifactId>
            <version>2.1.0</version>
        </dependency>



2.2.4.2 添加文件RequestAspect.java

package com.haokeed.apipassenger.gray;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;

/**
 * @date
 */
@Aspect
@Component
public class RequestAspect {

    /**
     * => * com.haokeed.apipassenger.controller..*Controller*.*(..)
     * => * 表示返回值
     * => com.haokeed.apipassenger.controller 表示包名
     * => .*Controller* 表示类名
     * => *(..) 表示方法
     */
    @Pointcut("execution(* com.haokeed.apipassenger.controller..*Controller*.*(..))")
    private void anyMethod(){
        // 这里有注解映射了,就无需添加代码
    }

    /**
     * 1行解决灰度问题
     * 这个方法覆盖上面的before方法,删除该目录包下的所有类(GrayRibbonConfiguration,GrayRule,RibbonParameters)
     * 删除入口文件(ApiPassengerApplication)中注解:@RibbonClient(name = "service-sms",configuration = GrayRibbonConfiguration.class)
     * 然后pom.xml添加包
     * <dependency>
     *  <groupId>io.jmnarloch</groupId>
     *  <artifactId>ribbon-discovery-filter-spring-cloud-starter</artifactId>
     *  <version>2.1.0</version>
     * </dependency>
     * 就完事了
     * @param joinPoint
     */
    @Before(value = "anyMethod()")
    public void before(JoinPoint joinPoint){

        HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
        String version = request.getHeader("version");

        // 灰度规则 匹配的地方 查db,redis ====
        if (version.trim().equals("v2")){
            RibbonFilterContextHolder.getCurrentContext().add("version","v2");
        }
    }
}

该处理方式太简单了O(∩_∩)O,测试改造和上面的一样利用api-massanger和service-sms来做处理。测试方式也和上面一样。



版权声明:本文为as_hswa原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。