天天看點

美團太細了:Springcloud微服務優雅停機,如何實作?

作者:架構師尼恩

▌說在前面:

關于Spring Boot、Spring Cloud應用的優雅停機,平時經常會被問到,這也是實際應用過程中,必須要掌握的點。

在40歲老架構師 尼恩的讀者社群(50+)中,最近有小夥伴拿到了一線網際網路企業如美團、拼多多、極兔、有贊、希音的面試資格,遇到一幾個很重要的面試題:

  • 雲原生 Springcloud 微服務的優雅停機,如何實作?

與之類似的、其他小夥伴遇到過的問題還有:

  • Springcloud 微服務的優雅下線,如何實作?
  • SpringBoot 的優雅下線,如何實作?
  • 等等等等.....

這裡尼恩給大家做一下系統化、體系化的梳理,使得大家可以充分展示一下大家雄厚的 “技術肌肉”,讓面試官愛到 “不能自已、口水直流”。

也一并把這個題目以及參考答案,收入咱們的 《尼恩Java面試寶典》V77版本,供後面的小夥伴參考,提升大家的 3高 架構、設計、開發水準。

最新《尼恩 架構筆記》、《尼恩高并發三部曲》、《尼恩Java面試寶典》 的PDF檔案,請到公号【技術自由圈】擷取

▌本文目錄:

- 說在前面
- 什麼才是SpringCloud 優雅下線?
- JVM的優雅退出
  - JVM 退出的鈎子函數
    - JVM 退出的鈎子函數 的應用場景
  - JVM 退出的鈎子函數的使用
  - Runtime.addShutDownHook(Thread hook)觸發場景
- SpringBoot應用如何優雅退出
  - - Spring如何添加鈎子函數?
    - registerShutdownHook()源碼分析
    - destroy() 方法如何使用
    - Springboot 如何自動注冊的鈎子函數的
- SpringCloud 微服務執行個體優雅的下線方式
  - Eureka 微服務執行個體優雅下線方式
  - Nacos 微服務執行個體優雅下線方式
- 雲原生場景下, SpringCloud 微服務執行個體,如何優雅退出
  - 回顧JVM關閉的鈎子函數在什麼情況下會被調用
  - 雲原生場景下,JVM關閉鈎子的所存在的問題
  - 定義preStop鈎子接口優先執行核心的下線邏輯
  - 微服務的無損下線小結
- 附:SpringBoot應用的優雅停機
  - 什麼是Web 容器優雅停機行為
  - 優雅停機的目的:
  - 優雅停機具體行為:
  - 優雅停機的使用和配置
  - 優雅退出原理
    - 注冊實作smartLifecycle的Bean
    - smartLifecycle回調執行的流程和時機
    - SpringBoot 優雅停機的執行流程總結:
  - SpringBoot應用的優雅停機如何觸發
    - 方式一:kill PID
    - 方式二:shutdown端點
    - shutdown端點的源碼分析
  - SpringCloud + SpringBoot優雅退出總結
- 說在最後
- 技術自由的實作路徑 PDF 擷取           

▌什麼才是SpringCloud 優雅下線?

java程式運作在JVM上,有很多情況可能會突然崩潰掉,比如OOM、使用者強制退出、業務其他報錯。。。等一系列的問題可能導緻我們的程序挂掉。

這時候,我們就有服務改進、新版本更新的需求。

如果我們要更新某個服務,前提就是要進行服務的優雅下線。

什麼才是SpringCloud 優雅下線?

那麼,咱們常用的 kill PID,它是優雅的下線政策嗎?

當然不是。

首先,優雅下線是目标,而不是手段,它是一個相對的概念。

雖然kill PID不是優雅下線,并且kill PID和kill -9 PID都是暴力殺死服務,相對于kill -9 PID來說,kill PID就是優雅的。

那麼,到底什麼才是SpringCloud 優雅下線呢?

包括以下内容:

  • 處理沒有完成的請求,注意,不再接收新的請求
  • 池化資源的釋放:資料庫連接配接池,HTTP 連接配接池
  • 處理線程的釋放:請求處理線程的釋放
  • SpringCloud 微服務執行個體優雅的下線方式,主動從注冊中心登出,保證其他的 RPC用戶端不會發生錯誤的RPC調用

那麼SpringCloud 優雅下線該如何實作呢?

要介紹清楚 SpringCloud 優雅下線實作機制,必須首先從JVM的優雅退出的基礎知識開始。

▌JVM的優雅退出:

JVM的優雅退出機制,主要是通過 Hook實作的。

jvm有shutdwonHook機制,中文習慣叫優雅退出。

VM的優雅退出Hook,和linux系統中執行SIGTERM(kill -15 或者 svc -d)時,退出前執行的一些操作。

▌JVM 退出的鈎子函數

▌JVM 退出的鈎子函數 的應用場景

首先看看,JVM 退出的鈎子函數 的應用場景。

我們的java程式運作在JVM上,有很多情況可能會突然崩潰掉,比如:

  • OOM
  • 使用者強制退出
  • 業務其他報錯

一系列的問題,可能導緻我們的JVM 程序挂掉。

JVM 退出的鈎子函數是指在 JVM 程序即将退出時,自動執行使用者指定的代碼段。

這個功能的應用場景比較廣泛,例如:

  1. 資源釋放:在 JVM 退出時,需要釋放一些資源,比如關閉資料庫連接配接、釋放檔案句柄等,可以使用鈎子函數來自動執行這些操作。
  2. 日志記錄:在 JVM 退出時,可以記錄一些關鍵資訊,比如程式運作時間、記憶體使用情況等,以便後續分析問題。
  3. 資料持久化:在 JVM 退出時,可以将一些重要的資料持久化到磁盤上,以便下次啟動時可以恢複狀态。
  4. 安全退出:在 JVM 退出時,可以執行一些清理操作,比如删除臨時檔案、關閉網絡連接配接等,以確定程式的安全退出。

總之,鈎子函數可以在 JVM 退出時執行一些自定義的操作,以便更好地管理和控制程式的運作。

40歲老架構師尼恩的提示:

應用的優雅關閉非常重要,可以在關閉之前做一些記錄操作、補救操作。

至少,能知道咱們的JVM程序,什麼時間,什麼原因發生過異常退出。

▌JVM 退出的鈎子函數的使用

在java程式中,可以通過添加關閉鈎子函數,實作在程式退出時關閉資源、優雅退出的功能。

如何做呢?

主要就是通過Runtime.addShutDownHook(Thread hook)來實作的。

Runtime.addShutdownHook(Thread hook) 是 Java 中的一個方法,用于在 JVM 關閉時注冊一個線程來執行清理操作。

Runtime.addShutdownHook(Thread hook) 每一次調用,就是注冊一個線程,參考代碼如下:

// 添加hook thread,重寫其run方法
Runtime.getRuntime().addShutdownHook(new Thread(){
    @Override
    public void run() {
        System.out.println("this is hook demo...");
        // jvm 退出的鈎子邏輯
    }
});           

Runtime.addShutdownHook(Thread hook) 可以調用多次,進而注冊多個線程。

當 JVM 即将關閉時,會按照注冊的順序依次執行這些線程,以便進行一些資源釋放、日志記錄或其他清理操作。

這個方法可以在應用程式中用來確定在程式退出前執行一些必要的清理工作,例如關閉資料庫連接配接或釋放檔案句柄等。

下面我們來簡單看一個Runtime.addShutDownHook(Thread hook) 使用示例

// 建立HookTest,我們通過main方法來模拟應用程式
public class HookTest {
 
    public static void main(String[] args) {
 
        // 添加hook thread,重寫其run方法
        Runtime.getRuntime().addShutdownHook(new Thread(){
            @Override
            public void run() {
                System.out.println("this is hook demo...");
                // jvm 退出的鈎子邏輯
            }
        });
 
        int i = 0;
        // 這裡會報錯,jvm回去執行hook thread
        int j = 10/i;
        System.out.println("j" + j);
    }
}           

執行之後,結果如下:

Exception in thread "main" java.lang.ArithmeticException: / by zero
 at hook.HookTest.main(HookTest.java:23)
this is hook demo...
 
Process finished with exit code 1           

總結:我們主動寫了一個報錯程式,在程式報錯之後,鈎子函數還是被執行了。

經驗證,我們是可以通過對Runtime添加鈎子函數,來完成退出時的善後工作。

▌Runtime.addShutDownHook(Thread hook)觸發場景

既然JDK提供的這個方法可以注冊一個JVM關閉的鈎子函數,那麼這個函數在什麼情況下會被調用呢?

上述我們展示了在程式異常情況下會被調用,還有沒有其他場景呢?

  • 程式正常退出
  • 使用System.exit()
  • 終端使用Ctrl+C觸發的中斷
  • 系統關閉
  • OutofMemory當機
  • 使用Kill pid殺死程序(使用kill -9是不會被調用的)
  • ..等等

Runtime.addShutDownHook(Thread hook)觸發場景,詳見下圖

美團太細了:Springcloud微服務優雅停機,如何實作?

40歲老架構師尼恩提示:強制關閉 不能 進行 Runtime.addShutDownHook(Thread hook)的觸發,

上圖的第三個分支,展示得清清楚楚

▌SpringBoot應用如何優雅退出:

▌Spring如何添加鈎子函數?

Spring/SpringBoot應用,如何手動添加鈎子函數呢?

Spring/SpringBoot 提供了一個方法:

// 通過這種方式來添加鈎子函數
ApplicationContext.registerShutdownHook();           

ApplicationContext.registerShutdownHook() 方法是 Spring 架構中的一個方法,用于注冊一個 JVM 關閉的鈎子(shutdown hook)。

當 JVM 關閉時,Spring 容器可以優雅地關閉并釋放資源,進而避免了可能的資源洩漏或其他問題。

ApplicationContext.registerShutdownHook() 方法應該在 Spring 應用程式的 main 方法中調用,以確定在 JVM 關閉時 Spring 容器能夠正确地關閉。

下面是 Spring 官方的原文介紹:

美團太細了:Springcloud微服務優雅停機,如何實作?

▌registerShutdownHook()源碼分析

spring通過JVM實作注冊退出鈎子的源碼如下:

// 通過源碼可以看到,
@Override
public void registerShutdownHook() {
    if (this.shutdownHook == null) {
        // No shutdown hook registered yet.
        this.shutdownHook = new Thread() {
            @Override
            public void run() {
                synchronized (startupShutdownMonitor) {
                    doClose();
                }
            }
        };
        // 也是通過這種方式來添加
        Runtime.getRuntime().addShutdownHook(this.shutdownHook);
    }
}
 
// 重點是這個doClose()方法
 
protected void doClose() {
    // Check whether an actual close attempt is necessary...
    if (this.active.get() && this.closed.compareAndSet(false, true)) {
        if (logger.isInfoEnabled()) {
            logger.info("Closing " + this);
        }
 
        LiveBeansView.unregisterApplicationContext(this);
 
        try {
            // Publish shutdown event.
            publishEvent(new ContextClosedEvent(this));
        }
        catch (Throwable ex) {
            logger.warn("Exception thrown from ApplicationListener handling ContextClosedEvent", ex);
        }
 
        // Stop all Lifecycle beans, to avoid delays during individual destruction.
        if (this.lifecycleProcessor != null) {
            try {
                this.lifecycleProcessor.onClose();
            }
            catch (Throwable ex) {
                logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
            }
        }
 
        // Destroy all cached singletons in the context's BeanFactory.
        destroyBeans();
 
        // Close the state of this context itself.
        closeBeanFactory();
 
        // Let subclasses do some final clean-up if they wish...
        onClose();
 
        // Switch to inactive.
        this.active.set(false);
    }
}           

