1. 背景

原来的系统是个单体服务,导致逻辑越来越复杂,牵一发而动全身。为了提高系统的可扩展性,我们把原来的单体系统,按照功能拆分成不同的微服务。

2. 弹性云配置

我们所有的微服务都是部署在弹性云上的,希望在部署服务时能够做到无损发布。要做到这一点,以下几个步骤是需要实现的:

  1. 容器销毁之前服务进程能够主动从eureka注册中心列表中删除;
  2. 在eureka注册中心列表删除实例后,该实例在一定的时间内还要能够承接一些流量,因为此时其他eureka客户端还有该实例的缓存;
  3. 最后等待其他线程全部处理完成后,再销毁容器。

下面看下如何实现上面的需求。

2.1 eureka主动下线方式

有以下几种eureka注册中心服务下线的方式:

  1. 直接kill服务

    这种方式简单粗暴,但是在这种情况下,虽然客户端已经停止服务了,但是仍然存在于注册中心列表中,会造成部分模块调用时出错,所以这个方案pass。

  2. 向Eureka service发送delete请求

    http://{eureka-server:port}/eureka/apps/{application.name}/{instance.name}

    这种方案只是取消注册服务,但是当eureka服务再一次接收到心跳请求时,会重新把这个实例注册到eureka上,所以这个方案也pass了。

  3. 客户端通知Eureka service下线

    DiscoveryManager.getInstance().shutdownComponent();

    eureka客户端可以通过上面一行代码主动通知注册中心下线,下线后也不会再注册到eureka上,这个方案符合我们的要求,但是我们需要确认这行代码需要在什么时候被调用?

2.2 下线时机

在这里我们首先需要确定从eureka注册中心删除实例的时机,有以下几种想法:

1. 自定义controller接口

@GetMapping("/shutdown")
public void shutdown() {
  DiscoveryManager.getInstance().shutdownComponent();
}

在容器部署之前,先调用此接口下线,然后再执行部署操作。但是这样做有很大的弊端:1. 该接口不能暴露出去,同时为了避免其他人恶意调用,还需要加一些鉴权操作;2. 无法集成到部署脚本中,因为和弹性云团队的同学了解到,容器销毁前并不会执行control.sh里的stop方法,而是发送一个SIGTERM信号,所以没办法将该接口调用写到部署脚本中。因此如果采用这种方式,只能每个容器上线前手动调用该接口,风险太大,因为此方案不合适。

2. 自定义Shutdown Hook

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  // 从eureka注册列表中删除实例
  DiscoveryManager.getInstance().shutdownComponent();
  // 休眠120S
  try {
  	Thread.sleep(120 * 1000);
  } catch (Exception ignore) {
  }
}));

JVM在接收到系统的SIGTERM信号后,会调用Shutdown Hook里的方法,这样注册一个这样的Shutdown Hook是不是就可以了呢?

经过测试发现并不完美,虽然下线时能够及时通知eureka服务下线改服务,但是同时Tomcat也会拒绝接收接下来的请求,druid线程池也会close;这样其他微服务由于缓存了改实例,还会有请求打到这个实例上,导致请求报错。

3. Spring Shutdown Hook

是什么原因导致上述情况的呢?翻阅Spring源码可以发现,SpringBoot在服务启动过程中,会自动注册一个Shutdown Hook,源码如下:

// org.springframework.boot.SpringApplication#refreshContext
private void refreshContext(ConfigurableApplicationContext context) {
  this.refresh((ApplicationContext)context);
  if (this.registerShutdownHook) {
    try {
      // 注册shutdownHook
      context.registerShutdownHook();
    } catch (AccessControlException var3) {
    }
  }
}

SpringBoot在启动过程中,刷新Context之后,如果没有手动关闭registerShutdownHook(默认开启),则会注册一个Shutdown Hook。

// org.springframework.context.support.AbstractApplicationContext#registerShutdownHook
@Override
public void registerShutdownHook() {
  if (this.shutdownHook == null) {
    // No shutdown hook registered yet.
    this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
      @Override
      public void run() {
        synchronized (startupShutdownMonitor) {
          // shutdownHook真正需要执行的逻辑
          doClose();
        }
      }
    };
    // 注册shutdownHook
    Runtime.getRuntime().addShutdownHook(this.shutdownHook);
  }
}

Spring Shutdown Hook的具体执行逻辑,我们稍后分析;现在来看下如果JVM注册了多个Shutdown Hook,那么它们的执行顺序是怎么样的?

// java.lang.Runtime#addShutdownHook
public void addShutdownHook(Thread hook) {
  SecurityManager sm = System.getSecurityManager();
  if (sm != null) {
    sm.checkPermission(new RuntimePermission("shutdownHooks"));
  }
  ApplicationShutdownHooks.add(hook);
}
// java.lang.ApplicationShutdownHooks

/* The set of registered hooks */
private static IdentityHashMap<Thread, Thread> hooks;

static synchronized void add(Thread hook) {
  if(hooks == null)
    throw new IllegalStateException("Shutdown in progress");

  if (hook.isAlive())
    throw new IllegalArgumentException("Hook already running");

  if (hooks.containsKey(hook))
    throw new IllegalArgumentException("Hook previously registered");

  hooks.put(hook, hook);
}

