spring cloud zuul使用記錄(2)路由接入流程以及併發重新整理問題

weixin_33850890發表於2018-09-14

最近在看spring cloud zuul(版本Finchley.SR1)的原始碼,一不小心還看到了個bug(我認為是哈),更神奇的是,這個bug一年前已經有人提了issue,並提交了PR(竟然搶在我之前了)。但是現在還沒有合併進來,7天前被管理員放進了icebox,這是什麼操作?我不太清楚?是說會拿出來合併麼?還是啥?哪位有經驗的同學知道麻煩告訴我。那這整個事情是怎麼樣的呢?這都得從Zuul路由管理與SpringMVC的請求接入說起

起源

我們知道在配置zuul property的時候,當配置了一個route的path之後,zuul就會自動讀取這些路由規則並進行配置。那這一切是怎麼做到的呢?我們首先看我們啟用@EnableZuulProxy啟動zuul之後spring具體做了什麼:

13670604-d6abe732884d6aa1.png
EnableZuulProxy配置類

這個配置類引入了一個ZuulProxyMarkerConfiguration

13670604-f3aac17b0baf1220.png
ZuulProxyMarkerConfiguration類

而這個類只是引入了一個Marker Bean,通過find usage我們看到

13670604-dca81ba9babadbcc.png
ZuulProxyAutoConfiguration類

這個autoConfiguration類是通過spring.factories來注入的自動配置。而這個類他繼承了ZuulServerAutoConfiguration:

13670604-d26da3f7afcb6d4f.png
ZuulServerAutoConfiguration類

這個類中我們注意的是一個SimpleRouteLocator,這個類注入了zuulProperties:

13670604-869455892b84099e.png
ZuulProperties類

這個類就是讀取的配置檔案中的zuul相關的properties,那注入這個properties的SimpleRouteLocator就很有可能是生成route的地方。

13670604-4d16967d2572b9e0.png
SimpleRouteLocator類

SimpleRouteLocator類中的程式碼也表明了我的判斷。到這裡我們知道了配置是怎麼對映到route規則的,當然僅僅這一點遠遠不夠,我們知道,當我們定義了一個route規則之後,我們可以直接請求訪問這個route的path來達到我們想要到達的serviceId或者url而不需要定義任何controller,這個spring是如何做到的呢?

引路人

針對上面的問題,我重新回到之前提到的幾個配置類,發現瞭如下資訊:

13670604-c7b778050646acde.png
ZuulController和Mapping配置

我們知道本身Zuul是通過servlet來做的入口,而我們上圖看到的這個ZuulController

13670604-2d6baaddf8f62fc0.png
ZuulController實現

我們可以發現他就是一個Servlet的包裝,通過將請求代理給ZuulServlet來實現zuul的功能,可見在這個Controller前面肯定需要一個元件去把請求forward給它,這個元件很有可能就是之前看到的ZuulHandlerMapping,因為它的初始化使用到了zuulController

13670604-acc805c4ab494f5b.png
zuulHandlerMapping繼承關係與註釋

通過檢視ZuulHandlerMapping繼承關係和註釋我們看到了他繼承了AbstractUrlHandlerMapping抽象類,熟悉SpringMVC的同學知道,對於請求入口或者我們自己編寫的Controller方法,SpringMVC會生成HandlerMapping示例,DispatcherServlet通過遍歷spring上下文中已經存在的HandlerMapping來進行http請求的查詢匹配,執行鏈路的組建和請求的執行,我給大家列出來DispatcherServlet中的程式碼,具體的呼叫鏈路和原理有興趣的同學可以下來看看:

13670604-d1db77e71c60cd37.png
DispatcherServlet呼叫入口

針對ZuulHandlerMapping中的程式碼,我們目前只需要知道,對於每次request請求進來,dispatcherServlet都會呼叫ZuulHandlerMapping的lookupHandler方法,來查詢是否有合適的zuul route規則,如果有就將請求匯入給ZuulController,那麼我們再來仔細看看具體的方法:

13670604-333410ad32c7e877.png
lookupHandler實現

之前一通常規操作,然後通過一個volatile變數dirty判斷目前的route是否有變更,如果有就重新註冊路由資訊並且重置dirty變數為false,最後呼叫父類的loopupHandler。dirty變數預設為true,就保證在請求進來的時候肯定會有一個初始化的過程,那我們進入registerHandlers方法看看

13670604-7e9d068606b3f079.png
registerHandlers方法

這裡我們就明白了,這個方法對當前所有的routes資訊都呼叫父類的registerHandler來註冊能處理的path。從而完成了整個呼叫鏈路的匹配與搭建。ZuulHandlerMapping就像是一個引路人一樣指引每一個能被zuul處理的request到ZuulController中。一切看起來都非常美好對吧。但是善於思考的同學又會有新的問題了,dirty只會在初始化的時候使用麼?routes可以中途重新整理麼?答案是可以的。

Bad Smell

我注意到ZuulHandlerMapping類中有這樣一個方法:

13670604-32ae5514043813ee.png
setDirty

通過find usage我們知道這個方法會在一些EventListener中被呼叫

13670604-8cf7af1bb3c88282.png
更新設定dirty呼叫

