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訊號後的處理邏輯:
如有謬誤,歡迎指正。