13年過去了,Spring官方竟然真的支援Bean的非同步初始化了!

why技术發表於2024-05-20

你好呀,我是歪歪。

兩年前我曾經發布過這樣的一篇文章《我是真沒想到,這個面試題居然從11年前就開始討論了,而官方今年才表態。》

文章主要就是由這個面試題引起:

Spring 在啟動期間會做類掃描,以單例模式放入 ioc。但是 spring 只是一個個類進行處理,如果為了加速,我們取消 spring 自帶的類掃描功能,用寫程式碼的多執行緒方式並行進行處理,這種方案可行嗎?為什麼?

當時我也不知道問題的答案,所以我嘗試著去尋找。

但是在找答案之前,我先大膽的猜一個答案:不可以。

為什麼?

因為當時我看的是 Spring 5.x 版本的原始碼,在這個版本里面還是單執行緒處理 Bean。

對於 Spring 這種使用規模如此之大的開源框架來說,如果能支援 Bean 的非同步多執行緒載入的話,肯定老早就支援了。

所以我先盲猜一個:不可以。

最後我找到了這樣的一個 issue 連結:

https://github.com/spring-projects/spring-framework/issues/13410

題目翻譯過來是“在啟動期間並行的處理 Bean 的初始化”,緊扣我們的面試題。

注意看這個 issue 的建立時間:2011 年 10 月 12 號。

2022 年看到這個 issue 的時候,才 11 年時間,誰能想到,僅僅兩年時間過去,就已經過去了近 13 年時間。(手動狗頭

這個連結的關鍵內容我在前面提到的文章中已經進行過描述了,就不再多說了。

只說 2022 年我寫這個話題的時候,最後一個回覆是這樣的:

回答的這個哥們,是 Spring 的官方人員,所以可以理解針對這個問題的官方回答。

這個哥們說了很長一段,我簡單的翻譯一下:

他說這個問題在最新的 6.0 版本中也不會被解決,因為它目前的優先順序並不是特別高。

在處理真正的啟動案例時,我們經常發現,時間都花在少數幾個相互依賴的特定 bean 上。在那裡引入並行化,在很多情況下並不能節省多少,因為這並不能加快關鍵路徑。這通常與 ORM 設定和資料庫遷移有關。

你也可以使用“應用程式啟動跟蹤功能”(application startup tracking)為自己的應用程式收集更多這方面的資訊:可以看到啟動時間花在哪裡以及是如何花的,以及並行化是否會改善這種情況。

對於 Spring Framework 6.0,我們正專注於本地用例的 Ahead Of Time 功能,以及啟動時間的改進。

所以,在 2022 年的時候,從這個回覆中就可以看出,官方對於並行化處理 bean 的態度是:

在這個 issue 裡面也有人給出了一些非官方的解決方案,但是並沒有被採納。

當時這個話題就算是在這裡打住了,所以當時對於這個面試題的回答應該是:

理論上是可行的,但是官方並不支援。因為官方覺得透過非同步化初始 Bean 只是治標,並不治本。還是應該找到 Bean 初始化慢的原因,分析這些的原因進行針對化的最佳化。

反轉

然而,前幾天聽到訊息說 Spring 6.2 版本要釋出了,所以我想著去看看裡面到底有些啥新東西。

然後我就找到了 v6.2.0-M1 版本的更新日誌:

https://github.com/spring-projects/spring-framework/releases/tag/v6.2.0-M1

畢竟是大版本更新,New Features 可以說是非常的多,一眼望去好幾十個,滑鼠都得劃好幾下。

心想這麼多新特性,得學到啥時候去啊。

突然劃到看到這個時候,我眼睛都直了:

在服務啟動時,非同步初始化 beans。

不是說好不支援嗎?怎麼突然變卦了呢?

於是我點到這個 New Features 後面的連結,準備一探究竟:

https://github.com/spring-projects/spring-framework/issues/19487

這個 issue 是 2016 年提出來的,提問的這個哥們給出了一個自己實際的案例,然後還是想要官方能夠支援 Bean 的非同步初始化。

在今年 2 月的時候,這個下面有一個官方回答:

把連結指引到了 13410 這個 issue 裡面。

而 13410 就是我們前面提到的這個 2011 年提出的 issue:

所以兜兜轉轉,還是回到了最開始的地方。

兩年過去了,這個問題下最新的回答是 2024 年 2 月 28 日,也是來自官方的回答:

這個回答可以說非常關鍵了,是整個 Bean 的非同步初始化的實現思路,我帶你盤一下關鍵點,強烈建議你自己去看看,並且根據這部分的描述找到對應的程式碼。

在這個回答裡面提到說會引入 backgroundInit 標識,以及在 @Bean 裡面加入 bootstrap=BACKGROUND 列舉,透過這樣的方式來支援 Bean 的非同步初始化。

會在 preInstantiateSingletons 方法中,覆蓋每個加了 BACKGROUND 的 Bean 的整個 getBean 步驟。

因為是非同步處理,相應的 Future 會儲存起來,這樣依賴的 Bean 就會自動等待 Bean 例項完成。

此外,所有常規的後臺初始化都會在 preInstantiateSingletons 結束時強制完成。只有被額外標記為 @Lazy 的 Bean 才允許稍後完成(直到第一次實際訪問)。

最後這個回答中還強調了一點:因為是非同步化操作,所以專案中還需要搞一個叫做 bootstrapExecutor 的執行緒池,來支援這個事情。

沒有,那就非同步化不了。

嚐鮮

氣氛都烘托到這裡了,那高低得給你整一個 Demo 跑跑才行啊。

目前 Spring 6.2.0 版本還沒正式釋出,最新的 SpringBoot 裡面也還沒有整合 Spring 6.2.0 版本。

所以我們不能透過新建一個 SpringBoot 專案來嚐鮮,得搞一個純粹的 Spring 專案。

沒想到歪師傅寫到這裡的時候遇到了一個卡點:怎麼去建立一個 Spring 專案來著?

這幾年要建立一個新的專案,都是直接使用 SpringBoot 的腳手架來搞了,這突然一下讓我搞一個純粹的 Spring 專案出來,還真的有點懵逼。

於是我還去網上搜尋了一番。搜尋的問題是:如何建立一個 Spring 專案。

這個問題,我當年剛入行的時候肯定也搜過。

要是放在幾年前,徒手擼一個 Spring 專案的架子出來就像是呼吸一樣簡單。

這幾年屬於是被 SpringBoot 喂的太好了。

經過一番搜尋,終於是搞定了。

首先,我們要指定 Spring 的版本為 6.2.0-SNAPSHOT:

然後搞兩個 Bean,在構造方法裡面 Sleep 5s,模擬初始化比較耗時的情況:

接著找個地方 @Bean,給 Spring 託管一下:

最後搞個 Main 方法,啟動 Spring 容器,同時 用 StopWatch 來統計一下時間:

啟動之後,觀察控制檯:

可以看出兩個 Bean 都是在主執行緒裡面初始化的,由於是序列啟動,耗費的時間為 10s。

基於我們這個案例,如果能非同步初始化的話,那麼理論上 5s 的時間就可以完成初始化。

那麼我們怎麼讓它非同步起來呢?

前面官方說了,要用 BACKGROUND 註解。

首先,我們要把 @Bean 的地方改造一下:

@Bean(bootstrap = Bean.Bootstrap.BACKGROUND)

隨便看一下這個 BACKGROUND 是啥情況:

透過原始碼我們可以知道,在 6.2 之後,@Bean 註解裡面提供了一個 Bootstrap 列舉,有兩個取值。

DEFAULT,和原來一樣,序列初始化,該值也是預設值:

BACKGROUND,表示這個 Bean 需要非同步初始化。

那麼加入 BACKGROUND 標識之後,是不是就代表改造完成,可以非同步化了呢?

在這個時候,啟動專案,我們可以看到這樣的提示:

Bean 'whyBean' marked for background initialization without bootstrap executor configured - falling back to mainline initialization

這波提示非常清晰,說 whyBean 這個 Bean 標註了需要非同步初始化,但是卻沒有找到 bootstrap 執行緒池配置,所以回退到主執行緒初始化模式。

這也就是前面官方提到的這句話:

也就是說我們還要搞個名字叫做 bootstrapExecutor 的執行緒池:

再次啟動,可以發現已經是在非同步執行緒中初始化了,啟動時間也來到了 5s:

一個最簡單的 Demo 就算是演示完成了。

就上面這個 Demo 你照著抄過去,應該花不了五分鐘時間吧?

自己拿到本地去跑跑,翻翻原始碼,debug 一把,這不就是新知識 Get 嗎?

然後再搞一點其他的稍微複雜的場景,比如 Bean 之間有依賴的情況。

非同步的 Bean 裡面依賴了同步的 Bean。

同步的 Bean 裡面有非同步的 Bean。

上面這些情況,Spring 是否支援,如果支援是怎麼處理的,如果不支援會丟擲什麼樣的異常。

這些就當是課後作業吧,我就不手摸手教學了。

主要是我看了一下這部分原始碼,真的是太好 debug 了,順著原始碼往下看就行了。

這個“太好 debug” 具體體現在什麼地方,我給你舉一個簡單的小例子。

比如剛剛我們提到的執行緒池,名稱必須叫做 bootstrapExecutor,你改個名字就不靈了,比如這樣:

你問為什麼?

別問,原始碼之下無秘密。

你可以透過兩個方式去找答案。

第一個是透過日誌:

[main] INFO org.springframework.beans.factory.support.DefaultListableBeanFactory - Bean 'whyBean' marked for background initialization without bootstrap executor configured - falling back to mainline initialization

透過上面這行日誌,我們可以在對應類裡面找到對應列印的地方:

當 getBootstrapExecutor 返回為 null 的時候就會列印這個日誌。

那麼什麼時候不為 null 呢?

可以看看 bootstrapExecutor 對應的 set 方法:

只有一個地方在呼叫這個方法,這就是我說的“太好 Debug”的表現之一。

然後點過去一看,是要從 beanFactory 裡面拿出一個叫做 bootstrapExecutor 的 bean 放進去:

bootstrapExecutor,是寫死在原始碼裡面的,所以你換另外一個 xxxExecutor,原始碼也不識別啊。

另外一個方式就是正向去找。

首先我們知道 BACKGROUND 是我們的一個“抓手”,而這個抓手在原始碼中也只有一個地方被呼叫:

點過去之後發現這裡是把 backgroundInit 設定為 true:

然後看 backgroundInit 標識被使用的地方:

又可以找到這裡來:

這不就和前面呼籲上了。

這部分真的是太好 debug 了,我不騙你,你自己玩去吧。

思考

在大概摸清楚具體實現之後,歪師傅開始思考另外一個問題:Spring 為什麼要支援 Bean 的非同步初始化?

非同步化,核心目標是為了加速專案啟動,減少專案啟動時間嘛。

按照官方最開始的說法,專案啟動慢,應該是使用者找到啟動慢的根本原因,而不是想著非同步化這個治標不治本的方法。

比如在前面的 issue 裡面,有個老哥說:我這邊有個應用啟動花了 2 小時 30 分...

在 2011 年,官方是這樣回覆的:

他們的核心觀點還是:在 Spring 容器中並行化 Bean 初始化的好處對於少數使用 Spring 的應用程式來說是非常重要的,而壞處是不可避免的 Bug、增加的複雜性和意想不到的副作用,這些可能會影響所有使用 Spring 的應用程式,恐怕這不是一個有吸引力的前景。

言外之意就是:我不改。

官方希望看到的是使用者去尋找啟動慢的真正原因。

使用者希望的是官方提供一個非同步化的方法先來解決當前的問題。

官方和使用者都知道這是一個治標不治本的方案。

官方覺得沒有必要,或者“太 low”,這樣的程式碼不應該出現在我們的專案中,因為使用者沒有按照我的預期去使用對應程式碼。

使用者覺得我不管治標還是治本,只要能解決問題就行。

這個時候就出現了分歧。

這個分歧甚至長達 13 年之久。在這期間官方和使用者反覆拉扯,都難以達成一致。

終於,在 6.2 版本里面,官方還是妥協了,Bean 的非同步初始化終於還是落地了。

13 年的時間已經足夠長了,長到 Spring 的使用者群體已經爆炸式的增長,官方不得不足夠重視使用者反覆提起的需求。

即使這個需求在官方看來是不合理的,這個解決方案看起來是不優雅的,但是由於使用者需要,所以不得不提供。

你看這個場景像不像是你在工作中接到了一個自認為不合理的需求,但是卻不得不去實施一樣。

或者像不像在你精心搭建的系統中,必須加入一坨你覺得很難接受的程式碼。

就像你剛剛開始工作的時候,甚至有一點程式碼潔癖。

然後隨著需求的疊加、時間的推移、日復一日的重複之後,開始變成“又不是不能用”。

沒關係,都是會變的。

相關文章