沒想到吧!關於Dubbo的『消費端執行緒池模型』官網也寫錯了。

why技術發表於2020-09-01

這是why的第 63 篇原創文章

荒腔走板

大家好,我是 why,歡迎來到我連續周更優質原創文章的第 63 篇。老規矩,先荒腔走板聊聊其他的。

上面這張圖片是我前幾天整理相簿的時候看到的。拍攝於 2016 年 8 月 20日,北京。

那個時候我剛剛去北京沒多久,住在公司的提供的宿舍裡面。宿舍位於北京二環內的一個叫做東廊下的衚衕裡。 位置極佳,條件極差。

我剛剛進入宿舍的時候,房間裡面只有一張大床、一個矮矮的電視櫃、一個不能搖頭的風扇。我的房間也沒有空調,到處都是灰濛濛的,用衛生間都是去樓下的公共衛生間。

有一次北京下暴雨,我才發現窗戶那邊有一個缺口,雨下的太大,可以順著那個缺口流下來,把我的鞋都打溼了。

宿舍裡面沒有冰箱,所以節假日我在宿舍只煮麵條或者用電飯煲做乾飯,然後就著各種醬吃。記得有一次週五領導請我們吃飯,最後菜點多了,有幾個羊蹄動都沒動,領導就叫我打包帶回家。我帶回去,掛在牆上掛鉤,準備第二天中午吃。第二天一聞,壞了,也就沒有吃。

宿舍裡面也沒有洗衣機,所以我在超市買了一個巨大的盆子,每週末的時候我會拿出一個下午的時間,邊看電視,邊手搓衣服,四季如此。

剛剛去北京的前一年,過的真的還是很艱難的。但是宿舍的好處是離公司近,所以我基本上也不怎麼在宿舍呆著,工作日在公司學習到很晚,週末也去公司學習。

艱苦的環境更能激發人的鬥志。

但是我還是簡單的裝飾了一下簡陋的出租屋,買了貼畫和綠植,因為我堅信房子是租來的,但是生活是自己的。

而且每週洗完衣服後我會用洗衣服的水再拖一下地。我的房間很小,擺上一張 1.5 米的大床之後基本上就沒有什麼空間了,所以我用不上拖把,一張帕子就夠了。

我可以蹲在地上,把房間裡面的每一塊地磚的邊邊角角都仔仔細細的擦拭一遍,然後跳到床上去,靜靜的坐著,開始放空自己。

當時並沒覺得有什麼困難,但是和現在的生活再對比一下,真的是天壤之別。現在回想起,才真真正正的覺得:我曾經也在北京用力的生活過,離開的時候回憶滿滿,風華正茂。

就像我之前寫過的:北漂就像在黑屋子裡洗衣服,你不知道洗乾淨了沒有,只能一遍又一遍地去洗。等到離開北京的那一刻,燈光亮了,你發現,如果你認真洗過了,那件衣服光亮如新。讓你以後每次穿這件衣服都會想起那段歲月。 所以你呢,有沒有在用力的生活?

好了,說迴文章。

大佬指點,糾正錯誤

前段時間一位大佬指出了我之前文章中的一處錯誤:

文章是這篇文章《Dubbo 2.7.5線上程模型上的優化》

錯誤具體是指下面紅框框起來的這句話的描述:

而這段話,我是引用的官方內容。而現在這部分內容已經一字不差的加入到官網中了:

http://dubbo.apache.org/zh-cn/docs/user/demos/consumer-threadpool.html

經過驗證後發現確實官網上的描述是有問題的。

所以本文就主要分享兩個問題:

  • Dubbo 協議的設計與解析。
  • 以 Dubbo 2.7.5 版本(因為執行緒池模型就是在這個版本變更的)為分界線,對比不同版本之間,業務資料返回後,反序列化的操作到底是在獨立的 Consumer 端執行緒池裡面進行的還是在 IO 執行緒裡面進行的?

