从分布式集群服务的更新到服务权重路由

3 篇文章 0 订阅
2 篇文章 0 订阅

从分布式集群服务的更新到服务权重路由

背景

分布式集群系统上线之后,任何一个服务都有可能面临更新升级的问题,这时候,通常的做法一般有两种,一是热更新,在线打补丁,第二种是停机升级。

我们的应用是SpringCloud + Nacos + FeignClient的微服务系统,请求到达网关(SpringCloud Gateway)后,由网关进行认证/鉴权(FeignClient调用认证/鉴权系统),再路由到业务服务,业务服务之间通过FeignClient相互调用。

以下基于这个系统聊一下如何做到不影响用户体验的情况下实现服务更新

解决方案

由于我们的是SpringBoot应用,服务更新的方法,只能选择第二种,停机更新。要停机,对请求到达当前服务的用户体验就会很差,那么需要一种不影响用户体验的方式进行。

假设一个场景:每个服务启动三个节点,请求到达网关1->到达服务A1->到达服务B1->到达服务C1->原路返回,当前需要更新服务B。

现在需要做的就是服务B的三个节点停机更新。运维部署上可以使用蓝绿部署、灰度发布、金丝雀发布,适合处理这种情况。其实就是一个服务流量控制功能。先确定一下系统中需要设置流量控制的地方:

  1. 网关-SpringCloud Gateway
  2. 服务远程调用-FeignCloud

这两处需要控制流量,流量控制属性需要放在外部源中,方便修改,其次还需要对所有使用到上述功能的服务进行实时更新,然后是修改路由方法,由配置的参数对服务进行控制。这里采用服务权重来进行路由,为0则不路由到服务,

流量控制源

Nacos配置中心

因为服务采用的是Nacos配置中心,它自带了配置自动刷新,这里只需要实现一个获取配置,并更新到服务缓存中即可。这里直接将Nacos自动刷新配置写到服务启动中来,使每次重启服务都会刷新一次。看下面代码:

Nacos监听配置文件更新:

package com.wuhenjian.api.config;

// import **; 导入的包就忽略了,都是Spring和一些基础包

/**
 * 服务权重配置
 * @author wuhenjian
 * @date 2019/8/26 16:04
 */
@Slf4j
// @Order 这里看具体业务情况决定这个类什么时候加载
@Component
public class ServerWeightNacosConfiguration implements CommandLineRunner {

	private final NacosConfigProperties nacosConfigProperties;

	// 更新服务权重的业务接口,就是对缓存进行增删改查操作
	private final ServerWeightConfigService serverWeightConfigService;

	// nacos的默认分组
	@Value("${server.weight.group:DEFAULT_GROUP}")
	private String group;
  
   // 需要监听的配置文件名称(dataId)
	@Value("${server.weight.dataId:server-weight.json}")
	private String dataId;

	// 处理超时时间
	@Value("${server.weight.timeoutMS:3000}")
	private long timeoutMS;

	@Autowired
	public ServerWeightNacosConfiguration(NacosConfigProperties nacosConfigProperties, ServerWeightConfigService serverWeightConfigService) {
		this.nacosConfigProperties = nacosConfigProperties;
		this.serverWeightConfigService = serverWeightConfigService;
	}

	@Override
	public void run(String... args) throws Exception {
		log.info("开始配置增加服务权重配置文件监听...");
		// 使用本服务的配置文件获取ConfigService实例
		ConfigService configService = nacosConfigProperties.configServiceInstance();
		// 获取配置并监听
		String config = configService.getConfigAndSignListener(dataId, group, timeoutMS, new Listener() {
			@Override
			public Executor getExecutor() {
				return null;
			}

			@Override
			public void receiveConfigInfo(String configInfo) {
				updateServerWeightConfig(configInfo);
			}
		});

		// 初始化更新配置
		updateServerWeightConfig(config);

		log.info("服务权重监听group[{}]-dataId[{}]配置完成", group, dataId);
	}

