如何設計一個優雅的心跳機制

Kirito的部落格發表於2019-01-16


來源:Fate/stay night [Heaven's Feel] lost butterfly

1 前言

在前一篇文章《聊聊 TCP 長連線和心跳那些事》中,我們已經聊過了 TCP 中的 KeepAlive,以及在應用層設計心跳的意義,但卻對長連線心跳的設計方案沒有做詳細地介紹。事實上,設計一個好的心跳機制並不是一件容易的事,就我所熟知的幾個 RPC 框架,它們的心跳機制可以說大相徑庭,這篇文章我將探討一下如何設計一個優雅的心跳機制,主要從 Dubbo 的現有方案以及一個改進方案來做分析

2 預備知識

因為後續我們將從原始碼層面來進行介紹,所以一些服務治理框架的細節還需要提前交代一下,方便大家理解。

2.1 客戶端如何得知請求失敗了?

高效能的 RPC 框架幾乎都會選擇使用 Netty 來作為通訊層的元件,非阻塞式通訊的高效不需要我做過多的介紹。但也由於非阻塞的特性,導致其傳送資料和接收資料是一個非同步的過程,所以當存在服務端異常、網路問題時,客戶端接是接收不到響應的,那我們如何判斷一次 RPC 呼叫是失敗的呢?

誤區一:Dubbo 呼叫不是預設同步的嗎?

Dubbo 在通訊層是非同步的,呈現給使用者同步的錯覺是因為內部做了阻塞等待,實現了非同步轉同步。

誤區二: Channel.writeAndFlush 會返回一個 channelFuture,我只需要判斷 channelFuture.isSuccess 就可以判斷請求是否成功了。

注意,writeAndFlush 成功並不代表對端接受到了請求,返回值為 true 只能保證寫入網路緩衝區成功,並不代表傳送成功。

避開上述兩個誤區,我們再來回到本小節的標題:客戶端如何得知請求失敗?正確的邏輯應當是以客戶端接收到失敗響應為判斷依據。等等,前面不還在說在失敗的場景中,服務端是不會返回響應的嗎?沒錯,既然服務端不會返回,那就只能客戶端自己造了。

一個常見的設計是:客戶端發起一個 RPC 請求,會設定一個超時時間 client_timeout,發起呼叫的同時,客戶端會開啟一個延遲 client_timeout 的定時器

  • 接收到正常響應時,移除該定時器。

  • 定時器倒數計時完畢,還沒有被移除,則認為請求超時,構造一個失敗的響應傳遞給客戶端。

Dubbo 中的超時判定邏輯:

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);
    }
}

主要邏輯涉及的類: DubboInvokerHeaderExchangeChannelDefaultFuture ,透過上述程式碼,我們可以得知一個細節,無論是何種呼叫,都會經過這個定時器的檢測,超時即呼叫失敗,一次 RPC 呼叫的失敗,必須以客戶端收到失敗響應為準

2.2 心跳檢測需要容錯

網路通訊永遠要考慮到最壞的情況,一次心跳失敗,不能認定為連線不通,多次心跳失敗,才能採取相應的措施。

2.3 心跳檢測不需要忙檢測

忙檢測的對立面是空閒檢測,我們做心跳的初衷,是為了保證連線的可用性,以保證及時採取斷連,重連等措施。如果一條通道上有頻繁的 RPC 呼叫正在進行,我們不應該為通道增加負擔去傳送心跳包。心跳扮演的角色應當是晴天收傘,雨天送傘。

3 Dubbo 現有方案

本文的原始碼對應 Dubbo 2.7.x 版本,在 apache 孵化的該版本中,心跳機制得到了增強。

介紹完了一些基礎的概念,我們便來看看 Dubbo 是如何設計應用層心跳的。Dubbo 的心跳是雙向心跳,客戶端會給服務端傳送心跳,反之,服務端也會向客戶端傳送心跳。

