資安

服務端架構治理|閒魚如何有效提高應用的編譯和啟動速度

作者:閒魚技術——泊垚

背景

應用的發佈是一件非常耗時的事情,尤其是當應用迭代了比較長的時間之後,一次預發的部署就可能需要花費十幾分鍾,其中服務啟動一次,就可能花費五六分鐘。如此漫長的發佈可能帶來兩方面的問題:

  1. 開發過程中,在使用測試環境的時候發佈驗證的時候,我們往往希望快速迭代,快速驗證,但是每次改完一個feature發佈驗證都要經過十幾分鐘的話,效率是非常低下的。
  2. 在線上發佈過程中,如果遇到機器數量非常多的應用,那麼一次發佈,一批一批的部署下來,耗費的時間非常長,很容易超出發佈窗口,帶來線上風險。

針對應用發佈耗時長的問題,筆者結合對手頭應用idle-local的耗時分析,參考和嘗試了多數的方案之後,制定了一套實施方案,能夠有效提高應用的編譯和啟動速度。同時也著手優化和沉澱了一個啟動加速工具(在其他同學的項目上迭代得到),能夠針對整個過程中最耗時的啟動階段進行加速,有不錯的效果。

構建部署耗時分析

idle-local項目耗時統計

image.png
注:

  1. mtop是我們項目的api層
  2. hsf是阿里的RPC框架
  3. pandora是一個的輕量級的隔離容器,用來隔離Webapp和中間件的依賴

重點優化分析

根據統計得到的應用編譯部署耗時情況,以及理論上的加速空間,我們制定了以下幾項優化的重點:

  1. 部署過程中,啟動應用是最主要的耗時項,也是最容易隨著應用迭代膨脹的部分,其中啟動應用的主要耗時是bean的初始化。
  2. 構建過程中,代碼編譯是主要的耗時項,理論上存在較大的優化空間。
  3. 鏡像中最後一層打包內容的大小會影響鏡像push和pull的耗時,如果能夠將變動範圍分層優化,會有較大的收益。
  4. 停止應用的過程中,為了處理RPC服務HSF優雅下線和應用優雅關閉,花了比較多的時間,在非生產環境可以省略

構建部署速度治理方案

應用啟動加速-Spring Bean異步初始化的原理與落地

加速效果:☆☆☆☆
配置簡易:☆☆
推薦指數:☆☆☆☆
我們通過分析spring的初始化過程會發現,spring對於bean的創建,不論是通過遍歷還是通過依賴觸發,都是通過同步的方式對bean進行初始化的。
這就導致了當一個bean的初始化過程很久的時候,會嚴重阻塞後續bean的初始化,哪怕這兩個bean之間完全沒有相互依賴。
如果能夠將bean的初始化過程放到異步線程中,則會大大提升bean的創建效率。
image.png
如圖,在完成bean的實例化後,異步進行初始化,異步初始化實現的關鍵是保證bean在被使用之前初始化完成。
本節我們將從理論出發,逐步分析springBean的異步初始化辦法:

孤立的Bean

我們將不被其他Bean依賴的Bean,定義為孤立的Bean。如圖,beanA和beanB1兩個bean,相互不依賴也不被其他bean依賴。
image.png
理論上,容器中存在孤立的Bean,這些Bean由於不被其他Bean依賴,在容器初始化過程中,這些Bean不會被其他bean使用,是可以自己異步進行初始化的,只需要容器初始化完成時,保障這些Bean已經被初始化完成即可。
然而完全孤立的Bean其實比較少,同時,找出孤立的Bean,需要遍歷整個依賴樹。我們需要一種覆蓋範圍更廣,更容易定義的方式進行異步化。

暫時孤立的Bean

我們有這樣的認知:

  1. 孤立的Bean一定是被spring通過遍歷的方式創建的
  2. 被spring通過遍歷的方式創建的bean不一定是孤立的Bean,他可能被後來的bean依賴並注入
  3. 被spring通過遍歷的方式創建的bean有較大概率是孤立的Bean
  4. 被spring通過遍歷的方式創建的bean的初始化距離它被其他的Bean依賴並注入,有一些時間差