spring裡registerShutdownHook的源碼所示,就是注冊一個jvm的shutdownHook鈎子函數。jvm退出前會執行這個鈎子函數。

通過源碼,可以看到:doClose()方法會執行bean的destroy(),也會執行SmartLifeCycle的stop()方法,我們就可以通過重寫這些方法來實作對象的關閉,生命周期的管理,實作平滑shutdown、優雅關閉。

spring 為何在容器銷毀時自動 調用destroy()等方法? 就是這裡的 destroyBeans() 方法的執行。 是以,這裡特别關注的是 destroyBeans() 方法。

destroyBeans() 是 Spring 架構中的一個方法,它是在 Spring 容器關閉時調用的方法,用于銷毀所有的單例 bean。

在 Spring 容器關閉時,會依次調用所有單例 bean 的 destroy() 方法,而 destroyBeans() 方法就是用于觸發這個過程的。

在 destroy() 方法中,我們可以釋放資源、關閉連接配接等操作,以確定應用程式正确地關閉。

40歲老架構師尼恩的提示:

destroy() 方法隻在單例 bean 中才會被調用,

而對于原型 bean,Spring 容器不會調用其 destroy() 方法。

▌destroy() 方法如何使用

兩個方式:

  • 方式一: 實作了 DisposableBean 接口,并重寫了其中的 destroy() 方法
  • 方式二:使用 @PreDestroy 注解來指定銷毀方法

destroy() 方法是在 Spring 容器銷毀時調用的方法,用于釋放資源或執行清理操作。

方式一: 實作了 DisposableBean 接口,并重寫了其中的 destroy() 方法

下面是destroy() 方法如何使用的一個示例:

public class MyBean implements DisposableBean {
    
    private Resource resource;
    
    public void setResource(Resource resource) {
        this.resource = resource;
    }
    
    // 實作 DisposableBean 接口中的 destroy() 方法
    @Override
    public void destroy() throws Exception {
        // 釋放資源
        if (this.resource != null) {
            this.resource.release();
        }
    }
}           

在這個示例中,MyBean 實作了 DisposableBean 接口,并重寫了其中的 destroy() 方法。

在 destroy() 方法中,我們釋放了 resource 資源。

當 Spring 容器銷毀時,會自動調用 MyBean 的 destroy() 方法,進而釋放資源。

方式二:使用 @PreDestroy 注解來指定銷毀方法

除了實作 DisposableBean 接口,還可以使用 @PreDestroy 注解來指定銷毀方法。例如:

public class MyBean {
    
    private Resource resource;
    
    public void setResource(Resource resource) {
        this.resource = resource;
    }
    
    // 使用 @PreDestroy 注解指定銷毀方法
    @PreDestroy
    public void releaseResource() {
        if (this.resource != null) {
            this.resource.release();
        }
    }
}           

在這個示例中,MyBean 沒有實作 DisposableBean 接口,而是使用 @PreDestroy 注解指定了銷毀方法 releaseResource()。當 Spring 容器銷毀時,會自動調用這個方法。

▌Springboot 如何自動注冊的鈎子函數的

實際上, registerShutdownHook() 鈎子方法,在新的Springboot 版本中,不需要手動調用,已經被自動的執行了。

看一個簡單的應用, 尼恩帶大家,一步一步的翻翻源碼:

美團太細了:Springcloud微服務優雅停機,如何實作?

順着run的調用鍊路,繼續往裡翻看,會看到refreshContext 方法的執行

美團太細了:Springcloud微服務優雅停機,如何實作?

關鍵就在于這個 refreshContext 方法

繼續往裡翻看,發現一個秘密: 自動調用了registerShutdownHook() 方法

美團太細了:Springcloud微服務優雅停機,如何實作?

好吧,終于回到了 registerShutdownHook()源碼 ,這個上面詳細介紹了的

美團太細了:Springcloud微服務優雅停機,如何實作?

細心的小夥伴,可能發現那兒有個條件,如果條件不滿足,基本上就跳過去了。

不着急。

40歲老架構師尼恩崇尚動手,咱們單步執行一下,發現一個秘密:條件預設就是true

美團太細了:Springcloud微服務優雅停機,如何實作?

▌SpringCloud 微服務執行個體優雅的下線方式:

在分布式微服務場景下, SpringCloud 微服務執行個體 都是通過 注冊中心如 Eureka /nacos 進行執行個體管理的。

  • Eureka 微服務執行個體優雅下線方式
  • nacos 微服務執行個體優雅下線方式

▌Eureka 微服務執行個體優雅下線方式

但如果的務發現元件使用的是 Eureka,那麼預設最長會有 90 秒的延遲,其他應用才會感覺到該服務下線,

這意味着:

該執行個體下線後的 90 秒内,其他服務仍然可能調用到這個已下線的執行個體。

Spring Boot 應用 退出的時候,如何在 Eureka 進行執行個體的主動删除呢?

可以借助 Spring Boot 應用的 Shutdown hook,結合 Eureka 的Client API,達到微服務執行個體優雅下線的目标。

Eureka 的兩個核心的 Client API 如下:

  • 執行eurekaAutoServiceRegistration.start()方法時,目前服務向 Eureka 注冊中心注冊服務;
  • 執行eurekaAutoServiceRegistration.stop()方法時,目前服務會向 Eureka 注冊中心進行反注冊,注冊中心收到請求後,會将此服務從注冊清單中删除。