可以看到,当我们添加一个Shutdown Hook时,会调用ApplicationShutdownHooks.add(hook),向ApplicationShutdownHooks类下的静态变量private static IdentityHashMap<Thread, Thread> hooks里添加一个hook,hook本身是一个thread对象。

// java.lang.ApplicationShutdownHooks#runHooks

/* Iterates over all application hooks creating a new thread for each
 * to run in. Hooks are run concurrently and this method waits for
 * them to finish.
 */
static void runHooks() {
  Collection<Thread> threads;
  synchronized(ApplicationShutdownHooks.class) {
    threads = hooks.keySet();
    hooks = null;
  }

  for (Thread hook : threads) {
    hook.start();
  }
  for (Thread hook : threads) {
    while (true) {
      try {
        hook.join();
        break;
      } catch (InterruptedException ignored) {
      }
    }
  }
}

上述源码是应用级hooks的执行逻辑,hook执行时调用的是tread类的start方法,所以多个hook是异步执行的,但是会等到所有hook全部执行完才会退出。

到这里,我们就可以确定方案2有问题的原因:虽然我们在自定义Shutdown Hook里自作聪明的sleep 120s,但是由于它和Spring Shutdown Hook执行并不是同步的,所以在自定义hook的睡眠过程中,spring同时也在做一些收尾工作,导致此时打到改实例上的请求报错。

既然自定义Shutdown Hook的方案行不通,那么是不是可以在Spring Shutdown Hook这里搞一些操作呢?接下来看下Spring Shutdown Hook的具体实现逻辑:

// org.springframework.context.support.AbstractApplicationContext#doClose
protected void doClose() {
  if (this.active.get() && this.closed.compareAndSet(false, true)) {
    
    LiveBeansView.unregisterApplicationContext(this);

    // 1. Publish shutdown event. 
    publishEvent(new ContextClosedEvent(this));

    // 2. Stop all Lifecycle beans, to avoid delays during individual destruction.
    if (this.lifecycleProcessor != null) {
      this.lifecycleProcessor.onClose();
    }

    // 3. Destroy all cached singletons in the context's BeanFactory.
    destroyBeans();

    // 4. Close the state of this context itself.
    closeBeanFactory();

    // 5. Let subclasses do some final clean-up if they wish...
    onClose();

    // 6. Reset local application listeners to pre-refresh state.
    if (this.earlyApplicationListeners != null) {
      this.applicationListeners.clear();
      this.applicationListeners.addAll(this.earlyApplicationListeners);
    }

    this.active.set(false);
  }
}

上面源码只保留了关键代码,可以看到,Spring Shutdown Hook一共做了这些事情:

  1. 发布Context Close事件,可以让监听此事件的listener在应用关闭前执行一些自定义逻辑;
  2. 执行lifecycleProcessor的onClose方法;
  3. 销毁Context BeanFactory中所有缓存的单例;
  4. 关闭当前上下文的状态;
  5. 子类可以自己实现OnClose方法,做一些各自的清理工作;
  6. 将本地应用监听者重置为pre-refresh状态;

既然Spring Shutdown Hook执行逻辑的第一步是发布Context Close事件,那我们就可以创建一个listener监听此事件,然后在监听回调里执行从eureka注册列表中删除实例的逻辑。实现如下:

@Component
public class EurekaShutdownConfig implements ApplicationListener<ContextClosedEvent>, PriorityOrdered {
    private static final Logger log = LoggerFactory.getLogger(EurekaShutdownConfig.class);

  	@Override
    public void onApplicationEvent(ContextClosedEvent event) {
        try {
            log.info(LogUtil.logMsg("_shutdown", "msg", "eureka instance offline begin!"));
            DiscoveryManager.getInstance().shutdownComponent();
            log.info(LogUtil.logMsg("_shutdown", "msg", "eureka instance offline end!"));
            log.info(LogUtil.logMsg("_shutdown", "msg", "start sleep 120S for cache!"));
            Thread.sleep(120 * 1000);
            log.info(LogUtil.logMsg("_shutdown", "msg", "stop sleep 120S for cache!"));
        } catch (Throwable ignore) {
        }
    }

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

至此主动从eureka注册中心删除实例的时机就已经确定了。

2.3 其他配置

application.yml

server:
	# 优雅关机策略
	shutdown: graceful
	# 其他配置
	...

tomcat执行优雅关机的时机是在lifecycleProcessor.onClose(),在这里不详细展开说明了,可自行翻阅源码。

自定义线程池

@Configuration
public class MyThreadTaskExecutor {

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();

        // 线程池参数
        taskExecutor.setCorePoolSize(8);
        taskExecutor.setMaxPoolSize(32);
        taskExecutor.setQueueCapacity(9999);
        taskExecutor.setKeepAliveSeconds(60);

        taskExecutor.setThreadNamePrefix("async-");

        taskExecutor.setTaskDecorator(new TraceIdTaskDecorator());

        // 服务停用前等待异步线程执行完成
        taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
        // 60S后强制关闭
        taskExecutor.setAwaitTerminationSeconds(60);

        taskExecutor.initialize();

        return taskExecutor;
    }
}

自定义线程池和数据库连接池的关闭是在销毁bean时执行的。

3. 总结

至此,我们可以总结下当服务接收到SIGTERM信号后的处理逻辑: