Java 技術棧中介軟體優雅停機方案設計與實現全景圖

2022-07-19 12:00:19

歡迎關注公眾號:bin的技術小屋,閱讀公眾號原文

本系列 Netty 原始碼解析文章基於 4.1.56.Final 版本

本文概要

在上篇文章 我為 Netty 貢獻原始碼 | 且看 Netty 如何應對 TCP 連線的正常關閉,異常關閉,半關閉場景 中筆者為大家詳細介紹了 Netty 在處理連線關閉時的完整過程,並詳細介紹了 Netty 如何應對 TCP 連線在關閉時會遇到的各種場景。

在連線關閉之後,接下來就輪到 Netty 的謝幕時刻了,本文筆者會為大家詳盡 Java 技術棧中介軟體中關於優雅停機方案的詳細設計和實現。

筆者會從日常開發工作中常見的版本釋出,服務上下線的場景聊起,引出服務優雅啟停的需求,並從這個需求出發,一步一步帶大家探究各個中介軟體裡的優雅停機的相關設計。

熟悉筆者文風的讀者朋友應該知道,筆者肯定不會只是簡單的介紹,要麼不講,要講就要把整個技術體系的前世今生給大家講清楚,講明白。

基於這目的,筆者會先從支援優雅停機的底層技術基石--核心中的號誌開始聊起。

從核心層我們接著會聊到 JVM 層,在 JVM 層一探優雅停機底層的技術玄機。

隨後我們會從 JVM 層一路奔襲到 Spring 然後到 Dubbo。在這個過程中,筆者還會帶大家一起 Shooting Dubbo 在優雅停機下的一個 Bug,併為大家詳細介紹修復過程。

最後由 Dubbo 層的優雅停機,引出我們的主角--Netty 優雅停機的設計與實現:

下面我們來正式開始本文的內容~~

1. Java 程序的優雅啟停

在我們的日常開發工作中,業務需求的迭代和優化伴隨圍繞著我們整個開發週期,當我們加班加點完成了業務需求的開發,然後又歷經各種艱難險阻通過了測試的驗證,最後經過和產品經理的各種糾纏相愛相殺之後,終於到了最最激動人心的時刻程式要部署上線了。

那麼在程式部署上線的過程中勢必會涉及到線上服務的關閉和重啟,關於對線上服務的啟停這裡面有很多的講究,萬萬不能簡單粗暴的進行關閉和重啟,因為此時線上服務可能承載著生產的流量,可能正在進行重要的業務處理流程。

比如:使用者正在購買商品,錢已經付了,恰好這時趕上程式上線,如果我們這時簡單粗暴的對服務進行關閉,重啟,可能就會導致使用者付了錢,但是訂單未建立或者商品未出現在使用者的購物清單中,給使用者造成了實質的損失,這是非常嚴重的後果。

為了保證能在程式上線的過程中做到業務無失真,所以線上服務的優雅關閉優雅啟動顯得就非常非常重要了。

1.1 優雅啟動

在 Java 程式的執行過程中,程式的執行速度一般會隨著程式的執行慢慢的提高,所以從線上表現上來看 Java 程式在執行一段時間後往往會比程式剛啟動的時候會快很多。

這是因為 Java 程式在執行過程中,JVM 會不斷收集到程式執行時的動態資料,這樣可以將高頻執行程式碼通過即時編譯成機器碼,隨後程式執行就直接執行機器碼,執行速度完全不輸 C 或者 C++ 程式。

同時在程式執行過程中,用到的類會被載入到 JVM 中快取,這樣當程式再次使用到的時候不會觸發臨時載入,影響程式執行效能。

我們可以將以上幾點當做 JVM 帶給我們的效能紅利,而當應用程式重新啟動之後,這些效能紅利也就消失了,如果我們讓新啟動的程式繼續承擔之前的流量規模,那麼就會導致程式在剛啟動的時候在沒有這些效能紅利的加持下直接進入高負荷的運轉狀態,這就可能導致線上請求大面積超時,對業務造成影響。

所以說優雅地啟動一個程式是非常重要的,優雅啟動的核心思想就是讓程式在剛啟動的時候不要承擔太大的流量,讓程式在低負荷的狀態下執行一段時間,使其提升到最佳的執行狀態時,在逐步的讓程式承擔更大的流量處理。

下面我們就來看下常用於優雅啟動場景的兩個技術方案:

1.1.1 啟動預熱

啟動預熱就是讓剛剛上線的應用程式不要一下就承擔之前的全部流量,而是在一個時間視窗內慢慢的將流量打到剛上線的應用程式上,目的是讓 JVM 先緩慢的收集程式執行時的一些動態資料,將高頻程式碼即時編譯為機器碼。

這個技術方案在眾多 RPC 框架的實現中我們都可以看到,服務呼叫方會從註冊中心拿到所有服務提供方的地址,然後從這些地址中通過特定的負載均衡演演算法從中選取一個服務提供方的傳送請求。

為了能夠使剛剛上線的服務提供方有時間去預熱,所以我們就要從源頭上控制服務呼叫方傳送的流量,服務呼叫方在發起 RPC 呼叫時應該儘量少的去負載均衡到剛剛啟動的服務提供方範例。

那麼服務呼叫方如何才能判斷哪些是剛剛啟動的服務提供方範例呢?

服務提供方在啟動成功後會向註冊中心註冊自己的服務資訊,我們可以將服務提供方的真實啟動時間包含在服務資訊中一起向註冊中心註冊,這樣註冊中心就會通知服務呼叫方有新的服務提供方範例上線並告知其啟動時間。

服務呼叫方可以根據這個啟動時間,慢慢的將負載權重增加到這個剛啟動的服務提供方範例上。這樣就可以解決服務提供方冷啟動的問題,呼叫方通過在一個時間視窗內將請求慢慢的打到提供方範例上,這樣就可以讓剛剛啟動的提供方範例有時間去預熱,達到平滑上線的效果。

1.1.2 延遲暴露

啟動預熱更多的是從服務呼叫方的角度通過降低剛剛啟動的服務提供方範例的負載均衡權重來實現優雅啟動。

而延遲暴露則是從服務提供方的角度,延遲暴露服務時間,利用延遲的這段時間,服務提供方可以預先載入依賴的一些資源,比如:快取資料,spring 容器中的 bean 。等到這些資源全部載入完畢就位之後,我們在將服務提供方範例暴露出去。這樣可以有效降低啟動前期請求處理出錯的概率。

比如我們可以在 dubbo 應用中可以設定服務的延遲暴露時間:

//延遲5秒暴露服務
<dubbo:service delay="5000" /> 

1.2 優雅關閉

優雅關閉需要考慮的問題和處理的場景要比優雅啟動要複雜的多,因為一個正常線上上執行的服務程式正在承擔著生產的流量,同時也正在進行業務流程的處理。

要對這樣的一個服務程式進行優雅關閉保證業務無失真還是非常有挑戰的,一個好的關閉流程,可以確保我們業務實現平滑的上下線,避免上線之後增加很多不必要的額外運維工作。

下面我們就來討論下具體應該從哪幾個角度著手考慮實現優雅關閉:

1.2.1 切走流量

第一步肯定是要將程式承擔的現有流量全部切走,告訴服務呼叫方,我要進行關閉了,請不要在給我傳送請求。那麼如果進行切流呢??

在 RPC 的場景中,服務呼叫方通過服務發現的方式從註冊中心中動態感知服務提供者的上下線變化。在服務提供方關閉之前,首先就要自己從註冊中心中取消註冊,隨後註冊中心會通知服務呼叫方,有服務提供者範例下線,請將其從本地快取列表中剔除。這樣就可以使得服務呼叫方之後的 RPC 呼叫不在請求到下線的服務提供方範例上。

但是這裡會有一個問題,就是通常我們的註冊中心都是 AP 型別的,它只會保證最終一致性,並不會保證實時一致性,基於這個原因,服務呼叫方感知到服務提供者下線的事件可能是延後的,那麼在這個延遲時間內,服務呼叫方極有可能會向正在下線的服務發起 RPC 請求。

因為服務提供方已經開始進入關閉流程,那麼很多物件在這時可能已經被銷燬了,這時如果在收到請求過來,肯定是無法處理的,甚至可能還會丟擲一個莫名其妙的異常出來,對業務造成一定的影響。

那麼既然這個問題是由於註冊中心可能存在的延遲通知引起的,那麼我們就很自然的想到了讓準備下線的服務提供方主動去通知它的服務呼叫方。

這種服務提供方主動通知在加上註冊中心被動通知的兩個方案結合在一起應該就能確保萬無一失了吧。

事實上,在大部分場景下這個方案是可行的,但是還有一種極端的情況需要應對,就是當服務提供方通知呼叫方自己下線的網路請求在到達服務呼叫方之前的很極限的一個時間內,服務呼叫者向正在下線的服務提供方發起了 RPC 請求,這種極端的情況,就需要服務提供方和呼叫方一起配合來應對了。

首先服務提供方在準備關閉的時候,就把自己設定為正在關閉狀態,在這個狀態下不會接受任何請求,如果這時遇到了上邊這種極端情況下的請求,那麼就丟擲一個 CloseException (這個異常是提供方和呼叫方提前約定好的),呼叫方收到這個 CloseException ,則將該服務提供方的節點剔除,並從剩餘節點中通過負載均衡選取一個節點進行重試,通過讓這個請求快速失敗從而保證業務無失真。

這三種方案結合在一起,筆者認為就是一個比較完美的切流方案了。

1.2.2 儘量保證業務無失真

當把流量全部切走後,可能此時將要關閉的服務程式中還有正在處理的部分業務請求,那麼我們就必須得等到這些業務處理請求全部處理完畢,並將業務結果響應給使用者端後,在對服務進行關閉。

當然為了保證關閉流程的可控,我們需要引入關閉超時時間限制,當剩下的業務請求處理超時,那麼就強制關閉。

為了保證關閉流程的可控,我們只能做到儘可能的保證業務無失真而不是百分之百保證。所以在程式上線之後,我們應該對業務異常資料進行監控並及時修復。


通過以上介紹的優雅關閉方案我們知道,當我們將要優雅關閉一個應用程式時,我們需要做好以下兩項工作:

  1. 我們首先要做的就是將當前將要關閉的應用程式上承載的生產流量全部切走,保證不會有新的流量打到將要關閉的應用程式範例上。

  2. 當所有的生產流量切走之後,我們還需要保證當前將要關閉的應用程式範例正在處理的業務請求要使其處理完畢,並將業務處理結果響應給使用者端。以保證業務無失真。當然為了使關閉流程變得可控,我們需要引入關閉超時時間。

以上兩項工作就是我們在應用程式將要被關閉時需要做的,那麼問題是我們如何才能知道應用程式要被關閉呢?換句話說,我們在應用程式裡怎麼才能感知到程式程序的關閉事件從而觸發上述兩項優雅關閉的操作執行呢?

既然我們有這樣的需求,那麼作業系統核心肯定會給我們提供這樣的機制,事實上我們可以通過捕獲作業系統給程序傳送的訊號來獲取關閉程序通知,並在相應訊號回撥中觸發優雅關閉的操作。

接下來讓我們來看一下作業系統核心提供的訊號機制:

2. 核心訊號機制

訊號是作業系統核心為我們提供用於在程序間通訊的機制,核心可以利用訊號來通知程序,當前系統所發生的的事件(包括關閉程序事件)。

訊號在核心中並沒有用特別複雜的資料結構來表示,只是用一個代號一樣的數位來標識不同的訊號。Linux 提供了幾十種訊號,分別代表不同的意義。訊號之間依靠它們的值來區分

訊號可以在任何時候傳送給程序,程序需要為這個訊號設定訊號處理常式。當某個訊號發生的時候,就預設執行對應的訊號處理常式就可以了。這就相當於一個作業系統的應急手冊,事先定義好遇到什麼情況,做什麼事情,提前準備好,出了事情照著做就可以了。

核心發出的訊號就代表當前系統遇到了某種情況,我們需要應對的步驟就封裝在對應訊號的回撥函數中。

訊號機制引入的目的就在於:

  • 讓應用程序知道當前已經發生了某個特定的事件(比如程序的關閉事件)。

  • 強制程序執行我們事先設定好的訊號處理常式(比如封裝優雅關閉邏輯)。

通常來說程式一旦啟動就會一直執行下去,除非遇到 OOM 或者我們需要重新發布程式時會在運維指令碼中呼叫 kill 命令關閉程式。Kill 命令從字面意思上來說是殺死程序,但是其本質是向程序傳送訊號,從而關閉程序。

下面我們使用 kill -l 命令檢視下 kill 命令可以向程序傳送哪些訊號:

# kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

筆者這裡提取幾個常見的訊號來簡要說明下:

  • SIGINT:訊號代號為 2 。比如我們在終端以非後臺模式執行一個程序範例時,要想關閉它,我們可以通過 Ctrl+C 來關閉這個前臺程式。這個 Ctrl+C 向程序傳送的正是 SIGINT 訊號。

  • SIGQUIT:訊號代號為 3 。比如我們使用 Ctrl+\ 來關閉一個前臺程序,此時會向程序傳送 SIGQUIT 訊號,與 SIGINT 訊號不同的是,通過 SIGQUIT 訊號終止的程序會在退出時,通過 Core Dump 將當前程序的執行狀態儲存在 core dump 檔案裡面,方便後續檢視。

  • SIGKILL:訊號代號為 9 。通過 kill -9 pid 命令結束程序是非常非常危險的動作,我們應該堅決制止這種關閉程序的行為,因為 SIGKILL 訊號是不能被程序捕獲和忽略的,只能執行核心定義的預設操作直接關閉程序。而我們的優雅關閉操作是需要通過捕獲作業系統訊號,從而可以在對應的訊號處理常式中執行優雅關閉的動作。由於 SIGKILL 訊號不能被捕獲,所以優雅關閉也就無法實現。現在大家就趕快檢查下自己公司生產環境的運維指令碼是否是通過 kill -9 pid 命令來結束程序的,一定要避免用這種方式,因為這種方式是極其無情並且略帶殘忍的關閉程序行為。

  • SIGSTOP :訊號代號為 19 。該訊號和 SIGKILL 訊號一樣都是無法被應用程式忽略和捕獲的。向程序傳送 SIGSTOP 訊號也是無法實現優雅關閉的。 通過 Ctrl+Z 來關閉一個前臺程序,傳送的訊號就是 SIGSTOP 訊號。

  • SIGTERM:訊號代號為 15 。我們通常會使用 kill 命令來關閉一個後臺執行的程序,kill 命令傳送的預設訊號就是 SIGTERM ,該訊號也是本文要討論的優雅關閉的基礎,我們通常會使用 kill pid 或者 kill -15 pid 來向後臺程序傳送 SIGTERM 訊號用以實現程序的優雅關閉。大家如果發現自己公司生產環境的運維指令碼中使用的是 kill -9 pid 命令來結束程序,那麼就要馬上換成 kill pid 命令。

以上列舉的都是我們常用的一些訊號,大家也可以通過 man 7 signal 命令檢視每種訊號對應的含義:

Signal     Value     Action   Comment
──────────────────────────────────────────────────────────────────────
SIGHUP        1       Term    Hangup detected on controlling terminal
                              or death of controlling process
SIGINT        2       Term    Interrupt from keyboard
SIGQUIT       3       Core    Quit from keyboard
SIGILL        4       Core    Illegal Instruction


SIGABRT       6       Core    Abort signal from abort(3)
SIGFPE        8       Core    Floating point exception
SIGKILL       9       Term    Kill signal
SIGSEGV      11       Core    Invalid memory reference
SIGPIPE      13       Term    Broken pipe: write to pipe with no
                              readers
SIGALRM      14       Term    Timer signal from alarm(2)
SIGTERM      15       Term    Termination signal
SIGUSR1   30,10,16    Term    User-defined signal 1
SIGUSR2   31,12,17    Term    User-defined signal 2
……

而應用程序對於訊號的處理一般分為以下三種方式:

  • 核心定義的預設操作: 系統核心對每種訊號都規定了預設操作,比如上面列表 Action 列中的 Term ,就是終止程序的意思。前邊介紹的 SIGINT 訊號和 SIGTERM 訊號的預設操作就是 Term 。Core 的意思是 Core Dump ,即終止程序後會通過 Core Dump 將當前程序的執行狀態儲存在檔案裡面,方便我們事後進行分析問題在哪裡。前邊介紹的 SIGQUIT 訊號預設操作就是 Core 。

  • 捕獲訊號:應用程式可以利用核心提供的系統呼叫來捕獲訊號,並將優雅關閉的步驟封裝在對應訊號的處理常式中。當向程序傳送關閉訊號 SIGTERM 的時候,在程序內我們可以通過捕獲 SIGTERM 訊號,隨即就會執行我們自定義的訊號處理常式。我們從而可以在訊號處理常式中執行程序優雅關閉的邏輯。

  • 忽略訊號:當我們不希望處理某些訊號的時候,就可以忽略該訊號,不做任何處理,但是前邊介紹的 SIGKILL 訊號和 SIGSTOP 是無法被捕獲和忽略的,核心會直接執行這兩個訊號定義的預設操作直接關閉程序。

當我們不希望訊號執行核心定義的預設操作時,我們就需要在程序內捕獲訊號,並註冊訊號的回撥函數來執行我們自定義的訊號處理邏輯。

比如我們在本文中要討論的優雅關閉場景,當程序接收到 SIGTERM 訊號時,為了實現程序的優雅關閉,我們並不希望程序執行 SIGTERM 訊號的預設操作直接關閉程序,所以我們要在程序中捕獲 SIGTERM 訊號,並將優雅關閉的操作步驟封裝在對應的訊號處理常式中。

2.1 如何捕獲訊號

在介紹完了核心訊號的分類以及程序對於訊號處理的三種方式之後,下面我們來看下如何來捕獲核心訊號,並在對應訊號回撥函數中自定義我們的處理邏輯。

核心提供了 sigaction 系統呼叫,來供我們捕獲訊號以及與相應的訊號處理常式繫結起來。

int sigaction(int signum, const struct sigaction *act,
                     struct sigaction *oldact);
  • int signum:表示我們想要在程序中捕獲的訊號,比如本文中我們要實現優雅關閉就需要在程序中捕獲 SIGTERM 訊號,對應的 signum = 15 。

  • struct sigaction *act:核心中會用一個 sigaction 結構體來封裝我們自定義的訊號處理邏輯。

  • struct sigaction *oldact:這裡是為了相容老的訊號處理常式,瞭解一下就可以了,和本文主線無關。

sigaction 結構體用來封裝訊號對應的處理常式,以及更加精細化控制訊號處理的資訊。

struct sigaction {
  __sighandler_t sa_handler;
  unsigned long sa_flags;
        .......
  sigset_t sa_mask; 
};
  • __sighandler_t sa_handler:其實本質上是一個函數指標,用來儲存我們為訊號註冊的訊號處理常式,優雅關閉的邏輯就封裝在這裡

  • long sa_flags:為了更加精細化的控制訊號處理邏輯,這個欄位儲存了一些控制訊號處理行為的選項集合。常見的選項有:

    • SA_ONESHOT:意思是我們註冊的訊號處理常式,僅僅只起一次作用。響應完一次後,就設定回預設行為。

    • SA_NOMASK:表示訊號處理常式在執行的過程中會被中斷。比如我們程序捕獲到一個感興趣的訊號,隨後會執行註冊的訊號處理常式,但是此時程序又收到其他的訊號或者和上次相同的訊號,此時正在執行的訊號處理常式會被中斷,從而轉去執行最新到來的訊號處理常式。如果連續產生多個相同的訊號,那麼我們的訊號處理常式就要做好同步,冪等等措施

    • SA_INTERRUPT:當程序正在執行一個非常耗時的系統呼叫時,如果此時程序接收到了訊號,那麼這個系統呼叫將會被訊號中斷,程序轉去執行相應的訊號處理常式。那麼當訊號處理常式執行完時,如果這裡設定了 SA_INTERRUPT ,那麼系統呼叫將不會繼續執行並且會返回一個 -EINTR 常數,告訴呼叫方,這個系統呼叫被訊號中斷了,怎麼處理你看著辦吧。

    • SA_RESTART:當系統呼叫被訊號中斷後,相應的訊號處理常式執行完畢後,如果這裡設定了 SA_RESTART 系統呼叫將會被自動重新啟動。

  • sigset_t sa_mask:這個欄位主要指定在訊號處理常式正在執行的過程中,如果連續產生多個訊號,需要遮蔽哪些訊號。也就是說當程序收到遮蔽的訊號時,正在進行的訊號處理常式不會被中斷。

遮蔽並不意味著訊號一定丟失,而是暫存,這樣可以使相同訊號的處理常式,在程序連續接收到多個相同的訊號時,可以一個一個的處理。

最終通過 sigaction 函數會呼叫到底層的系統呼叫 rt_sigaction 函數,在
rt_sigaction 中會將上邊介紹的使用者態 struct sigaction 結構拷貝為核心態的
k_sigaction ,然後呼叫 do_sigaction 函數。

最後在 do_sigaction 函數中將使用者要在程序中捕獲的訊號以及相應的訊號處理常式設定到程序描述符 task_struct 結構裡。

程序在核心中的資料結構 task_struct 中有一個 struct sighand_struct 結構的屬性 sighand ,struct sighand_struct 結構中包含一個 k_sigaction 型別的陣列 action[] ,這個陣列儲存的就是程序中需要捕獲的訊號以及對應的訊號處理常式在核心中的結構體 k_sigaction ,陣列下標為程序需要捕獲的訊號。

#include <signal.h>

static void sig_handler(int signum) {

    if (signum == SIGTERM) {

        .....執行優雅關閉邏輯....

    }

}

int main (Void) {

    struct sigaction sa_usr; //定義sigaction結構體
    sa_usr.sa_flags = 0;
    sa_usr.sa_handler = sig_handler;   //設定訊號處理常式

    sigaction(SIGTERM, &sa_usr, NULL);//程序捕獲訊號,註冊訊號處理常式
        
        ,,,,,,,,,,,,
}

我們可以通過如上簡單的範例程式碼,將 SIGTERM 訊號及其對應的自定義訊號處理常式註冊到程序中,當我們執行 kill -15 pid 命令之後,程序就會捕獲到 SIGTERM 訊號,隨後就可以執行優雅關閉步驟了。

3. JVM 中的 ShutdownHook

在《2. 核心訊號機制》小節中為大家介紹的內容是作業系統核心為我們實現程序的優雅關閉提供的最底層系統級別的支援機制,在核心的強力支援下,那麼本文的主題 Java 程序的優雅關閉就很容易實現了。

我們要想實現 Java 程序的優雅關閉功能,只需要在程序啟動的時候將優雅關閉的操作封裝在一個 Thread 中,隨後將這個 Thread 註冊到 JVM 的 ShutdownHook 中就好了,當 JVM 程序接收到 kill -15 訊號時,就會執行我們註冊的 ShutdownHook 關閉勾點,進而執行我們定義的優雅關閉步驟。

        Runtime.getRuntime().addShutdownHook(new Thread(){
            @Override
            public void run() {
                .....執行優雅關閉步驟.....
            }
        });

3.1 導致 JVM 退出的幾種情況

  1. JVM 程序中最後一個非守護執行緒退出。

  2. 在程式程式碼中主動呼叫 java.lang.System#exit(int status) 方法,會導致 JVM 程序的退出並觸發 ShutdownHook 的呼叫。引數 int status 如果是非零值,則表示本次關閉是在一個非正常情況下的關閉行為。比如:程序發生 OOM 異常或者其他執行時異常。

public static void main(String[] args) {
        try {

           ......程序啟動main函數.......

        } catch (RuntimeException e) {
            logger.error(e.getMessage(), e);
            // JVM 程序主動關閉觸發呼叫 shutdownHook
            System.exit(1);
        }
}
  1. 當 JVM 程序接收到第二小節《2.核心訊號機制》介紹的那些關閉訊號時, JVM 程序會被關閉。由於 SIGKILL 訊號和 SIGSTOP 訊號不能夠被程序捕獲和忽略,這兩個訊號會直接粗暴地關閉 JVM 程序,所以一般我們會傳送 SIGTERM 訊號,JVM 程序通過捕獲 SIGTERM 訊號,從而可以執行我們定義的 ShutdownHook 完成優雅關閉的操作。

  2. Native Method 執行過程中發生錯誤,比如試圖存取一個不存在的記憶體,這樣也會導致 JVM 強制關閉,ShutdownHook 也不會執行。

3.2 使用 ShutdownHook 的注意事項

  1. ShutdownHook 其實本質上是一個已經被初始化但是未啟動的 Thread ,這些通過 Runtime.getRuntime().addShutdownHook 方法註冊的 ShutdownHooks ,在 JVM 程序關閉的時候會被啟動並行執行,但是並不會保證執行順序

所以在編寫 ShutdownHook 中的邏輯時,我們應該確保程式的執行緒安全性,並儘可能避免死鎖。最好是一個 JVM 程序只註冊一個 ShutdownHook 。

  1. 如果我們通過 java.lang.Runtime#runFinalizersOnExit(boolean value) 開啟了 finalization-on-exit ,那麼當所有 ShutdownHook 執行完畢之後,JVM 在關閉之前將會繼續呼叫所有未被呼叫的 finalizers 方法。預設 finalization-on-exit 選項是關閉的。

注意:當 JVM 開始關閉並執行上述關閉操作的時候,守護執行緒是會繼續執行的,如果使用者使用 java.lang.System#exit(int status) 方法主動發起 JVM 關閉,那麼關閉期間非守護執行緒也是會繼續執行的。

  1. 一旦 JVM 程序開始關閉,一般情況下這個過程是不可以被中斷的,除非作業系統強制中斷或者使用者通過呼叫 java.lang.Runtime#halt(int status) 來強制關閉。
   public void halt(int status) {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            sm.checkExit(status);
        }
        Shutdown.halt(status);
    }

java.lang.Runtime#halt(int status) 方法是用來強制關閉正在執行的 JVM 程序的,它會導致我們註冊的 ShutdownHook 不會被執行和執行,如果此時 JVM 正在執行 ShutdownHook ,當呼叫該方法後,JVM 程序將會被強制關閉,並不會等待 ShutdownHook 執行完畢。

  1. 當 JVM 關閉流程開始的時候,就不能在向其註冊 ShutdownHook 或者取消註冊之前已經註冊好的 ShutdownHook 了,否則將會丟擲 IllegalStateException異常。

  2. ShutdownHook 中的程式應該儘快的完成優雅關閉邏輯,因為當用戶呼叫 System#exit 方法的時候是希望 JVM 在保證業務無失真的情況下儘快完成關閉動作。這裡並不適合做一些需要長時間執行的任務或者和使用者互動的操作。

如果是因為物理機關閉從而導致的 JVM 關閉,那麼作業系統只會允許 JVM 限定的時間內儘快的關閉,超過限定時間作業系統將會強制關閉 JVM 。

  1. ShutdownHook 中可能也會丟擲異常,而 ShutdownHook 對於 JVM 來說本質上是一個 Thread ,那麼對於 ShutdownHook 中未捕獲的異常,JVM 的處理方法和其他普通的執行緒一樣,都是通過呼叫 ThreadGroup#uncaughtException 方法來處理。此方法的預設實現是將異常的堆疊跟蹤列印到 System#err 並終止異常的 ShutdownHook 執行緒。

注意:這裡只會停止異常的 ShutdownHook ,但不會影響其他 ShutdownHook 執行緒的執行更不會導致 JVM 退出。

  1. 最後也是非常重要的一點是,當 JVM 程序接收到 SIGKILL 訊號和 SIGSTOP 訊號時,是會強制關閉,並不會執行 ShutdownHook 。另外一種導致 JVM 強制關閉的情況就是 Native Method 執行過程中發生錯誤,比如試圖存取一個不存在的記憶體,這樣也會導致 JVM 強制關閉,ShutdownHook 也不會執行。

3.3 ShutdownHook 執行原理

我們在 JVM 中通過 Runtime.getRuntime().addShutdownHook 新增關閉勾點,當 JVM 接收到 SIGTERM 訊號之後,就會呼叫我們註冊的這些 ShutdownHooks 。

本小節介紹的 ShutdownHook 就類似於我們在第二小節《核心訊號機制》中介紹的訊號處理常式。

大家這裡一定會有個疑問,那就是在介紹核心訊號機制小節中,我們可以通過系統呼叫 sigaction 函數向核心註冊程序要捕獲的訊號以及對應的訊號處理常式。

int sigaction(int signum, const struct sigaction *act,
                     struct sigaction *oldact);

但是在本小節介紹的 JVM 中,我們只是通過 Runtime.getRuntime().addShutdownHook 註冊了一個關閉勾點。但是並未註冊 JVM 程序所需要捕獲的訊號。那麼 JVM 是怎麼捕獲關閉訊號的呢?

        Runtime.getRuntime().addShutdownHook(new Thread(){
            @Override
            public void run() {
                .....執行優雅關閉步驟.....
            }
        });

事實上,JVM 捕獲作業系統訊號的部分在 JDK 中已經幫我們處理好了,在使用者層我們並不需要關注捕獲訊號的處理,只需要關注訊號的處理邏輯即可。

下面我們就來看一下 JDK 是如何幫助我們將要捕獲的訊號向核心註冊的?

當 JVM 第一個執行緒被初始化之後,隨後就會呼叫 System#initializeSystemClass 函數來初始化 JDK 中的一些系統類,其中就包括註冊 JVM 程序需要捕獲的訊號以及訊號處理常式。

public final class System {

    private static void initializeSystemClass() {

           .......省略.......

            // Setup Java signal handlers for HUP, TERM, and INT (where available).
           Terminator.setup();

           .......省略.......

    }

}

從這裡可以看出,JDK 在向 JVM 註冊需要捕獲的核心訊號是在 Terminator 類中進行的。


class Terminator {
    //訊號處理常式
    private static SignalHandler handler = null;

    static void setup() {
        if (handler != null) return;
        SignalHandler sh = new SignalHandler() {
            public void handle(Signal sig) {
                Shutdown.exit(sig.getNumber() + 0200);
            }
        };
        handler = sh;

        try {
            Signal.handle(new Signal("HUP"), sh);
        } catch (IllegalArgumentException e) {
        }
        try {
            Signal.handle(new Signal("INT"), sh);
        } catch (IllegalArgumentException e) {
        }
        try {
            Signal.handle(new Signal("TERM"), sh);
        } catch (IllegalArgumentException e) {
        }
    }

}

JDK 向我們提供了 sun.misc.Signal#handle(Signal signal, SignalHandler signalHandler) 函數來實現在 JVM 程序中對核心訊號的捕獲。底層依賴於我們在第二小節介紹的系統呼叫 sigaction 。

int sigaction(int signum, const struct sigaction *act,
                     struct sigaction *oldact);

sun.misc.Signal#handle 函數的引數含義和系統呼叫函數 sigaction 中的引數含義是一一對應的:

  • Signal signal:表示要捕獲的核心訊號。從這裡我們可以看出 JVM 主要捕獲三種訊號:SIGHUP(1),SIGINT(2),SIGTERM(15)。

除了上述的這三種訊號之外,JVM 如果接收到其他訊號,會執行系統核心預設的操作,直接關閉程序,並不會觸發 ShutdownHook 的執行。

  • SignalHandler handler:訊號響應函數。我們看到這裡直接呼叫了 Shutdown#exit 函數。
    SignalHandler sh = new SignalHandler() {
            public void handle(Signal sig) {
                Shutdown.exit(sig.getNumber() + 0200);
            }
        };

我們這裡應該很容易就會猜測出 ShutdownHook 的呼叫應該就是在 Shutdown#exit 函數中被觸發的。

class Shutdown {

    static void exit(int status) {

          ........省略.........

          synchronized (Shutdown.class) {
              // 開始 JVM 關閉流程,執行 ShutdownHooks
              sequence();
              // 強制關閉 JVM
              halt(status);
          }

    }

    private static void sequence() {
        synchronized (lock) {
            if (state != HOOKS) return;
        }
        //觸發 ShutdownHooks
        runHooks();
        boolean rfoe;
        synchronized (lock) {
            state = FINALIZERS;
            rfoe = runFinalizersOnExit;
        }
        //如果 runFinalizersOnExit = true
        //開始執行所有未被呼叫過的 Finalizers
        if (rfoe) runAllFinalizers();
    }
}

Shutdown#sequence 函數中的邏輯就是我們在《3.2 使用ShutdownHook的注意事項》小節中介紹的 JVM 關閉時的執行邏輯:在這裡會觸發所有 ShutdownHook 的並行執行。注意這裡並不會保證執行順序。

當所有 ShutdownHook 執行完畢之後,如果我們通過 java.lang.Runtime#runFinalizersOnExit(boolean value) 開啟了 finalization-on-exit 選項,JVM 在關閉之前將會繼續呼叫所有未被呼叫的 finalizers 方法。預設 finalization-on-exit 選項是關閉的。

3.4 ShutdownHook 的執行

如上圖所示,在 JDK 的 Shutdown 類中,包含了一個 Runnable[] hooks 陣列,容量為 10 。JDK 中的 ShutdownHook 是以型別來分類的,陣列 hooks 每一個槽中存放的是一種特定型別的 ShutdownHook 。

而我們通常在程式程式碼中通過 Runtime.getRuntime().addShutdownHook 註冊的是 Application hooks 型別的 ShutdownHook ,存放在陣列 hooks 中索引為 1 的槽中。

當在 Shutdown#sequence 中觸發 runHooks() 函數開始執行 JVM 中所有型別的 ShutdownHooks 時,會在 runHooks() 函數中依次遍歷陣列 hooks 中的 Runnable ,進而開始執行 Runnable 中封裝的 ShutdownHooks 。

當遍歷到陣列 Hooks 的第二個槽(索引為 1 )的時候,Application hooks 型別的 ShutdownHook 得以執行,也就是我們通過 Runtime.getRuntime().addShutdownHook 註冊的 ShutdownHook 在這個時候開始執行起來。


    // The system shutdown hooks are registered with a predefined slot.
    // The list of shutdown hooks is as follows:
    // (0) Console restore hook
    // (1) Application hooks
    // (2) DeleteOnExit hook
    private static final int MAX_SYSTEM_HOOKS = 10;
    private static final Runnable[] hooks = new Runnable[MAX_SYSTEM_HOOKS];

    /* Run all registered shutdown hooks
     */
    private static void runHooks() {
        for (int i=0; i < MAX_SYSTEM_HOOKS; i++) {
            try {
                Runnable hook;
                synchronized (lock) {
                    // acquire the lock to make sure the hook registered during
                    // shutdown is visible here.
                    currentRunningHook = i;
                    hook = hooks[i];
                }
                if (hook != null) hook.run();
            } catch(Throwable t) {
                if (t instanceof ThreadDeath) {
                    ThreadDeath td = (ThreadDeath)t;
                    throw td;
                }
            }
        }
    }

下面我們就來看一下,JDK 是如果通過 Runtime.getRuntime().addShutdownHook 函數將我們自定義的 ShutdownHook 註冊到 Shutdown 類中的陣列 Hooks 裡的。

3.5 ShutdownHook 的註冊

public class Runtime {

    public void addShutdownHook(Thread hook) {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            sm.checkPermission(new RuntimePermission("shutdownHooks"));
        }
        //注意 這裡註冊的是 Application 型別的 hooks
        ApplicationShutdownHooks.add(hook);
    }

}

從 JDK 原始碼中我們看到在 Runtime 類中的 addShutdownHook 方法裡,JDK 會將我們自定義的 ShutdownHook 封裝在 ApplicationShutdownHooks 類中,從這類的命名上看,它裡邊封裝的就是我們在上小節《3.4 ShutdownHook 的執行》提到的 Application hooks 型別的 ShutdownHook ,由使用者自定義實現。

class ApplicationShutdownHooks {
    // 存放使用者自定義的 Application 型別的 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);
    }

    static void runHooks() {
        Collection<Thread> threads;
        synchronized(ApplicationShutdownHooks.class) {
            threads = hooks.keySet();
            hooks = null;
        }
        // 順序啟動 shutdownhooks
        for (Thread hook : threads) {
            hook.start();
        }
        // 並行呼叫 shutdownhooks ,等待所有 hooks 執行完畢退出
        for (Thread hook : threads) {
            try {
                hook.join();
            } catch (InterruptedException x) { }
        }
    }
}

ApplicationShutdownHooks 類中也有一個集合 IdentityHashMap<Thread, Thread> hooks ,專門用來存放由使用者自定義的 Application hooks 型別的 ShutdownHook 。通過 ApplicationShutdownHooks#add 方法新增進 hooks 集合中。

然後在 runHooks 方法裡挨個啟動 ShutdownHook 執行緒,並行執行。注意這裡的 runHooks 方法是 ApplicationShutdownHooks 類中的

在 ApplicationShutdownHooks 類的靜態程式碼塊 static{.....} 中會將 runHooks 方法封裝成 Runnable 新增進 Shutdown 類中的 hooks 陣列中。注意這裡 Shutdown#add 方法傳遞進的索引是 1 。

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

    static {
        try {
            Shutdown.add(1 /* shutdown hook invocation order */,
                false /* not registered if shutdown in progress */,
                new Runnable() {
                    public void run() {
                        runHooks();
                    }
                }
            );
            hooks = new IdentityHashMap<>();
        } catch (IllegalStateException e) {
            // application shutdown hooks cannot be added if
            // shutdown is in progress.
            hooks = null;
        }
    }
}

Shutdown#add 方法的邏輯就很簡單了:

class Shutdown {

    private static final int MAX_SYSTEM_HOOKS = 10;
    private static final Runnable[] hooks = new Runnable[MAX_SYSTEM_HOOKS];

    static void add(int slot, boolean registerShutdownInProgress, Runnable hook) {
        synchronized (lock) {
            if (hooks[slot] != null)
                throw new InternalError("Shutdown hook at slot " + slot + " already registered");

            if (!registerShutdownInProgress) {
                if (state > RUNNING)
                    throw new IllegalStateException("Shutdown in progress");
            } else {
                if (state > HOOKS || (state == HOOKS && slot <= currentRunningHook))
                    throw new IllegalStateException("Shutdown in progress");
            }

            hooks[slot] = hook;
        }
    }
}
  • 引數 Runnable hook 就是在 ApplicationShutdownHooks 中的靜態程式碼塊 static{....} 中將 runHooks 方法封裝成的 Runnable。

  • 引數 int slot 表示將封裝好的 Runnable 放入 hooks 陣列中的哪個槽中。這裡我們註冊的是 Application hooks 型別的 ShutdonwHook ,所以這裡的索引為 1 。

  • 引數 registerShutdownInProgress 表示是否允許在 JVM 關閉流程開始之後,繼續向 JVM 新增 ShutdownHook 。預設為 false 表示不允許。否則將會丟擲 IllegalStateException 異常。這一點筆者在小節《3.2 使用ShutdownHook的注意事項》中強調過。

以上就是 JVM 如何捕獲作業系統核心訊號,如何註冊 ShutdownHook ,以及何時觸發 ShutdownHook 的執行的一個全面介紹。

讀到這裡大家應該徹底明白了為什麼不能使用 kill -9 pid 命令來關閉程序了吧,現在趕快去檢查一下你們公司生產環境的運維指令碼吧!!


俗話說的好 talk is cheap! show me the code! ,在介紹了這麼多關於優雅關閉的理論方案和原理之後,我想大家現在一定很好奇究竟我們該如何實現這一套優雅關閉的方案呢?

那麼接下來筆者就從一些知名框架原始碼實現角度,為大家詳細闡述一下優雅關閉是如何實現的?

4. Spring 的優雅關閉機制

前面兩個小節中我們從支援優雅關閉最底層的核心訊號機制開始聊起然後到 JVM 程序實現優雅關閉的 ShutdwonHook 原理,經過這一系列的介紹,我們現在對優雅關閉在核心層和 JVM 層的相關機制原理有了一定的瞭解。

那麼在真實 Java 應用中,我們到底該如何基於上述機制實現一套優雅關閉方案呢?本小節我們來從 Spring 原始碼中獲取下答案!!

在介紹 Spring 優雅關閉機制原始碼實現之前,筆者先來帶大家回顧下,在 Spring 的應用上下文關閉的時候,Spring 究竟給我們提供了哪些關閉時的回撥機制,從而可以讓我們在這些回撥中編寫 Java 應用的優雅關閉邏輯。

4.1 釋出 ContextClosedEvent 事件

在 Spring 上下文開始關閉的時候,首先會發布 ContextClosedEvent 事件,注意此時 Spring 容器的 Bean 還沒有開始銷燬,所以我們可以在該事件回撥中執行優雅關閉的操作。

@Component
public class ShutdownListener implements ApplicationListener<ContextClosedEvent> {
       @Override
       public void onApplicationEvent(ContextClosedEvent event) {
                  ........優雅關閉邏輯.....
       }
}

4.2 Spring 容器中的 Bean 銷燬前回撥

當 Spring 開始銷燬容器中管理的 Bean 之前,會回撥所有實現 DestructionAwareBeanPostProcessor 介面的 Bean 中的 postProcessBeforeDestruction 方法。

@Component
public class DestroyBeanPostProcessor implements DestructionAwareBeanPostProcessor {

    @Override
    public void postProcessBeforeDestruction(Object bean, String beanName) throws BeansException {

             ........Spring容器中的Bean開始銷燬前回撥.......
    }
}

4.3 回撥標註 @PreDestroy 註解的方法

@Component
public class Shutdown {
    @PreDestroy
    public void preDestroy() {
        ......釋放資源.......
    }
}

4.4 回撥 DisposableBean 介面中的 destroy 方法

@Component
public class Shutdown implements DisposableBean{

    @Override
    public void destroy() throws Exception {
         ......釋放資源......
    }

}

4.5 回撥自定義的銷燬方法

<bean id="Shutdown" class="com.test.netty.Shutdown"  destroy-method="doDestroy"/>
public class Shutdown {

    public void doDestroy() {
        .....自定義銷燬方法....
    }
}

4.6 Spring 優雅關閉機制的實現

Spring 相關應用程式本質上也是一個 JVM 程序,所以 Spring 框架想要實現優雅關閉機制也必須依託於我們在本文第三小節中介紹的 JVM 的 ShutdownHook 機制。

在 Spring 啟動的時候,需要向 JVM 註冊 ShutdownHook ,當我們執行 kill - 15 pid 命令時,隨後 Spring 會在 ShutdownHook 中觸發上述介紹的五種回撥。

下面我們來看一下 Spring 中 ShutdownHook 的註冊邏輯:

4.6.1 Spring 中 ShutdownHook 的註冊

public abstract class AbstractApplicationContext extends DefaultResourceLoader
		implements ConfigurableApplicationContext, DisposableBean {

	@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);
		}
	}
}

在 Spring 啟動的時候,我們需要呼叫 AbstractApplicationContext#registerShutdownHook 方法向 JVM 註冊 Spring 的 ShutdownHook ,從這段原始碼中我們看出,Spring 將 doClose() 方法封裝在 ShutdownHook 執行緒中,而 doClose() 方法裡邊就是 Spring 優雅關閉的邏輯。

這裡需要強調的是,當我們在一個純 Spring 環境下,Spring 框架是不會為我們主動呼叫 registerShutdownHook 方法去向 JVM 註冊 ShutdownHook 的,我們需要手動呼叫 registerShutdownHook 方法去註冊。

public class SpringShutdownHook {

    public static void main(String[] args) throws IOException {
        GenericApplicationContext context = new GenericApplicationContext();
                      ........
        // 註冊 Shutdown Hook
        context.registerShutdownHook();
                      ........
    }
}

而在 SpringBoot 環境下,SpringBoot 在啟動的時候會為我們呼叫這個方法去主動註冊 ShutdownHook 。我們不需要手動註冊。

public class SpringApplication {

	public ConfigurableApplicationContext run(String... args) {

                  ...............省略.................

                  ConfigurableApplicationContext context = null;
                  context = createApplicationContext();
                  refreshContext(context);

                  ...............省略.................
	}

	private void refreshContext(ConfigurableApplicationContext context) {
		refresh(context);
		if (this.registerShutdownHook) {
			try {
				context.registerShutdownHook();
			}
			catch (AccessControlException ex) {
				// Not allowed in some environments.
			}
		}
	}

}

4.6.2 Spring 中的優雅關閉邏輯

	protected void doClose() {
		// 更新上下文狀態
		if (this.active.get() && this.closed.compareAndSet(false, true)) {
			if (logger.isInfoEnabled()) {
				logger.info("Closing " + this);
			}
            // 取消 JMX 託管
			LiveBeansView.unregisterApplicationContext(this);

			try {
				// 釋出 ContextClosedEvent 事件
				publishEvent(new ContextClosedEvent(this));
			}
			catch (Throwable ex) {
				logger.warn("Exception thrown from ApplicationListener handling ContextClosedEvent", ex);
			}

			// 回撥 Lifecycle beans,相關 stop 方法
			if (this.lifecycleProcessor != null) {
				try {
					this.lifecycleProcessor.onClose();
				}
				catch (Throwable ex) {
					logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
				}
			}

			// 銷燬 bean,觸發前面介紹的幾種回撥
			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);
		}
	}

在這裡我們可以看出最終是在 AbstractApplicationContext#doClose 方法中觸發本小節開始介紹的五種回撥:

  1. 釋出 ContextClosedEvent 事件。注意這裡是一個同步事件,也就是說 Spring 的 ShutdownHook 執行緒在這裡釋出完事件之後會繼續同步執行事件的處理,等到事件處理完畢後,才會去執行後面的 destroyBeans() 方法對 IOC 容器中的 Bean 進行銷燬。

所以在 ContextClosedEvent 事件監聽類中,可以放心地去做優雅關閉相關的操作,因為此時 Spring 容器中的 Bean 還沒有被銷燬。

  1. destroyBeans() 方法中依次觸發剩下的四種回撥。

最後結合前邊小節中介紹的內容,總結 Spring 的整個優雅關閉流程如下圖所示:

5. Dubbo 的優雅關閉

本小節優雅關閉部分原始碼基於 apache dubbo 2.7.7 版本,該版本中的優雅關閉是有 Bug 的,下面讓我們一起來 Shooting Bug !

在前邊幾個小節的內容中,我們從核心提供的底層技術支援開始聊到了 JVM 的 ShutdonwHook ,然後又從 JVM 聊到了 Spring 框架的優雅關閉機制。

在瞭解了這些內容之後,本小節我們就來看下 dubbo 中的優雅關閉實現,由於現在幾乎所有 Java 應用都會採用 Spring 作為開發框架,所以 dubbo 一般是整合在 Spring 框架中供我們使用的,它的優雅關閉和 Spring 有著緊密的聯絡。

5.1 Dubbo 在 Spring 環境下的優雅關閉

在本文第四小節《4. Spring的優雅關閉機制》的介紹中,我們知道在 Spring 的優雅關閉流程中,Spring 的 ShutdownHook 執行緒會首先發布 ContextClosedEvent 事件,該事件是一個同步事件,ShutdownHook 執行緒釋出完該事件緊接著就會同步執行該事件的監聽器,當在事件監聽器中處理完 ContextClosedEvent 事件之後,在回過頭來執行 destroyBeans() 方法並依次觸發剩下的四種回撥來銷燬 IOC 容器中的 Bean 。

由於在處理 ContextClosedEvent 事件的時候,Dubbo 所依賴的一些關鍵 bean 這時還沒有被銷燬,所以 dubbo 定義了一個 DubboBootstrapApplicationListener 用來監聽 ContextClosedEvent 事件,並在 onContextClosedEvent 事件處理方法中呼叫 dubboBootstrap.stop() 方法開啟 dubbo 的優雅關閉流程。

public class DubboBootstrapApplicationListener extends OneTimeExecutionApplicationContextEventListener
        implements Ordered {

    @Override
    public void onApplicationContextEvent(ApplicationContextEvent event) {
        // 這裡是 Spring 的同步事件,publishEvent 和處理 Event 是在同一個執行緒中
        if (event instanceof ContextRefreshedEvent) {
            onContextRefreshedEvent((ContextRefreshedEvent) event);
        } else if (event instanceof ContextClosedEvent) {
            onContextClosedEvent((ContextClosedEvent) event);
        }
    }

    private void onContextClosedEvent(ContextClosedEvent event) {
        // spring 在 shutdownhook 中會先觸發 ContextClosedEvent ,然後在銷燬 spring beans
        // 所以這裡 dubbo 開始優雅關閉時,依賴的 spring beans 並未銷燬
        dubboBootstrap.stop();
    }

}

當服務提供者 ServiceBean 和服務消費者 ReferenceBean 被初始化時,會將 DubboBootstrapApplicationListener 註冊到 Spring 容器中。並開始監聽 ContextClosedEvent 事件和 ContextRefreshedEvent 事件。

public class ServiceClassPostProcessor implements BeanDefinitionRegistryPostProcessor, EnvironmentAware,
        ResourceLoaderAware, BeanClassLoaderAware {

    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {

        // @since 2.7.5 註冊spring啟動 關閉事件的listener
        //在事件回撥中中呼叫啟動類 DubboBootStrap的start  stop來啟動 關閉dubbo應用
        registerBeans(registry, DubboBootstrapApplicationListener.class);
      
                  ........省略.......
    }
}

5.2 Dubbo 優雅關閉流程簡介

由於本文的主題是介紹優雅關閉的一整條流程主線,所以這裡筆者只是簡要介紹 Dubbo 優雅關閉的主流程,相關細節部分筆者會在後續的 dubbo 原始碼解析系列裡為大家詳細介紹 Dubbo 優雅關閉的細節。為了避免本文發散太多,我們這裡還是聚焦於流程主線。

public class DubboBootstrap extends GenericEventListener {

    public DubboBootstrap stop() throws IllegalStateException {
        destroy();
        return this;
    }

}

這裡的核心邏輯其實就是我們在《1.2 優雅關閉》小節中介紹的兩大優雅關閉主題:

  • 從當前正在關閉的應用範例上切走現有生產流量。

  • 保證業務無失真。

這裡大家只需要瞭解 Dubbo 優雅關閉的主流程即可,相關細節筆者後續會有一篇專門的文章詳細為大家介紹。

    public void destroy() {
        if (destroyLock.tryLock()) {
            try {
                DubboShutdownHook.destroyAll();

                if (started.compareAndSet(true, false)
                        && destroyed.compareAndSet(false, true)) {

                    //取消註冊
                    unregisterServiceInstance();
                    //取消後設資料服務
                    unexportMetadataService();
                    //停止暴露服務
                    unexportServices();
                    //取消訂閱服務
                    unreferServices();
                    //登出註冊中心
                    destroyRegistries();
                    //關閉服務
                    DubboShutdownHook.destroyProtocols();
                    //銷燬註冊中心使用者端範例
                    destroyServiceDiscoveries();
                    //清除應用設定類以及相關應用模型
                    clear();
                    //關閉執行緒池
                    shutdown();
                    //釋放資源
                    release();
                }
            } finally {
                destroyLock.unlock();
            }
        }
    }

從以上內容可以看出,Dubbo 的優雅關閉依託於 Spring ContextClosedEvent 事件的釋出,而 ContextClosedEvent 事件的釋出又依託於 Spring ShutdownHook 的註冊。

從《4.6.1 Spring 中 ShutdownHook 的註冊》小節的介紹中我們知道,在 SpringBoot 環境下,SpringBoot 在啟動的時候會為我們呼叫 ApplicationContext#registerShutdownHook 方法去主動註冊 ShutdownHook 。我們不需要手動註冊。

而在一個純 Spring 環境下,Spring 框架並不會為我們主動呼叫 registerShutdownHook 方法去向 JVM 註冊 ShutdownHook 的,我們需要手動呼叫 registerShutdownHook 方法去註冊。

所以 Dubbo 這裡為了相容 SpringBoot 環境和純 Spring 環境下的優雅關閉,引入了 SpringExtensionFactory類 ,只要在 Spring 環境下都會呼叫 registerShutdownHook 去向 JVM 註冊 Spring 的 ShutdownHook 。

public class SpringExtensionFactory implements ExtensionFactory {
    private static final Logger logger = LoggerFactory.getLogger(SpringExtensionFactory.class);

    private static final Set<ApplicationContext> CONTEXTS = new ConcurrentHashSet<ApplicationContext>();

    public static void addApplicationContext(ApplicationContext context) {
        CONTEXTS.add(context);
        if (context instanceof ConfigurableApplicationContext) {
            //在spring啟動成功之後設定shutdownHook(相容非SpringBoot環境)
            ((ConfigurableApplicationContext) context).registerShutdownHook();
        }
    }

}

當服務提供者 ServiceBean 和服務消費者 ReferenceBean 在初始化完成之後,會回撥 SpringExtensionFactory#addApplicationContext 方法註冊 ShutdownHook 。

public class ServiceBean<T> extends ServiceConfig<T> implements InitializingBean, DisposableBean,
        ApplicationContextAware, BeanNameAware, ApplicationEventPublisherAware {

   @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
        SpringExtensionFactory.addApplicationContext(applicationContext);
    }

}
public class ReferenceBean<T> extends ReferenceConfig<T> implements FactoryBean,
        ApplicationContextAware, InitializingBean, DisposableBean {

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
        SpringExtensionFactory.addApplicationContext(applicationContext);
    }

}

以上就是 Dubbo 在 Spring 整合環境下的優雅關閉全流程,下面我們來看下 Dubbo 在非 Spring 環境下的優雅關閉流程。

5.3 Dubbo 在非 Spring 環境下的優雅關閉

在上小節的介紹中我們知道 Dubbo 在 Spring 環境下依託 Spring 的 ShutdownHook ,通過監聽 ContextClosedEvent 事件,從而觸發 Dubbo 的優雅關閉流程。

而到了非 Spring 環境下,Dubbo 就需要定義自己的 ShutdownHook ,從而引入了 DubboShutdownHook ,直接將優雅關閉流程封裝在自己的 ShutdownHook 中執行。

public class DubboBootstrap extends GenericEventListener {

    private DubboBootstrap() {
        configManager = ApplicationModel.getConfigManager();
        environment = ApplicationModel.getEnvironment();

        DubboShutdownHook.getDubboShutdownHook().register();
        ShutdownHookCallbacks.INSTANCE.addCallback(new ShutdownHookCallback() {
            @Override
            public void callback() throws Throwable {
                DubboBootstrap.this.destroy();
            }
        });
    }

}
public class DubboShutdownHook extends Thread {

   public void register() {
        if (registered.compareAndSet(false, true)) {
            DubboShutdownHook dubboShutdownHook = getDubboShutdownHook();
            Runtime.getRuntime().addShutdownHook(dubboShutdownHook);
            dispatch(new DubboShutdownHookRegisteredEvent(dubboShutdownHook));
        }
    }

    @Override
    public void run() {
        if (logger.isInfoEnabled()) {
            logger.info("Run shutdown hook now.");
        }

        callback();
        doDestroy();
    }

   private void callback() {
        callbacks.callback();
    }

}

從原始碼中我們看到,當我們的 Dubbo 應用程式接收到 kill -15 pid 訊號時,JVM 捕獲到 SIGTERM(15) 訊號之後,就會觸發 DubboShutdownHook 執行緒執行,從而通過 callback() 又回撥了上小節中介紹的 DubboBootstrap#destroy 方法(dubbo 的整個優雅關閉邏輯全部封裝在這裡)。

public class DubboBootstrap extends GenericEventListener {

    public void destroy() {
        if (destroyLock.tryLock()) {
            try {
                DubboShutdownHook.destroyAll();

                if (started.compareAndSet(true, false)
                        && destroyed.compareAndSet(false, true)) {

                    ........取消註冊......
                  
                    ........取消後設資料服務........
                  
                    ........停止暴露服務........
                 
                    ........取消訂閱服務........
                 
                    ........登出註冊中心........
                 
                    ........關閉服務........
                  
                    ........銷燬註冊中心使用者端範例........
                 
                    ........清除應用設定類以及相關應用模型........
                
                    ........關閉執行緒池........
                 
                    ........釋放資源........
                 
                }
            } finally {
                destroyLock.unlock();
            }
        }
    }

}

5.4 啊哈!Bug!

前邊我們在《5.1 Dubbo在Spring環境下的優雅關閉》小節和《5.3 Dubbo在非Spring環境下的優雅關閉》小節中介紹的這兩個環境的下的優雅關閉方案,當它們在各自的場景下執行的時候是沒有任何問題的。

但是當這兩種方案結合在一起執行,就出大問題了~~~

還記得筆者在《3.2 使用 ShutdownHook 的注意事項》小節中特別強調的一點:

  • ShutdownHook 其實本質上是一個已經被初始化但是未啟動的 Thread ,這些通過 Runtime.getRuntime().addShutdownHook 方法註冊的 ShutdownHooks ,在 JVM 程序關閉的時候會被啟動並行執行,但是並不會保證執行順序

所以在編寫 ShutdownHook 中的邏輯時,我們應該確保程式的執行緒安全性,並儘可能避免死鎖。最好是一個 JVM 程序只註冊一個 ShutdownHook 。

那麼現在 JVM 中我們註冊了兩個 ShutdownHook 執行緒,一個 Spring 的 ShutdownHook ,另一個是 Dubbo 的 ShutdonwHook 。那麼這會引出什麼問題呢?

經過前邊的內容介紹我們知道,無論是在 Spring 的 ShutdownHook 中觸發的 ContextClosedEvent 事件還是在 Dubbo 的 ShutdownHook 中執行的 CallBack 。最終都會呼叫到 DubboBootstrap#destroy 方法執行真正的優雅關閉邏輯。

public class DubboBootstrap extends GenericEventListener {

    private final Lock destroyLock = new ReentrantLock();

    public void destroy() {
        if (destroyLock.tryLock()) {
            try {
                DubboShutdownHook.destroyAll();

                if (started.compareAndSet(true, false)
                        && destroyed.compareAndSet(false, true)) {
                    
                        .......dubbo應用的優雅關閉.......
                 
                }
            } finally {
                destroyLock.unlock();
            }
        }
    }

}

讓我們來設想一個這種的場景:當 Spring 的 ShutdownHook 執行緒和 Dubbo 的 ShutdownHook 執行緒同時執行並且在同一個時間點來到 DubboBootstrap#destroy 方法中爭奪 destroyLock 。

  • Dubbo 的 ShutdownHook 執行緒獲得 destroyLock 進入 destroy() 方法體開始執行優雅關閉邏輯。

  • Spring 的 ShutdownHook 執行緒沒有獲得 destroyLock,退出 destroy() 方法。

在 Spring 的 ShutdownHook 執行緒退出 destroy() 方法之後緊接著就會執行 destroyBeans() 方法銷燬 IOC 容器中的 Bean ,這裡邊肯定涉及到一些關鍵業務 Bean 的銷燬,比如:資料庫連線池,以及 Dubbo 相關的核心 Bean。

於此同時 Dubbo 的 ShutdownHook 執行緒開始執行優雅關閉邏輯,《1.2 優雅關閉》小節中我們提到,優雅關閉要保證業務無失真。所以需要將剩下正在進行中的業務流程繼續處理完畢並將業務處理結果響應給使用者端。但是這時依賴的一些業務關鍵 Bean 已經被銷燬,比如資料庫連線池,這時執行資料庫操作就會丟擲 CannotGetJdbcConnectionException 。導致優雅關閉失敗,對業務造成了影響。

5.5 Bug 的修復

該 Bug 最終在 apache dubbo 2.7.15 版本中被修復

詳情可檢視Issue:https://github.com/apache/dubbo/issues/7093

經過上小節的分析,我們知道既然這個 Bug 產生的原因是由於 Spring 的 ShutdownHook 執行緒和 Dubbo 的 ShutdownHook 執行緒並行執行所導致的。

那麼當我們處於 Spring 環境下的時候,就將 Dubbo 的 ShutdownHook 登出掉即可。

public class SpringExtensionFactory implements ExtensionFactory {
    private static final Logger logger = LoggerFactory.getLogger(SpringExtensionFactory.class);

    private static final Set<ApplicationContext> CONTEXTS = new ConcurrentHashSet<ApplicationContext>();

    public static void addApplicationContext(ApplicationContext context) {
        CONTEXTS.add(context);
        if (context instanceof ConfigurableApplicationContext) {
            // 註冊 Spring 的 ShutdownHook
            ((ConfigurableApplicationContext) context).registerShutdownHook();
            // 在 Spring 環境下將 Dubbo 的 ShutdownHook 取消掉
            DubboShutdownHook.getDubboShutdownHook().unregister();
        }
    }
}

而在非 Spring 環境下,我們依然保留 Dubbo 的 ShutdownHook 。

public class DubboBootstrap {

    private DubboBootstrap() {
        configManager = ApplicationModel.getConfigManager();
        environment = ApplicationModel.getEnvironment();

        DubboShutdownHook.getDubboShutdownHook().register();
        ShutdownHookCallbacks.INSTANCE.addCallback(DubboBootstrap.this::destroy);
    }

}

以上內容就是 Dubbo 的整個優雅關閉主線流程,以及優雅關閉 Bug 產生的原因和修復方案。


在 Dubbo 的優雅關閉流程中最終會通過 DubboShutdownHook.destroyProtocols() 關閉底層服務。

public class DubboBootstrap extends GenericEventListener {

    private final Lock destroyLock = new ReentrantLock();

    public void destroy() {
        if (destroyLock.tryLock()) {
            try {
                DubboShutdownHook.destroyAll();

                if (started.compareAndSet(true, false)
                        && destroyed.compareAndSet(false, true)) {
                    
                        .......dubbo應用的優雅關閉.......
                    //關閉服務
                    DubboShutdownHook.destroyProtocols();

                        .......dubbo應用的優雅關閉.......

                }
            } finally {
                destroyLock.unlock();
            }
        }
    }

}

在 Dubbo 服務的銷燬過程中,會通過呼叫 server.close 關閉底層的 Netty 服務。

public class DubboProtocol extends AbstractProtocol {

   @Override
    public void destroy() {
        for (String key : new ArrayList<>(serverMap.keySet())) {
            ProtocolServer protocolServer = serverMap.remove(key);
            RemotingServer server = protocolServer.getRemotingServer();
            server.close(ConfigurationUtils.getServerShutdownTimeout());
             ...........省略........
        }

         ...........省略........
}

最終觸發 Netty 的優雅關閉。

public class NettyServer extends AbstractServer implements RemotingServer {

    @Override
    protected void doClose() throws Throwable {
        ..........關閉底層Channel......
        try {
            if (bootstrap != null) {
                // 關閉 Netty 的主從 Reactor 執行緒組
                bossGroup.shutdownGracefully();
                workerGroup.shutdownGracefully();
            }
        } catch (Throwable e) {
            logger.warn(e.getMessage(), e);
        }
        .........清除快取Channel資料.......
    }

}

6. Netty 的優雅關閉

通過上小節介紹 dubbo 優雅關閉的相關內容,我們很自然的引出了 Netty 的優雅關閉觸發時機,那麼在本小節中筆者將為大家詳細介紹下 Netty 是如何優雅地裝..........優雅地謝幕的~~

在之前的系列文章中,我們圍繞下圖所展示的 Netty 整個核心框架的運轉流程介紹了主從 ReactorGroup 的建立啟動執行接收網路連線接收網路資料傳送網路資料,以及如何在pipeline中處理相關IO事件的整個原始碼實現。

本小節就到了 Netty 優雅謝幕的時刻了,在這謝幕的過程中,Netty 會對它的主從 ReactorGroup ,以及對應 ReactorGroup 中的 Reacto r進行優雅的關閉。下面讓我們一起來看下這個優雅關閉的過程~~~

6.1 ReactorGroup 的優雅謝幕


public abstract class AbstractEventExecutorGroup implements EventExecutorGroup {

    static final long DEFAULT_SHUTDOWN_QUIET_PERIOD = 2;
    static final long DEFAULT_SHUTDOWN_TIMEOUT = 15;

   @Override
    public Future<?> shutdownGracefully() {
        return shutdownGracefully(DEFAULT_SHUTDOWN_QUIET_PERIOD, DEFAULT_SHUTDOWN_TIMEOUT, TimeUnit.SECONDS);
    }

}

在 Netty 進行優雅關閉的整個過程中,這裡涉及到了兩個非常重要的控制引數:

  • gracefulShutdownQuietPeriod:優雅關閉靜默期,預設為 2s 。這個引數主要來保證 Netty 整個關閉過程中的優雅。在關閉流程開始後,如果 Reactor 中還有遺留的非同步任務需要執行,那麼 Netty 就不能關閉,需要把所有非同步任務執行完畢才可以。當所有非同步任務執行完畢後,Netty 為了實現更加優雅的關閉操作,一定要保障業務無失真,這時候就引入了靜默期這個概念,如果在這個靜默期內,使用者沒有新的任務向 Reactor 提交那麼就開始關閉。如果在這個靜默期內,還有使用者繼續提交非同步任務,那麼就不能關閉,需要把靜默期內使用者提交的非同步任務執行完畢才可以放心關閉。

  • gracefulShutdownTimeout:優雅關閉超時時間,預設為 15s 。這個引數主要來保證 Netty 整個關閉過程的可控。我們知道一個生產級的優雅關閉方案既要保證優雅做到業務無失真,更重要的是要保證關閉流程的可控,不能無限制的優雅下去。導致長時間無法完成關閉動作。於是 Netty 就引入了這個引數,如果優雅關閉超時,那麼無論此時有無非同步任務需要執行都要開始關閉了。

這兩個控制引數是非常重要核心的兩個引數,我們在後面介紹 Netty 關閉細節的時候還會為大家詳細剖析,這裡大家先從概念上大概理解一下。

在介紹完這兩個重要核心引數之後,我們接下來看下 ReactorGroup 的關閉流程:

我們都知道 Netty 為了保證整個系統的吞吐量以及保證 Reactor 可以執行緒安全地,有序地處理各個 Channel 上的 IO 事件。基於這個目的 Netty 將其承載的海量連線分攤打散到不同的 Reactor 上處理。

ReactorGroup 中包含多個 Reactor ,每個 Channel 只能註冊到一個固定的 Reactor 上,由這個固定的 Reactor 負責處理該 Channel 上整個生命週期的事件。

一個 Reactor 上註冊了多個 Channel ,負責處理註冊在其上的所有 Channel 的 IO 事件以及非同步任務。

ReactorGroup 的結構如下圖所示:

ReactorGroup 的關閉流程本質上其實是 ReactorGroup 中包含的所有 Reactor 的關閉,當 ReactorGroup 中的所有 Reactor 完成關閉後,ReactorGroup 才算是真正的關閉。


public abstract class MultithreadEventExecutorGroup extends AbstractEventExecutorGroup {

    // Reactor執行緒組中的Reactor集合
    private final EventExecutor[] children;

    // 關閉future
    private final Promise<?> terminationFuture = new DefaultPromise(GlobalEventExecutor.INSTANCE);

    @Override
    public Future<?> shutdownGracefully(long quietPeriod, long timeout, TimeUnit unit) {
        for (EventExecutor l: children) {
            l.shutdownGracefully(quietPeriod, timeout, unit);
        }
        return terminationFuture();
    }

    @Override
    public Future<?> terminationFuture() {
        return terminationFuture;
    }

}

  • EventExecutor[] children:陣列中存放的是當前 ReactorGroup 中包含的所有 Reactor,型別為 EventExecutor。

  • Promise<?> terminationFuture:ReactorGroup 中的關閉 Future ,使用者執行緒通過這個 terminationFuture 可以知道 ReactorGroup 完成關閉的時機,也可以向 terminationFuture 註冊一些 listener 。當 ReactorGroup 完成關閉動作後,會回撥使用者註冊的這些 listener 。大家可以根據各自的業務場景靈活運用。

在 ReactorGroup 的關閉過程中,會挨個觸發它所包含的所有 Reactor 的關閉流程。並返回 terminationFuture 給使用者執行緒。

當 ReactorGroup 中的所有 Reactor 完成關閉之後,這個 terminationFuture 會被設定為 success,這樣一來使用者執行緒可以感知到 ReactorGroup 已經完成關閉了。

這一點筆者也在《Reactor在Netty中的實現(建立篇)》一文中的第四小節《4. 向Reactor執行緒組中所有的Reactor註冊terminated回撥函數》強調過。

在 ReactorGroup 建立的最後一步,會定義 Reactor 關閉的 terminationListener。在 Reactor 的 terminationListener 中會判斷當前 ReactorGroup 中的 Reactor 是否全部關閉,如果已經全部關閉,則會設定 ReactorGroup的 terminationFuture 為 success 。

    //記錄關閉的Reactor個數,當Reactor全部關閉後,ReactorGroup才可以認為關閉成功
    private final AtomicInteger terminatedChildren = new AtomicInteger();
    //ReactorGroup的關閉future
    private final Promise<?> terminationFuture = new DefaultPromise(GlobalEventExecutor.INSTANCE);

    protected MultithreadEventExecutorGroup(int nThreads, Executor executor,
                                            EventExecutorChooserFactory chooserFactory, Object... args) {

        ........挨個建立Reactor............

        final FutureListener<Object> terminationListener = new FutureListener<Object>() {
            @Override
            public void operationComplete(Future<Object> future) throws Exception {
                if (terminatedChildren.incrementAndGet() == children.length) {
                    //當所有Reactor關閉後 ReactorGroup才認為是關閉成功
                    terminationFuture.setSuccess(null);
                }
            }
        };

        for (EventExecutor e: children) {
            //向每個Reactor註冊terminationListener
            e.terminationFuture().addListener(terminationListener);
        }
    }

從以上 ReactorGroup 的關閉流程我們可以看出,ReactorGroup 的關閉邏輯只是挨個去觸發它所包含的所有 Reactor 的關閉,Netty 的整個優雅關閉核心其實是在單個 Reactor 的關閉邏輯上。畢竟 Reactor 才是真正驅動 Netty 運轉的核心引擎。

6.2 Reactor 的優雅謝幕

Reactor 的狀態特別重要,從《一文聊透Netty核心引擎Reactor的運轉架構》一文中我們知道 Reactor 是在一個 for (;