借助 Spring Boot 應用的 Shutdown hook ,微服務執行個體優雅下線的目标, 源碼如下:

美團太細了:Springcloud微服務優雅停機,如何實作?

執行的結果,如下:

美團太細了:Springcloud微服務優雅停機,如何實作?

▌Nacos 微服務執行個體優雅下線方式

40歲老架構師在自己的 技術自由圈( Future Super Architect Community 未來 超級 架構師社群)做個調查,大部分的線上項目,現在都使用了Nacos 作為注冊中心。

那麼,Nacos 是否支援微服務執行個體的 上線和下線呢?

理論上,Nacos 不僅僅支援優雅的上線和下線,而且可以通過控制台、API 或 SDK 進行操作。

在控制台上線服務時,可以選擇“上線方式”,有“快速上線”和“灰階釋出”兩種方式。當然,這個是需要運維人員配合的。其中,“快速上線”會直接将服務執行個體上線,而“灰階釋出”則會先将服務執行個體釋出到灰階環境,等待一段時間後再逐漸将其釋出到生産環境。

這裡不關注運維人員的活,咱們專注的是開發、架構師的活兒。

在 API 或 SDK 中,可以使用以下方法進行上線和下線操作:

  • 上線服務執行個體:調用 registerInstance 接口,指定服務名、IP、端口等資訊即可。
  • 下線服務執行個體:調用 deRegisterInstance 接口,指定執行個體 ID 即可。

可以借助 Spring Boot 應用的 Shutdown hook,結合 Eureka 的nacos API,達到微服務執行個體優雅下線的目标。

實作的方式和 前面的Eureka 類似, 僅僅是API的調用上的差別。

這裡不做展開,有興趣的,可以來 尼恩的 技術自由圈( Future Super Architect Community 未來 超級 架構師社群)溝通。

▌雲原生場景下, SpringCloud 微服務執行個體,如何優雅退出

一般來說,咱們的線上應用,都是在 Kubernetes部署的。

關于 K8S+ SpringCloud 雲原生微服務架構,具體請看尼恩的 PDF電子書 《K8S學習聖經》

問題來了,雲原生場景下, SpringCloud 微服務執行個體,如何優雅退出?

還是使用 JVM關閉的鈎子函數嗎?

首先,尼恩帶大家來看一下JVM關閉的鈎子函數的問題。

▌回顧JVM關閉的鈎子函數在什麼情況下會被調用

前面的用到的SpringShutDownHook,最終還是調用的 JVM關閉的鈎子函數。

既然JDK提供的這個方法可以注冊一個JVM關閉的鈎子函數,那麼這個函數在什麼情況下會被調用呢?

  • 程式正常退出
  • 使用System.exit()
  • 終端使用Ctrl+C觸發的中斷
  • 系統關閉
  • OutofMemory當機
  • 使用Kill pid殺死程序(使用kill -9是不會被調用的)
  • ..等等

▌雲原生場景下,JVM關閉鈎子的所存在的問題

一般來說,咱們的線上應用,都是在 Kubernetes部署的。

關于 K8S+ SpringCloud 雲原生微服務架構,具體請看尼恩的 PDF電子書 《K8S學習聖經》

Kubernetes 是如何優雅的停止 Pod 的?

當 kill 掉一個 Pod 的時候, Pod 的狀态為 Terminating,開啟終止流程。

  • 首先 Service 會把這個 Pod 從 Endpoint 中摘掉,這樣Service 負載均衡不會再給這個 Pod 流量,
  • 然後,他會先看看是否有 preStop鈎子,如果定義了,就執行他,
  • 最後之後給 Pod 發 SIGTERM 信号讓 Pod 中的所有容器優雅退出。

JVM的優雅退出,發生在最後一步。

但是實際情況中,我們可能會遇到以下情況,導緻最後一步發生了意外,比如:

  • 容器裡的SpringBoot代碼有很多處理優雅退出的邏輯,但是其中部分處理邏輯問題,導緻一直退出不了
  • 容器裡的SpringBoot已經處理優雅已經卡死,導緻資源耗盡,處理不了微服務優雅下線的代碼邏輯,或需要很久才能處理完成

Kubernetes 怎麼進行逾時處理的呢?

Kubernetes 還有一個 terminationGracePeriodSeconds 的硬停止時間,預設是 30s,如果 30s 内還是無法完成上述的過程,那就就會發送 SIGKILL,強制幹掉 Pod。

在POSIX相容的平台上,SIGKILL是發送給一個程序來導緻它立即終止的信号。

SIGKILL的符号常量在頭檔案signal.h中定義。因為在不同平台上,信号數字可能變化,是以符号信号名被使用,然而在大量主要的系統上,SIGKILL是信号#9

Runtime.addShutDownHook(Thread hook)觸發場景,詳見下圖

美團太細了:Springcloud微服務優雅停機,如何實作?

40歲老架構師尼恩提示:強制關閉 不能 進行 Runtime.addShutDownHook(Thread hook)的觸發,上圖的第三個分支,展示得清清楚楚

是以,JVM的優雅退出的問題就昭然若揭了。

在實際情況中,如果會遇到以下情況,導緻最後一步發生了意外,比如:

  • 容器裡的SpringBoot代碼有很多處理優雅退出的邏輯,但是其中部分處理邏輯問題,導緻一直退出不了
  • 容器裡的SpringBoot已經處理優雅已經卡死,導緻資源耗盡,處理不了微服務優雅下線的代碼邏輯,或需要很久才能處理完成

JVM的優雅退出就失效了。

怎麼辦呢?

比較直接、有效的政策是:

利用優雅的停止 Pod 的前置鈎子, 定義preStop鈎子接口優先執行核心的下線邏輯

