這是why的第 64 篇原創文章
荒腔走板
大家好,我是 why,歡迎來到我連續周更優質原創文章的第 64 篇。老規矩,先荒腔走板聊聊其他的。
上面這圖是我之前拼的一個拼圖。
我經常玩拼圖,我大概拼了 50 副左右的 1000 個小塊的拼圖,但是玩的都是背后有字母或者數字分區提醒的那種,最快紀錄是一天拼完一副 1000 塊的拼圖。
但是上面這幅,只有 800 個小塊,卻是我拼過的最難的一幅。因為這個背后沒有任何提示,只能按照前面的色彩、花紋、邊框進行一點點的拼湊。前后花了我兩周多的時間。
這完全是一種找虐的行為。
但是你知道這個拼圖拼出來的圖案叫什么嗎?
壇城,傳說中佛祖居住的地方。
第一次知道這個名詞是 2015 年,窩在寢室看紀錄片《第三極》。
其中有一個片段講的就是僧人為了某個節日用專門收集來的彩沙繪畫壇城,他們的那種專注、虔誠、真摯深深的打動了我,當宏偉的壇城畫完之后,它靜靜的等待節日的到來。
本以為節日當天眾人會對壇城頂禮膜拜,而實際情況是典禮開始的時候,大家手握一炷香,然后看着眾僧人快速的用掃把摧毀壇城。
還沒來得及仔細欣賞那復雜的美麗的圖案,卻又用掃把掃的干干凈凈。掃把掃下去的那一瞬間,我的心受到了一種強烈的撞擊:可以辛苦地拿起,也可以輕松地放下。
那個畫面對我的視覺沖擊太大了,質本潔來還潔去。以至於我一下就牢牢的記住了這個詞:壇城。
后來去了北京,在北京的出租屋里面,看着空盪盪的牆面,我想:要不拼個壇城吧,把北漂當做一場修行,應景。
拼的時候我又看了一遍《第三極》,看到摧毀壇城的片段的時候,有一個彈幕是這樣說的:
一切有為法,如夢幻泡影,如露亦如電,應作如是觀。
這句話出自《金剛般若波羅蜜經》第三十二品,應化非真分。之前翻閱過幾次《金剛經》讀到這里的時候我就覺得這句話很有哲理,但是也似懂非懂。所以印象比較深刻。
當它再次以這樣的形式展現在我的眼前的時候,我一下就懂了其中的哲理,不敢說大徹大悟,至少領悟一二。
觀看摧毀壇城,這個色彩斑斕的世界變幻消失的過程,我的感受是震撼,可惜,放不下。
但是僧人卻風輕雲淡的說:一切有為法,如夢幻泡影,如露亦如電,應作如是觀。
紀錄片《第三極》,豆瓣評分 9.4 分,推薦給你。
好了,說回文章。
一道面試題
讓我們開門見山,直面主題:Dubbo 服務里面有個服務端,還有個消費端你知道吧?
服務端和消費端都各有一個線程池你知道吧?
那么面試題來了:一般情況下,服務提供者比服務消費者多吧。一個服務消費方可能會並發調用多個服務提供者,每個用戶線程發送請求后,會進行超時時間內的等待。多個服務提供者可能同時做完業務,然后返回,服務消費方的線程池會收到多個響應對象。這個時候要考慮一個問題,如何將線程池里面的每個響應對象傳遞給相應等待的用戶線程,且不出錯呢?
先說答案。
這個題和答案其實就寫在 Dubbo 的官網上:
http://dubbo.apache.org/zh-cn/docs/source_code_guide/service-invoking-process.html
以下回答來自官網:
答案是通過調用編號進行串聯。
DefaultFuture 被創建時(下面我們會講這個 DefaultFuture 是個什么東西),會要求傳入一個 Request 對象。
此時 DefaultFuture 可從 Request 對象中獲取調用編號,並將 <調用編號, DefaultFuture 對象> 映射關系存入到靜態 Map 中,即 FUTURES。
線程池中的線程在收到 Response 對象后,會根據 Response 對象中的調用編號到 FUTURES 集合中取出相應的 DefaultFuture 對象,然后再將 Response 對象設置到 DefaultFuture 對象中。
最后再喚醒用戶線程,這樣用戶線程即可從 DefaultFuture 對象中獲取調用結果了。整個過程大致如下圖:
上面是官網上的答案,寫的比較清楚了,但是官網上是在寫服務調用過程的時候順便講解了這個考察點,源碼散布在各處,看起來比較散亂,不太直觀。有的讀者反映看的不是特別的明白。
我知道你為什么看的不是那么明白,我在之前的文章里面說過了,你根本就只是在官網白嫖,也不自己動手,像極了看我文章時候的樣子:
好了,反正我也習慣被白嫖了,蹭我還寫的動,你們就可勁嫖吧。
源碼之中無秘密。帶你從源碼之中尋找答案,讓你把官網上的回答和源碼能對應起來,這樣就更方便你自己動手了。
需要說明一下的是本文 Dubbo 源碼版本為 2.7.5。而官網文檔演示的源碼版本是 2.6.4 。這兩個版本上還是有一點差異的,寫到的地方我會進行強調。
Demo演示
Demo 大家可以直接參照官方的快速啟動:
dubbo.apache.org/zh-cn/docs/user/quick-start.html
我這里就是一個非常簡單的服務端:
客戶端在單元測試里面進行消費:
是的,細心的老朋友肯定看出來了,這個 Demo 我已經用過非常多次了。基本上我每篇 Dubbo 相關的文章里面都會出現這個 Demo。
我建議你自己也花了 10 分鍾時間搭一個吧。對你的學習有幫助。別懶,好嗎?
我給你一個地址,然后你拉下來就能跑,這種也不是不行。這種我也考慮過。主要是治一治你不想自己動手的毛病,其次那不是我也懶得弄嘛。
好了,上面的 Demo 跑一下:
輸出也是在我們的意料之中。當然了,大家都知道這個輸出也必須是這樣的。
那么你再細細的品一品。
我們扣一下題,把最開始的問題簡化一下。
最開始的問題是一個服務消費端,多個服務提供者,然后服務提供者同時返回響應數據,消費端怎么處理。
其實核心問題就是服務消費端同時收到了多個響應數據,它應該怎么把響應數據對應的請求找到,只有正確找到了請求,才能正確返回數據。
所以我們把重心放到客戶端。
在上面的例子中:參數 why1 和 why2 幾乎是同時發到服務端的請求 ,然后服務端對於這兩個請求也幾乎同時響應到了客戶端。
在服務端沒有返回的時候客戶端的兩個請求是在干什么?是不是在用戶線程上里面等着的接收數據?
那么問題就來了:Dubbo 是怎么把這兩個響應對象和兩個等待接收數據的用戶線程配對成功的?
接下來,我們就帶着這個問題,去源碼里面尋找答案。
請求發起,等待響應
首先前面兩節我們都說到了客戶端用戶線程的等待,也就是一次請求在等待響應。
這個等待在代碼里面是怎么體現的呢?
答案藏在這個方法里面:
org.apache.dubbo.rpc.protocol.AsyncToSyncInvoker#invoke
首先你看這個類名,AsyncToSyncInvoker,異步調用轉同步調用,就感覺不簡單,里面肯定搞事情了。
標號為 ① 的地方,是 invoker 調用,調用之后的返回是一個AsyncRpcResult。
在這個方法繼續往下 Debug,沒幾步就可以走到這個地方:
org.apache.dubbo.remoting.exchange.support.header.HeaderExchangeChannel#request(java.lang.Object, int, java.util.concurrent.ExecutorService)
135 行就是 channel.send(req)。在往外發請求了,在發請求之前構建了一個 DefaultFuture。然后在請求發送出去之后,140 行返回了這個 future 。
最關鍵的秘密就藏在 133 行的這個 newFuture 里面。
看一看對應代碼:
這個 newFuture 主要干了兩件事:
-
初始化 DefaultFuture 。
-
檢測是否超時。
我們看看初始化 DefaultFuture 的時候干了啥事:
首先我們在這里看到了 FUTURES 對象,這是一個 ConcurrentHashMap。這個 FUTURES 就是官網上說的靜態 Map:
Map 里面的 key 是調用編號,也就是第 82 行代碼中,從 request 里面獲得的 id:
這個 id 是 AtomicLong 從 0 開始自增出來的。
代碼里面還給了這樣一行注釋:
getAndIncrement() When it grows to MAX_VALUE, it will grow to MIN_VALUE, and the negative can be used as ID
說這個方法當增加到 MAX_VALUE 后再次調用會變成 MIN_VALUE。但是沒有關系,負數也是可以當做 ID 來用的。
這個 DefaultFuture 對象構建完成后是返回回去了。
返回到哪里去呢?
就是 DubboInvoker 的 doInvoker 方法中下面框起來的代碼:
在 103 行,包裝之后的 DefaultFuture 會通過構造方法放到 AsyncRpcResult 對象中:
而 DubboInvoker 的 doInvoker 方法返回的這個 result,即 AsyncRpcResult 就是前面標號為 ① 這里的返回值:
接着說說標號為 ② 的地方。
首先是判斷當前調用模式是否是同步調用。我們這里就是同步調用,於是進入到 if 判斷里面的邏輯。在這里面一看,調用的 get 方法,還帶有超時時間。
看一下這個 get 方法是怎么樣的:
可以看到這個 get 方法不是一個簡單的異步編程的 CompletableFuture.get 。里面還包含了一個 ThreadlessExecutor 的 waitAndDrain 方法的邏輯。
這個方法一進來就是 queue.take 方法,阻塞等待。
這個隊列里面裝的是什么東西?
全局查找往這個隊列里面放東西的邏輯,只有下面這一處:
說明這個隊列里面扔的是一個 runable 的任務。
這個任務是什么呢?
我們這里先買個關子,放到下一小節里面去講。
你只要知道:如果隊列里面沒有任務,那么用戶線程就會一直在 take 這里阻塞等待。
有的小伙伴就要問了:這里怎么能是阻塞式的無限等待呢?接口調用不是有超時時間嗎?
注意了,這里並不是無限等待。Dubbo 會保證當接口不管是否超時,都會有一個 Runable 的任務被扔到隊列里面。所以 take 這里最多也就是等待超時時間這么長時間。
先記着這里,下面會給大家講到超時檢測的邏輯。
看到這里,我們已經和官網上的回答產生一點聯系了,我再給大家捋一捋我們現在有的東西:
第一點:用戶線程在 AsyncToSyncInvoker 類里面調用了下面這個方法,在等結果。代碼和官網上的描述的對應關系如下:
官網上說:會調用不同 DefaultFuture 對象的 get 方法進行等待,這應該是 2.6.x 版本的做法了。
在 2.7.5 版本中是在 AsyncRpcResult 對象的 get 方法中進行等待。而在該方法中,其實是調用了隊列的 take 方法,阻塞等待。
在這兩個不同對象上的等待是兩種完全不同的實現方式。2.7.5 版本里面這樣做也是為了做客戶端的共享線程池。實現起來優雅了很多,大家可以拿着兩個版本的代碼自行比較一下,理解到他的設計思路之后覺得真的是妙啊。
但是不論哪個版本,萬變不離其宗,請求發出去后,還是需要在用戶線程等待。
第二點:發送 request 對象之前構建了一個 DefaultFuture 對象。在這個對象里面維護了一個靜態 MAP:
有了調用編號和 DefaultFuture 對象的映射關系。等收到 Response 響應之后,我們從 Response 中取出這個調用編號,就知道這個調用編號對應的是哪個 DefaultFuture 了,妙啊。
但是,等等。“從 Response 中取出這個調用編號”,那不是意外着我們得把調用編號送到服務端去?在哪送的?
答案是在協議里面,還記得上一篇文章中講協議的時候里面也有個調用編號嗎?
呼應上了沒有?
每個請求和響應的 header 里面都有一個請求編號,這個編號是一一對應的,這是協議規定好的。
在發送 request 之前,對其進行 encode 的時候寫進去的:
org.apache.dubbo.remoting.exchange.codec.ExchangeCodec#encodeRequest
然后 Dubbo 就拿着這個攜帶着 requestId 的請求這么輕輕的一發。
你猜怎么着?
就等着響應了。
接收響應,尋找請求
請求發出去是一件很簡單的事情。
但是作為響應回來之后就懵逼。一個響應回來了,找不到是誰發起的它,你說它難受不難受?難受就算了,你就不怕它隨便找一個請求就返回了,當場讓你懵逼。
你說響應消息是在哪兒處理的?
上篇文章專門講過哈,說不知道的都是假粉絲:
org.apache.dubbo.rpc.protocol.dubbo.DubboCodec#decodeBody
你看上門代碼截圖的第 66 行:get request id(獲取請求編號)。
從哪里獲取?
從 header 中獲取。
header 中的請求編號是哪里來的?
發起 request 請求的時候,從 request 對象中取出來寫到協議里面的。
request 對象中的請求編號是哪里來的?
通過 AtomicLong 從 0 開始自增來的。
好了,知道這個 id 是怎么來的了,也獲取到了。它是在哪里用的呢?
org.apache.dubbo.remoting.exchange.support.DefaultFuture#received(org.apache.dubbo.remoting.Channel, org.apache.dubbo.remoting.exchange.Response, boolean)
標號為 ① 的地方就是根據 response 里面的 id,即調用編號從 FUTURES 這個 MAP 中移除並獲取出對應的請求。
如果獲取到的請求是 null,說明超時了。
如果獲取到的請求不為 null,則判斷是否超時了。超時邏輯我們最后再講。
標號為 ② 地方是要把響應返回給對應的用戶線程了。
在 doReceived 里面使用了響應式編程:
這的 this 就是當前類,即 DefaultFuture。
那么這個 doReceived 方法是怎么調到這里的呢?
之前的文章說過 Dubbo 默認的派發策略是 ALL,所以所有的響應都會被派發到客戶端線程池里面去,也就是這個地方:
當接收到服務端的響應后,響應事件也會被扔到線程池里面,從代碼中可以看到,扔進去的就是一個 Runable 任務。
然后執行了 execute 方法,這個方法就和上一小節講請求的地方呼應上了。
還記得我們的請求是調用了 queue.take 方法,進入阻塞等待嗎?
而這里就是在往 queue 里面添加任務。
隊列里面有任務啦!在阻塞等待的用戶線程就活過來了!
接下來用戶線程怎么執行?
看代碼:
取到任務后執行了任務的 run 方法。注意是 run 方法哦,並不會起新的線程。
而這個任務是什么任務?
是 ChannelEventRunnable。看一下這個任務重寫的 run 方法:
這不是巧了嗎,這不是?
上周的文章也說到了這個方法。
而 handler.received 方法最終就會調用到我們前說的 doReceived 方法:
閉環完成。
所以當用戶線程執行完這個 Runable 任務后,繼續往下執行:
這里返回的 Result 就是最終的服務端返回的數據了,或者是返回的異常。
現在你再回過頭去看官網這張圖,應該就能看明白了:
超時檢查
前面說 newFuture 的時候不是說它還干了一件事就是檢測是否超時嘛。其實原理也是很簡單:
首先有一個 TimeoutCheckTask 類,這是一個待執行的任務。
觸發后會根據調用編號去 FUTURES 里面取 DefaultFuture。
前面我剛剛說了:如果一個 future 正常完成之后,會從 FUTURES 里面移除掉。
那么如果到點了,根據編號沒有取到 Future 或者取到的這個 Future 的狀態是 done 了,則說明這個請求沒有超時。
如果這個 Future 還在 FUTURES 里面,含義就是到點了你咋還在里面呢?那肯定是超時了,調用 notifyTimeout 方法,是否超時參數給 true:
這個 received 方法全局只有兩個調用的地方,一個是前面講的正常返回后的調用,一個就是這里超時之后的調用:
也就是不論怎樣,最終都會調用這個 received 方法,最終都會通過這個方法把對應調用編號的 DefaultFuture 對象從 FUTURE 這個 MAP 中移除。
上面這個任務怎么觸發呢?
Dubbo 自己搞了個 HashedWheelTimer ,這是什么東西?
時間輪調度算法呀:
你發起一個請求,指定時間內沒有返回結果,於是就取消(future.cancel)這個請求。
這個需求不就類似於你下單買個東西,30 分鍾還沒有支付,於是平台自動給你取消了訂單嗎?
時間輪,可以解決你這個問題。之前的這篇文章中有介紹:《面試時遇到『看門狗』脖子上掛着『時間輪』,我就問你怕不怕?》
一個 2.7.5 版本關於檢查 Dubbo 超時的小知識點,送給大家。
驗證編號
前面一直在強調,這個調用編號很重要。
所以為了讓大家有個更加直觀的認識,我截個簡單的圖,給大家驗證一下這個編號確實是貫穿請求和響應的。
首先,改造一下我們的服務端:
當傳進來的 name 是指定參數(why-debug)時,直接返回。否則都睡眠 10 秒,目的是讓客戶端用戶線程一直等待響應。
客戶端改造如下:
先連續發 40 個請求到服務端,對於這些請求服務端都需要 10 秒的時間才能處理完成。
然后再發生一個特定請求到服務端,能即使返回。並在 39 行打上斷點。
首先,看一下 DefaultFuture 里面的調用編號。
沒看之前,你先猜一下,當前 debug 的這個請求的調用編號是多少?
是不是 40 號(編號從 0 開始)?
來驗證一下:
所以在發送請求的地方,在 header 里面設置調用編號為 40:
然后看一下響應回來之后,對應的調用編號是否是 40:
這樣,一個調用編號,串聯起了請求和響應。讓請求必有回應,讓響應必定能找到是哪個請求發起的。
才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,可以在留言區提出來,我對其加以修改。
感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。
我是 why,一個被代碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。