基於這樣的認知,我們可以按照以下方案進行異步化:
image.png
如圖所示,被spring通過遍歷的方式創建的bean我們可以暫時認為他是孤立的Bean,當我們發現他不是的時候(被其他Bean調用了getBean),阻塞並等待他的初始化,那麼期間的異步初始化過程,也能為我們節省時間;如果他始終沒有被依賴,那麼說明他就是孤立的Bean。
至於在實際實現中,如何判斷一個bean是被spring遍歷到還是被其他bean依賴導致的創建,有一個可行的方法是:定義一個全局的標記,用來記錄當前spring遍歷到的bean,當且僅當這個標記是null的時候,表示當前正在獲取的bean是被spring遍歷到的,然後立即將當前bean寫入標記,並在bean返回前將標記清除,我們可以通過增強beanFactory的getBean方法實現這部分邏輯。
這個方案的優點是能夠自動識別並嘗試異步初始化,無需複雜的配置即可實現效果不錯的加速。

暫時不被使用的Bean

上述的兩種異步化中,我們通過bean是否被“依賴”來決定是否異步初始化。但還有很多Bean,不是被spring通過遍歷的方式創建的,這就導致我們上面的方案覆蓋不到一些耗時的bean。
但事實上,我們Bean的初始化過程,只要在Bean被“使用”前完成即可,被“依賴”這個條件,是過於嚴格的。如果我們將Bean對象的方法訪問判定為被“使用”的入口,那麼我們可以通過對Bean進行代理,攔截Bean的方法訪問,在其被“使用”之前,等待他初始化完成。
image.png
這樣我們可以指定任意的Bean進行異步初始化。但有一種情況是不安全的:這個Bean定義了公共的變量,如果在初始化之前被訪問,是不能被代理攔截的。我們在實現bean的時候,要注意不要暴露內部變量,這是一個很重要的習慣。

FactoryBean的處理

然而上述的並行化方式,對於有一種類型的bean是不適用的,那就是FactoryBean。不論有沒有刻意注意過,寫java應用的同學應該都接觸過FactoryBean,最常見的就是我們的Mapper。FactoryBean創建bean的過程比較特殊,他會先創建一個FactoryBean的實例,然後由這個FactoryBean實例如創建出我們最終想要的bean實例。因此FactoryBean初始化的不是最終得到的實例,而是生成這個實例的工廠,而這個工廠的初始化完成與否,大概率會影響到bean的生成,因此他不能簡單的將初始化過程異步化。
image.png
如圖是一個Bean的獲取過程:

  1. 如果這個Bean是一個單例並且沒有被創建過,那麼就會進入createBean,並且在其中完成初始化。
  2. 如果這個Bean是一個FactoryBean,那麼createBean返回的不是bean本身,而是一個factory,真正的bean要在之後的getInstance方法中獲取。

在我們的項目中,存在著大量HSFSpringConsumerBean的實例,他們都是FactoryBean,而且這些bean的初始化還相當的耗時。(HSFSpringConsumerBean是我們RPC框架HSF的consumer的FactoryBean)
好在FactoryBean也不是完全不能異步初始化,我們分析一個Bean的get獲取過程,會發現,他分為createBean和getInstance兩個階段,在FactoryBean的處理中,如果能夠將兩個階段人為分開,先異步完成第一階段的調用,再觸發第二階段,就可以實現我們的目標。
image.png
如圖,實現對factoryBean的加速,我們需要在FactoryBean被getBean之前將需要加速的Bean一起找出來,先手動觸發它們的異步初始化,但不觸發getObject方法,等這些FactoryBean初始化完成後,再交由spring按照原來的創建順序,去觸發他們的getBean方法(此時singleton已經創建,會直接進入getObject調用)。但如果這些Bean對其他的bean有依賴,可能會導致在第一步的異步初始化中產生間接依賴而觸發getBean。
好消息是HSFSpringConsumerBean不對其他的bean有依賴,而且項目中絕大多數耗時的FactoryBean都是HSFSpringConsumerBean。如果在Spring初始化所有Bean之前,我們可以一次性並行把所有HSFSpringConsumerBean初始化掉,也能夠獲得較大的提升。對於其他不產生依賴的FactoryBean,也可以按照一樣的方式處理,比如我們比較常見的mapper。
對於可能對其他bean產生依賴的FactoryBean,理論上我們也可以通過去阻塞這些bean的getBean方法,等待我們第一階段預初始化的完成。目前項目中這些bean的比例很小,因此這部分功能尚未著手實現。

編譯加速-module依賴關係優化

