问题

使用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方法实际调用了ILoadBalancerchooseServer方法,
继续跟踪源码,发现调用栈为ZoneAwareLoadBalancer#chooseServer->BaseLoadBalancer#chooseServer->IRule#choose
最终是调用的IRule里的choose方法来选择服务的某节点,而PredicateBasedRule#choose里又是通过ILoadBalancer来获取所有服务提供方节点,
实际调用BaseLoadBalancer#getAllServers,那么getAllServers里面的List<Server> allServerList是如何初始化的呢?

通过查找该变量赋值的地方,发现是通过DynamicServerListLoadBalancer类里的ServerListUpdater接口来更新的。
PollingServerListUpdater接口有2个实现类,PollingServerListUpdaterEurekaNotificationServerListUpdater,默认配置使用的是哪个?

查看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个缓存readWriteCacheMapreadOnlyCacheMap
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禁用readOnlyCacheMapenable-self-preservation: false禁用eureka的保护模式。
配置重启Eureka服务,再次停止/启动测试的服务提供方,发现服务可用的延迟时间又缩短了。

由于Ribbon里PollingServerListUpdaterServerListRefreshInterval配置为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组件对优雅停机支持更好,更易于使用。