需要說明的是由於本文需要做不同版本之間的對比,所以會涉及到兩個 Dubbo 版本,分別是 2.7.4.1 和 2.7.5 。寫的時候我都會標註清楚,大家看的時候和自己動手的時候需要注意一下。

另外再提前說明一下,文章有點長:如果你自己看 Dubbo 原始碼,可以先看整體,忽略細節。把整體摸個遍了之後,再去扣細節,精進原始碼。本文就屬於扣細節,看的似懂非懂沒關係,先一鍵三連,然後收藏起來,你自己學的時候總是會學到這個地方來的,而且本文也不是一個非常難的技術點。

如果你沒有學到,只能說明你潛入的深度還是差了一點,也許你差一點就走到這個地方了,然後你想:算了吧,差不多得了。

但是你要知道,越往下,越難懂。而越難懂的,越值錢。

你想想,正在抗住流量的東西,是你寫的那幾行程式碼嗎?不是的,是你係統裡面用到的 Nginx、MQ、Redis、Dubbo、SpringCloud 等等這些中介軟體。而這些中介軟體裡面,抗住流量的,除了它們的叢集功能、容錯功能、限流熔斷、呼叫鏈路的優化等待這些手段之外,還有底層的網路、IO、記憶體、資料結構、排程演算法等等這些東西。

這是值錢的。

可惜這些值錢的,不好講清楚,要說清楚就是長篇大論。所以我常常說的勸退長文都是說說而已的,你這麼愛學習,我怎麼會勸退你呢,鼓勵你都來不及呢,你說是吧?

再說了,我寫的長文,也並沒有涉及到這麼底層的東西。只是我沒有想過敷衍這事,我想把它做好了,儘量把它寫清楚了,中間再夾雜著幾句“騷話”,所以寫著寫著就長了。

總之,你要堅信三點:

一:我沒有看懂,一定是因為這個博主寫的太爛。

二:我沒有看懂,理論上大多數人也應該看不懂。

三:我沒有看懂,那我自己研究一下得讓自己懂。

程式設計師就應該這樣,明明天天寫著這麼普通的 crud,但是聊起技術來卻是那麼的迷之自信。

Dubbo協議的設計與解析

為什麼要先聊一下 Dubbo 的協議呢?

因為反序列化的時候涉及到一些響應頭(head)和響應體(body)解析的相關內容,是需要先進行一下鋪墊的。

首先去官網上擼個圖片過來:

可以看到 Dubbo 資料包分為訊息頭(head)和訊息體(body)。

訊息頭用於儲存一些元資訊,包括:魔數、資料包型別、呼叫方式、事件標識、序列化器編號、狀態、請求編號、訊息體長度。

訊息體中用於儲存具體的呼叫訊息,包含七部分內容:

  • Dubbo 版本號(Dubbo version)
  • 服務介面名(service name)
  • 服務介面版本(service version)
  • 方法名(method name)
  • 引數型別(parameter types)
  • 方法引數值(arguments)
  • 上下文資訊(attachments)

客服端發起請求的時候嚴格按照上面的順序寫入訊息,服務端按照同樣的順序讀取訊息,這樣就能解析出訊息體裡面的內容。

對於協議欄位的解析,官網上也是有詳細說明的。擼過來:

再具體的解釋一下,首先這圖得和協議圖一起看,我怕你不會,再給你搞一張示意圖:

上面的截圖只是演示了三個對應關係,但是這兩張圖就是這樣看的。

我主要再解釋一下里面的某些欄位。

第一個:魔數

作為 Java 開發者,提到魔數,你第一個想到了什麼?

0xCAFEBABY,對吧。

每個 class 檔案的頭 4 個位元組就是魔數,它的唯一作用就是確定這個檔案是否為一個能被 JVM 接受的 class 檔案。

在 Dubbo 中這個魔數是用來幹什麼的呢?

也許你不太清楚,但是我希望我一說你就能恍然大悟。因為你不悟,也不是本文要講的東西,我也不好給你解釋清楚。