3.1 連線建立時建立定時器

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();
        }
    }
 }



不僅 HeaderExchangeClient 客戶端開起了定時器, HeaderExchangeServer 服務端同樣開起了定時器,由於服務端的邏輯和客戶端幾乎一致,所以後續我並不會重複貼上服務端的程式碼。

Dubbo 在早期版本版本中使用的是 shedule 方案,在 2.7.x 中替換成了 HashWheelTimer。

3.2 開啟兩個定時任務

  1. private void startHeartbeatTimer() {

  2.    long heartbeatTick = calculateLeastDuration(heartbeat);

  3.    long heartbeatTimeoutTick = calculateLeastDuration(heartbeatTimeout);

  4.    HeartbeatTimerTask heartBeatTimerTask = new HeartbeatTimerTask(cp, heartbeatTick, heartbeat); <1>

  5.    ReconnectTimerTask reconnectTimerTask = new ReconnectTimerTask(cp, heartbeatTimeoutTick, heartbeatTimeout); <2>


  6.    heartbeatTimer.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS);

  7.    heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS);

  8. }

Dubbo 在 startHeartbeatTimer 方法中主要開啟了兩個定時器: HeartbeatTimerTaskReconnectTimerTask



至於方法中的其他程式碼,其實也是本文的重要分析內容,先容我賣個關子,後面再來看追溯。

3.3 定時任務一:傳送心跳請求

詳細解析下心跳檢測定時任務的邏輯 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,普通請求也會。這對應了我們預備知識中的空閒檢測機制。

3.4 定時任務二:處理重連和斷連

繼續研究下重連和斷連定時器都實現了什麼 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 是不太準確的,因為它處理的是重連和斷連兩個邏輯。

3.5 定時不精確的問題

在 Dubbo 的 issue 中曾經有人反饋過定時不精確的問題,我們來看看是怎麼一回事。

Dubbo 中預設的心跳週期是 60s,設想如下的時序:

  • 第 0 秒,心跳檢測發現連線活躍

  • 第 1 秒,連線實際斷開

  • 第 60 秒,心跳檢測發現連線不活躍

由於時間視窗的問題,死鏈不能夠被及時檢測出來,最壞情況為一個心跳週期

為了解決上述問題,我們再倒回去看一下上面的 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 操作。事實上,所有的定時檢測的邏輯都存在類似的問題。

3.6 Dubbo 心跳總結

Dubbo 對於建立的每一個連線,同時在客戶端和服務端開啟了 2 個定時器,一個用於定時傳送心跳,一個用於定時重連、斷連,執行的頻率均為各自檢測週期的 1/3。定時傳送心跳的任務負責在連線空閒時,向對端傳送心跳包。定時重連、斷連的任務負責檢測 lastRead 是否在超時週期內仍未被更新,如果判定為超時,客戶端處理的邏輯是重連,服務端則採取斷連的措施。

先不急著判斷這個方案好不好,再來看看改進方案是怎麼設計的。

4 Dubbo 改進方案

實際上我們可以更優雅地實現心跳機制,本小節開始,我將介紹一個新的心跳機制。

4.1 IdleStateHandler 介紹

Netty 對空閒連線的檢測提供了天然的支援,使用 IdleStateHandler 可以很方便的實現空閒檢測邏輯。

public IdleStateHandler(
            long readerIdleTime, long writerIdleTime, long allIdleTime,
            TimeUnit unit) {}
  • readerIdleTime:讀超時時間

  • writerIdleTime:寫超時時間

  • allIdleTime:所有型別的超時時間

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 執行緒的好處是還同時保證了執行緒安全,這裡是一個小細節。

4.2 客戶端和服務端配置

首先是將 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,先在此埋下兩個伏筆:

  1. 為什麼客戶端和服務端配置的超時時間不一致?

  2. 為什麼客戶端檢測的是讀超時,而服務端檢測的是讀寫超時?

4.3 空閒超時邏輯 — 客戶端

