在前一篇文章 《聊聊 TCP 長鏈接和心跳那些事》 中,咱們已經聊過了 TCP 中的 KeepAlive,以及在應用層設計心跳的意義,但卻對長鏈接心跳的設計方案沒有作詳細地介紹。事實上,設計一個好的心跳機制並非一件容易的事,就我所熟知的幾個 RPC 框架,它們的心跳機制能夠說截然不同,這篇文章我將探討一下 如何設計一個優雅的心跳機制,主要從 Dubbo 的現有方案以及一個改進方案來作分析。java
由於後續咱們將從源碼層面來進行介紹,因此一些服務治理框架的細節還須要提早交代一下,方便你們理解。apache
高性能的 RPC 框架幾乎都會選擇使用 Netty 來做爲通訊層的組件,非阻塞式通訊的高效不須要我作過多的介紹。但也因爲非阻塞的特性,致使其發送數據和接收數據是一個異步的過程,因此當存在服務端異常、網絡問題時,客戶端接是接收不到響應的,那咱們如何判斷一次 RPC 調用是失敗的呢?bootstrap
誤區一:Dubbo 調用不是默認同步的嗎?安全
Dubbo 在通訊層是異步的,呈現給使用者同步的錯覺是由於內部作了阻塞等待,實現了異步轉同步。微信
誤區二: Channel.writeAndFlush
會返回一個 channelFuture
,我只須要判斷 channelFuture.isSuccess
就能夠判斷請求是否成功了。網絡
注意,writeAndFlush 成功並不表明對端接受到了請求,返回值爲 true 只能保證寫入網絡緩衝區成功,並不表明發送成功。框架
避開上述兩個誤區,咱們再來回到本小節的標題:客戶端如何得知請求失敗?正確的邏輯應當是以客戶端接收到失敗響應爲判斷依據。等等,前面不還在說在失敗的場景中,服務端是不會返回響應的嗎?沒錯,既然服務端不會返回,那就只能客戶端本身造了。異步
一個常見的設計是:客戶端發起一個 RPC 請求,會設置一個超時時間 client_timeout
,發起調用的同時,客戶端會開啓一個延遲 client_timeout
的定時器ide
Dubbo 中的超時斷定邏輯:oop
public static DefaultFuture newFuture(Channel channel, Request request, int timeout) {
final DefaultFuture future = new DefaultFuture(channel, request, timeout);
// timeout check
timeoutCheck(future);
return future;
}
private static void timeoutCheck(DefaultFuture future) {
TimeoutCheckTask task = new TimeoutCheckTask(future);
TIME_OUT_TIMER.newTimeout(task, future.getTimeout(), TimeUnit.MILLISECONDS);
}
private static class TimeoutCheckTask implements TimerTask {
private DefaultFuture future;
TimeoutCheckTask(DefaultFuture future) {
this.future = future;
}
@Override
public void run(Timeout timeout) {
if (future == null || future.isDone()) {
return;
}
// create exception response.
Response timeoutResponse = new Response(future.getId());
// set timeout status.
timeoutResponse.setStatus(future.isSent() ? Response.SERVER_TIMEOUT : Response.CLIENT_TIMEOUT);
timeoutResponse.setErrorMessage(future.getTimeoutMessage(true));
// handle response.
DefaultFuture.received(future.getChannel(), timeoutResponse);
}
}
複製代碼
主要邏輯涉及的類:DubboInvoker
,HeaderExchangeChannel
,DefaultFuture
,經過上述代碼,咱們能夠得知一個細節,不管是何種調用,都會通過這個定時器的檢測,超時即調用失敗,一次 RPC 調用的失敗,必須以客戶端收到失敗響應爲準。
網絡通訊永遠要考慮到最壞的狀況,一次心跳失敗,不能認定爲鏈接不通,屢次心跳失敗,才能採起相應的措施。
忙檢測的對立面是空閒檢測,咱們作心跳的初衷,是爲了保證鏈接的可用性,以保證及時採起斷連,重連等措施。若是一條通道上有頻繁的 RPC 調用正在進行,咱們不該該爲通道增長負擔去發送心跳包。心跳扮演的角色應當是晴天收傘,雨天送傘。
本文的源碼對應 Dubbo 2.7.x 版本,在 apache 孵化的該版本中,心跳機制獲得了加強。
介紹完了一些基礎的概念,咱們便來看看 Dubbo 是如何設計應用層心跳的。Dubbo 的心跳是雙向心跳,客戶端會給服務端發送心跳,反之,服務端也會向客戶端發送心跳。
public class HeaderExchangeClient implements ExchangeClient {
private int heartbeat;
private int heartbeatTimeout;
private HashedWheelTimer heartbeatTimer;
public HeaderExchangeClient(Client client, boolean needHeartbeat) {
this.client = client;
this.channel = new HeaderExchangeChannel(client);
this.heartbeat = client.getUrl().getParameter(Constants.HEARTBEAT_KEY, dubbo != null && dubbo.startsWith("1.0.") ? Constants.DEFAULT_HEARTBEAT : 0);
this.heartbeatTimeout = client.getUrl().getParameter(Constants.HEARTBEAT_TIMEOUT_KEY, heartbeat * 3);
if (needHeartbeat) { <1>
long tickDuration = calculateLeastDuration(heartbeat);
heartbeatTimer = new HashedWheelTimer(new NamedThreadFactory("dubbo-client-heartbeat", true), tickDuration,
TimeUnit.MILLISECONDS, Constants.TICKS_PER_WHEEL); <2>
startHeartbeatTimer();
}
}
}
複製代碼
<1> 默認開啓心跳檢測的定時器
<2> 建立了一個 HashWheelTimer
開啓心跳檢測,這是 Netty 所提供的一個經典的時間輪定時器實現,至於它和 jdk 的實現有何不一樣,不瞭解的同窗也能夠關注下,我就拓展了。
不只 HeaderExchangeClient
客戶端開起了定時器,HeaderExchangeServer
服務端一樣開起了定時器,因爲服務端的邏輯和客戶端幾乎一致,因此後續我並不會重複粘貼服務端的代碼。
Dubbo 在早期版本版本中使用的是 shedule 方案,在 2.7.x 中替換成了 HashWheelTimer。
private void startHeartbeatTimer() {
long heartbeatTick = calculateLeastDuration(heartbeat);
long heartbeatTimeoutTick = calculateLeastDuration(heartbeatTimeout);
HeartbeatTimerTask heartBeatTimerTask = new HeartbeatTimerTask(cp, heartbeatTick, heartbeat); <1>
ReconnectTimerTask reconnectTimerTask = new ReconnectTimerTask(cp, heartbeatTimeoutTick, heartbeatTimeout); <2>
heartbeatTimer.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS);
heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS);
}
複製代碼
Dubbo 在 startHeartbeatTimer
方法中主要開啓了兩個定時器: HeartbeatTimerTask
,ReconnectTimerTask
<1> HeartbeatTimerTask
主要用於定時發送心跳請求
<2> ReconnectTimerTask
主要用於心跳失敗以後處理重連,斷連的邏輯
至於方法中的其餘代碼,其實也是本文的重要分析內容,先容我賣個關子,後面再來看追溯。
詳細解析下心跳檢測定時任務的邏輯 HeartbeatTimerTask#doTask
:
protected void doTask(Channel channel) {
Long lastRead = lastRead(channel);
Long lastWrite = lastWrite(channel);
if ((lastRead != null && now() - lastRead > heartbeat)
|| (lastWrite != null && now() - lastWrite > heartbeat)) {
Request req = new Request();
req.setVersion(Version.getProtocolVersion());
req.setTwoWay(true);
req.setEvent(Request.HEARTBEAT_EVENT);
channel.send(req);
}
}
}
複製代碼
前面已經介紹過,Dubbo 採起的是設計是雙向心跳,即服務端會向客戶端發送心跳,客戶端也會向服務端發送心跳,接收的一方更新 lastRead 字段,發送的一方更新 lastWrite 字段,超過心跳間隙的時間,便發送心跳請求給對端。這裏的 lastRead/lastWrite 一樣會被同一個通道上的普通調用更新,經過更新這兩個字段,實現了只在鏈接空閒時纔會真正發送空閒報文的機制,符合咱們一開始科普的作法。
注意:不只僅心跳請求會更新 lastRead 和 lastWrite,普通請求也會。這對應了咱們預備知識中的空閒檢測機制。
繼續研究下重連和斷連定時器都實現了什麼 ReconnectTimerTask#doTask
。
protected void doTask(Channel channel) {
Long lastRead = lastRead(channel);
Long now = now();
if (lastRead != null && now - lastRead > heartbeatTimeout) {
if (channel instanceof Client) {
((Client) channel).reconnect();
} else {
channel.close();
}
}
}
複製代碼
第二個定時器則負責根據客戶端、服務端類型來對鏈接作不一樣的處理,當超過設置的心跳總時間以後,客戶端選擇的是從新鏈接,服務端則是選擇直接斷開鏈接。這樣的考慮是合理的,客戶端調用是強依賴可用鏈接的,而服務端能夠等待客戶端從新創建鏈接。
細心的朋友會發現,這個類被命名爲 ReconnectTimerTask 是不太準確的,由於它處理的是重連和斷連兩個邏輯。
在 Dubbo 的 issue 中曾經有人反饋過定時不精確的問題,咱們來看看是怎麼一回事。
Dubbo 中默認的心跳週期是 60s,設想以下的時序:
因爲時間窗口的問題,死鏈不可以被及時檢測出來,最壞狀況爲一個心跳週期。
爲了解決上述問題,咱們再倒回去看一下上面的 startHeartbeatTimer()
方法
long heartbeatTick = calculateLeastDuration(heartbeat);
long heartbeatTimeoutTick = calculateLeastDuration(heartbeatTimeout);
複製代碼
其中 calculateLeastDuration
根據心跳時間和超時時間分別計算出了一個 tick 時間,實際上就是將兩個變量除以了 3,使得他們的值縮小,並傳入了 HashWeelTimer
的第二個參數之中
heartbeatTimer.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS);
heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS);
複製代碼
tick 的含義即是定時任務執行的頻率。這樣,經過減小檢測間隔時間,增大了及時發現死鏈的機率,原先的最壞狀況是 60s,現在變成了 20s。這個頻率依舊能夠加快,但須要考慮資源消耗的問題。
定時不許確的問題出如今 Dubbo 的兩個定時任務之中,因此都作了 tick 操做。事實上,全部的定時檢測的邏輯都存在相似的問題。
Dubbo 對於創建的每個鏈接,同時在客戶端和服務端開啓了 2 個定時器,一個用於定時發送心跳,一個用於定時重連、斷連,執行的頻率均爲各自檢測週期的 1/3。定時發送心跳的任務負責在鏈接空閒時,向對端發送心跳包。定時重連、斷連的任務負責檢測 lastRead 是否在超時週期內仍未被更新,若是斷定爲超時,客戶端處理的邏輯是重連,服務端則採起斷連的措施。
先不急着判斷這個方案好很差,再來看看改進方案是怎麼設計的。
實際上咱們能夠更優雅地實現心跳機制,本小節開始,我將介紹一個新的心跳機制。
Netty 對空閒鏈接的檢測提供了自然的支持,使用 IdleStateHandler
能夠很方便的實現空閒檢測邏輯。
public IdleStateHandler( long readerIdleTime, long writerIdleTime, long allIdleTime, TimeUnit unit) {}
複製代碼
IdleStateHandler
這個類會根據設置的超時參數,循環檢測 channelRead 和 write 方法多久沒有被調用。當在 pipeline 中加入 IdleSateHandler
以後,能夠在此 pipeline 的任意 Handler 的 userEventTriggered
方法之中檢測 IdleStateEvent
事件,
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
//do something
}
ctx.fireUserEventTriggered(evt);
}
複製代碼
爲何須要介紹 IdleStateHandler
呢?其實提到它的空閒檢測 + 定時的時候,你們應該可以想到了,這不自然是給心跳機制服務的嗎?不少服務治理框架都選擇了藉助 IdleStateHandler
來實現心跳。
IdleStateHandler 內部使用了 eventLoop.schedule(task) 的方式來實現定時任務,使用 eventLoop 線程的好處是還同時保證了線程安全,這裏是一個小細節。
首先是將 IdleStateHandler
加入 pipeline 中。
客戶端:
bootstrap.handler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast("clientIdleHandler", new IdleStateHandler(60, 0, 0));
}
});
複製代碼
服務端:
serverBootstrap.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast("serverIdleHandler",new IdleStateHandler(0, 0, 200));
}
}
複製代碼
客戶端配置了 read 超時爲 60s,服務端配置了 write/read 超時爲 200s,先在此埋下兩個伏筆:
對於空閒超時的處理邏輯,客戶端和服務端是不一樣的。首先來看客戶端
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
// send heartbeat
sendHeartBeat();
} else {
super.userEventTriggered(ctx, evt);
}
}
複製代碼
檢測到空閒超時以後,採起的行爲是向服務端發送心跳包,具體是如何發送,以及處理響應的呢?僞代碼以下
public void sendHeartBeat() {
Invocation invocation = new Invocation();
invocation.setInvocationType(InvocationType.HEART_BEAT);
channel.writeAndFlush(invocation).addListener(new CallbackFuture() {
@Override
public void callback(Future future) {
RPCResult result = future.get();
//超時 或者 寫失敗
if (result.isError()) {
channel.addFailedHeartBeatTimes();
if (channel.getFailedHeartBeatTimes() >= channel.getMaxHeartBeatFailedTimes()) {
channel.reconnect();
}
} else {
channel.clearHeartBeatFailedTimes();
}
}
});
}
複製代碼
行爲並不複雜,構造一個心跳包發送到服務端,接受響應結果
不只僅是心跳,普通請求返回成功響應時也會清空標記
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
channel.close();
} else {
super.userEventTriggered(ctx, evt);
}
}
複製代碼
服務端處理空閒鏈接的方式很是簡單粗暴,直接關閉鏈接。
爲何客戶端和服務端配置的超時時間不一致?
由於客戶端有重試邏輯,不斷髮送心跳失敗 n 次以後,才認爲是鏈接斷開;而服務端是直接斷開,留給服務端時間得長一點。60 * 3 < 200 還說明了一個問題,雙方都擁有斷開鏈接的能力,但鏈接的建立是由客戶端主動發起的,那麼客戶端也更有權利去主動斷開鏈接。
爲何客戶端檢測的是讀超時,而服務端檢測的是讀寫超時?
這實際上是一個心跳的共識了,仔細思考一下,定時邏輯是由客戶端發起的,因此整個鏈路中不通的狀況只有多是:服務端接收,服務端發送,客戶端接收。也就是說,只有客戶端的 pong,服務端的 ping,pong 的檢測是有意義的。
主動追求別人的是你,主動說分手的也是你。
利用 IdleStateHandler
實現心跳機制能夠說是十分優雅的,藉助 Netty 提供的空閒檢測機制,利用客戶端維護單向心跳,在收到 3 次心跳失敗響應以後,客戶端斷開鏈接,交由異步線程重連,本質仍是表現爲客戶端重連。服務端在鏈接空閒較長時間後,主動斷開鏈接,以免無謂的資源浪費。
Dubbo 現有方案 | Dubbo 改進方案 | |
---|---|---|
主體設計 | 開啓兩個定時器 | 藉助 IdleStateHandler,底層使用 shedule |
心跳方向 | 雙向 | 單向(客戶端 -> 服務端) |
心跳失敗斷定方式 | 心跳成功更新標記,藉助定時器定時掃描標記,若是超過心跳超時週期未更新標記,認爲心跳失敗。 | 經過判斷心跳響應是否失敗,超過失敗次數,認爲心跳失敗 |
擴展性 | Dubbo 存在 mina,grizzy 等其餘通訊層實現,自定義定時器很容易適配多種擴展 | 多通訊層各自實現心跳,不作心跳的抽象 |
設計性 | 編碼複雜度高,代碼量大,方案複雜,不易維護 | 編碼量小,可維護性強 |
私下請教過美團點評的長鏈接負責人:俞超(閃電俠),美點使用的心跳方案和 Dubbo 改進方案几乎一致,能夠該方案是標準實現了。
鑑於 Dubbo 存在一些其餘通訊層的實現,因此能夠保留現有的定時發送心跳的邏輯。
雙向心跳的設計是沒必要要的,兼容現有的邏輯,可讓客戶端在鏈接空閒時發送單向心跳,服務端定時檢測鏈接可用性。定時時間儘可能保證:客戶端超時時間 * 3 ≈ 服務端超時時間
去除處理重連和斷連的定時任務,Dubbo 能夠判斷心跳請求是否響應失敗,能夠借鑑改進方案的設計,在鏈接級別維護一個心跳失敗次數的標記,任意響應成功,清除標記;連續心跳失敗 n 次,客戶端發起重連。這樣能夠減小一個沒必要要的定時器,任何輪詢的方式,都是不優雅的。
最後再聊聊可擴展性這個話題。其實我是建議把定時器交給更加底層的 Netty 去作,也就是徹底使用 IdleStateHandler
,其餘通訊層組件各自實現本身的空閒檢測邏輯,可是 Dubbo 中 mina,grizzy 的兼容問題囿住了個人拳腳,但試問一下,現在的 2019 年,又有多少人在使用 mina 和 grizzy?由於一些不太可能用的特性,而限制了主流用法的優化,這確定不是什麼好事。抽象,功能,可擴展性並非越多越好,開源產品的人力資源是有限的,框架使用者的理解能力也是有限的,能解決大多數人問題的設計,纔是好的設計。哎,誰讓我不會 mina,grizzy,還懶得去學呢[攤手]。
歡迎關注個人微信公衆號:「Kirito的技術分享」,關於文章的任何疑問都會獲得回覆,帶來更多 Java 相關的技術分享。