RPC框架的可靠性設計

1. 背景

1.1 分佈式調用引入的故障

在傳統的單體架構中,業務服務調用都是本地方法調用,不會涉及到網絡通信、協議棧、消息序列化和反序列化等,當使用RPC框架將業務由單體架構改造成分佈式系統之後,本地方法調用將演變成跨進程的遠程調用,會引入一些新的故障點,如下所示:

圖1 RPC調用引入的潛在故障點

新引入的潛在故障點包括:

1.消息的序列化和反序列化故障,例如,不支持的數據類型。

2.路由故障:包括服務的訂閱、發佈故障,服務實例故障之後沒有及時刷新路由表,導致RPC調用仍然路由到故障節點。

3.網絡通信故障,包括網絡閃斷、網絡單通、丟包、客戶端浪涌接入等。

1.2 第三方服務依賴

RPC服務通常會依賴第三方服務,包括數據庫服務、文件存儲服務、緩存服務、消息隊列服務等,這種第三方依賴同時也引入了潛在的故障:

1.網絡通信類故障,如果採用BIO調用第三方服務,很有可能被阻塞。

2.“雪崩效用”導致的級聯故障,例如服務端處理慢導致客戶端線程被阻塞。

3.第三方不可用導致RPC調用失敗。

典型的第三方依賴示例如下:

圖2 RPC服務端的第三方依賴

2. 通信層的可靠性設計

2.1 鏈路有效性檢測

當網絡發生單通、連接被防火牆Hang住、長時間GC或者通信線程發生非預期異常時,會導致鏈路不可用且不易被及時發現。特別是異常發生在凌晨業務低谷期間,當早晨業務高峯期到來時,由於鏈路不可用會導致瞬間的大批量業務失敗或者超時,這將對系統的可靠性產生重大的威脅。

從技術層面看,要解決鏈路的可靠性問題,必須週期性的對鏈路進行有效性檢測。目前最流行和通用的做法就是心跳檢測。

心跳檢測機制分爲三個層面:

1.TCP層面的心跳檢測,即TCP的Keep-Alive機制,它的作用域是整個TCP協議棧。

2.協議層的心跳檢測,主要存在於長連接協議中。例如MQTT協議。

3.應用層的心跳檢測,它主要由各業務產品通過約定方式定時給對方發送心跳消息實現。

心跳檢測的目的就是確認當前鏈路可用,對方活着並且能夠正常接收和發送消息。做爲高可靠的NIO框架,Netty也提供了心跳檢測機制,下面我們一起熟悉下心跳的檢測原理。

心跳檢測的原理示意圖如下:

圖3 鏈路心跳檢測

不同的協議,心跳檢測機制也存在差異,歸納起來主要分爲兩類:

1.Ping-Pong型心跳:由通信一方定時發送Ping消息,對方接收到Ping消息之後,立即返回Pong應答消息給對方,屬於請求-響應型心跳。

2.Ping-Ping型心跳:不區分心跳請求和應答,由通信雙方按照約定定時向對方發送心跳Ping消息,它屬於雙向心跳。

心跳檢測策略如下:

1.連續N次心跳檢測都沒有收到對方的Pong應答消息或者Ping請求消息,則認爲鏈路已經發生邏輯失效,這被稱作心跳超時。

2.讀取和發送心跳消息的時候如何直接發生了IO異常,說明鏈路已經失效,這被稱爲心跳失敗。

無論發生心跳超時還是心跳失敗,都需要關閉鏈路,由客戶端發起重連操作,保證鏈路能夠恢復正常。

Netty的心跳檢測實際上是利用了鏈路空閒檢測機制實現的,它的空閒檢測機制分爲三種:

1.讀空閒,鏈路持續時間t沒有讀取到任何消息。

2.寫空閒,鏈路持續時間t沒有發送任何消息。

3.讀寫空閒,鏈路持續時間t沒有接收或者發送任何消息。

Netty的默認讀寫空閒機制是發生超時異常,關閉連接,但是,我們可以定製它的超時實現機制,以便支持不同的用戶場景,鏈路空閒接口定義如下:

protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception {

        ctx.fireUserEventTriggered(evt);

    }

鏈路空閒的時候並沒有關閉鏈路,而是觸發IdleStateEvent事件,用戶訂閱IdleStateEvent事件,用於自定義邏輯處理,例如關閉鏈路、客戶端發起重新連接、告警和打印日誌等。利用Netty提供的鏈路空閒檢測機制,可以非常靈活的實現鏈路空閒時的有效性檢測。

2.2 客戶端斷連重連

當發生如下異常時,客戶端需要釋放資源,重新發起連接:

1.服務端因爲某種原因,主動關閉連接,客戶端檢測到鏈路被正常關閉。

2.服務端因爲宕機等故障,強制關閉連接,客戶端檢測到鏈路被Rest掉。

3.心跳檢測超時,客戶端主動關閉連接。

4.客戶端因爲其它原因(例如解碼失敗),強制關閉連接。

5.網絡類故障,例如網絡丟包、超時、單通等,導致鏈路中斷。

客戶端檢測到鏈路中斷後,等待INTERVAL時間,由客戶端發起重連操作,如果重連失敗,間隔週期INTERVAL後再次發起重連,直到重連成功。

爲了保證服務端能夠有充足的時間釋放句柄資源,在首次斷連時客戶端需要等待INTERVAL時間之後再發起重連,而不是失敗後就立即重連。

爲了保證句柄資源能夠及時釋放,無論什麼場景下的重連失敗,客戶端都必須保證自身的資源被及時釋放,包括但不限於SocketChannel、Socket等。重連失敗後,需要打印異常堆棧信息,方便後續的問題定位。

利用Netty Channel提供的CloseFuture,可以非常方便的檢測鏈路狀態,一旦鏈路關閉,相關事件即被觸發,可以重新發起連接操作,代碼示例如下:

future.channel().closeFuture().sync();

   } finally {

       // 所有資源釋放完成之後,清空資源,再次發起重連操作

       executor.execute(new Runnable() {

      public void run() {

          try {

         TimeUnit.SECONDS.sleep(3);//3秒之後發起重連,等待句柄釋放

         try {

                // 發起重連操作

             connect(NettyConstant.PORT, NettyConstant.REMOTEIP);         } catch (Exception e) {

          ......異常處理相關代碼省略

      }

       });

2.3 緩存重發

當我們調用消息發送接口的時候,消息並沒有真正被寫入到Socket中,而是先放入NIO通信框架的消息發送隊列中,由Reactor線程掃描待發送的消息隊列,異步的發送給通信對端。假如很不幸,消息隊列中積壓了部分消息,此時鏈路中斷,這會導致部分消息並沒有真正發送給通信對端,示例如下:

圖4 鏈路中斷導致積壓消息沒有發送

發生此故障時,我們希望NIO框架能夠自動實現消息緩存和重新發送,遺憾的是作爲基礎的NIO通信框架,無論是Mina還是Netty,都沒有提供該功能,需要通信框架自己封裝實現,基於Netty的實現策略如下:

1.調用Netty ChannelHandlerContext的write方法時,返回ChannelFuture對象,我們在ChannelFuture中註冊發送結果監聽Listener。

2.在Listener的operationComplete方法中判斷操作結果,如果操作不成功,將之前發送的消息對象添加到重發隊列中。

3.鏈路重連成功之後,根據策略,將緩存隊列中的消息重新發送給通信對端。

需要指出的是,並非所有場景都需要通信框架做重發,例如服務框架的客戶端,如果某個服務提供者不可用,會自動切換到下一個可用的服務提供者之上。假定是鏈路中斷導致的服務提供者不可用,即便鏈路重新恢復,也沒有必要將之前積壓的消息重新發送,因爲消息已經通過FailOver機制切換到另一個服務提供者處理。所以,消息緩存重發只是一種策略,通信框架應該支持鏈路級重發策略。

2.4 客戶端超時保護

在傳統的同步阻塞編程模式下,客戶端Socket發起網絡連接,往往需要指定連接超時時間,這樣做的目的主要有兩個:

1.在同步阻塞I/O模型中,連接操作是同步阻塞的,如果不設置超時時間,客戶端I/O線程可能會被長時間阻塞,這會導致系統可用I/O線程數的減少。

2.業務層需要:大多數系統都會對業務流程執行時間有限制,例如WEB交互類的響應時間要小於3S。客戶端設置連接超時時間是爲了實現業務層的超時。

對於NIO的SocketChannel,在非阻塞模式下,它會直接返回連接結果,如果沒有連接成功,也沒有發生I/O異常,則需要將SocketChannel註冊到Selector上監聽連接結果。所以,異步連接的超時無法在API層面直接設置,而是需要通過用戶自定義定時器來主動監測。

Netty在創建NIO客戶端時,支持設置連接超時參數。Netty的客戶端連接超時參數與其它常用的TCP參數一起配置,使用起來非常方便,上層用戶不用關心底層的超時實現機制。這既滿足了用戶的個性化需求,又實現了故障的分層隔離。

2.5 針對客戶端的併發連接數流控

以Netty的HTTPS服務端爲例,針對客戶端的併發連接數流控原理如下所示:

圖5 服務端HTTS連接數流控

基於Netty的Pipeline機制,可以對SSL握手成功、SSL連接關閉做切面攔截(類似於Spring的AOP機制,但是沒采用反射機制,性能更高),通過流控切面接口,對HTTPS連接做計數,根據計數器做流控,服務端的流控算法如下:

1.獲取流控閾值。

2.從全局上下文中獲取當前的併發連接數,與流控閾值對比,如果小於流控閾值,則對當前的計數器做原子自增,允許客戶端連接接入。

3.如果等於或者大於流控閾值,則拋出流控異常給客戶端。

4.SSL連接關閉時,獲取上下文中的併發連接數,做原子自減。

在實現服務端流控時,需要注意如下幾點:

1.流控的ChannelHandler聲明爲@ChannelHandler.Sharable,這樣全局創建一個流控實例,就可以在所有的SSL連接中共享。

2.通過userEventTriggered方法攔截SslHandshakeCompletionEvent和SslCloseCompletionEvent事件,在SSL握手成功和SSL連接關閉時更新流控計數器。

3.流控並不是單針對ESTABLISHED狀態的HTTP連接,而是針對所有狀態的連接,因爲客戶端關閉連接,並不意味着服務端也同時關閉了連接,只有SslCloseCompletionEvent事件觸發時,服務端才真正的關閉了NioSocketChannel,GC纔會回收連接關聯的內存。

4.流控ChannelHandler會被多個NioEventLoop線程調用,因此對於相關的計數器更新等操作,要保證併發安全性,避免使用全局鎖,可以通過原子類等提升性能。

2.6 內存保護

NIO通信的內存保護主要集中在如下幾點:

1.鏈路總數的控制:每條鏈路都包含接收和發送緩衝區,鏈路個數太多容易導致內存溢出。

2.單個緩衝區的上限控制:防止非法長度或者消息過大導致內存溢出。

3.緩衝區內存釋放:防止因爲緩衝區使用不當導致的內存泄露。

4.NIO消息發送隊列的長度上限控制。

當我們對消息進行解碼的時候,需要創建緩衝區。緩衝區的創建方式通常有兩種:

1.容量預分配,在實際讀寫過程中如果不夠再擴展。

2.根據協議消息長度創建緩衝區。

在實際的商用環境中,如果遇到畸形碼流攻擊、協議消息編碼異常、消息丟包等問題時,可能會解析到一個超長的長度字段。筆者曾經遇到過類似問題,報文長度字段值竟然是2G多,由於代碼的一個分支沒有對長度上限做有效保護,結果導致內存溢出。系統重啓後幾秒內再次內存溢出,幸好及時定位出問題根因,險些釀成嚴重的事故。

Netty提供了編解碼框架,因此對於解碼緩衝區的上限保護就顯得非常重要。下面,我們看下Netty是如何對緩衝區進行上限保護的:

首先,在內存分配的時候指定緩衝區長度上限:

/**

     * Allocate a {@link ByteBuf} with the given initial capacity and the given

     * maximal capacity. If it is a direct or heap buffer depends on the actual

     * implementation.

     */

    ByteBuf buffer(int initialCapacity, int maxCapacity);

其次,在對緩衝區進行寫入操作的時候,如果緩衝區容量不足需要擴展,首先對最大容量進行判斷,如果擴展後的容量超過上限,則拒絕擴展:

 @Override

    public ByteBuf capacity(int newCapacity) {

        ensureAccessible();

        if (newCapacity < 0 || newCapacity > maxCapacity()) {

            throw new IllegalArgumentException("newCapacity: " + newCapacity);

        }


在消息解碼的時候,對消息長度進行判斷,如果超過最大容量上限,則拋出解碼異常,拒絕分配內存,以LengthFieldBasedFrameDecoder的decode方法爲例進行說明:

if (frameLength > maxFrameLength) {

            long discard = frameLength - in.readableBytes();

            tooLongFrameLength = frameLength;

            if (discard < 0) {

                in.skipBytes((int) frameLength);

            } else {

                discardingTooLongFrame = true;

                bytesToDiscard = discard;

                in.skipBytes(in.readableBytes());

            }

            failIfNecessary(true);

            return null;

        }

3. RPC調用層的可靠性設計

3.1 RPC調用異常場景

RPC調用過程中除了通信層的異常,通常也會遇到如下幾種故障:

  • 服務路由失敗。

  • 服務端超時。

  • 服務端調用失敗。

RPC框架需要能夠針對上述常見的異常做容錯處理,以提升業務調用的可靠性。

3.1.1 服務路由失敗

RPC客戶端通常會基於訂閱/發佈的機制獲取服務端的地址列表,並將其緩存到本地,RPC調用時,根據負載均衡策略從本地緩存的路由表中獲取到一個唯一的服務端節點發起調用,原理如下所示:

圖6 基於訂閱發佈機制的RPC調用

通過緩存的機制能夠提升RPC調用的性能,RPC客戶端不需要每次調用都向註冊中心查詢目標服務的地址信息,但是也可能會發生如下兩類潛在故障:

1.某個RPC服務端發生故障,或者下線,客戶端沒有及時刷新本地緩存的服務地址列表,就會導致RPC調用失敗。

2.RPC客戶端和服務端都工作正常,但是RPC客戶端和服務端的連接或者網絡發生了故障,如果沒有鏈路的可靠性檢測機制,就會導致RPC調用失敗。

3.1.2 服務端超時

當服務端無法在指定的時間內返回應答給客戶端,就會發生超時,導致超時的原因主要有:

1.服務端的I/O線程沒有及時從網絡中讀取客戶端請求消息,導致該問題的原因通常是I/O線程被意外阻塞或者執行長週期操作。

2.服務端業務處理緩慢,或者被長時間阻塞,例如查詢數據庫,由於沒有索引導致全表查詢,耗時較長。

3.服務端發生長時間Full GC,導致所有業務線程暫停運行,無法及時返回應答給客戶端。

3.1.3 服務端調用失敗

有時會發生服務端調用失敗,導致服務端調用失敗的原因主要有如下幾種:

1.服務端解碼失敗,會返回消息解碼失敗異常。

2.服務端發生動態流控,返回流控異常。

3.服務端消息隊列積壓率超過最大閾值,返回系統擁塞異常。

4.訪問權限校驗失敗,返回權限相關異常。

5.違反SLA策略,返回SLA控制相關異常。

6.其他系統異常。

需要指出的是,服務調用異常不包括業務層面的處理異常,例如數據庫操作異常、用戶記錄不存在異常等。

3.2 RPC調用可靠性方案

3.2.1註冊中心與鏈路檢測雙保險機制

因爲註冊中心有集羣內所有RPC客戶端和服務端的實例信息,因此通過註冊中心向每個服務端和客戶端發送心跳消息,檢測對方是否在線,如果連續N次心跳超時,或者心跳發送失敗,則判斷對方已經發生故障或者下線(下線可以通過優雅停機的方式主動告知註冊中心,實時性會更好)。註冊中心將故障節點的服務實例信息通過心跳消息發送給客戶端,由客戶端將故障的服務實例信息從本地緩存的路由表中刪除,後續消息調用不再路由到該節點。

在一些特殊場景下,儘管註冊中心與服務端、客戶端的連接都沒有問題,但是服務端和客戶端之間的鏈路發生了異常,由於發生鏈路異常的服務端仍然在緩存表中,因此消息還會繼續調度到故障節點上,所以,利用RPC客戶端和服務端之間的雙向心跳檢測,可以及時發現雙方之間的鏈路問題,利用重連等機制可以快速的恢復連接,如果重連N次都失敗,則服務路由時不再將消息發送到連接故障的服務節點上。

利用註冊中心對服務端的心跳檢測和通知機制、以及服務端和客戶端針對鏈路層的雙向心跳檢測機制,可以有效檢測出故障節點,提升RPC調用的可靠性,它的原理如下所示:

圖7 註冊中心與鏈路雙向心跳檢測機制原理

3.2.2 集羣容錯策略

常用的集羣容錯策略包括:

1.失敗自動切換(Failover)。

2.失敗通知(Failback)。

3.失敗緩存(Failcache)。

4.快速失敗(Failfast)。

失敗自動切換策略:服務調用失敗自動切換策略指的是當發生RPC調用異常時,重新選路,查找下一個可用的服務提供者。

服務發佈的時候,可以指定服務的集羣容錯策略。消費者可以覆蓋服務提供者的通用配置,實現個性化的容錯策略。

Failover策略的設計思路如下:消費者路由操作完成之後,獲得目標地址,調用通信框架的消息發送接口發送請求,監聽服務端應答。如果返回的結果是RPC調用異常(超時、流控、解碼失敗等系統異常),根據消費者集羣容錯的策略進行容錯路由,如果是Failover,則重新返回到路由Handler的入口,從路由節點繼續執行。選路完成之後,對目標地址進行比對,防止重新路由到故障服務節點,過濾掉上次的故障服務提供者之後,調用通信框架的消息發送接口發送請求消息。

RPC框架提供Failover容錯策略,但是用戶在使用時需要自己保證用對地方,下面對Failover策略的應用場景進行總結:

1.讀操作,因爲通常它是冪等的。

2.冪等性服務,保證調用1次與N次效果相同。

需要特別指出的是,失敗重試會增加服務調用時延,因此框架必須對失敗重試的最大次數做限制,通常默認爲3,防止無限制重試導致服務調用時延不可控。

失敗通知(Failback):在很多業務場景中,客戶端需要能夠獲取到服務調用失敗的具體信息,通過對失敗錯誤碼等異常信息的判斷,決定後續的執行策略,例如非冪等性的服務調用。

Failback的設計方案如下:RPC框架獲取到服務提供者返回的RPC異常響應之後,根據策略進行容錯。如果是Failback模式,則不再重試其它服務提供者,而是將RPC異常通知給客戶端,由客戶端捕獲異常進行後續處理。

失敗緩存(Failcache):Failcache策略是失敗自動恢復的一種,在實際項目中它的應用場景如下:

1.服務有狀態路由,必須定點發送到指定的服務提供者。當發生鏈路中斷、流控等服務暫時不可用時,RPC框架將消息臨時緩存起來,等待週期T,重新發送,直到服務提供者能夠正常處理該消息。

2.對時延要求不敏感的服務。系統服務調用失敗,通常是鏈路暫時不可用、服務流控、GC掛住服務提供者進程等,這種失敗不是永久性的失敗,它的恢復是可預期的。如果客戶端對服務調用時延不敏感,可以考慮採用自動恢復模式,即先緩存,再等待,最後重試。

3.通知類服務。例如通知粉絲積分增長、記錄接口日誌等,對服務調用的實時性要求不高,可以容忍自動恢復帶來的時延增加。

爲了保證可靠性,Failcache策略在設計的時候需要考慮如下幾個要素:

1.緩存時間、緩存對象上限數等需要做出限制,防止內存溢出。

2.緩存淘汰算法的選擇,是否支持用戶配置。

3.定時重試的週期T、重試的最大次數等需要做出限制並支持用戶指定。

4.重試達到最大上限仍失敗,需要丟棄消息,記錄異常日誌。

快速失敗(Failfast):在業務高峯期,對於一些非核心的服務,希望只調用一次,失敗也不再重試,爲重要的核心服務節約寶貴的運行資源。此時,快速失敗是個不錯的選擇。快速失敗策略的設計比較簡單,獲取到服務調用異常之後,直接忽略異常,記錄異常日誌。

4. 第三方服務依賴故障隔離

4.1 總體策略

儘管很多第三方服務會提供SLA,但是RPC服務本身並不能完全依賴第三方服務自身的可靠性來保障自己的高可靠,第三方服務依賴隔離的總體策略如下:

1.第三方依賴隔離可以採用線程池 + 響應式編程(例如RxJava)的方式實現。

2.對第三方依賴進行分類,每種依賴對應一個獨立的線程/線程池。

3.服務不直接調用第三方依賴的API,而是使用異步封裝之後的API接口。

4.異步調用第三方依賴API之後,獲取Future對象。利用響應式編程框架,

可以訂閱後續的事件,接收響應,針對響應進行編程。

4.2 異步化

如果第三方服務提供的是標準的HTTP/Restful服務,則利用異步HTTP客戶端,例如Netty、Vert.x、異步RestTemplate等發起異步服務調用,這樣無論是服務端自身處理慢還是網絡慢,都不會導致調用方被阻塞。

如果對方是私有或者定製化的協議,SDK沒有提供異步接口,則需要採用線程池或者利用一些開源框架實現故障隔離。

異步化示例圖如下所示:

圖8 異步化原理示意圖

異步化的幾個關鍵技術點:

1.異步具有依賴和傳遞性,如果想在某個業務流程的某個過程中做異步化,則入口處就需要做異步。例如如果想把Redis服務調用改造成異步,則調用Redis服務之前的流程也需要同時做異步化,否則意義不大(除非調用方不需要返回值)。

2.通常而言,全棧異步對於業務性能和可靠性提升的意義更大,全棧異步會涉及到內部服務調用、第三方服務調用、數據庫、緩存等平臺中間件服務的調用,異步化改造成本比較高,但是收益也比較明顯。

3.不同框架、服務的異步編程模型儘量保持一致,例如統一採取RxJava風格的接口、或者JDK8的CompletableFuture。如果不同服務SDK的異步API接口風格差異過大,會增加業務的開發成本,也不利用線程模型的歸併和整合。

4.3.基於Hystrix的第三方依賴故障隔離

集成Netflix開源的Hystrix框架,可以非常方便的實現第三方服務依賴故障隔離,它提供的主要功能包括:

1.依賴隔離。

2.熔斷器。

3.優雅降級。

4.Reactive編程。

5.信號量隔離。

建議的集成策略如下:

1.第三方依賴隔離:使用HystrixCommand做一層異步封裝,實現業務的RPC服務調用線程和第三方依賴的線程隔離。

2.依賴分類管理:對第三方依賴進行分類、分組管理,根據依賴的特點設置熔斷策略、優雅降級策略、超時策略等,以實現差異化的處理。

總體集成視圖如下所示:

圖9 基於Hystrix的第三方故障隔離框架

基於Hystrix可以非常方便的實現第三方依賴服務的熔斷降級,它的工作原理如下:

1.熔斷判斷:服務調用時,對熔斷開關狀態進行判斷,當熔斷器開關關閉時, 請求被允許通過熔斷器。

2.熔斷執行:當熔斷器開關打開時,服務調用請求被禁止通過,執行失敗回調接口。

3.自動恢復:熔斷之後,週期T之後允許一條消息通過,如果成功,則取消熔斷狀態,否則繼續處於熔斷狀態。

流程如下所示:

圖10 基於Hystrix的熔斷降級

5. 作者簡介

李林鋒,10年Java NIO、平臺中間件設計和開發經驗,精通Netty、Mina、分佈式服務框架、API Gateway、PaaS等,《Netty進階之路》、《分佈式服務框架原理與實踐》作者。目前在華爲終端應用市場負責業務微服務化、雲化、全球化等相關設計和開發工作。

聯繫方式:新浪微博 Nettying 微信:Nettying

Email:[email protected]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章