面試官:要不我們聊一下“心跳”的設計?

why技術發表於2021-12-14

你好呀,我是歪歪。

是這樣的,我最近又看到了這篇文章《工商銀行分散式服務 C10K 場景解決方案 》。

為什麼是又呢?

因為這篇文章最開始釋出的時候我就看過了,當時就覺得寫得挺好的,宇宙行(工商銀行)果然是很叼的樣子。

但是看過了也就看過了,當時沒去細琢磨。

這次看到的時候,剛好是在下班路上,就仔仔細細的又看了一遍。

嗯,常讀常新,還是很有收穫的。

所以寫篇文章,給大家彙報一下我再次閱讀之後的一下收穫。

文章提要

我知道很多同學應該都沒有看過這篇文章,所以我先放個連結,《工商銀行分散式服務 C10K 場景解決方案 》

先給大家提煉一下文章的內容,但是如果你有時間的話,也可以先去細細的讀一下這篇文章,感受一下宇宙行的實力。

文章內容大概是這樣的。

在宇宙行的架構中,隨著業務的發展,在可預見的未來,會出現一個提供方為數千個、甚至上萬個消費方提供服務的場景。

在如此高負載量下,若服務端程式設計不夠良好,網路服務在處理數以萬計的客戶端連線時、可能會出現效率低下甚至完全癱瘓的情況,即為 C10K 問題。

C10K 問題就不展開講了,網上查一下,非常著名的程式相關問題,只不過該問題已經成為歷史了。

而宇宙行的 RPC 框架使用的是 Dubbo,所以他們那篇文章就是基於這個問題去展開的:

基於 Dubbo 的分散式服務平臺能否應對複雜的 C10K 場景?

為此,他們搭建了大規模連線環境、模擬服務呼叫進行了一系列探索和驗證。

首先他們使用的 Dubbo 版本是 2.5.9。版本確實有點低,但是銀行嘛,懂的都懂,架構升級是能不動就不動,穩當執行才是王道。

在這個版本里面,他們搞了一個服務端,服務端的邏輯就是 sleep 100ms,模擬業務呼叫,部署在一臺 8C16G 的伺服器上。

對應的消費方配置服務超時時間為 5s,然後把消費方部署在數百臺 8C16G 的伺服器上(我滴個乖乖,數百臺 8C16G 的伺服器,這都是白花花的銀子啊,有錢真好),以容器化方式部署 7000 個服務消費方。

每個消費方啟動後每分鐘呼叫 1 次服務。

然後他們定製了兩個測試的場景:

場景 2 先暫時不說,異常是必然的,因為只有一個提供方嘛,重啟期間消費方還在發請求,這必然是要涼的。

但是場景 1 按理來說不應該的啊。

你想,消費方配置的超時時間是 5s,而提供方業務邏輯只處理 100ms。再怎麼說時間也是夠夠的了。

需要額外多說一句的是:本文也只聚焦於場景 1。

但是,朋友們,但是啊。

雖然呼叫方一分鐘發一次請求的頻率不高,但是架不住呼叫方有 7000 個啊,這 7000 個呼叫方,這就是傳說中的突發流量,只是這個“突發”是每分鐘一次。

所以,偶現超時也是可以理解的,畢竟服務端處理能力有限,有任務在佇列裡面稍微等等就超時了。

可以寫個小例子示意一下,是這樣的:

就是搞個執行緒池,執行緒數是 200。然後提交 7000 個任務,每個任務耗時 100ms,用 CountDownLatch 模擬了一下併發,在我的 12 核的機器上執行耗時 3.8s 的樣子。

也就是說如果在 Dubbo 的場景下,每一個請求再加上一點點網路傳輸的時間,一點點框架內部的消耗,這一點點時間再乘以 7000,最後被執行的任務理論上來說,是有可能超過 5s 的。

所以偶現超時是可以理解的。

但是,朋友們,又來但是了啊。

我前面都說的是理論上,然而實踐才是檢驗真理的唯一辦法。

看一下宇宙行的驗證結果:

首先我們可以看到消費方不論是發起請求還是處理響應都是非常迅速的,但是卡殼就卡在服務方從收到請求到處理請求之間。

經過抓包分析,他們得出結論:導致交易超時的原因不在消費方側,而在提供方側。

這個結論其實也很好理解,因為壓力都在服務提供方這邊,所以阻塞也應該是在它這裡。

