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


你好呀,我是歪歪。

是這樣的,我最近又看到了這篇文章《工商銀行分布式服務 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/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2026 CODEPRJ.COM