why哥這裡有一道Dubbo高頻面試題,請查收。
這是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,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。