	/**
	 * 更新配置
	 * @param configInfo 配置文件文本
	 */
	private void updateServerWeightConfig(String configInfo) {
		if (StringUtil.isBlank(configInfo)) {
			log.info("配置文本为空白,跳过更新");
			return;
		}
		log.info("获取到配置文件变更:{}", configInfo);
		List<ServerWeight> serverWeightList;
		try {
			serverWeightList = JsonUtil.json2List(configInfo, ServerWeight.class);
		} catch (Exception e) {
			log.error("权重配置文件Json转换异常", e);
			throw new RuntimeException("权重配置文件Json转换异常", e);
		}
		try {
			serverWeightConfigService.insert(serverWeightList);
		} catch (Exception e) {
			log.error("刷新权重配置缓存发生异常", e);
			throw new RuntimeException("刷新权重配置缓存发生异常", e);
		}
	}
}

ServerWeightConfigService没什么看的,就是对ServerWeightInfo的缓存进行增删改操作。

ServerWeightInfo这里将ServerWeight构建成一个Map,以服务ip:port为key,服务权重参数作为value,缓存起来:

package com.wuhenjian.api.config.weight;

// import **;

/**
 * 服务权重缓存
 * @author wuhenjian
 * @date 2019/8/26 17:18
 */
@Slf4j
public class ServerWeightInfo {

	private ServerWeightInfo() {}

	/** 权重缓存,key-Server#ip:Server#port,value-ServerWeight*/
	private final static Map<String, ServerWeight> serverWeightMap = new ConcurrentHashMap<>();

	/**
	 * 更新权重
	 */
	public static void save(ServerWeight serverWeight) {
		serverWeightMap.put(serverWeight.getId(), serverWeight);
		log.info(LogUtil.obj2log("更新请求权重:{}"), serverWeight);
	}

	/**
	 * 删除权重
	 */
	public static void delete(String id) {
		if (!serverWeightMap.containsKey(id)) {
			log.warn("不存在的请求权重ID[{}],无法删除", id);
			return;
		}

		serverWeightMap.remove(id);
		log.info(LogUtil.obj2log("删除请求权重ID[{}]"), id);
	}

	/**
	 * 按id排序的权重列表
	 */
	public static List<ServerWeight> getServerWeightList() {
		return serverWeightMap.values()
				.stream()
				.sorted(Comparator.comparing(ServerWeight::getId))
				.collect(Collectors.toList());
	}

	/**
	 * 根据id获取权重
	 */
	public static ServerWeight getServerWeightById(String id) {
		return serverWeightMap.get(id);
	}

	/**
	 * 根据applicationName获取按id排序的权重
	 */
	public static List<ServerWeight> getServerWeightByGroup(String group) {
		return serverWeightMap.values()
				.stream()
				.filter(serverWeight -> Objects.equals(group, serverWeight.getGroup()))
				.sorted(Comparator.comparing(ServerWeight::getId))
				.collect(Collectors.toList());
	}
}

服务权重对象:

package com.wuhenjian.model.ops.entity;

// import **;

/**
 * @author wuhenjian
 * @date 2019/8/22 15:05
 */
@Data
public class ServerWeight implements Serializable {

	private static final long serialVersionUID = 567484766931625332L;

	/** ID,格式为IP:PORT,唯一确定一个服务 */
	private String id;

	/** 服务名称 */
	private String applicationName;

	/** IP */
	private String ip;

	/** 端口号 */
	private int port;

	/** 分组,不填为默认组 */
	private String group = "DEFAULT_GROUP";

	/** 权重值,值越大,权重越高,默认权重值为50 */
	private int weight;
}

如果使用SpringCloud Config那么操作与当前服务是一样的,只是监听类需要用对应的方法。

其他配置
  • 如果使用的配置源是DB,那么可以这么做:在页面上进行配置修改,提交表单之后进行参数校验,成功之后写入DB,同时交给MQ,将配置广播出去,其他应用消费
  • 如果使用Redis等缓存,那么与DB不一样的地方在于,更新Redis之后,不交给MQ,由每个服务定时获取缓存配置
  • 如果使用注册中心配置,那么需要在页面上获取注册中心所有服务,更新注册中心中服务应用的MetaInfo,每个服务自动刷新注册中心信息的时候,读取MetaInfo信息来更新路由信息

接下来来对比一下几种配置方式的优劣:

配置方式错误通知实时性持久化
配置中心(Nacos/config等)只能在服务中打印错误,需要依赖日志组件集成通知基本上实时更新可持久化,依托配置文件/DB
DB修改配置后,下发服务前即可获取配置错误信息依赖于MQ传递消息,如果使用独立MQ,基本上算是实时更新可持久化
Redis等缓存同DB定时刷新,取决于刷新频率,不算实时不一定持久化,需要依赖外部持久化工具
注册中心同DB定时刷新,取决于刷新频率,不算实时不一定持久化,需要依赖外部持久化工具

综上,配置方式最好的,我觉得是DB配置,开发量最小的,是依托于已有的配置中心。如何选择,还是看具体应用场景与资源。

服务路由

我的上一篇文章由分布式本地缓存到一致性hash中有讲到过如何对SpringCloud GatewayFeignClient的负载均衡进行重写,如何重写这里就不再讲了。

这里增加一个如果重写了FeignClient的IRule方法,如何在SpringCloud Gateway中继续使用FeignClient的办法。

为什么要这么说呢,这里主要是因为两个框架底层都是使用的netflix的ribbon进行的负载均衡,如果你重写了最底层的IRule方法,那么,两个框架都会受影响。但是两个框架不是同一个组织的产物,他们在获取服务注册信息的时候有差异,会导致Feignclient框架中获取不到注册信息,每次请求都报没有可用服务。

这个类其实就是不依赖Spring框架使用feign:

package com.wuhenjian.gateway.portal.http;

// import **;

/**
 * 对feign-core的封装。
 * 请求path只能写在方法上,写在@FeignClient注解上的path无法加载。
 * @author 無痕剑
 * @date 2019/8/3 20:00
 */
@Slf4j
@Getter
public class FeignLoadBalanceClient<T> {

	private final Class<T> feignClientInterface;

	private final String serviceId;

	private final ServerWeightRule serverWeightRule;

	private DiscoveryClient discoveryClient;

	private Integer connectTimeoutMillis = 10 * 1000;

	private Integer readTimeoutMillis = 60 * 1000;

	private Long period = 500L;

	private Long maxPeriod = 1000L;

	private Integer maxAttempts = 0;

	private final Feign.Builder feignBuilder;

	public FeignLoadBalanceClient(Class<T> feignClientInterface, DiscoveryClient discoveryClient, ServerWeightRule serverWeightRule) {
		this(feignClientInterface, discoveryClient, serverWeightRule, null, null, null, null, null);
	}

	public FeignLoadBalanceClient(Class<T> feignClientInterface,
	                              DiscoveryClient discoveryClient,
	                              ServerWeightRule serverWeightRule,
	                              Integer connectTimeoutMillis,
	                              Integer readTimeoutMillis,
	                              Long period,
	                              Long maxPeriod,
	                              Integer maxAttempts) {
		this.feignClientInterface = feignClientInterface;
		this.serviceId = buildServiceId();
		this.discoveryClient = discoveryClient;
		this.serverWeightRule = serverWeightRule;
		if (Objects.nonNull(connectTimeoutMillis)) {
			this.connectTimeoutMillis = connectTimeoutMillis;
		}
		if (Objects.nonNull(readTimeoutMillis)) {
			this.readTimeoutMillis = readTimeoutMillis;
		}
		if (Objects.nonNull(period)) {
			this.period = period;
		}
		if (Objects.nonNull(maxPeriod)) {
			this.maxPeriod = maxPeriod;
		}
		if (Objects.nonNull(maxAttempts)) {
			this.maxAttempts = maxAttempts;
		}

		this.feignBuilder = initFeignBuilder();
	}

	/**
	 * 获取serviceId
	 */
	private String buildServiceId() {
		// 获取注解
		FeignClient feignClientAnnotation = feignClientInterface.getAnnotation(FeignClient.class);
		// 判断注解是否存在
		Asserts.check(feignClientAnnotation != null, "feignClientInterface must be annotated by FeignClient.");
		// 获取serviceId
		return feignClientAnnotation.value();
	}

	/**
	 * 每次创建一个新的HTTP客户端执行请求
	 */
	public T build() {
		return feignBuilder.target(feignClientInterface, getServerUri());
	}