其實到這裡我們基本上就可以確認,肯定是 Dubbo 框架裡面的某一些操作導致了耗時的增加。

難的就是定位到,到底是什麼操作呢?

宇宙行通過一系列操作,經過縝密的分析,得出了一個結論:

心跳密集導致 netty worker 執行緒忙碌,從而導致交易耗時增長。

也就是結論中提到的這一點:

有了結論,找到了病灶就好辦了,對症下藥嘛。

因為前面說過,本文只聚焦於場景一,所以我們看一下對於場景一宇宙行給出的解決方案:

全都是圍繞著心跳的優化處理,處理完成後的效果如下:

其中效果最顯著的操作是“心跳繞過序列化”。

消費方與提供方之間平均處理時差由 27ms 降低至 3m,提升了 89%。

前 99% 的交易耗時從 191ms 下降至 133ms,提升了 30%。

好了,寫到這,就差不多是把那篇文章裡面我當時看到的一些東西複述了一遍,沒啥大營養。

只是我還記得第一次看到這篇文章的時候,我是這樣的:

我覺得挺牛逼的,一個小小的心跳,在 C10K 的場景下竟然演變成了一個效能隱患。

我得去研究一下,順便宇宙行給出的方案中最重要的是“心跳繞過序列化”,我還得去研究一下 Dubbo 怎麼去實現這個功能,研究明白了這玩意就是我的了啊。

但是...

我忘記當時為啥沒去看了,但是沒關係,我現在想起來了嘛,馬上就開始研究。

心跳如何繞過序列化

我是怎麼去研究呢?

直接往原始碼裡面衝嗎?

是的,就是往原始碼裡面衝。

但是衝之前,我先去 Dubb 的 github 上逛了一圈:

https://github.com/apache/dubbo

然後在 Pull request 裡面先搜尋了一下“Heartbeat”,這一搜還搜出不少好東西呢:

我一眼看到這兩個 pr 的時候,眼睛都在放光。

好傢伙,我本來只是想隨便看看,沒想到直接定位了我要研究的東西了。

我只需要看看這兩個 pr,就知道是怎麼實現的“心跳繞過序列化”,這直接就讓我少走了很多彎路。

首先看這個:

https://github.com/apache/dubbo/pull/7077

從這段描述中可以知道,我找到對的地方了。而從他的描述中知道“心跳跳過序列化”,就是用 null 來代替了序列化的這個過程。

同時這個 pr 裡面還說明了自己的改造思路:

接著就帶大家看一下這一次提交的程式碼。

怎麼看呢?

可以在 git 上看到他對應這次提交的檔案:

到原始碼裡面找到對應地方即可,這也是一個去找原始碼的方法。

我比較熟悉 Dubbo 框架,不看這個 pr 我也大概知道去哪裡找對應的程式碼。但是如果換成另外一個我不熟悉的框架呢?

從它的 git 入手其實是一個很好的角度。

一個翻閱原始碼的小技巧,送給你。

如果你不瞭解 Dubbo 框架也沒有關係,我們只是聚焦於“心跳是如何跳過序列化”的這一個點。至於心跳是由誰如何在什麼時間發起的,這一節暫時不講。

接著,我們從這個類下手:

org.apache.dubbo.rpc.protocol.dubbo.DubboCodec

從提交記錄可以看出主要有兩處改動,且兩處改動的程式碼是一模一樣的,都位於 decodeBody 這個方法,只是一個在 if 分支,一個在 else 分支:

這個程式碼是幹啥的?

你想一個 RPC 呼叫,肯定是涉及到報文的 encode(編碼) 和 decode(解碼) 的,所以這裡主要就是對請求和響應報文進行 decode 。

一個心跳,一來一回,一個請求,一個響應,所以有兩處改動。

所以我帶著大家看請求包這一處的處理就行了:

可以看到程式碼改造之後,對心跳包進行了一個特殊的判斷。

在心跳事件特殊處理裡面涉及到兩個方法,都是本次提交新增的方法。

第一個方法是這樣的:

org.apache.dubbo.remoting.transport.CodecSupport#getPayload

就是把 InputStream 流轉換成位元組陣列,然後把這個位元組陣列作為入參傳遞到第二個方法中。

第二個方法是這樣的:

org.apache.dubbo.remoting.transport.CodecSupport#isHeartBeat

從方法名稱也知道這是判斷請求是不是心跳包。