▌定義preStop鈎子接口優先執行核心的下線邏輯

定義preStop鈎子接口優先執行核心的下線邏輯,比如這裡的 微服務執行個體下線

具體如何操作呢?實操上有個步驟:

  • step1:在SpringBoot應用中,定義WEB接口,實作核心線下邏輯
  • step2:在 pod的preStop鈎子接口的設定上,設定為SpringBoot應用的下線鈎子連結

step1: 在SpringBoot應用中,定義WEB接口,實作核心線下邏輯

先看第一步:

美團太細了:Springcloud微服務優雅停機,如何實作?

step2:在 pod的preStop鈎子接口的設定上,設定為SpringBoot應用下線鈎子連結

具體的做法是:為容器加上PreStop配置項,設定為SpringBoot應用的下線鈎子連結

/demo-provider/graceful/service/offline           
美團太細了:Springcloud微服務優雅停機,如何實作?

當 kill 掉一個 Pod 的時候, Pod 的狀态為 Terminating,開啟終止流程。

  • step1: Service 會把這個 Pod 從 Endpoint 中摘掉,這樣Service 負載均衡不會再給這個 Pod 流量, 這樣,這個微服務執行個體,就收不到使用者的請求
  • 然後,他會先看看是否有 preStop鈎子,如果定義了,就執行他, 這樣,從注冊中心下線,也就收不到 其他微服務的 rpc 請求
  • 最後,之後給 Pod 發 SIGTERM 信号讓 Pod 中的所有容器優雅退出。

preStop鈎子 執行完成後, 最後一步, Pod 發 SIGTERM 信号讓 Pod 中的所有容器優雅,SIGTERM 信号發給 jvm後,Jvm會執行優雅退出邏輯,主要是:

  • 處理沒有完成的請求,注意,不再接收新的請求
  • 池化資源的釋放:資料庫連接配接池,HTTP 連接配接池
  • 處理線程的釋放:已經被連接配接的HTTP請求

這些,咱們就先用放在前面介紹的 JVM 處理鈎子方法裡邊去了。

▌微服務的無損下線小結

我們在應用服務下線前,通過HTTP鈎子調用,主動通知注冊中心下線該執行個體

不論是Dubbo還是Cloud 的分布式服務架構,都關注的是怎麼能在服務停止前,先将提供者在注冊中心進行下線,然後在停止服務提供者,

唯有這樣,才能保證業務微服務之間RPC遠端調用,不會産生各種503、timeout等現象。

▌附:SpringBoot應用的優雅停機

除了 微服務的無損下線,作為 SpringBoot應用, 還有 單體服務優雅停機的需求:

  • 處理沒有完成的請求,注意,不再接收新的請求
  • 池化資源的釋放:資料庫連接配接池,HTTP 連接配接池
  • 處理線程的釋放:已經被連接配接的HTTP請求

這些前面介紹到 ,咱們就先用放在 JVM 處理鈎子方法裡邊去了。

SpringBoot應用的優雅停機,實際上指的是内嵌WEB伺服器的優雅停機。

目前Spring Boot已經發展到了2.3.4.RELEASE,伴随着2.3版本的到來,優雅停機機制也更加完善了。

▌什麼是Web 容器優雅停機行為

Web 容器優雅停機行為指的是在關閉容器時,讓目前正在處理的請求處理完成或者等待一段時間,讓正在處理的請求完成後再關閉容器,而不是直接強制終止正在處理的請求。這樣可以避免正在處理的請求被中斷,進而提高系統的可用性和穩定性。

一般來說,Web 容器的優雅停機行為需要滿足以下幾個條件:

  1. 等待正在處理的請求完成,不再接受新的請求。
  2. 如果等待時間超過了一定門檻值,容器可以強制關閉。
  3. 在容器關閉之前,需要給用戶端一個響應,告知他們目前正在關閉容器,不再接受新的請求

▌優雅停機的目的:

如果沒有優雅停機,伺服器此時直接直接關閉(kill -9),那麼就會導緻目前正在容器内運作的業務直接失敗,在某些特殊的場景下産生髒資料。

▌優雅停機具體行為:

在伺服器執行關閉(kill -2)時,會預留一點時間使容器内部業務線程執行完畢,

增加了優雅停機配置後, 此時容器也不允許新的請求進入。

目前版本的Spring Boot 優雅停機支援Jetty, Reactor Netty, Tomcat和 Undertow 以及反應式和基于 Servlet 的 web 應用程式都支援優雅停機功能。

新請求的處理方式跟web伺服器有關,Reactor Netty、 Tomcat将停止接入請求,Undertow的處理方式是傳回503.

具體行為,如下表所示:

web 容器名稱 行為說明
tomcat 9.0.33+ 停止接收請求,用戶端新請求等待逾時。
Reactor Netty 停止接收請求,用戶端新請求等待逾時。
Undertow 停止接收請求,用戶端新請求直接傳回 503。

不同的 Web 容器實作優雅停機的方式可能會有所不同,但是一般都會提供相關的配置選項或者 API 接口來實作這個功能。

另外,和SpringBoot内嵌的WEB伺服器類似,其他的非SpringBoot内嵌WEB伺服器,也可以進行設定。

下面是 Nginx 和 Apache 的優雅停機配置:

  • Nginx 可以通過配置檔案中的 worker_shutdown_timeout 選項來設定等待時間
  • Apache 可以通過 graceful-stop 指令來實作優雅停機。

▌優雅停機的使用和配置

新版本配置非常簡單,server.shutdown=graceful 就搞定了(注意,優雅停機配置需要配合Tomcat 9.0.33(含)以上版本)

server:
  port: 6080
  shutdown: graceful #開啟優雅停機
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s #設定緩沖時間 預設30s           