它是用來解決網路粘包/解包問題的。恍然大悟有沒有?

沒有?

對不起,本文不擴充套件相關內容。大學的時候《計算機網路》課程的時候逃課處物件去了吧?

在 Dubbo 協議中,它的魔數:0xdabb。你可以簡單的把它理解為一個分隔符,用來解決粘包問題的。

第二個再說說:呼叫方式

首先這個欄位僅在第 16 位設定為 1 的情況下有效。

從表裡面我們可以知道,第 16 位為 1 就是指:request 請求。

在 rpc 中既然是 request ,那麼就分為兩種呼叫方式:有去無回(單向)、有來有回(雙向)。

熟悉嗎?

不熟悉?呸,你個假粉絲,這張圖在我的文章中至少出現過兩次:

oneway 就是單向,其他的呼叫型別都是有返回的。

所以呼叫分為兩種型別,因此需要一個 bit 來存放呼叫方式。

第三個說說事件標識欄位

事件標識沒啥說的,取值裡面的描述也說的很清楚了。只是說明一下其中的 1 (心跳包),不在本次文章的分享範圍內。

第四個說說狀態欄位

狀態裡面有個省略號,說明沒有列舉完。但是程式碼裡面肯定是齊的,這些狀態對應的程式碼在這個類裡面,一共 11 個,給大家補充完整: org.apache.dubbo.remoting.exchange.Response

另外,再說一下返回的型別,講到後面的時候需要知道這個點。主要依據這個類裡面定義的欄位: org.apache.dubbo.rpc.protocol.dubbo.DubboCodec

對應的程式碼邏輯如下: org.apache.dubbo.rpc.protocol.dubbo.DubboCodec#encodeResponseData(org.apache.dubbo.remoting.Channel, org.apache.dubbo.common.serialize.ObjectOutput, java.lang.Object, java.lang.String)

這個方法從名稱也知道,是對響應資料做解碼操作的。

標號為①的地方是判斷當前版本是否支援上下文資訊傳遞。

標號為②的地方是判斷是否是異常返回。

標號為③的地方表明不是異常返回,則判斷返回值是否為 null。

標號為④的地方表明是正常返回,根據是否支援上下文資訊傳遞,從而判斷是隻返回響應結果的還是既有響應結果,也有上下文資訊的返回型別。

標號為⑤的地方表明是異常返回,根據是否支援上下文資訊傳遞,從而判斷是隻返回異常結果的還是既有異常結果,也有上下文資訊的返回型別。

好了,寫到這裡,協議就差不多說完了。其實不難發現這個協議就是一個偏理論的東西,這就是一個大家的約定。

所以我記起之前在一個分享大會上,一位嘉賓說的:

跨語言特性實際是RPC層的支援,本質是協議層面的支援。

我現在對這句話的理解更加深刻了。

跨語言,也就是服務異構的一種。

為什麼我用 Java 傳送 http 請求的時候可以不用關心對方使用的是什麼開發語言?

因為大家都遵守了 http 協議,協議是可以跨語言的。

Dubbo 這種 rpc 呼叫的框架也一樣。我發起遠端呼叫之後,只要你能按照我們約定好的協議進行報文的解析,那你就能正常的處理我發過來的請求,我不管你的開發語言是什麼。

反序列化操作到底在哪進行?

業務資料返回後,反序列化的操作到底是在哪個執行緒裡面進行的?

是在 IO 執行緒裡面直接解析,還是被派發到客戶端執行緒池裡面進行解析?

這個問題我們先試著在官網的執行緒模型介紹中去尋找答案。 http://dubbo.apache.org/zh-cn/docs/user/demos/thread-model.html

線上程模型的描述裡面,是這樣寫的:

如果事件處理的邏輯能迅速完成,並且不會發起新的 IO 請求,比如只是在記憶體中記個標識,則直接在 IO 執行緒上處理更快,因為減少了執行緒池排程

