问题
使用Spring Cloud搭建微服务体系,如果注册中心选用Eureka,使用spring-cloud-starter-netflix-eureka-client
包,能在项目中方便的整合Eureka。
在日常开发中经常会遇到一个问题,某提供方服务的停止和启动,调用方仍然会调用到已停止的服务,而服务启动完成后不能马上调用到。
即:服务不能优雅停机,服务调用方不能实时的感知服务提供方的下线/上线。
比如某服务提供方注册到Eureka,由于修改代码本地IDE重启服务,或者测试容器环境(docker/k8s)重启服务,通过Eureka的控制台
可以看到服务状态展示为红色的DOWN,但在重启的过程中,调用方通过Feign Client调用服务仍能调到为状态为DOWN的服务,而此时服务还未启动完成。
例:网关(spring-cloud-gateway
)调用微服务,网关里日志里报错如下:
Connection refused: /ip:port
ip和port为已经停止或者正在启动中的服务的ip和port。
服务重启完成后,短时间内仍然会调用失败,网关日志里报错如下:
Unable to find instance for xxx
其实服务已启好,通过Eureka的控制台能看到服务状态为UP,日志报错信息为找不到xxx服务,需要等一段时间才能正常调用。
在服务停止、启动的过程中部分调用失败:
对于开发、测试环境,会影响开发同学调试的效率,而测试同学正在进行测试会出现报错,影响测试工作进行。
对于生产环境,部分流量负载到了已下线的服务,会导致部分业务失败,影响用户的正常使用,如APP界面功能报错、运营后台界面提示报错等。
分析&解决
看第1个异常信息:Connection refused: /ip:port
是netty的连接报错,异常类io.netty.channel.AbstractChannel$AnnotatedConnectException
。
看第2个异常信息:Unable to find instance for xxx
,我们在异常堆栈里找到来源是LoadBalancerClientFilter#filter
。LoadBalancerClientFilter
是spring-cloud-gateway里提供的filter,实现了GlobalFilter
接口。
它用于调用方实现负载均衡,选择一个服务提供方节点来进行调用:
final ServiceInstance instance = choose(exchange);
if (instance == null) {
throw new NotFoundException("Unable to find instance for " + url.getHost());
}
其中choose
方法里,调用LoadBalancerClient
实例的choose
方法,LoadBalancerClient
接口的实现类为RibbonLoadBalancerClient
,里面的getServer
方法实际调用了ILoadBalancer
的chooseServer
方法,
继续跟踪源码,发现调用栈为ZoneAwareLoadBalancer#chooseServer
->BaseLoadBalancer#chooseServer
->IRule#choose
,
最终是调用的IRule
里的choose
方法来选择服务的某节点,而PredicateBasedRule#choose
里又是通过ILoadBalancer
来获取所有服务提供方节点,
实际调用BaseLoadBalancer#getAllServers
,那么getAllServers
里面的List<Server> allServerList
是如何初始化的呢?
通过查找该变量赋值的地方,发现是通过DynamicServerListLoadBalancer
类里的ServerListUpdater
接口来更新的。PollingServerListUpdater
接口有2个实现类,PollingServerListUpdater
和EurekaNotificationServerListUpdater
,默认配置使用的是哪个?
查看Ribbon的自动配置类RibbonAutoConfiguration
,里面初始了1个Bean实例SpringClientFactory
,它集成了NamedContextFactory
类,
通过子容器隔离机制创建不同服务的client,RibbonClientConfiguration
作为默认配置类,
在该配置类里,
@Bean
@ConditionalOnMissingBean
public ServerListUpdater ribbonServerListUpdater(IClientConfig config) {
return new PollingServerListUpdater(config);
}
ServerListUpdater
接口默认配置的实现类是PollingServerListUpdater
。
查看PollingServerListUpdater
类,里面的start
方法里构建了1个定时任务来进行更新操作,其时间间隔默认为30s。
通过IClientConfig
里的配置项CommonClientConfigKey.ServerListRefreshInterval
,
因此找到了该值可在yml里自定义配置,如把任务的30s刷新间隔修改为5s:
ribbon:
ServerListRefreshInterval: 5000
修改配置为5s后,停止/启动服务,发现等待调用恢复正常的时间确实比之前要短了一些,但延迟明显是大于了5s,问题在哪儿呢?
考虑到Eureka的缓存机制,里面有2个缓存readWriteCacheMap
和readOnlyCacheMap
,readWriteCacheMap
更新数据定时同步给readOnlyCacheMap
,客户端从readOnlyCacheMap
查询服务列表,定时时间默认为30s。
可通过配置禁用readOnlyCacheMap
,避免定时同步产生的延迟。
Eureka服务的yml配置如下:
eureka:
client:
service-url:
defaultZone: http://localhost:7777/eureka
register-with-eureka: false
fetch-registry: false
server:
enable-self-preservation: false
use-read-only-response-cache: false
其中use-read-only-response-cache: false
禁用readOnlyCacheMap
,enable-self-preservation: false
禁用eureka的保护模式。
配置重启Eureka服务,再次停止/启动测试的服务提供方,发现服务可用的延迟时间又缩短了。
由于Ribbon里PollingServerListUpdater
的ServerListRefreshInterval
配置为5s,那么至少有5s的延迟,这段时间内服务调用报错。
刚才跟踪源码,发现ServerListUpdater
接口有2个实现类,另1个为EurekaNotificationServerListUpdater
,看类名有点像是Eureka配置变更后通知更新,
尝试修改默认配置,使用此实现类:
/**
* @author cdfive
*/
@Configuration
public class RibbonConfig {
/**
* use {@link com.netflix.niws.loadbalancer.EurekaNotificationServerListUpdater}
* instead of {@link com.netflix.loadbalancer.PollingServerListUpdater}
*/
@Bean
public ServerListUpdater ribbonServerListUpdater() {
return new EurekaNotificationServerListUpdater();
}
}
在网关服务配置后,再次重启测试服务来进行测试,发现服务的下线、上线,几乎没有延迟了,在重启的过程中只有很少的报错。
仍有少量报错的原因是什么呢?
在本地停止服务是直接在IDEA里点击停止按钮,测试环境停止服务是通过脚本里kill PID
,推测可能Eureka刷新配置还需要时间。
于是想到增加1个controller,增加2个方法来进行服务的下线和上线,里面通过EurekaAutoServiceRegistration
来主动更新Eureka配置。
新建ApplicationController
类:
/**
* @author cdfive
*/
@Slf4j
@RequestMapping("/application")
@RestController
public class ApplicationController {
@Autowired
private EurekaAutoServiceRegistration eurekaAutoServiceRegistration;
@GetMapping("/online")
public String online() {
log.info("online start");
eurekaAutoServiceRegistration.start();
log.info("online end");
return "online";
}
@GetMapping("/offline")
public String offline() {
log.info("offline start");
eurekaAutoServiceRegistration.stop();
log.info("offline end");
return "offline";
}
}
本地开发环境可通过postman调用接口,测试和线上环境可修改服务脚本,停止服务前先调用/application/offline
接口。
实测通过这种方式,在服务重启过程中,没有了调用失败,保证服务调用方感知的实时性。
总结
使用Eureka作为注册中心,由于Eureka本身有缓存,Ribbon更新服务列表有缓存,导致服务下线后,上游负载均衡可能还会调用到已下线的服务导致调用失败。
- 通过对Eureka配置
use-read-only-response-cache: false
禁用readOnlyCacheMap缓存
, - 配置Ribbon里
ServerListUpdater
接口的实现类为EurekaNotificationServerListUpdater
, - 通过在接口里使用
EurekaAutoServiceRegistration
主动更新服务注册配置,
以上是使用spring-cloud技术栈,Eureka作为服务注册中心,实现服务优雅停机的思路和实践。
注:如果使用Nacos作为服务注册中心,其服务本身和提供的starter组件对优雅停机支持更好,更易于使用。