通過上述兩份程式碼和查閱Spring Cloud Zuul的文件我知道,如果你想要使得你的RouteLocator能夠可以更新,那就讓你的RouteLocator類實現RefreshableRouteLocator介面並實現refresh方法,然後在每次需要更新的時候向spring 上下文釋出RoutesRefreshedEvent就行了,剩下的一切就交給剛剛看到的程式碼和spring做就行了。這一切看上去也很perfect。但是我總覺得哪裡不對,感覺聞到了怪怪的味道。這邊再給大家仔細列一下程式碼:

13670604-b77c40e0296d7f8e.png
怪怪的味道

當一個更新事件發起的時候,setDirty方法會先設定dirty為true,然後呼叫routeLocator的refresh方法,這沒問題。在一個請求進來的時候,會檢查dirty是否為true,如果有,則重新註冊path和handler,這似乎也沒問題,還用了double check。但是這兩個加在一起,是否存在這樣的場景,當一次routeLocator refresh的時間比較長而這時候zuul的請求load比較高的時候,一個請求進來發現此時需要重新註冊handler,但這是routes資訊並沒有完成重新整理,或者說根本沒有開始重新整理,那這時候註冊的,還是重新整理前的老的資料,也就是說,更新之後的路由資訊完完全全沒有被註冊到springmvc的處理鏈路中,整個閘道器並不會處理新增加的path,或者還會接入已經刪除的path,這是個bug!歸納起來,就是當registerHandler的呼叫執行緒優先於routeLocator的refresh的呼叫,那麼路由資料的更新就會失效並且這是不可恢復的!我在github上查詢有關dirty的issue,也發現了下面的記錄

https://github.com/spring-cloud/spring-cloud-netflix/pull/2259

這個issue跟我描述的基本上一毛一樣,並且也提了PR,也就是本文最開始所提到(有木有哪位老鐵告訴我啥叫icebox啊)。

當然BB是不夠的,我下面會通過一個例子來闡述這個bug:

證據

下面所講的程式碼都已經提交的github:

https://github.com/ro9er/zuul-dirty-bug-sample

首先我們定義一個RefreshableRouteLocator

13670604-7417063dc13f6d3c.png
RefreshableRouteLocator實現

這個實現其實很簡單,用Entiry抽象了路由資訊,並且在每次重新整理的時候重新完成Entiry到Route的對映工作,並且實現了SimpleRouteLocator的getRoutes和getMatchingRoute方法,getRoutes在我們之前看到的ZuulHandlerMapping中registerHandlers中被呼叫,用來註冊handler資訊。getMatchingRoute方法在Spring Cloud Zuul實現的PreDecorationFilter中被呼叫,用來確定一個具體的route,並設定到跳轉規則中,這裡就不具體展開了。這裡我在refresh方法中註釋了執行緒sleep10秒的操作,後面我會開啟它。當這個routeLocator初始化的時候只有一個/baidu路由規則跳轉到百度

然後我實現了一個Controller:

13670604-ab0a807cedd72cdd.png
重新整理controller方法

這個controller暴露一個重新整理路由資訊,這裡我們看到它會向我之前定義的routeLocator增加一條/163跳轉網易的規則,並且發出一個RoutesRefreshedEvent,從而觸發路由規則觸發流程。然後我們來看看整個呼叫場景:

13670604-4d45d71cee37b72f.png
呼叫百度場景
13670604-b48ce35413c667a9.png
呼叫163報404
13670604-abee54fa384c2d51.png
呼叫重新整理路由介面
13670604-de1731c2ab1494e3.png
再次呼叫163

通過上面整個場景流程我們知道,在在開始啟動的時候,只有/baidu規則有效,/163會直接404,在我們重新整理路由之後,在此訪問/163,成功跳轉到網易,證明我們的重新整理機制是生效了。那麼我們現在來複現bug,為了讓這個bug比較容易的復現,我在refresh方法中開啟了執行緒sleep 10s的操作,使得我們的重新整理路由操作會延遲執行:

13670604-d1792f035d25e641.png
開啟執行緒sleep

再次重複之前的流程,重複的步驟我就不貼圖了,我們知道在增加了這個執行緒sleep的情況下,我們的refreshRoutes介面會變慢,當我們在這個介面執行的過程中我們呼叫一個/163,會因為dirty重新出發regsiterHandler,並且返回404(顯然的,因為現在根本沒有增加163這個規則),然後我們在refreshRoutes返回之後再次執行/163:

13670604-bf158dc15315f23d.png
呼叫refresh
13670604-7496254b84c4eec5.png
第一次呼叫163


13670604-80ea8e379d0105fc.png
第二次呼叫163

從上述的呼叫可以發現,重新整理之後新的路由並沒有生效,而且這個除非你重新呼叫一次refresh,不然不可能恢復。然鵝,就算你呼叫refresh,也不一定能夠恢復,因為有可能下次request進來又把你沖掉了。

解決方案

問題明確了,怎麼解決呢?如之前PR中所說的,可以把setDirty中的dirty賦值操作放到最後:

13670604-a981dd8ef903844d.png
dirty放到最後

這個修改應該就能解決這個問題,但是現在並沒有合併進來,還有沒有其他辦法呢?

我的辦法是增加一個Listener,並且通過Ordered介面保證第一個執行,在訊息處理裡面手動觸發refresh,不過弊端就是refresh會呼叫兩次

13670604-d9f4f7fc2b985d91.png
增加訊息處理

親測可用。

結語

到此整個bug的出現我大概已經說明清楚了,並且順帶把zuul的handler mapping流程也梳理了一遍,大家有什麼問題歡迎留言,希望能跟大家一起交流,共同進步。

相關文章