但如果事件處理邏輯較慢,或者需要發起新的 IO 請求,比如需要查詢資料庫,則必須派發到執行緒池,否則IO 執行緒阻塞,將導致不能接收其它請求

如果用 IO 執行緒處理事件,又在事件處理過程中發起新的 IO 請求,比如在連線事件中發起登入請求,會報“可能引發死鎖”異常,但不會真死鎖。

因此,需要通過不同的派發策略和不同的執行緒池配置的組合來應對不同的場景。

本文不關心執行緒池配置,我們只看派發策略:

預設的派發策略是 all。

一看到這幾個策略,熟悉 Dubbo 的朋友肯定就知道了,按照 Dubbo 的尿性,這必須得是一個 SPI 介面啊。

果不其然,原始碼裡面就是這樣的,你說巧不巧:

然後官方還給出了一張描述不太清晰的圖片:

圖片中的 Dispatch 就是派發策略發揮作用的地方。

所以我們能從這部分得出一個結論:在預設的情況下,客戶端接收到響應後,由於 Dubbo 使用 all 的派發策略,會把響應請求派發到客戶端執行緒池中去。

那我們可以推導:出響應的解析一定是在客戶端執行緒池裡面進行的嗎?

不可以,推不出來的。

只能說響應會進入客戶端執行緒池中去,但是這個響應可能是一個經過解析後的響應,也可能是一個沒有經過解析的響應。

所以,這個響應有可能在進入執行緒池之前就被解析過了。被誰解析?

IO 執行緒。

如果 IO 執行緒沒有解析,那就在客戶端執行緒裡面去解析。

根據上面這段話。我們能提煉出一個關鍵語句,或者說是需求:我們現在要實現響應報文可以在不同的地方進行解析的功能,請問你怎麼做?

你用腳指頭想也知道了。首先肯定是有一個 if 判斷的,判斷到底在哪(IO執行緒/客戶端執行緒池)進行響應解析。而這個 if 判斷的判斷條件,按照 Dubbo 的尿性,肯定是可以配置的。

所以我們找到這個地方,問題就瞭然於心了。

我們去哪裡找答案呢?

這個類裡面,這個類是一個請求/響應解碼的非常核心的類: org.apache.dubbo.rpc.protocol.dubbo.DubboCodec

這個類的主要乾了兩件事,一個是對響應報文進行解碼,一個是對請求報文進行解碼。

接下來我們怎麼搞?強擼原始碼嗎?不可能的。直接擼肯定費勁。

還是要搞個 Demo 跑起來,然後 Debug。

我這裡的 Demo 非常簡單,服務端介面實現類如下:

消費者在測試類中進行消費:

然後 Debug 起來,注意,下面演示的程式碼沒有特別說明的地方,都是 2.7.5 版本。

執行起來後先不看別的,看看當前卡在這個地方,被 Debug 的執行緒是什麼執行緒:

到這裡你先冷靜一下,你想一下這個問題:

在這個方法裡面可以對響應和請求進行解析。那它怎麼知道當前到底是響應還是請求報文呢?

答案就在前面說的 Dubbo 協議裡面:

呼應上了沒有?header 裡面第 16 bit 如果是 0 代表響應,如果是 1 代表請求。

你說巧不巧,上面這個方法的入參裡面就有一個 header 陣列。

讓我們看看他裡面裝的是什麼東西:

長度是 16,和 header 的長度吻合,但是裡面裝的玩意還是沒看出來。

但是這樣一看,看前兩個位元組,你就明白了:

嘿,你說巧了嗎,這不是巧了嗎,這不是。

魔數也對上了。說明這是一個 Dubbo 的 header。

然後取出第 3 位元組,進行位運算,判斷這是什麼報文:

前面,我們解決了怎麼知道當前到底是響應還是請求報文這個問題。

接下來,進入分支裡面就重點關注對響應報文的解析了:

首先,上面標記為①的地方是判斷當前資料包是不是一個心跳包,經過 Debug 我們可以知道這不是一個心跳包:

然後標記為②的地方獲取 header 中的第 4 個位元組,第 4 個位元組代表的是狀態位:

從 Debug 的截圖裡面我們可以看出,當前的狀態為 20,表示正常返回。

標記為③的地方,是對心跳包的解析,我們這裡不關心。

標記為④的地方,是我們需要重點關注的地方,也是我們一直在尋找的程式碼。

這個地方就很關鍵了,大家集中注意力了。

首先,下面程式碼的截圖是 2.7.5 版本的:

這裡的 if 分支和分支裡面的判斷條件,就是我們前面說的:

你用腳指頭想也知道了。首先肯定是有一個 if 判斷的,判斷到底在哪(IO執行緒/客戶端執行緒池)進行響應解析。而這個分支判斷的判斷條件,按照 Dubbo 的尿性,肯定是可以配置的。

下面這張圖片對 2.7.4.1 和 2.7.5 版本這個地方進行一個對比:

你仔細看著兩個版本之間的程式碼,發現一模一樣,也沒有差異啊。

這就把我幹懵逼了:咋回事?說好的差異呢?

別忘了,上面的程式碼裡面是有一個變數的:

差異就差異在這個地方。

2.7.5 版本之後,這個引數的預設值從 true 變為了 false。

換句話說就是:2.7.5 版本之前,業務資料返回後,預設在 IO 執行緒裡面進行反序列化的操作。而2.7.5 版本之後,預設是延遲到客戶端執行緒池裡面進行反序列化的操作。

(建議朗讀並背誦)

同時這個引數,不管在哪個版本里面,都是可以配置。雖然基本上也沒有人更改過這個配置,配置方法如下:

朋友們,到這裡還跟的上不?跟不上你就再捋捋?別硬看,傷身體。

解碼操作原始碼解析

接下來我們再看看解碼操作的程式碼到底是怎麼樣的。

首先解碼操作,解的什麼碼?

解的是響應報文的響應體,也就是我們的返回內容: org.apache.dubbo.rpc.protocol.dubbo.DecodeableRpcResult#decode(org.apache.dubbo.remoting.Channel, java.io.InputStream)

標號為①的地方代表序列化型別是 2 。

2 是什麼?看錶:

標號為②的地方代表本次響應型別為 4。

4 是什麼?前面說了,看截圖:

所以,在標號為③的地方即處理了返回值(handleValue)也處理了上下文資訊(handleAttachment)。

handleValue 就不細看了,你就關注這個地方解析出來的就是我們的響應內容:

響應內容的解碼就是上面說的邏輯。

不管是在 IO 執行緒裡面解碼還是在客戶端執行緒池裡面解碼,都要呼叫這個方法。只不過是誰先誰後的問題。

那麼問題又來了,需求又發生變化了。

因為 IO 執行緒和客戶端執行緒池都要呼叫這個方法進行解碼,我們總不能解碼兩次吧,那怎麼保證只解碼一次呢?

答案就是設定標識位。

因為我們知道如果是在 IO 執行緒裡面解碼,那麼該操作呼叫解碼方法後,肯定是先於客戶端執行緒池呼叫的。

有先後順序就好辦了。我們就可以設定標識位:

當在 IO 執行緒解析後,會把標識位設定為 true。然後客戶端執行緒池再走到這個邏輯的時候,發現標識位是 true 了,不進行再次操作,問題就這樣被解決了。

接下來,我給大家對比一下 decodeBody 方法在 IO 執行緒裡面解碼和在客戶端執行緒池裡面解碼時分別返回什麼。也就是這行程式碼返回的時候:

這樣一對比就很清晰了:

這樣也解釋了,為什麼說是“延遲”到客戶端執行緒池裡面解碼。

好了,到這裡你有沒有發現一個問題。前面解析的這麼多原始碼,然後咔一下,直接我們就看到了最終返回的“Hello why”了。