對於空閒超時的處理邏輯,客戶端和服務端是不同的。首先來看客戶端

@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();
            }
        }
    });
}

行為並不複雜,構造一個心跳包傳送到服務端,接受響應結果

  • 響應成功,清空請求失敗標記

  • 響應失敗,心跳失敗標記+1,如果超過配置的失敗次數,則重新連線

不僅僅是心跳,普通請求返回成功響應時也會清空標記

4.4 空閒超時邏輯 — 服務端

@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
    if (evt instanceof IdleStateEvent) {
        channel.close();
    } else {
        super.userEventTriggered(ctx, evt);
    }
}

服務端處理空閒連線的方式非常簡單粗暴,直接關閉連線。

4.5 改進方案心跳總結


  1. 為什麼客戶端和服務端配置的超時時間不一致?

    因為客戶端有重試邏輯,不斷髮送心跳失敗 n 次之後,才認為是連線斷開;而服務端是直接斷開,留給服務端時間得長一點。60 * 3 < 200 還說明了一個問題,雙方都擁有斷開連線的能力,但連線的建立是由客戶端主動發起的,那麼客戶端也更有權利去主動斷開連線。


  2. 為什麼客戶端檢測的是讀超時,而服務端檢測的是讀寫超時?

    這其實是一個心跳的共識了,仔細思考一下,定時邏輯是由客戶端發起的,所以整個鏈路中不通的情況只有可能是:服務端接收,服務端傳送,客戶端接收。也就是說,只有客戶端的 pong,服務端的 ping,pong 的檢測是有意義的。

主動追求別人的是你,主動說分手的也是你。

利用 IdleStateHandler 實現心跳機制可以說是十分優雅的,藉助 Netty 提供的空閒檢測機制,利用客戶端維護單向心跳,在收到 3 次心跳失敗響應之後,客戶端斷開連線,交由非同步執行緒重連,本質還是表現為客戶端重連。服務端在連線空閒較長時間後,主動斷開連線,以避免無謂的資源浪費。

5 心跳設計方案對比

如何設計一個優雅的心跳機制

私下請教過美團點評的長連線負責人:俞超(閃電俠),美點使用的心跳方案和 Dubbo 改進方案几乎一致,可以該方案是標準實現了。

6 Dubbo 實際改動點建議

鑑於 Dubbo 存在一些其他通訊層的實現,所以可以保留現有的定時傳送心跳的邏輯。

  • 建議改動點一:

雙向心跳的設計是不必要的,相容現有的邏輯,可以讓客戶端在連線空閒時傳送單向心跳,服務端定時檢測連線可用性。定時時間儘量保證:客戶端超時時間 * 3 ≈ 服務端超時時間

  • 建議改動點二:

去除處理重連和斷連的定時任務,Dubbo 可以判斷心跳請求是否響應失敗,可以借鑑改進方案的設計,在連線級別維護一個心跳失敗次數的標記,任意響應成功,清除標記;連續心跳失敗 n 次,客戶端發起重連。這樣可以減少一個不必要的定時器,任何輪詢的方式,都是不優雅的。

最後再聊聊可擴充套件性這個話題。其實我是建議把定時器交給更加底層的 Netty 去做,也就是完全使用 IdleStateHandler ,其他通訊層元件各自實現自己的空閒檢測邏輯,但是 Dubbo 中 mina,grizzy 的相容問題囿住了我的拳腳,但試問一下,如今的 2019 年,又有多少人在使用 mina 和 grizzy?因為一些不太可能用的特性,而限制了主流用法的最佳化,這肯定不是什麼好事。抽象,功能,可擴充套件性並不是越多越好,開源產品的人力資源是有限的,框架使用者的理解能力也是有限的,能解決大多數人問題的設計,才是好的設計。哎,mina、grizzy,學不動了。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31556476/viewspace-2563653/,如需轉載,請註明出處,否則將追究法律責任。

相關文章