怎麼去判斷它是心跳包呢?

首先得看一下發起心跳的地方:

org.apache.dubbo.remoting.exchange.support.header.HeartbeatTimerTask#doTask

從發起心跳的地方我們可以知道,它發出去的東西就是 null。

所以在接受包的地方,判斷其內容是不是 null,如果是,就說明是心跳包。

通過這簡單的兩個方法,就完成了心跳跳過序列化這個操作,提升了效能。

而上面兩個方法都是在這個類中,所以核心的改動還是在這個類,但是改動點其實也不算多:

org.apache.dubbo.remoting.transport.CodecSupport

在這個類裡面有兩個小細節,可以帶大家再看看。

首先是這裡:

這個 map 裡面快取的就是不同的序列化的方式對應的 null,程式碼乾的也就是作者這裡說的這件事兒:

另外一個細節是看這個類的提交記錄:

還有一次優化性的提交,而這一次提交的內容是這樣的。

首先定義了一個 ThreadLocal,並使其初始化的時候是 1024 位元組:

那麼這個 ThreadLocal 是用在哪兒的呢?

在讀取 InputStream 的時候,需要開闢一個位元組陣列,為了避免頻繁的建立和銷燬這個位元組資料,所以搞了一個 ThreadLocal:

有的同學看到這裡就要問了:為什麼這個 ThreadLocal 沒有呼叫 remove 方法呢,不會記憶體洩漏嘛?

不會的,朋友們,在 Dubbo 裡面執行這個玩意的是 NIO 執行緒,這個執行緒是可以複用的,且裡面只是放了一個 1024 的位元組陣列,不會有髒資料,所以不需要移除,直接複用。

正是因為可以複用,所以才提升了效能。

這就是細節,魔鬼都在細節裡面。

這一處細節,就是前面提到的另外一個 pr:

https://github.com/apache/dubbo/pull/7168

看到這裡,我們也就知道了宇宙行到底是怎麼讓心跳跳過序列化操作了,其實也沒啥複雜的程式碼,幾十行程式碼就搞定了。

但是,朋友們,又要但是了。

寫到這裡的時候,我突然感覺到不太對勁。

因為我之前寫過這篇文章,Dubbo 協議那點破事

在這篇文章裡面有這樣的一個圖:

這是當時在官網上截下來的。

在協議裡面,事件標識欄位之前只有 0 和 1。

但是現在不一樣了,從程式碼看,是把 1 的範圍給擴大了,它不一定代表的是心跳,因為這裡面有個 if-else

所以,我就去看了一下現在官網上關於協議的描述。

https://dubbo.apache.org/zh/docs/v3.0/concepts/rpc-protocol/#dubbo2

果然,發生了變化:

並不是說 1 就是心跳包,而是改口為:1 可能是心跳包。

嚴謹,這就是嚴謹。

所以開源專案並不是程式碼改完就改完了,還要考慮到一些周邊資訊的維護。

心跳的多種設計方案

在研究 Dubbo 心跳的時候,我還找到了這樣一個 pr。

https://github.com/apache/dubbo/issues/3151

標題是這樣的:

翻譯過來就是使用 IdleStateHandler 代替使用 Timer 傳送心跳的建議。

我定睛一看,好機會,這不是 95 後老徐嘛,老熟人了。

看一下老徐是怎麼說的,他建議具體是這樣的:

幾位 Dubbo 大佬,在這個 pr 裡面交換了很多想法,我仔細的閱讀之後都受益匪淺。

大家也可以點進去看看,我這裡給大家彙報一下自己的收穫。

首先是幾位老哥在心跳實時性上的一頓 battle。

總之,大家知道 Dubbo 的心跳檢測是有一定延時的,因為是基於時間輪做的,相當於是定時任務,觸發的時效性是不能保證實時觸發的。

這玩意就類似於你有一個 60 秒執行一次的定時任務,在第 0 秒的時候任務啟動了,在第 1 秒的時候有一個資料準備好了,但是需要等待下一次任務觸發的時候才會被處理。因此,你處理資料的最大延遲就應該是 60 秒。

這個大家應該能明白過來。

額外說一句,上面討論的結果是“目前是 1/4 的 heartbeat 延時”,但是我去看了一下最新的 master 分支的原始碼,怎麼感覺是 1/3 的延時呢:

從原始碼裡可以看到,計算時間的時候 HEARTBEAT_CHECK_TICK 引數是 3。所以我理解是 1/3 的延時。

但是不重要,這不重要,反正你知道是有延時的就行了。

而 kexianjun 老哥認為如果基於 netty 的 IdleStateHandler 去做,每次檢測超時都重新計算下一次檢測的時間,因此相對來說就能比較及時的檢查到超時了。

這是在實時性上的一個優化。

而老徐覺得,除了實時性這個考慮外,其實 IdleStateHandler 更是一個針對心跳的優雅的設計。但是呢,由於是基於 Netty 的,所以當通訊框架使用的不是 Netty 的時候,就回天無力了,所以可以保留 Timer 的設計來應對這種情況。

很快,carryxyh 老哥就給出了很有建設性的意見:

由於 Dubbo 是支援多個通訊框架的。

這裡說的“多個”,其實不提我都忘記了,除了 Netty 之外,它還支援 Girzzly 和 Mina 這兩種底層通訊框架,而且還支援自定義。

但是我尋思都 2021 年了,Girzzly 和 Mina 還有人用嗎?

從原始碼中我們也能找到它們的影子:

org.apache.dubbo.remoting.transport.AbstractEndpoint

Girzzly、Mina 和 Netty 都各有自己的 Server 和 Client。

其中 Netty 有兩個版本,是因為 Netty4 步子邁的有點大,難以在之前的版本中進行相容,所以還不如直接多搞一個實現。

但是不管它怎麼變,它都還是叫做 Netty。

好了,說回前面的建設性意見。

如果是採用 IdleStateHandler 的方式做心跳,而其他的通訊框架保持 Timer 的模式,那麼勢必會出現類似於這樣的程式碼:

if transport == netty {
     don't start heartbeat timer
}

這是一個開源框架中不應該出現的東西,因為會增加程式碼複雜度。

所以,他的建議是最好還是使用相同的方式來進行心跳檢測,即都用 Timer 的模式。

正當我覺得這個哥們說的有道理的時候,我看了老徐的回答,我又瞬間覺得他說的也很有道理:

我覺得上面不需要我解釋了,大家邊讀邊思考就行了。

接著看看 carryxyh 老哥的觀點:

這個時候對立面就出現了。

老徐的角度是,心跳肯定是要有的,只是他覺得不同通訊框架的實現方式可以不必保持一致(現在都是基於 Timer 時間輪的方式),他並不認為 Timer 抽象成一個統一的概念去實現連線保活是一個優雅的設計。

在 Dubbo 裡面我們主要用的就是 Netty,而 Netty 的 IdleStateHandler 機制,天生就是拿來做心跳的。

所以,我個人認為,是他首先覺得使用 IdleStateHandler 是一種比較優雅的實現方式,其次才是時效性的提升。

但是 carryxyh 老哥是覺得 Timer 抽象的這個定時器,是非常好的設計,因為它的存在,我們才可以不關心底層是netty還是mina,而只需要關心具體實現。

而對於 IdleStateHandler 的方案,他還是認為在時效性上有優勢。但是我個人認為,他的想法是如果真的有優勢的話,我們可以參考其實現方式,給其他通訊框架也賦能一個 “Idle” 的功能,這樣就能實現大統一。

看到這裡,我覺得這兩個老哥 battle 的點是這樣的。

首先前提是都圍繞著“心跳”這個功能。

一個認為當使用 Netty 的時候“心跳”有更好的實現方案,且 Netty 是 Dubbo 主要的通訊框架,所以應該可以只改一下 Netty 的實現。

一個認為“心跳”的實現方案應該統一,如果 Netty 的 IdleStateHandler 方案是個好方案,我們應該把這個方案拿過來。

我覺得都有道理,一時間竟然不知道給誰投票。

但是最終讓我選擇投老徐一票的,是看了他寫的這篇文章:《一種心跳,兩種設計》

這篇文章裡面他詳細的寫了 Dubbo 心跳的演變過程,其中也涉及到部分的原始碼。

最終他給出了這樣的一個圖, 心跳設計方案對比:

然後,是這段話:

老徐是在阿里搞中介軟體的,原來搞中介軟體的人每天想的是這些事情。

有點意思。

看看程式碼

帶大家看一下程式碼,但是不會做詳細分析,相當於是指個路,如果想要深入瞭解的話,自己翻原始碼去。

首先是這裡:

org.apache.dubbo.remoting.exchange.support.header.HeaderExchangeClient

可以看到在 HeaderExchangeClient 的構造方法裡面呼叫了 startHeartBeatTask 方法來開啟心跳。

同時這裡面有個 HashedWheelTimer,這玩意我熟啊,時間輪嘛,之前分析過的。

然後我們把目光放在這個方法 startHeartBeatTask:

這裡面就是構建心跳任務,然後扔到時間輪裡面去跑,沒啥複雜的邏輯。

這一個實現,就是 Dubbo 對於心跳的預設處理。

但是需要注意的是,整個方法被 if 判斷包裹了起來,這個判斷可是大有來頭,看名字叫做 canHandleIdle,即是否可以處理 idle 操作,預設是 false:

所以,前面的 if 判斷的結果是 true。

那麼什麼情況下 canHandleIdle 是 true 呢?

在使用 Netty4 的時候是 true。

也就是 Netty4 不走預設的這套心跳實現。

那麼它是怎麼實現的呢?

由於服務端和客戶端的思路是一樣的,所以我們看一下客戶端的程式碼就行。

關注一下它的 doOpen 方法:

org.apache.dubbo.remoting.transport.netty4.NettyClient#doOpen

在 pipeline 裡面加入了我們前面說到的 IdleStateHandler 事件,這個事件就是如果 heartbeatInterval 毫秒內沒有讀寫事件,那麼就會觸發一個方法,相當於是一個回撥。

heartbeatInterval 預設是 6000,即 60s。

然後加入了 nettyClientHandler,它是幹什麼呢?

看一眼它的這個方法:

org.apache.dubbo.remoting.transport.netty4.NettyClientHandler#userEventTriggered

這個方法裡面在傳送心跳事件。

也就是說你這樣寫,含義是在 60s 內,客戶端沒有發生讀寫時間,那麼 Netty 會幫我們觸發 userEventTriggered 方法,在這個方法裡面,我們可以傳送一次心跳,去看看服務端是否正常。

從目前的程式碼來看, Dubbo 最終是採用的老徐的建議,但是預設實現還是沒變,只是在 Netty4 裡面採用了 IdleStateHandler 機制。

這樣的話,其實我就覺得更奇怪了。

同樣是 Netty,一個採用的是時間輪,一個採用的 IdleStateHandler。

同時我也很理解,步子不能邁的太大了,容易扯著蛋。

但是,在翻原始碼的過程中,我發現了一個程式碼上的小問題。

org.apache.dubbo.remoting.exchange.codec.ExchangeCodec#decode(org.apache.dubbo.remoting.Channel, org.apache.dubbo.remoting.buffer.ChannelBuffer, int, byte[])

在上面這個方法中,有兩行程式碼是這樣的:

你先別管它們是幹啥的,我就帶你看看它們的邏輯是怎麼樣的:

可以看到兩個方法都執行了這樣的邏輯:

int payload = getPayload(channel);
boolean overPayload = isOverPayload(payload, size);

如果 finishRespWhenOverPayload 返回的不是 null,沒啥說的,返回 return 了,不會執行 checkPayload 方法。

如果 finishRespWhenOverPayload 返回的是 null,則會執行 checkPayload 方法。

這個時候會再次做檢查報文大小的操作,這不就重複了嗎?

所以,我認為這一行的程式碼是多餘的,可以直接刪除。

你明白我意思吧?

又是一個給 Dubbo 貢獻原始碼的機會,送給你,可以衝一波。

最後,再給大家送上幾個參考資料。

第一個是可以去了解一下 SOFA-RPC 的心跳機制。 SOFA-PRC 也是阿里開源出來的框架。

在心跳這塊的實現就是完完全全的基於 IdleStateHandler 來實現的。

可以去看一下官方提供的這兩篇文章:

https://www.sofastack.tech/search/?page=1&query=%E5%BF%83%E8%B7%B3&type=all

第二個是極客時間《從0開始學微服務》,第 17 講裡面,老師在關於心跳這塊的一點分享,提到的一個保護機制,這是我之前沒有想到過的:

反正我是覺得,我文章中提到的這一些連結,你都去仔仔細細的看了,那麼對於心跳這塊的東西,也就掌握的七七八八了,夠用了。

好了,就到這吧。

本文已收錄至個人部落格,歡迎大家來玩。

https://www.whywhy.vip/

相關文章