在設定了緩沖參數timeout-per-shutdown-phase 後,在規定時間内如果線程無法執行完畢則會被強制停機。

下面我們來看下停機時,加了優雅停日志和不加的差別:

//未加優雅停機配置
Disconnected from the target VM, address: '127.0.0.1:49754', transport: 'socket'
Process finished with exit code 130 (interrupted by signal 2: SIGINT)           

加了優雅停機配置後,

日志可明顯發現的 Waiting for active requests to cpmplete, 此時容器将在ShutdownHook執行完畢後停止。

▌優雅退出原理

前面講到,SpringBoot 的優雅退出,最終在 Java 程式中可以通過添加鈎子,在程式退出時會執行鈎子方法,進而實作關閉資源、平滑退出、優雅退出等功能。

SpringBoot 在啟動過程中,則會預設注冊一個JVM Shutdown Hook,在應用被關閉的時候,會觸發鈎子調用 doClose()方法,去關閉容器。

這部分代碼,前面介紹了,具體在 org.springframework.boot.SpringApplication#refreshContext 方法中

美團太細了:Springcloud微服務優雅停機,如何實作?

▌注冊實作smartLifecycle的Bean

在建立 webserver 的時候,會建立一個實作smartLifecycle的 bean,用來支撐 server 的優雅關閉。

美團太細了:Springcloud微服務優雅停機,如何實作?
// 注冊webServerGracefulShutdown用來實作server優雅關閉
this.getBeanFactory().registerSingleton("webServerGracefulShutdown", new WebServerGracefulShutdownLifecycle(this.webServer));
}           

可以看到 WebServerGracefulShutdownLifecycle 類實作SmartLifecycle接口,重寫了 stop 方法,stop 方法會觸發 webserver 的優雅關閉方法(取決于具體使用的 webserver 如 tomcatWebServer)。

美團太細了:Springcloud微服務優雅停機,如何實作?
// 優雅關閉server
this.webServer.shutDownGracefully((result) -> callback.run());           
美團太細了:Springcloud微服務優雅停機,如何實作?
@Override
public void shutDownGracefully(GracefulShutdownCallback callback) {
    if (this.gracefulShutdown == null) {
        // 如果沒有開啟優雅停機,會立即關閉tomcat伺服器
        callback.shutdownComplete(GracefulShutdownResult.IMMEDIATE);
        return;
    }
    // 優雅關閉伺服器
    this.gracefulShutdown.shutDownGracefully(callback);
}           

至此,優雅退出的代碼,通過smartLifecycle的Bean 的stop 方法實作退出的回調注冊。

▌smartLifecycle回調執行的流程和時機

smartLifecycle的Bean 的stop 方法什麼時候被執行呢?

上文提到JVM鈎子方法被調用後,會執行 doColse()方法,

而這個 doColse()方法, 在關閉容器之前,會通過 lifecycleProcessor 調用 lifecycle 的方法。

protected void doClose() {
   if (this.active.get() && this.closed.compareAndSet(false, true)) {
      LiveBeansView.unregisterApplicationContext(this);
      // 釋出 ContextClosedEvent 事件
      publishEvent(new ContextClosedEvent(this));
      // 回調所有實作Lifecycle 接口的Bean的stop方法
      if (this.lifecycleProcessor != null) {
            this.lifecycleProcessor.onClose();
      }
      // 銷毀bean, 關閉容器
      destroyBeans();
      closeBeanFactory();
      onClose();
      if (this.earlyApplicationListeners != null) {
         this.applicationListeners.clear();
         this.applicationListeners.addAll(this.earlyApplicationListeners);
      }
      // Switch to inactive.
      this.active.set(false);
   }
}           

關閉 Lifecycle Bean 的入口:

org.springframework.context.support.DefaultLifecycleProcessor           

具體的代碼如下:

public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactoryAware {

  @Override
  public void onClose() {
    stopBeans();
    this.running = false;
  }

  private void stopBeans() {
    //擷取所有的 Lifecycle bean
    Map<String, Lifecycle> lifecycleBeans = getLifecycleBeans();
    //按Phase值對bean分組, 如果沒有實作 Phased 接口則認為 Phase 是 0
    Map<Integer, LifecycleGroup> phases = new HashMap<>();
    lifecycleBeans.forEach((beanName, bean) -> {
      int shutdownPhase = getPhase(bean);
    LifecycleGroup group = phases.get(shutdownPhase);
    if (group == null) {
      group = new LifecycleGroup(shutdownPhase, this.timeoutPerShutdownPhase, lifecycleBeans, false);
      phases.put(shutdownPhase, group);
    }
    group.add(beanName, bean);
  });

if (!phases.isEmpty()) {
  List<Integer> keys = new ArrayList<>(phases.keySet());
  //按照 Phase 值倒序
  keys.sort(Collections.reverseOrder());
  // Phase值越大優先級越高,先執行
  for (Integer key : keys) {
    phases.get(key).stop();
  }
}
}           

DefaultLifecycleProcessor 的 stop 方法執行流程:

  • 擷取容器中的所有實作了 Lifecycle 接口的 Bean。 由于 smartLifecycle 接口繼承了 Lifecycle, 在這裡被擷取到了。
  • 再對包含所有 bean 的 List 分組按 phase 值倒序排序,值大的排前面。 (沒有實作 Phased 接口, Phase 預設為0)
  • 依次調用各分組的裡 bean 的 stop 方法 ( Phase 越大 stop 方法優先執行)

stop 方法的實作,在這裡就終于被執行了。

完成 tomcat的優雅退出行為,執行完之前接收到的請求,實作優雅退出。

▌SpringBoot 優雅停機的執行流程總結:

  • SpringBoot 通過 Shutdown Hook 來注冊 doclose() 回調方法,在應用關閉的時候觸發執行。
  • SpringBoot 在建立 webserver的時候,會注冊實作 smartLifecycel 接口的 bean,用來優雅關閉 tomcat
  • doClose()在銷毀 bean, 關閉容器之前會執行所有實作 Lifecycel 接口 bean 的 stop方法,并且會按 Phase 值分組, phase 大的優先執行。
  • WebServerGracefulShutdownLifecycle,Phase=Inter.MAX_VALUE,處于最優先執行序列,是以會先觸發優雅關閉 tomcat ,并且tomcat 關閉方法是異步執行的,主線會繼續調用執行本組其他 bean 的關閉方法,然後等待所有 bean 關閉完畢,超過等待時間,會執行下一組 Lifecycle bean 的關閉。

▌SpringBoot應用的優雅停機如何觸發

通過源碼分析,大家也發現了,SpringBoot應用的優雅停機,是注冊了 JVM 優雅退出的鈎子方法

JVM 優雅退出的鈎子方法如何觸發的呢?

常見的觸發方式有:

  • 方式一:kill PID
  • 方式二:shutdown端點

▌方式一:kill PID

使用方式:kill java程序ID

kill指令的格式是 kill -Signal pid,其中 pid 就是程序的編号,signal是發送給程序的信号,預設參數下,kill 發送 SIGTERM(15)信号給程序,告訴程序,你需要被關閉,請自行停止運作并退出。

kill、kill -9、kill -3的差別

kill 會預設傳15代表的信号為SIGTERM,這是告訴程序你需要被關閉,請自行停止運作并退出,程序可以清理緩存自行結束,也可以拒絕結束。

kill -9代表的信号是SIGKILL,表示程序被終止,需要立即退出,強制殺死該程序,這個信号不能被捕獲也不能被忽略。

kill -3可以列印程序各個線程的堆棧資訊,kill -3 pid 後檔案的儲存路徑為:/proc/${pid}/cwd,檔案名為:antBuilderOutput.log

其他的Kill信号清單如下:

信号 取值 預設動作 含義(發出信号的原因)
SIGHUP 1 Term 終端的挂斷或程序死亡
SIGINT 2 Term 來自鍵盤的中斷信号
SIGQUIT 3 Core 來自鍵盤的離開信号
SIGILL 4 Core 非法指令
SIGABRT 6 Core 來自abort的異常信号
SIGFPE 8 Core 浮點例外
SIGKILL 9 Term 殺死
SIGSEGV 11 Core 段非法錯誤(記憶體引用無效)
SIGPIPE 13 Term 管道損壞:向一個沒有讀程序的管道寫資料
SIGALRM 14 Term 來自alarm的計時器到時信号
SIGTERM 15 Term 終止
SIGUSR1 30,10,16 Term 使用者自定義信号1
SIGUSR2 31,12,17 Term 使用者自定義信号2
SIGCHLD 20,17,18 Ign 子程序停止或終止
SIGCONT 19,18,25 Cont 如果停止,繼續執行
SIGSTOP 17,19,23 Stop 非來自終端的停止信号
SIGTSTP 18,20,24 Stop 來自終端的停止信号
SIGTTIN 21,21,26 Stop 背景程序讀終端
SIGTTOU 22,22,27 Stop 背景程序寫終端
SIGBUS 10,7,10 Core 總線錯誤(記憶體通路錯誤)
SIGPOLL Term Pollable事件發生(Sys V),與SIGIO同義
SIGPROF 27,27,29 Term 統計分布圖用計時器到時
SIGSYS 12,-,12 Core 非法系統調用(SVr4)
SIGTRAP 5 Core 跟蹤/斷點自陷
SIGURG 16,23,21 Ign socket緊急信号(4.2BSD)
SIGVTALRM 26,26,28 Term 虛拟計時器到時(4.2BSD)
SIGXCPU 24,24,30 Core 超過CPU時限(4.2BSD)
SIGXFSZ 25,25,31 Core 超過檔案長度限制(4.2BSD)
SIGIOT 6 Core IOT自陷,與SIGABRT同義
SIGEMT 7,-,7 Term
SIGSTKFLT -,16,- Term 協處理器堆棧錯誤(不使用)
SIGIO 23,29,22 Term 描述符上可以進行I/O操作
SIGCLD -,-,18 Ign 與SIGCHLD同義
SIGPWR 29,30,19 Term 電力故障(System V)
SIGINFO 29,-,- 與SIGPWR同義
SIGLOST -,-,- Term 檔案鎖丢失
SIGWINCH 28,28,20 Ign 視窗大小改變(4.3BSD, Sun)
SIGUNUSED -,31,- Term 未使用信号(will be SIGSYS)

▌方式二:shutdown端點

Spring Boot 提供了/shutdown端點,可以借助它實作優雅停機。

使用方式:在想下線應用的application.yml中添加如下配置,進而啟用并暴露/shutdown端點:

management:
  endpoint:
    shutdown:
      enabled: true
  endpoints:
    web:
      exposure:
        include: shutdown           

發送 POST 請求到/shutdown端點

curl -X http://ip:port/actuator/shutdown           

該方式本質和方式一是一樣的,也是借助 Spring Boot 應用的 Shutdown hook 去實作的。

▌shutdown端點的源碼分析

actuator 都使用了SPI的擴充方式,

先看下AutoConfiguration,可以看到關鍵點就是ShutdownEndpoint

@Configuration(
    proxyBeanMethods = false
)
@ConditionalOnAvailableEndpoint(
    endpoint = ShutdownEndpoint.class
)
public class ShutdownEndpointAutoConfiguration {
    public ShutdownEndpointAutoConfiguration() {
    }

    @Bean(
        destroyMethod = ""
    )
    @ConditionalOnMissingBean
    public ShutdownEndpoint shutdownEndpoint() {
        return new ShutdownEndpoint();
    }
}           

ShutdownEndpoint,的核心代碼如下:

@Endpoint(
    id = "shutdown",
    enableByDefault = false
)
public class ShutdownEndpoint implements ApplicationContextAware {
     
    @WriteOperation
    public Map<String, String> shutdown() {
        if (this.context == null) {
            return NO_CONTEXT_MESSAGE;
        } else {
            boolean var6 = false;

            Map var1;
            try {
                var6 = true;
                var1 = SHUTDOWN_MESSAGE;
                var6 = false;
            } finally {
                if (var6) {
                    Thread thread = new Thread(this::performShutdown);
                    thread.setContextClassLoader(this.getClass().getClassLoader());
                    thread.start();
                }
            }

            Thread thread = new Thread(this::performShutdown);
            thread.setContextClassLoader(this.getClass().getClassLoader());
            thread.start();
            return var1;
        }
    }
  
      private void performShutdown() {
        try {
            Thread.sleep(500L);
        } catch (InterruptedException var2) {
            Thread.currentThread().interrupt();
        }

        this.context.close();  //這裡才是核心
    }
}           

在調用了 this.context.close() ,其實就是AbstractApplicationContext 的close() 方法 (重點是其中的doClose())

/**
 * Close this application context, destroying all beans in its bean factory.
 * <p>Delegates to {@code doClose()} for the actual closing procedure.
 * Also removes a JVM shutdown hook, if registered, as it's not needed anymore.
 * @see #doClose()
 * @see #registerShutdownHook()
 */
@Override
public void close() {
    synchronized (this.startupShutdownMonitor) {
        doClose(); //重點:銷毀bean 并執行jvm shutdown hook
        // If we registered a JVM shutdown hook, we don't need it anymore now:
        // We've already explicitly closed the context.
        if (this.shutdownHook != null) {
            try {
                Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
            }
            catch (IllegalStateException ex) {
                // ignore - VM is already shutting down
            }
        }
    }
}           

doClose() 方法,又回到了前面的 Spring 的核心關閉方法。

doClose() 在銷毀 bean, 關閉容器之前會執行所有實作 Lifecycel 接口 bean 的 stop方法,并且會按 Phase 值分組, phase 大的優先執行。

▌SpringCloud + SpringBoot優雅退出總結

Spring Boot、Spring Cloud應用的優雅停機,平時經常會被問到,這也是實際應用過程中,必須要掌握的點,

這裡簡單總結下以前我們一般在實作的時候要把握的幾個要點:

  1. 關閉指令方面,一定要杜絕 kill -9 操作
  2. 多線程采用線程池實作,并且線程池要優雅關閉,進而保證每個異步線程都可以随Spring的生命周期完成正常關閉操作
  3. 有服務注冊與發現機制下的時候,優先通過自定義K8S的POD的preStop鈎子接口,來保障執行個體的主動下線。

▌說在最後:

SpringCloud + SpringBoot 面試題,是非常常見的面試題。

以上的5大方案,如果大家能對答如流,如數家珍,基本上 面試官會被你 震驚到、吸引到。

最終,讓面試官愛到 “不能自已、口水直流”。 offer, 也就來了。

學習過程中,如果有啥問題,大家可以來 找 40歲老架構師尼恩溝通。

▌技術自由的實作路徑 PDF 擷取:

▌實作你的架構自由:

  • 《吃透8圖1模闆,人人可以做架構》PDF
  • 《10Wqps評論中台,如何架構?B站是這麼做的!!!》PDF
  • 《阿裡二面:千萬級、億級資料,如何性能優化? 教科書級 答案來了》PDF
  • 《峰值21WQps、億級DAU,小遊戲《羊了個羊》是怎麼架構的?》PDF
  • 《100億級訂單怎麼排程,來一個大廠的極品方案》PDF
  • 《2個大廠 100億級 超大流量 紅包 架構方案》PDF

… 更多架構文章,正在添加中

▌實作你的 響應式 自由:

  • 《響應式聖經:10W字,實作Spring響應式程式設計自由》PDF
  • 這是老版本 《Flux、Mono、Reactor 實戰(史上最全)》PDF

▌實作你的 spring cloud 自由:

  • 《Spring cloud Alibaba 學習聖經》 PDF
  • 《分庫分表 Sharding-JDBC 底層原理、核心實戰(史上最全)》PDF
  • 《一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之間混亂關系(史上最全)》PDF

▌實作你的 linux 自由:

  • 《Linux指令大全:2W多字,一次實作Linux自由》PDF

▌實作你的 網絡 自由:

  • 《TCP協定詳解 (史上最全)》PDF
  • 《網絡三張表:ARP表, MAC表, 路由表,實作你的網絡自由!!》PDF

▌實作你的 分布式鎖 自由:

  • 《Redis分布式鎖(圖解 - 秒懂 - 史上最全)》PDF
  • 《Zookeeper 分布式鎖 - 圖解 - 秒懂》PDF

▌實作你的 王者元件 自由:

  • 《隊列之王: Disruptor 原理、架構、源碼 一文穿透》PDF
  • 《緩存之王:Caffeine 源碼、架構、原理(史上最全,10W字 超級長文)》PDF
  • 《緩存之王:Caffeine 的使用(史上最全)》PDF
  • 《Java Agent 探針、位元組碼增強 ByteBuddy(史上最全)》PDF

▌實作你的 面試題 自由:

4000頁《尼恩Java面試寶典》PDF 40個專題

....

注:以上尼恩 架構筆記、面試題 的PDF檔案,請到【技術自由圈】公号擷取

還需要啥自由,可以告訴尼恩。 尼恩幫你實作.......

美團太細了:Springcloud微服務優雅停機,如何實作?