這個是響應訊息體,是 body。

頭呢?header 呢?

別急,這不是馬上就給你講一下嘛。

前面講這個方法的時候說了:header 是作為引數傳進來的嘛,那我們還可以去找一下 header 到底是怎麼傳進來的:

怎麼看呢?

順著呼叫鏈往回找就行,一個除錯小技巧,送給大家,不客氣:

可以看到 header 是從 buffer 裡面取出來的,最多讀取 HEADER_LENGTH (16) 個位元組。

什麼?你還問我為什麼最多讀 16 個位元組?

我懷疑前面講協議的時候你就在走神。別問,問就是協議規定。大家遵守就好了。

再跟著呼叫鏈往前一步,你會發現這裡主要是在做解碼響應頭的部分:

上面這個方法裡面就是在搞 header 的事情。

其中有一個檢查報文長度的方法:checkPayLoad。

那麼問題又來了:請問 Dubbo 預設的報文長度限制是多少呢?

帶大家去原始碼裡面找答案:

答案是 8M。

另外,既然是有預設值,那必須是可以配置的。所以上圖示號為①的地方是從配置中獲取,獲取不到,就返回預設值。

稍微有點意思的是標號為②的地方,我第一次看的時候愣是看了一分鐘沒反應過來。主要是前面的這個 payload > 0,我想著這不是廢話嘛,長度不都是大於 0 的。興奮的我以為發現了一個無用程式碼呢。

後來才理解到,如果當 payload 設定為負數的時候,就代表不限制報文長度。

可以進行如下配置:

一個基本上用不到的 Dubbo 小知識點,免費贈送給大家。

好了,header 和 body 都齊活了。

到這裡,再總結一下:2.7.5 版本之前,業務資料返回後,預設在 IO 執行緒裡面進行反序列化的操作。而2.7.5 版本之後,預設是延遲到客戶端執行緒池裡面進行反序列化的操作。

所以,對於官網中,紅框框起來這個地方的描述是有問題的: http://dubbo.apache.org/zh-cn/docs/user/demos/consumer-threadpool.html

正確的說法應該是:在老的(2.7.5 版本之前)執行緒池模型中,當業務資料返回後,預設在 IO 執行緒上進行反序列化操作,如果配置了 decode.in.io 引數為 false,則延遲到獨立的客戶端執行緒池進行反序列化操作。

聊聊執行緒池模型的變化

接下來再聊聊執行緒池模型的變化。這裡的執行緒池指的都是客戶端執行緒池。

先拋兩個知識點:

  • 不論是新老執行緒池模型,預設的 Dispatch 策略都是 all。所有響應還是會轉發到客戶端執行緒池裡面,在這個裡面進行解碼操作(如果 IO 執行緒沒有解碼的話)把結果返回到使用者執行緒中去。

  • 對於執行緒池客戶端的預設實現是 cached,服務端的預設實現是 fixed。

官網這裡的 fixed 預設,特指服務端:

下面是官網上的截圖:

首先,不管 2.7.5 版本之前還是之後客戶端的預設實現都是 cached ,這個執行緒池並沒有限制執行緒數量:

所以會出現消費端執行緒數分配多的問題。

但官網的描述是:分配過多。多和過多還不一樣。

為什麼會過多呢?

因為在 2.7.5 版本之前,是每一個連結都對應一個客戶端執行緒池。相當於做了連結級別的執行緒隔離,但是實際上這個執行緒隔離是沒有必要的。反而影響了效能。

而在 2.7.5 版本里面,就是不管你多少連結,大家共用一個客戶端執行緒池,引入了 threadless executor 的概念。

簡單的來說,優化結果就是從多個執行緒池改為了共用一個執行緒池。

執行緒池模型的變化,我在《Dubbo 2.7.5線上程模型上的優化》裡面比較詳細的聊過了,就不在重複講了,有興趣的可以去翻一下。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是 why,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。

相關文章