	/**
	 * 初始化HTTP客户端配置
	 */
	private Feign.Builder initFeignBuilder() {
		return Feign.builder()
				.decoder(new JacksonDecoder())
				.encoder(new JacksonEncoder())
				.contract(new SpringMvcContract())
				.options(new Request.Options(connectTimeoutMillis, readTimeoutMillis))
				.retryer(new Retryer.Default(period, maxPeriod, maxAttempts));
	}

	private String getServerUri() {
		List<ServiceInstance> serviceInstanceList = getInstances(serviceId);
		ServiceInstance serviceInstance = serverWeightRule.chooseByServiceInstance(serviceInstanceList);
		if (serviceInstance == null) {
			throw BizException.build(ResultEnum.DISCOVER_INSTANCE_EMPTY);
		}

		return serviceInstance.getUri().toString();
	}

	/**
	 * 根据serviceId获取服务实例列表
	 */
	private List<ServiceInstance> getInstances(String serviceId) {
		List<ServiceInstance> serviceInstanceList = discoveryClient.getInstances(serviceId);
		if (CollectionUtils.isEmpty(serviceInstanceList)) {
			log.warn("服务[{}]获取到的服务为空", serviceId);
			throw BizException.build(ResultEnum.DISCOVER_INSTANCE_EMPTY);
		}

		return serviceInstanceList;
	}

	/**
	 * 执行方法并进行熔断处理
	 * @param callable 执行的方法
	 * @param <R> 返回值类型
	 * @return 返回值
	 */
	public static <R> R fusing(Callable<R> callable) {
		try {
			return callable.call();
		} catch (BizException e) {
			throw e;
		} catch (Exception e) {
			log.warn("自定义FeignClient发生异常:{}", e.getMessage());
			throw BizException.build(ResultEnum.SYSTEM_ERROR);
		}
	}
}

重写的网关负载均衡类public class ServerWeightLoadBalancerClientFilter extends LoadBalancerClientFilter和Feignclient的负载均衡类public class ServerWeightRule extends AbstractLoadBalancerRule这里就不写了,参考上一篇文章即可,具体的实现,就是使用加权随机算法进行的路由,展示一下具体的实现:

private final static int DEFAULT_WEIGHT = 50;

/* 
 * 还有一个public Server chooseByServer(List<Server> reachableServerList)方法,在feign中使用。
 * 下面这个方法是在网关中使用,网关的ServerWeightLoadBalancerClientFilter注入ServerWeightRule 这个Bean,使用这个方法即可。
 */
public ServiceInstance chooseByServiceInstance(List<ServiceInstance> serviceInstanceList) {
		AtomicInteger sum = new AtomicInteger(0);
		List<ServerWrapper<ServiceInstance>> serverWrapperList = serviceInstanceList.stream()
				.map(serviceInstance -> {
					ServerWeight serverWeight = ServerWeightInfo.getServerWeightById(serviceInstance.getHost() + ":" + serviceInstance.getPort());
					int weight = serverWeight == null ? DEFAULT_WEIGHT : serverWeight.getWeight();
					sum.addAndGet(weight);
					return new ServerWrapper<>(serviceInstance, weight);
				})
				.collect(Collectors.toList());

		if (sum.get() <= 0) {
			log.info(LogUtil.obj2log("服务权重总合为0,没有可用服务"));
			return null;
		}

		int random = RandomUtils.nextInt(sum.get());
		AtomicInteger count = new AtomicInteger(0);
		for (ServerWrapper serverWrapper : serverWrapperList) {
			if (random < count.addAndGet(serverWrapper.getWeight())) {
				return (ServiceInstance) serverWrapper.getServer();
			}
		}

		// 其实根本不会出现这个异常
		log.error("随机数超出权重总和范围");
		throw new RuntimeException("随机数超出权重总和范围");
	}

配置文件

到此,服务权重配置就完成了,可以通过配置文件,对服务路由权重进行修改了:

[
    {
        "id": "192.168.10.8:22103",
        "applicationName": "service-app",
        "ip": "192.168.10.8",
        "port": 22103,
        "weight": 0
    },
    {
        "id": "192.168.10.8:22102",
        "applicationName": "service-app",
        "ip": "192.168.10.8",
        "port": 22102,
        "weight": 0
    },
    {
        "id": "192.168.10.8:22101",
        "applicationName": "service-app",
        "ip": "192.168.10.8",
        "port": 22101,
        "weight": 0,d
    }
]
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值