1. 背景
原来的系统是个单体服务,导致逻辑越来越复杂,牵一发而动全身。为了提高系统的可扩展性,我们把原来的单体系统,按照功能拆分成不同的微服务。
2. 弹性云配置
我们所有的微服务都是部署在弹性云上的,希望在部署服务时能够做到无损发布。要做到这一点,以下几个步骤是需要实现的:
- 容器销毁之前服务进程能够主动从eureka注册中心列表中删除;
- 在eureka注册中心列表删除实例后,该实例在一定的时间内还要能够承接一些流量,因为此时其他eureka客户端还有该实例的缓存;
- 最后等待其他线程全部处理完成后,再销毁容器。
下面看下如何实现上面的需求。
2.1 eureka主动下线方式
有以下几种eureka注册中心服务下线的方式:
-
直接kill服务
这种方式简单粗暴,但是在这种情况下,虽然客户端已经停止服务了,但是仍然存在于注册中心列表中,会造成部分模块调用时出错,所以这个方案pass。
-
向Eureka service发送delete请求
http://{eureka-server:port}/eureka/apps/{application.name}/{instance.name}
这种方案只是取消注册服务,但是当eureka服务再一次接收到心跳请求时,会重新把这个实例注册到eureka上,所以这个方案也pass了。
-
客户端通知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一共做了这些事情:
- 发布Context Close事件,可以让监听此事件的listener在应用关闭前执行一些自定义逻辑;
- 执行lifecycleProcessor的onClose方法;
- 销毁Context BeanFactory中所有缓存的单例;
- 关闭当前上下文的状态;
- 子类可以自己实现OnClose方法,做一些各自的清理工作;
- 将本地应用监听者重置为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信号后的处理逻辑: