分布式集群系统上线之后,任何一个服务都有可能面临更新升级的问题,这时候,通常的做法一般有两种,一是热更新,在线打补丁,第二种是停机升级。
我们的应用是SpringCloud + Nacos + FeignClient的微服务系统,请求到达网关(SpringCloud Gateway)后,由网关进行认证/鉴权(FeignClient调用认证/鉴权系统),再路由到业务服务,业务服务之间通过FeignClient相互调用。
以下基于这个系统聊一下如何做到不影响用户体验的情况下实现服务更新。
由于我们的是SpringBoot应用,服务更新的方法,只能选择第二种,停机更新。要停机,对请求到达当前服务的用户体验就会很差,那么需要一种不影响用户体验的方式进行。
假设一个场景:每个服务启动三个节点,请求到达网关1->到达服务A1->到达服务B1->到达服务C1->原路返回,当前需要更新服务B。
现在需要做的就是服务B的三个节点停机更新。运维部署上可以使用蓝绿部署、灰度发布、金丝雀发布,适合处理这种情况。其实就是一个服务流量控制功能。先确定一下系统中需要设置流量控制的地方:
这两处需要控制流量,流量控制属性需要放在外部源中,方便修改,其次还需要对所有使用到上述功能的服务进行实时更新,然后是修改路由方法,由配置的参数对服务进行控制。这里采用服务权重来进行路由,为0则不路由到服务,
因为服务采用的是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 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 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 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 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那么操作与当前服务是一样的,只是监听类需要用对应的方法。
MetaInfo
,每个服务自动刷新注册中心信息的时候,读取MetaInfo
信息来更新路由信息接下来来对比一下几种配置方式的优劣:
配置方式 | 错误通知 | 实时性 | 持久化 |
---|---|---|---|
配置中心(Nacos/config等) | 只能在服务中打印错误,需要依赖日志组件集成通知 | 基本上实时更新 | 可持久化,依托配置文件/DB |
DB | 修改配置后,下发服务前即可获取配置错误信息 | 依赖于MQ传递消息,如果使用独立MQ,基本上算是实时更新 | 可持久化 |
Redis等缓存 | 同DB | 定时刷新,取决于刷新频率,不算实时 | 不一定持久化,需要依赖外部持久化工具 |
注册中心 | 同DB | 定时刷新,取决于刷新频率,不算实时 | 不一定持久化,需要依赖外部持久化工具 |
综上,配置方式最好的,我觉得是DB配置,开发量最小的,是依托于已有的配置中心。如何选择,还是看具体应用场景与资源。
我的上一篇文章由分布式本地缓存到一致性hash中有讲到过如何对SpringCloud Gateway及FeignClient的负载均衡进行重写,如何重写这里就不再讲了。
这里增加一个如果重写了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 {
private final Class 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 feignClientInterface, DiscoveryClient discoveryClient, ServerWeightRule serverWeightRule) {
this(feignClientInterface, discoveryClient, serverWeightRule, null, null, null, null, null);
}
public FeignLoadBalanceClient(Class 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 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 getInstances(String serviceId) {
List serviceInstanceList = discoveryClient.getInstances(serviceId);
if (CollectionUtils.isEmpty(serviceInstanceList)) {
log.warn("服务[{}]获取到的服务为空", serviceId);
throw BizException.build(ResultEnum.DISCOVER_INSTANCE_EMPTY);
}
return serviceInstanceList;
}
/**
* 执行方法并进行熔断处理
* @param callable 执行的方法
* @param 返回值类型
* @return 返回值
*/
public static R fusing(Callable 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 reachableServerList)方法,在feign中使用。
* 下面这个方法是在网关中使用,网关的ServerWeightLoadBalancerClientFilter注入ServerWeightRule 这个Bean,使用这个方法即可。
*/
public ServiceInstance chooseByServiceInstance(List serviceInstanceList) {
AtomicInteger sum = new AtomicInteger(0);
List> 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
}
]