加速效果:☆☆☆
配置簡易:☆
推薦指數:☆☆☆
目前項目使用的多mudule結構,往往含有start,mtop(接口層),service等層,其中module之間又相互依賴,導致一個低層module依賴的中間件,又會繼續被上層模塊解析。而往往上層模塊自身非常薄,卻因為依賴了低層模塊,不得不反覆解析龐大的依賴樹,導致編譯時間非常長。調整module的方式,是一種解決方案,但會破壞項目的module,失去來原來多module的優勢。
我們針對含有start,mtop,service的項目,提出一種改動較小的優化方式:
image.png
如圖所示:

  1. mtop層在依賴service層的時候,排除service層的所有間接依賴,僅針對mtop層自身也需要依賴的內容進行手動引入。
  2. start層依賴mtop和service,這裡不能再做排除,因為項目是在start層進行打包的,如果排除,則會導致依賴包沒有被正常打包到項目中而無法啟動。
  3. 按照這種方式優化,能夠節省在mtop層解析service依賴樹的開銷,往往有幾十秒之多。

我們同時也提出約定,在使用這種module結構的時候,控制好mtop和service的邊界:

  1. mtop層是對service提供的服務進行mtop接口級別的封裝,儘量僅依賴應用內service層和common定義的服務和對象,不處理複雜的中間件邏輯
  2. 對於外部服務的使用和中間件定義的服務和對象的使用,儘量在service層封裝,同時不宜將外部服務和中間件定義的對象直接透給mtop層,造成依賴擴散
  3. 這約定之後,清晰mtop層與service層的邊界,mtop層就是操作service層定義的方法和對象來完成接口,涉及外部定義的方法和對象的,收口到service。

鏡像治理-分層構建

加速效果:☆☆☆
配置簡易:☆☆☆
推薦指數:☆☆☆☆
image.png
如圖,我們使用docker進行構建時,push/pull image 的時候, 如果某一層的鏡像已經存在了, 就會直接使用緩存, 跳過重複的推送和拉取過程。而正常情況下,應用打出的包 (一般是 tgz 包) 是一個整體, 即使用戶只修改了一行代碼, 也要打出一個完整的包,包含所有依賴的jar文件, 導致每次push和pull的時候,都要傳輸所有的jar包,導致效率低下。
如果在打包的時候將jar包和項目代碼分到不同的層裡面,在絕大多數構建中,jar包不發生變化,則需要被更新的內容大大減小,進而提升push和pull的速度。同時,在應用啟動過程中,tgz包解壓需要花費一定的時間,在分層打包的改造中,去除了壓縮解壓過程,使得速度進一步提升。
image.png
該方案對鏡像構建、鏡像拉取、應用啟動三個過程均有提速,在idle-local中,綜合收益超過30秒(一次構建加一次部署)。

應用停止過程加速

加速效果:☆☆☆☆
配置簡易:☆☆
推薦指數:☆☆☆☆
停止應用過程中,比較耗時的有兩個步驟:1、hsf優雅下線;2、應用停止。其中,hsf優雅下線過程,會先通知應用進行hsf provider下線,然後等待一個比較安全的時間,對於生產環境來說,15秒是相對安全的值,對於預發環境來說,可以不等待。應用停止是通過kill -0 信號進行應用停止,應用會進行一些停止前的操作,不同應用不盡相同,如果停止失敗,則使用kill -9強制退出;對於已經進行HSF優雅下線並且沒有其他關鍵退出動作的應用來說,可以直接關閉應用,可以加速停止的耗時,對於非線上環境環境來說,是比較適用的。

效果及展望

image.png
經過一系列的優化措施,我們可以看到我們的系統編譯和啟動過程得到了不錯的優化。其中紫色部分的耗時基本可以忽略,紅色部分的耗時減少了一倍。
image.png
到目前為止,我們落地了一套有效的加速方案,在idle-local上取得了不錯的效果。後續我們將繼續完善這一系列方案,一方面,我們將著力建設一個基於監控的長效管控方案,能夠讓應用長期保持較好的狀態;另一方面,我們會將上述內容抽象成一個一站式落地方案,支持在其他項目快速的配置落地。

項目 優化效果 配置成本
Spring Bean異步初始化(自動) 10秒*n,隨代碼規格提升效果更明顯 引入實現了加速的jar包
Spring Bean異步初始化(手動) 20秒*n,隨代碼規格提升效果更明顯 引入實現了加速的jar包並配置bean
HSFSpringConsumerBean優化 10秒*n,隨代碼規格提升效果更明顯 引入實現了加速的jar包
module依賴關係優化 40秒 需要調整pom並處理依賴
docker鏡像分層 30秒 修改打包腳本
停止過程加速 40秒(非線上環境) 修改停止腳本

Leave a Reply

Your email address will not be published. Required fields are marked *