《Java架構師的第一性原理》31分布式計算之RPC實戰與核心原理(極客時間 何小峰)


00 開篇詞 | 別老想着怎么用好RPC框架,你得多花時間琢磨原理

RPC 是解決分布式系統通信問題的一大利器。

分布式系統中的網絡通信一般都會采用四層的 TCP 協議或七層的 HTTP 協議,在我的了解中,前者占大多數,這主要得益於 TCP 協議的穩定性和高效性。網絡通信說起來簡單,但實際上是一個非常復雜的過程,這個過程主要包括:對端節點的查找、網絡連接的建立、傳輸數據的編碼解碼以及網絡連接的管理等等,每一項都很復雜。
 
我把整個專欄的內容分為了三大部分,分別是基礎篇、進階篇和高級篇。
  • 基礎篇:重點講解 RPC 的基礎知識,包括 RPC 的基本原理以及它的基本功能模塊,夯實基礎之后,我們會以一場實戰,通過剖析一款 RPC 框架來將知識點串聯起來。
  • 進階篇:重點講解 RPC 框架的架構設計,以及 RPC 框架集群、治理相關的知識。這部分我會列舉很多我在運營 RPC 框架中遇到的實際問題,以及這些問題的解決方案。
  • 高級篇:通過對上述兩部分的學習,你已經對 RPC 有了較高層次的理解了。在這部分,我主要會從性能優化、線上問題排查以及一些比較有特色的功能設計上講解 RPC 的應用

01丨核心原理:能否畫張圖解釋下RPC的通信流程?

 

02丨協議:怎么設計可擴展且向后兼容的協議?

1)那怎么設計一個私有 RPC 協議呢?

在協議頭里面,我們除了會放協議長度、序列化方式,還會放一些像協議標示、消息 ID、消息類型這樣的參數,而協議體一般只放請求接口方法、請求的業務參數值和一些擴展屬性。這樣一個完整的 RPC 協議大概就出來了,協議頭是由一堆固定的長度參數組成,而協議體是根據請求接口和參數構造的,長度屬於可變的,具體協議如下圖所示:

 

2)可擴展的協議

剛才講的協議屬於定長協議頭,那也就是說往后就不能再往協議頭里加新參數了,如果加參數就會導致線上兼容問題。舉個具體例子,假設你設計了一個 88Bit 的協議頭,其中協議長度占用 32bit,然后你為了加入新功能,在協議頭里面加了 2bit,並且放到協議頭的最后。升級后的應用,會用新的協議發出請求,然而沒有升級的應用收到的請求后,還是按照88bit 讀取協議頭,新加的 2 個 bit 會當作協議體前 2 個 bit 數據讀出來,但原本的協議體最后 2 個 bit 會被丟棄了,這樣就會導致協議體的數據是錯的。

所以為了保證能平滑地升級改造前后的協議,我們有必要設計一種支持可擴展的協議。其關鍵在於讓協議頭支持可擴展,擴展后協議頭的長度就不能定長了。那要實現讀取不定長的協議頭里面的內容,在這之前肯定需要一個固定的地方讀取長度,所以我們需要一個固定的寫入協議頭的長度。整體協議就變成了三部分內容:固定部分、協議頭內容、協議體內容,前兩部分我們還是可以統稱為“協議頭”,具體協議如下:
最后,我想說,設計一個簡單的 RPC 協議並不難,難的就是怎么去設計一個可“升級”的協議。不僅要讓我們在擴展新特性的時候能做到向下兼容,而且要盡可能地減少資源損耗,所以我們協議的結構不僅要支持協議體的擴展,還要做到協議頭也能擴展。上述這種設計方法來源於我多年的線上經驗,可以說做好擴展性是至關重要的,期待這個協議模版能幫你避掉一些坑。

03丨序列化:對象怎么在網絡中傳輸?

1)為什么要序列化

因為網絡傳輸的數據必須是二進制數據,所以在 RPC 調用中,對入參對象與返回值對象進行序列化與反序列化是一個必須的過程。

 

 

 

2)有哪些常用的序列化

(1)JDK原生序列化

JDK 自帶的序列化機制對使用者而言是非常簡單的。序列化具體的實現是由 ObjectOutputStream 完成的,而反序列化的具體實現是由 ObjectInputStream 完成的。

實際上任何一種序列化框架,核心思想就是設計一種序列化協議,將對象的類型、屬性類型、屬性值一一按照固定的格式寫到二進制字節流中來完成序列化,再按照固定的格式一一讀出對象的類型、屬性類型、屬性值,通過這些信息重新創建出一個新的對象,來完成反序列化。

(2)JSON

用 JSON 進行序列化有這樣兩個問題,你需要格外注意:

  • JSON 進行序列化的額外空間開銷比較大,對於大數據量服務這意味着需要巨大的內存和磁盤開銷;
  • JSON 沒有類型,但像 Java 這種強類型語言,需要通過反射統一解決,所以性能不會太好。
 (3)Hessian

Hessian 是動態類型、二進制、緊湊的,並且可跨語言移植的一種序列化框架。Hessian 協議要比 JDK、JSON 更加緊湊,性能上要比 JDK、JSON 序列化高效很多,而且生成的字節數也更小。

相對於 JDK、JSON,由於 Hessian 更加高效,生成的字節數更小,有非常好的兼容性和穩定性,所以 Hessian 更加適合作為 RPC 框架遠程通信的序列化協議。

但 Hessian 本身也有問題,官方版本對 Java 里面一些常見對象的類型不支持,比如:

  • Linked 系列,LinkedHashMap、LinkedHashSet 等,但是可以通過擴展CollectionDeserializer 類修復;
  • Locale 類,可以通過擴展 ContextSerializerFactory 類修復;
  • Byte/Short 反序列化的時候變成 Integer。
(4)Protobuf
Protobuf 是 Google 公司內部的混合語言數據標准,是一種輕便、高效的結構化數據存儲格式,可以用於結構化數據序列化,支持 Java、Python、C++、Go 等語言。Protobuf使用的時候需要定義 IDL(Interface description language),然后使用不同語言的 IDL編譯器,生成序列化工具類,它的優點是:
  • 序列化后體積相比 JSON、Hessian 小很多;
  • IDL 能清晰地描述語義,所以足以幫助並保證應用程序之間的類型不會丟失,無需類似XML 解析器;
  • 序列化反序列化速度很快,不需要通過反射獲取類型;
  • 消息格式升級和兼容性不錯,可以做到向后兼容。 
Protobuf 非常高效,但是對於具有反射和動態能力的語言來說,這樣用起來很費勁,這一點就不如 Hessian,比如用 Java 的話,這個預編譯過程不是必須的,可以考慮使用Protostuff。
Protostuff 不需要依賴 IDL 文件,可以直接對 Java 領域對象進行反 / 序列化操作,在效率上跟 Protobuf 差不多,生成的二進制格式和 Protobuf 是完全相同的,可以說是一個 Java版本的 Protobuf 序列化框架。但在使用過程中,我遇到過一些不支持的情況,也同步給你:
  • 不支持 null;
  • ProtoStuff 不支持單純的 Map、List 集合對象,需要包在對象里面。

 3)RPC 框架中如何選擇序列化?

 

04丨網絡通信:RPC框架在網絡通信上更傾向於哪種網絡IO模型?

1)網絡通信在 RPC 調用中起到什么作用呢?

RPC 是解決進程間通信的一種方式。一次 RPC 調用,本質就是服務消費者與服務提供者間的一次網絡信息交換的過程。服務調用者通過網絡 IO 發送一條請求消息,服務提供者接收並解析,處理完相關的業務邏輯之后,再發送一條響應消息給服務調用者,服務調用者接收並解析響應消息,處理完相關的響應邏輯,一次 RPC 調用便結束了。可以說,網絡通信是整個 RPC 調用流程的基礎。

 2)常見的網絡 IO 模型

 那說到網絡通信,就不得不提一下網絡 IO 模型。為什么要講網絡 IO 模型呢?因為所謂的兩台 PC 機之間的網絡通信,實際上就是兩台 PC 機對網絡 IO 的操作。

常見的網絡 IO 模型分為四種:同步阻塞 IO(BIO)、同步非阻塞 IO(NIO)、IO 多路復用和異步非阻塞 IO(AIO)。在這四種 IO 模型中,只有 AIO 為異步 IO,其他都是同步IO。

其中,最常用的就是同步阻塞 IO 和 IO 多路復用,這一點通過了解它們的機制,你會 get到。至於其他兩種 IO 模型,因為不常用,則不作為本講的重點,有興趣的話我們可以在留言區中討論。

3)阻塞 IO(blocking IO)

同步阻塞 IO 是最簡單、最常見的 IO 模型,在 Linux 中,默認情況下所有的 socket 都是blocking 的,先看下操作流程。

首先,應用進程發起 IO 系統調用后,應用進程被阻塞,轉到內核空間處理。之后,內核開始等待數據,等待到數據之后,再將內核中的數據拷貝到用戶內存中,整個 IO 處理完畢后返回進程。最后應用的進程解除阻塞狀態,運行業務邏輯。

這里我們可以看到,系統內核處理 IO 操作分為兩個階段——等待數據和拷貝數據。而在這兩個階段中,應用進程中 IO 操作的線程會一直都處於阻塞狀態,如果是基於 Java 多線程開發,那么每一個 IO 操作都要占用線程,直至 IO 操作結束。

這個流程就好比我們去餐廳吃飯,我們到達餐廳,向服務員點餐,之后要一直在餐廳等待后廚將菜做好,然后服務員會將菜端給我們,我們才能享用。

4)IO 多路復用(IO multiplexing)

多路復用 IO 是在高並發場景中使用最為廣泛的一種 IO 模型,如 Java 的 NIO、Redis、Nginx 的底層實現就是此類 IO 模型的應用,經典的 Reactor 模式也是基於此類 IO 模型。

那么什么是 IO 多路復用呢?通過字面上的理解,多路就是指多個通道,也就是多個網絡連接的 IO,而復用就是指多個通道復用在一個復用器上。

多個網絡連接的 IO 可以注冊到一個復用器(select)上,當用戶進程調用了 select,那么整個進程會被阻塞。同時,內核會“監視”所有 select 負責的 socket,當任何一個 socket 中的數據准備好了,select 就會返回。這個時候用戶進程再調用 read 操作,將數據從內核中拷貝到用戶進程。

這里我們可以看到,當用戶進程發起了 select 調用,進程會被阻塞,當發現該 select 負責的 socket 有准備好的數據時才返回,之后才發起一次 read,整個流程要比阻塞 IO 要復雜,似乎也更浪費性能。但它最大的優勢在於,用戶可以在一個線程內同時處理多個 socket 的 IO 請求。用戶可以注冊多個 socket,然后不斷地調用 select 讀取被激活的 socket,即可達到在同一個線程內同時處理多個 IO 請求的目的。而在同步阻塞模型中,必須通過多線程的方式才能達到這個目的。

同樣好比我們去餐廳吃飯,這次我們是幾個人一起去的,我們專門留了一個人在餐廳排號等位,其他人就去逛街了,等排號的朋友通知我們可以吃飯了,我們就直接去享用了

5)為什么說阻塞 IO 和 IO 多路復用最為常用?

了解完二者的機制,我們就可以回到起初的問題了——我為什么說阻塞 IO 和 IO 多路復用最為常用。

對比這四種網絡 IO 模型:阻塞 IO、非阻塞 IO、IO 多路復用、異步 IO。實際在網絡 IO 的應用上,需要的是系統內核的支持以及編程語言的支持。

在系統內核的支持上,現在大多數系統內核都會支持阻塞 IO、非阻塞 IO 和 IO 多路復用,但像信號驅動 IO、異步 IO,只有高版本的 Linux 系統內核才會支持。

在編程語言上,無論 C++ 還是 Java,在高性能的網絡編程框架的編寫上,大多數都是基於 Reactor 模式,其中最為典型的便是 Java 的 Netty 框架,而 Reactor 模式是基於 IO 多路復用的。

當然,在非高並發場景下,同步阻塞 IO 是最為常見的。綜合來講,在這四種常用的 IO 模型中,應用最多的、系統內核與編程語言支持最為完善的,便是阻塞 IO 和 IO 多路復用。這兩種 IO 模型,已經可以滿足絕大多數網絡 IO 的應用場景。

6)RPC 框架在網絡通信上傾向選擇哪種網絡 IO 模型?

講完了這兩種最常用的網絡 IO 模型,我們可以看看它們都適合什么樣的場景。

IO 多路復用更適合高並發的場景,可以用較少的進程(線程)處理較多的 socket 的 IO 請求,但使用難度比較高。當然高級的編程語言支持得還是比較好的,比如 Java 語言有很多的開源框架對 Java 原生 API 做了封裝,如 Netty 框架,使用非常簡便;而 GO 語言,語言本身對 IO 多路復用的封裝就已經很簡潔了。

而阻塞 IO 與 IO 多路復用相比,阻塞 IO 每處理一個 socket 的 IO 請求都會阻塞進程(線程),但使用難度較低。在並發量較低、業務邏輯只需要同步進行 IO 操作的場景下,阻塞IO 已經滿足了需求,並且不需要發起 select 調用,開銷上還要比 IO 多路復用低。

RPC 調用在大多數的情況下,是一個高並發調用的場景,考慮到系統內核的支持、編程語言的支持以及 IO 模型本身的特點,在 RPC 框架的實現中,在網絡通信的處理上,我們會選擇 IO 多路復用的方式。開發語言的網絡通信框架的選型上,我們最優的選擇是基於Reactor 模式實現的框架,如 Java 語言,首選的框架便是 Netty 框架(Java 還有很多其他 NIO 框架,但目前 Netty 應用得最為廣泛),並且在 Linux 環境下,也要開啟 epoll 來提升系統性能(Windows 環境下是無法開啟 epoll 的,因為系統內核不支持)。

了解完以上內容,我們可以繼續看這樣一個關鍵問題——零拷貝。在我們應用的過程中,他是非常重要的。

7)什么是零拷貝?

剛才講阻塞 IO 的時候我講到,系統內核處理 IO 操作分為兩個階段——等待數據和拷貝數據。等待數據,就是系統內核在等待網卡接收到數據后,把數據寫到內核中;而拷貝數據,就是系統內核在獲取到數據后,將數據拷貝到用戶進程的空間中。以下是具體流程:

 

應用進程的每一次寫操作,都會把數據寫到用戶空間的緩沖區中,再由 CPU 將數據拷貝到系統內核的緩沖區中,之后再由 DMA 將這份數據拷貝到網卡中,最后由網卡發送出去。這里我們可以看到,一次寫操作數據要拷貝兩次才能通過網卡發送出去,而用戶進程的讀操作則是將整個流程反過來,數據同樣會拷貝兩次才能讓應用程序讀取到數據。

應用進程的一次完整的讀寫操作,都需要在用戶空間與內核空間中來回拷貝,並且每一次拷貝,都需要 CPU 進行一次上下文切換(由用戶進程切換到系統內核,或由系統內核切換到用戶進程),這樣是不是很浪費 CPU 和性能呢?那有沒有什么方式,可以減少進程間的數據拷貝,提高數據傳輸的效率呢?

這時我們就需要零拷貝(Zero-copy)技術。

所謂的零拷貝,就是取消用戶空間與內核空間之間的數據拷貝操作,應用進程每一次的讀寫操作,可以通過一種方式,直接將數據寫入內核或從內核中讀取數據,再通過 DMA 將內核中的數據拷貝到網卡,或將網卡中的數據 copy 到內核。

那怎么做到零拷貝?你想一下是不是用戶空間與內核空間都將數據寫到一個地方,就不需要拷貝了?此時你有沒有想到虛擬內存?

 

零拷貝有兩種解決方式,分別是 mmap+write 方式和 sendfile 方式,其核心原理都是通過虛擬內存來解決的。這兩種實現方式都不難,市面上可查閱的資料也很多,在此就不詳述了,有問題,可以在留言區中解決。

8)Netty 中的零拷貝

了解完零拷貝,我們再看看 Netty 中的零拷貝。

我剛才講到,RPC 框架在網絡通信框架的選型上,我們最優的選擇是基於 Reactor 模式實現的框架,如 Java 語言,首選的便是 Netty 框架。那么 Netty 框架是否也有零拷貝機制呢?Netty 框架中的零拷貝和我之前講的零拷貝又有什么不同呢?

剛才我講的零拷貝是操作系統層面上的零拷貝,主要目標是避免用戶空間與內核空間之間的數據拷貝操作,可以提升 CPU 的利用率。

而 Netty 的零拷貝則不大一樣,他完全站在了用戶空間上,也就是 JVM 上,它的零拷貝主要是偏向於數據操作的優化上。

9)那么 Netty 這么做的意義是什么呢?

回想下[第 02 講],在這一講中我講解了 RPC 框架如何去設計協議,其中我講到:在傳輸過程中,RPC 並不會把請求參數的所有二進制數據整體一下子發送到對端機器上,中間可能會拆分成好幾個數據包,也可能會合並其他請求的數據包,所以消息都需要有邊界。那么一端的機器收到消息之后,就需要對數據包進行處理,根據邊界對數據包進行分割和合並,最終獲得一條完整的消息。
那收到消息后,對數據包的分割和合並,是在用戶空間完成,還是在內核空間完成的呢?

當然是在用戶空間,因為對數據包的處理工作都是由應用程序來處理的,那么這里有沒有可能存在數據的拷貝操作?可能會存在,當然不是在用戶空間與內核空間之間的拷貝,是用戶空間內部內存中的拷貝處理操作。Netty 的零拷貝就是為了解決這個問題,在用戶空間對數據操作進行優化。

那么 Netty 是怎么對數據操作進行優化的呢?

  • Netty 提供了 CompositeByteBuf 類,它可以將多個 ByteBuf 合並為一個邏輯上的ByteBuf,避免了各個 ByteBuf 之間的拷貝。
  • ByteBuf 支持 slice 操作,因此可以將 ByteBuf 分解為多個共享同一個存儲區域的ByteBuf,避免了內存的拷貝。
  • 通過 wrap 操作,我們可以將 byte[] 數組、ByteBuf、ByteBuffer 等包裝成一個 NettyByteBuf 對象, 進而避免拷貝操作。

Netty 框架中很多內部的 ChannelHandler 實現類,都是通過 CompositeByteBuf、slice、wrap 操作來處理 TCP 傳輸中的拆包與粘包問題的。

那么 Netty 有沒有解決用戶空間與內核空間之間的數據拷貝問題的方法呢?

Netty 的 ByteBuffer 可以采用 Direct Buffers,使用堆外直接內存進行 Socketd 的讀寫操作,最終的效果與我剛才講解的虛擬內存所實現的效果是一樣的。

Netty 還提供 FileRegion 中包裝 NIO 的 FileChannel.transferTo() 方法實現了零拷貝,這與 Linux 中的 sendfile 方式在原理上也是一樣的。 

05丨動態代理:面向接口編程,屏蔽RPC處理流程

其實關於網絡通信,你只要記住一個關鍵字就行了——可靠的傳輸。

1)遠程調用的魔法

我們都知道,接口里並不會包含真實的業務邏輯,業務邏輯都在服務提供方應用里,但我們通過調用接口方法,確實拿到了想要的結果,是不是感覺有點神奇呢?想一下,在 RPC 里面,我們是怎么完成這個魔術的。

這里面用到的核心技術就是前面說的動態代理。RPC 會自動給接口生成一個代理類,當我們在項目中注入接口的時候,運行過程中實際綁定的是這個接口生成的代理類。這樣在接口方法被調用的時候,它實際上是被生成代理類攔截到了,這樣我們就可以在生成的代理類里面,加入遠程調用邏輯。

通過這種“偷梁換柱”的手法,就可以幫用戶屏蔽遠程調用的細節,實現像調用本地一樣地調用遠程的體驗,整體流程如下圖所示:

 

 2)實現原理

重點是代理類的生成,那我們就去看下 Proxy.newProxyInstance 里面究竟發生了什么?

一起看下下面的流程圖,具體代碼細節你可以對照着 JDK 的源碼看(上文中有類和方法,可以直接定位),我是按照 1.7.X 版本梳理的。

 

 3)實現方法

其實在 Java 領域,除了 JDK 默認的 nvocationHandler 能完成代理功能,我們還有很多其他的第三方框架也可以,比如像 Javassist、Byte Buddy 這樣的框架。

06丨RPC實戰:剖析gRPC源碼,動手實現一個完整的RPC

這一講我們就來實戰一下,看看具體落實到代碼上,我們應該怎么實現一個 RPC 框架?
為了能讓咱們快速達成共識,我選擇剖析 gRPC 源碼(源碼地址:https://github.com/grpc/grpc-java)。
通過分析 gRPC 的通信過程,我們可以清楚地知道在 gRPC 里面這些知識點是怎么落地到具體代碼上的。
 
gRPC 是由 Google 開發並且開源的一款高性能、跨語言的 RPC 框架,當前支持 C、Java和 Go 等語言,當前 Java 版本最新 Release 版為 1.27.0。gRPC 有很多特點,比如跨語言,通信協議是基於標准的 HTTP/2 設計的,序列化支持 PB(Protocol Buffer)和JSON,整個調用示例如下圖所示: 

 

總的來說,其實我們可以簡單地認為 gRPC 就是采用 HTTP/2 協議,並且默認采用 PB 序列化方式的一種 RPC,它充分利用了 HTTP/2 的多路復用特性,使得我們可以在同一條鏈路上雙向發送不同的 Stream 數據,以解決 HTTP/1.X 存在的性能問題。

07丨架構設計:設計一個靈活的RPC框架

1)RPC架構

傳輸模塊

協議封裝

Spring封裝的Bootstrap 模塊

服務發現模塊

 

 

 

2)可拓展的架構

插件化架構。

在 RPC 框架里面,我們是怎么支持插件化架構的呢?我們可以將每個功能點抽象成一個接口,將這個接口作為插件的契約,然后把這個功能的接口與功能的實現分離,並提供接口的默認實現。

在 Java 里面,JDK 有自帶的 SPI(Service Provider Interface)服務發現機制,它可以動態地為某個接口尋找服務實現。使用 SPI 機制需要在 Classpath 下的 META-INF/services 目錄里創建一個以服務接口命名的文件,這個文件里的內容就是這個接口的具體實現類。

但在實際項目中,我們其實很少使用到 JDK 自帶的 SPI 機制,首先它不能按需加載,ServiceLoader 加載某個接口實現類的時候,會遍歷全部獲取,也就是接口的實現類得全部載入並實例化一遍,會造成不必要的浪費。另外就是擴展如果依賴其它的擴展,那就做不到自動注入和裝配,這就很難和其他框架集成,比如擴展里面依賴了一個 Spring Bean,原生的 Java SPI 就不支持。

加上了插件功能之后,我們的 RPC 框架就包含了兩大核心體系——核心功能體系與插件體系,如下圖所示:插件化RPC

 

 這時,整個架構就變成了一個微內核架構,我們將每個功能點抽象成一個接口,將這個接口作為插件的契約,然后把這個功能的接口與功能的實現分離並提供接口的默認實現。

這樣的架構相比之前的架構,有很多優勢。首先它的可擴展性很好,實現了開閉原則,用戶可以非常方便地通過插件擴展實現自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精簡,依賴外部包少,這樣可以有效減少開發人員引入 RPC 導致的包版本沖突問題。

08丨服務發現:到底是要CP還是AP?

1)為什么需要服務發現?

對於服務調用方和服務提供方來說,其契約就是接口,相當於“通信錄”中的姓名,服務節點就是提供該契約的一個具體實例。服務 IP 集合作為“通信錄”中的地址,從而可以通過接口獲取服務 IP 的集合來完成服務的發現。這就是我要說的 PRC 框架的服務發現機制,如下圖所示:
  • 服務注冊:在服務提供方啟動的時候,將對外暴露的接口注冊到注冊中心之中,注冊中心將這個服務節點的 IP 和接口保存下來。
  • 服務訂閱:在服務調用方啟動的時候,去注冊中心查找並訂閱服務提供方的 IP,然后緩存到本地,並用於后續的遠程調用。
2)RPC服務發現原理圖為什么不使用 DNS?
既然服務發現這么“厲害”,那是不是很難實現啊?其實類似機制一直在我們身邊,我們回想下服務發現的本質,就是完成了接口跟服務提供者 IP 的映射。那我們能不能把服務提供者 IP 統一換成一個域名啊,利用已經成熟的 DNS 機制來實現?
好,先帶着這個問題,簡單地看下 DNS 的流程:
如果我們用 DNS 來實現服務發現,所有的服務提供者節點都配置在了同一個域名下,調用方的確可以通過 DNS 拿到隨機的一個服務提供者的 IP,並與之建立長連接,這看上去並沒有太大問題,但在我們業界為什么很少用到這種方案呢?不知道你想過這個問題沒有,如果沒有,現在可以停下來想想這樣兩個問題:
  • 如果這個 IP 端口下線了,服務調用者能否及時摘除服務節點呢?
  • 如果在之前已經上線了一部分服務節點,這時我突然對這個服務進行擴容,那么新上線的服務節點能否及時接收到流量呢?
這兩個問題的答案都是:“不能”。這是因為為了提升性能和減少 DNS 服務的壓力,DNS 采取了多級緩存機制,一般配置的緩存時間較長,特別是 JVM 的默認緩存是永久有效的,所以說服務調用者不能及時感知到服務節點的變化。
這時你可能會想,我是不是可以加一個負載均衡設備呢?將域名綁定到這台負載均衡設備上,通過 DNS 拿到負載均衡的 IP。這樣服務調用的時候,服務調用方就可以直接跟 VIP 建立連接,然后由 VIP 機器完成 TCP 轉發,如下圖所示:
這個方案確實能解決 DNS 遇到的一些問題,但在 RPC 場景里面也並不是很合適,原因有以下幾點:
  • 搭建負載均衡設備或 TCP/IP 四層代理,需求額外成本;
  • 請求流量都經過負載均衡設備,多經過一次網絡傳輸,會額外浪費些性能;
  • 負載均衡添加節點和摘除節點,一般都要手動添加,當大批量擴容和下線時,會有大量的人工操作和生效延遲;
  • 我們在服務治理的時候,需要更靈活的負載均衡策略,目前的負載均衡設備的算法還滿足不了靈活的需求。 
由此可見,DNS 或者 VIP 方案雖然可以充當服務發現的角色,但在 RPC 場景里面直接用還是很難的。
 
3)基於 ZooKeeper 的服務發現
那么在 RPC 里面我們該如何實現呢?我們還是要回到服務發現的本質,就是完成接口跟服務提供者 IP 之間的映射。這個映射是不是就是一種命名服務?當然,我們還希望注冊中心能完成實時變更推送,是不是像開源的 ZooKeeper、etcd 就可以實現?我很肯定地說“確實可以”。下面我就來介紹下一種基於 ZooKeeper 的服務發現方式。
整體的思路很簡單,就是搭建一個 ZooKeeper 集群作為注冊中心集群,服務注冊的時候只需要服務節點向 ZooKeeper 節點寫入注冊信息即可,利用 ZooKeeper 的 Watcher 機制完成服務訂閱與服務下發功能,整體流程如下圖:

 

  • 服務平台管理端先在 ZooKeeper 中創建一個服務根路徑,可以根據接口名命名(例如:/service/com.demo.xxService),在這個路徑再創建服務提供方目錄與服務調用方目錄(例如:provider、consumer),分別用來存儲服務提供方的節點信息和服務調用方的節點信息。
  • 當服務提供方發起注冊時,會在服務提供方目錄中創建一個臨時節點,節點中存儲該服務提供方的注冊信息。
  • 當服務調用方發起訂閱時,則在服務調用方目錄中創建一個臨時節點,節點中存儲該服務調用方的信息,同時服務調用方 watch 該服務的服務提供方目錄(/service/com.demo.xxService/provider)中所有的服務節點數據。
  • 當服務提供方目錄下有節點數據發生變更時,ZooKeeper 就會通知給發起訂閱的服務調用方。
我所在的技術團隊早期使用的 RPC 框架服務發現就是基於 ZooKeeper 實現的,並且還平穩運行了一年多,但后續團隊的微服務化程度越來越高之后,ZooKeeper 集群整體壓力也越來越高,尤其在集中上線的時候越發明顯。“集中爆發”是在一次大規模上線的時候,當時有超大批量的服務節點在同時發起注冊操作,ZooKeeper 集群的 CPU 突然飆升,導致 ZooKeeper 集群不能工作了,而且我們當時也無法立馬將 ZooKeeper 集群重新啟動,一直到 ZooKeeper 集群恢復后業務才能繼續上線。
經過我們的排查,引發這次問題的根本原因就是 ZooKeeper 本身的性能問題,當連接到ZooKeeper 的節點數量特別多,對 ZooKeeper 讀寫特別頻繁,且 ZooKeeper 存儲的目錄達到一定數量的時候,ZooKeeper 將不再穩定,CPU 持續升高,最終宕機。而宕機之后,由於各業務的節點還在持續發送讀寫請求,剛一啟動,ZooKeeper 就因無法承受瞬間的讀寫壓力,馬上宕機。
這次“意外”讓我們意識到,ZooKeeper 集群性能顯然已經無法支撐我們現有規模的服務集群了,我們需要重新考慮服務發現方案。
4)基於消息總線的最終一致性的注冊中心
我們知道,ZooKeeper 的一大特點就是強一致性,ZooKeeper 集群的每個節點的數據每次發生更新操作,都會通知其它 ZooKeeper 節點同時執行更新。它要求保證每個節點的數據能夠實時的完全一致,這也就直接導致了 ZooKeeper 集群性能上的下降。這就好比幾個人在玩傳遞東西的游戲,必須這一輪每個人都拿到東西之后,所有的人才能開始下一輪,而不是說我只要獲得到東西之后,就可以直接進行下一輪了。
而 RPC 框架的服務發現,在服務節點剛上線時,服務調用方是可以容忍在一段時間之后(比如幾秒鍾之后)發現這個新上線的節點的。畢竟服務節點剛上線之后的幾秒內,甚至更長的一段時間內沒有接收到請求流量,對整個服務集群是沒有什么影響的,所以我們可以犧牲掉 CP(強制一致性),而選擇 AP(最終一致),來換取整個注冊中心集群的性能和穩定性。
那么是否有一種簡單、高效,並且最終一致的更新機制,能代替 ZooKeeper 那種數據強一致的數據更新機制呢?
因為要求最終一致性,我們可以考慮采用消息總線機制。注冊數據可以全量緩存在每個注冊中心內存中,通過消息總線來同步數據。當有一個注冊中心節點接收到服務節點注冊時,會產生一個消息推送給消息總線,再通過消息總線通知給其它注冊中心節點更新數據並進行服務下發,從而達到注冊中心間數據最終一致性,具體流程如下圖所示:

 

當有服務上線,注冊中心節點收到注冊請求,服務列表數據發生變化,會生成一個消息,推送給消息總線,每個消息都有整體遞增的版本。

消息總線會主動推送消息到各個注冊中心,同時注冊中心也會定時拉取消息。對於獲取到消息的在消息回放模塊里面回放,只接受大於本地版本號的消息,小於本地版本號的消息直接丟棄,從而實現最終一致性。

消費者訂閱可以從注冊中心內存拿到指定接口的全部服務實例,並緩存到消費者的內存里面。

采用推拉模式,消費者可以及時地拿到服務實例增量變化情況,並和內存中的緩存數據進行合並。

為了性能,這里采用了兩級緩存,注冊中心和消費者的內存緩存,通過異步推拉模式來確保最終一致性。

另外,你也可能會想到,服務調用方拿到的服務節點不是最新的,所以目標節點存在已經下線或不提供指定接口服務的情況,這個時候有沒有問題?這個問題我們放到了 RPC 框架里面去處理,在服務調用方發送請求到目標節點后,目標節點會進行合法性驗證,如果指定接口服務不存在或正在下線,則會拒絕該請求。服務調用方收到拒絕異常后,會安全重試到其它節點。
通過消息總線的方式,我們就可以完成注冊中心集群間數據變更的通知,保證數據的最終一致性,並能及時地觸發注冊中心的服務下發操作。在 RPC 領域精耕細作后,你會發現,服務發現的特性是允許我們在設計超大規模集群服務發現系統的時候,舍棄強一致性,更多地考慮系統的健壯性。最終一致性才是分布式系統設計中更為常用的策略。
5)總結
RPC 框架依賴的注冊中心的服務數據的一致性其實並不需要滿足 CP,只要滿足 AP 即可。我們就是采用“消息總線”的通知機制,來保證注冊中心數據的最終一致性,來解決這些問題的。

09丨健康檢測:這個節點都掛了,為啥還要瘋狂發請求?

1)怎么保證選擇出來的連接一定是可用的呢?

終極的解決方案是讓調用方實時感知到節點的狀態變化。
2)遇到的問題

 

 為了進一步了解事情的真相,我查看了問題時間點的監控和日志,在案發現場發現了這樣幾個線索:

  • 通過日志發現請求確實會一直打到這台有問題的機器上,因為我看到日志里有很多超時的異常信息。
  • 從監控上看,這台機器還是有一些成功的請求,這說明當時調用方跟服務之間的網絡連接沒有斷開。因為如果連接斷開之后,RPC 框架會把這個節點標識為“不健康”,不會被選出來用於發業務請求。
  • 深入進去看異常日志,我發現調用方到目標機器的定時心跳會有間歇性失敗。
  • 從目標機器的監控上可以看到該機器的網絡指標有異常,出問題時間點 TCP 重傳數比正常高 10 倍以上。
有了對這四個線索的分析,我基本上可以得出這樣的結論:那台問題服務器在某些時間段出現了網絡故障,但也還能處理部分請求。換句話說,它處於半死不活的狀態。但是(是轉折,也是關鍵點),它還沒徹底“死”,還有心跳,這樣,調用方就覺得它還正常,所以就沒有把它及時挪出健康狀態列表。
 
3)健康檢測的邏輯
應用健康狀況不僅包括 TCP 連接狀況,還包括應用本身是否存活,很多情況下 TCP 連接沒有斷開,但應用可能已經“僵死了”。
所以,業內常用的檢測方法就是用心跳機制。心跳機制說起來也不復雜,其實就是服務調用方每隔一段時間就問一下服務提供方,“兄弟,你還好吧?”,然后服務提供方很誠實地告訴調用方它目前的狀態。
節點的狀態並不是固定不變的,它會根據心跳或者重連的結果來動態變化,具體狀態間轉換圖如下:

 

 

  • 健康狀態:建立連接成功,並且心跳探活也一直成功;
  • 亞健康狀態:建立連接成功,但是心跳請求連續失敗;
  • 死亡狀態:建立連接失敗。
首先,一開始初始化的時候,如果建立連接成功,那就是健康狀態,否則就是死亡狀態。這里沒有亞健康這樣的中間態。緊接着,如果健康狀態的節點連續出現幾次不能響應心跳請求的情況,那就會被標記為亞健康狀態,也就是說,服務調用方會覺得它生病了。
生病之后(亞健康狀態),如果連續幾次都能正常響應心跳請求,那就可以轉回健康狀態,證明病好了。如果病一直好不了,那就會被斷定為是死亡節點,死亡之后還需要善后,比如關閉連接。
當然,死亡並不是真正死亡,它還有復活的機會。如果某個時間點里,死亡的節點能夠重連成功,那它就可以重新被標記為健康狀態。
4)具體的解決方案
理解了服務健康檢測的邏輯,我們再回到開頭我描述的場景里,看看怎么優化。現在你理解了,一個節點從健康狀態過渡到亞健康狀態的前提是“連續”心跳失敗次數必須到達某一個閾值,比如 3 次(具體看你怎么配置了)。
而我們的場景里,節點的心跳日志只是間歇性失敗,也就是時好時壞,這樣,失敗次數根本沒到閾值,調用方會覺得它只是“生病”了,並且很快就好了。那怎么解決呢?我還是建議你先停下來想想。
你是不是會脫口而出,說改下配置,調低閾值唄。是的,這是最快的解決方法,但是我想說,它治標不治本。第一,像前面說的那樣,調用方跟服務節點之間網絡狀況瞬息萬變,出現網絡波動的時候會導致誤判。第二,在負載高情況,服務端來不及處理心跳請求,由於心跳時間很短,會導致調用方很快觸發連續心跳失敗而造成斷開連接。
我們回到問題的本源,核心是服務節點網絡有問題,心跳間歇性失敗。我們現在判斷節點狀態只有一個維度,那就是心跳檢測,那是不是可以再加上業務請求的維度呢?
起碼我當時是順着這個方向解決問題的。但緊接着,我又發現了新的麻煩:
  • 調用方每個接口的調用頻次不一樣,有的接口可能 1 秒內調用上百次,有的接口可能半個小時才會調用一次,所以我們不能把簡單的把總失敗的次數當作判斷條件。
  • 服務的接口響應時間也是不一樣的,有的接口可能 1ms,有的接口可能是 10s,所以我們也不能把 TPS 至來當作判斷條件。
和同事討論之后,我們找到了可用率這個突破口,應該相對完美了。可用率的計算方式是某一個時間窗口內接口調用成功次數的百分比(成功次數 / 總調用次數)。當可用率低於某個比例就認為這個節點存在問題,把它挪到亞健康列表,這樣既考慮了高低頻的調用接口,也兼顧了接口響應時間不同的問題。

5)總結

10丨路由策略:怎么讓請求按照設定的規則發到不同的節點上?

1)為什么選擇路由策略

2)如何實現路由策略

我們可以重新回到調用方發起 RPC 調用的流程。在 RPC 發起真實請求的時候,有一個步驟就是從服務提供方節點集合里面選擇一個合適的節點(就是我們常說的負載均衡),那我們是不是可以在選擇節點前加上“篩選邏輯”,把符合我們要求的節點篩選出來。那這個篩選的規則是什么呢?就是我們前面說的灰度過程中要驗證的規則。

舉個具體例子你可能就明白了,比如我們要求新上線的節點只允許某個 IP 可以調用,那我們的注冊中心會把這條規則下發到服務調用方。在調用方收到規則后,在選擇具體要發請求的節點前,會先通過篩選規則過濾節點集合,按照這個例子的邏輯,最后會過濾出一個節點,這個節點就是我們剛才新上線的節點。通過這樣的改造,RPC 調用流程就變成了這樣:

這個篩選過程在我們的 RPC 里面有一個專業名詞,就是“路由策略”,而上面例子里面的路由策略是我們常見的 IP 路由策略,用於限制可以調用服務提供方的 IP。使用了 IP 路由策略后,整個集群的調用拓撲如下圖所示:

 

 3)參數路由

有了 IP 路由之后,上線過程中我們就可以做到只讓部分調用方請求調用到新上線的實例,相對傳統的灰度發布功能來說,這樣做我們可以把試錯成本降到最低。
但在有些場景下,我們可能還需要更細粒度的路由方式。比如,在升級改造應用的時候,為了保證調用方能平滑地切調用我們的新應用邏輯,在升級過程中我們常用的方式是讓新老應用並行運行一段時間,然后通過切流量百分比的方式,慢慢增大新應用承接的流量,直到新應用承擔了 100% 且運行一段時間后才能去下線老應用。
在流量切換的過程中,為了保證整個流程的完整性,我們必須保證某個主題對象的所有請求都使用同一種應用來承接。假設我們改造的是商品應用,那主題對象肯定是商品 ID,在切流量的過程中,我們必須保證某個商品的所有操作都是用新應用(或者老應用)來完成所有請求的響應。
很顯然,上面的 IP 路由並不能滿足我們這個需求,因為 IP 路由只是限制調用方來源,並不會根據請求參數請求到我們預設的服務提供方節點上去。
那我們怎么利用路由策略實現這個需求呢?其實你只要明白路由策略的本質,就不難明白這種參數路由的實現。
我們可以給所有的服務提供方節點都打上標簽,用來區分新老應用節點。在服務調用方發生請求的時候,我們可以很容易地拿到請求參數,也就是我們例子中的商品 ID,我們可以根據注冊中心下發的規則來判斷當前商品 ID 的請求是過濾掉新應用還是老應用的節點。因為規則對所有的調用方都是一樣的,從而保證對應同一個商品 ID 的請求要么是新應用的節點,要么是老應用的節點。使用了參數路由策略后,整個集群的調用拓撲如下圖所示:

 

 

相比 IP 路由,參數路由支持的灰度粒度更小,他為服務提供方應用提供了另外一個服務治理的手段。灰度發布功能是 RPC 路由功能的一個典型應用場景,通過 RPC 路由策略的組合使用可以讓服務提供方更加靈活地管理、調用自己的流量,進一步降低上線可能導致的風險。
4)總結
在日常工作中,我們幾乎每天都在做線上變更,每次變更都有可能帶來一次事故,為了降低事故發生的概率,我們不光要從流程上優化操作步驟,還要使我們的基礎設施能支持更低的試錯成本。
灰度發布功能作為 RPC 路由功能的一個典型應用場景,我們可以通過路由功能完成像定點調用、黑白名單等一些高級服務治理功能。在 RPC 里面,不管是哪種路由策略,其核心思想都是一樣的,就是讓請求按照我們設定的規則發送到目標節點上,從而實現流量隔離的效果。

11丨負載均衡:節點負載差距這么大,為什么收到的流量還一樣?

 1)一個需求
在進入主題之前,我想先和你分享一個需求,這是我們公司的業務部門給我們提的。 
他們反饋的問題是這樣的:有一次碰上流量高峰,他們突然發現線上服務的可用率降低了,經過排查發現,是因為其中有幾台機器比較舊了。當時最早申請的一批容器配置比較低,縮容的時候留下了幾台,當流量達到高峰時,這幾台容器由於負載太高,就扛不住壓力了。業務問我們有沒有好的服務治理策略?

 

這個問題其實挺好解決的,我們當時給出的方案是:在治理平台上調低這幾台機器的權重,這樣的話,訪問的流量自然就減少了。

但業務接着反饋了,說:當他們發現服務可用率降低的時候,業務請求已經受到影響了,這時再如此解決,需要時間啊,那這段時間里業務可能已經有損失了。緊接着他們就提出了需求,問:RPC 框架有沒有什么智能負載的機制?能否及時地自動控制服務節點接收到的訪問量?

這個需求其實很合理,這也是一個比較普遍的問題。確實,雖說我們的服務治理平台能夠動態地控制線上服務節點接收的訪問量,但當業務方發現部分機器負載過高或者響應變慢的時候再去調整節點權重,真的很可能已經影響到線上服務的可用率了。

看到這兒,你有沒有想到什么好的處理方案呢?接下來,我們就以這個問題為背景,一起看看 RPC 框架的負載均衡。

2)負載均衡

我先來簡單地介紹下負載均衡。當我們的一個服務節點無法支撐現有的訪問量時,我們會部署多個節點,組成一個集群,然后通過負載均衡,將請求分發給這個集群下的每個服務節點,從而達到多個服務節點共同分擔請求壓力的目的。
負載均衡主要分為軟負載和硬負載,軟負載就是在一台或多台服務器上安裝負載均衡的軟件,如 LVS、Nginx 等,硬負載就是通過硬件設備來實現的負載均衡,如 F5 服務器等。負載均衡的算法主要有隨機法、輪詢法、最小連接法等。

我剛才介紹的負載均衡主要還是應用在 Web 服務上,Web 服務的域名綁定負載均衡的地址,通過負載均衡將用戶的請求分發到一個個后端服務上。

3)RPC中的負載均衡

那 RPC 框架中的負載均衡是不是也是如此呢?和我上面講的負載均衡,你覺得會有區別嗎?

 

 RPC 負載均衡策略一般包括隨機權重、Hash、輪詢。當然,這還是主要看 RPC 框架自身的實現。其中的隨機權重策略應該是我們最常用的一種了,通過隨機算法,我們基本可以保證每個節點接收到的請求流量是均勻的;同時我們還可以通過控制節點權重的方式,來進行流量控制。比如我們默認每個節點的權重都是 100,但當我們把其中的一個節點的權重設置成 50 時,它接收到的流量就是其他節點的 1/2。

4)如何設計自適應的負載均衡

我剛才講過,RPC 的負載均衡完全由 RPC 框架自身實現,服務調用者發起請求時,會通過配置的負載均衡插件,自主地選擇服務節點。那是不是只要調用者知道每個服務節點處理請求的能力,再根據服務處理節點處理請求的能力來判斷要打給它多少流量就可以了?當一個服務節點負載過高或響應過慢時,就少給它發送請求,反之則多給它發送請求。

那服務調用者節點又該如何判定一個服務節點的處理能力呢?

這里我們可以采用一種打分的策略,服務調用者收集與之建立長連接的每個服務節點的指標數據,如服務節點的負載指標、CPU 核數、內存大小、請求處理的耗時指標(如請求平均耗時、TP99、TP999)、服務節點的狀態指標(如正常、亞健康)。通過這些指標,計算出一個分數,比如總分 10 分,如果 CPU 負載達到 70%,就減它 3 分,當然了,減 3 分只是個類比,需要減多少分是需要一個計算策略的。

我們又該如果根據這些指標來打分呢?

這就有點像公司對員工進行年終考核。假設我是老板,我要考核專業能力、溝通能力和工作態度,這三項的占比分別是 30%、30%、40%,我給一個員工的評分是 10、8、8,那他的綜合分數就是這樣計算的:10*30%+8*30%+8*40%=8.6 分。
給服務節點打分也一樣,我們可以為每個指標都設置一個指標權重占比,然后再根據這些指標數據,計算分數。
服務調用者給每個服務節點都打完分之后,會發送請求,那這時候我們又該如何根據分數去控制給每個服務節點發送多少流量呢?
我們可以配合隨機權重的負載均衡策略去控制,通過最終的指標分數修改服務節點最終的權重。例如給一個服務節點綜合打分是 8 分(滿分 10 分),服務節點的權重是 100,那么計算后最終權重就是 80(100*80%)。服務調用者發送請求時,會通過隨機權重的策略來選擇服務節點,那么這個節點接收到的流量就是其他正常節點的 80%(這里假設其他節點默認權重都是 100,且指標正常,打分為 10 分的情況)。
到這兒,一個自適應的負載均衡我們就完成了,整體的設計方案如下圖所示:
關鍵步驟我來解釋下:
  • 添加服務指標收集器,並將其作為插件,默認有運行時狀態指標收集器、請求耗時指標收集器。
  • 運行時狀態指標收集器收集服務節點 CPU 核數、CPU 負載以及內存等指標,在服務調用者與服務提供者的心跳數據中獲取。
  • 請求耗時指標收集器收集請求耗時數據,如平均耗時、TP99、TP999 等。
  • 可以配置開啟哪些指標收集器,並設置這些參考指標的指標權重,再根據指標數據和指標權重來綜合打分。
  • 通過服務節點的綜合打分與節點的權重,最終計算出節點的最終權重,之后服務調用者會根據隨機權重的策略,來選擇服務節點。 

12丨異常重試:在約定時間內安全可靠地重試

1)為什么要異常重試

網絡抖動導致請求失敗。

2)RPC 框架的重試機制

 

 

在使用 RPC 框架的時候,我們要確保被調用的服務的業務邏輯是冪等的,這樣我們才能考慮根據事件情況開啟 RPC 框架的異常重試功能。這一點你要格外注意,這算是一個高頻誤區了。

3)何在約定時間內安全可靠地重試?

連續的異常重試可能會出現一種不可靠的情況,那就是連續的異常重試並且每次處理的請求時間比較長,最終會導致請求處理的時間過長,超出用戶設置的超時時間。

解決這個問題最直接的方式就是,在每次重試后都重置一下請求的超時時間。
當調用端發起 RPC 請求時,如果發送請求發生異常並觸發了異常重試,我們可以先判定下這個請求是否已經超時,如果已經超時了就直接返回超時異常,否則就先重置下這個請求的超時時間,之后再發起重試。
那么解決了因多次異常重試引發的超時時間失效的問題,這個重試機制是不是就完全可靠了呢?
我們接着考慮,當調用端設置了異常重試策略,發起了一次 RPC 調用,通過負載均衡選擇了節點,將請求消息發送到這個節點,這時這個節點由於負載壓力較大,導致這個請求處理失敗了,調用端觸發了重試,再次通過負載均衡選擇了一個節點,結果恰好仍選擇了這個節點,那么在這種情況下,重試的效果是否受影響了呢?
當然有影響。因此,我們需要在所有發起重試、負載均衡選擇節點的時候,去掉重試之前出現過問題的那個節點,以保證重試的成功率。
那我們現在再完整地回顧一下,考慮了業務邏輯必須是冪等的、超時時間需要重置以及去掉有問題的服務節點后,這樣的異常重試機制,還有沒有可優化的地方呢?
我剛才講過,RPC 框架的異常重試機制,是調用端發送請求之后,如果發送失敗會捕獲異常,觸發重試,但並不是所有的異常都會觸發重試的,只有 RPC 框架中特定的異常才會如此,比如連接異常、超時異常。

13丨優雅關閉:如何避免服務停機帶來的業務損失?

1)關閉為什么會有問題

在重啟服務的過程中,RPC 怎么做到讓調用方系統不出問題呢?

 

在服務重啟的時候,對於調用方來說,這時候可能會存在以下幾種情況:

  • 調用方發請求前,目標服務已經下線。對於調用方來說,跟目標節點的連接會斷開,這時候調用方可以立馬感知到,並且在其健康列表里面會把這個節點挪掉,自然也就不會被負載均衡選中。
  • 調用方發請求的時候,目標服務正在關閉,但調用方並不知道它正在關閉,而且兩者之間的連接也沒斷開,所以這個節點還會存在健康列表里面,因此該節點就有一定概率會被負載均衡選中。
 2)關閉流程
這時候你可能會想到,我是不是在重啟服務機器前,先通過“某種方式”把要下線的機器從調用方維護的“健康列表”里面刪除就可以了,這樣負載均衡就選不到這個節點了?你說得一點都沒錯,但這個具體的“某種方式”是怎么完成呢?
這時候,可能你還會想到,RPC 里面不是有服務發現嗎?它的作用不就是用來“實時”感知服務提供方的狀態嗎?當服務提供方關閉前,是不是可以先通知注冊中心進行下線,然后通過注冊中心告訴調用方進行節點摘除?關閉流程如下圖所示:

 

這樣不就可以實現不通過“人肉”的方式,從而達到一種自動化方式,但這么做就能完全保證實現無損上下線嗎?

如上圖所示,整個關閉過程中依賴了兩次 RPC 調用,一次是服務提供方通知注冊中心下線操作,一次是注冊中心通知服務調用方下線節點操作。注冊中心通知服務調用方都是異步的,我們在“服務發現”一講中講過在大規模集群里面,服務發現只保證最終一致性,並不保證實時性,所以注冊中心在收到服務提供方下線的時候,並不能成功保證把這次要下線的節點推送到所有的調用方。所以這么來看,通過服務發現並不能做到應用無損關閉。
不能強依賴“服務發現”來通知調用方要下線的機器,那服務提供方自己來通知行不行?因為在 RPC 里面調用方跟服務提供方之間是長連接,我們可以在提供方應用內存里面維護一份調用方連接集合,當服務要關閉的時候,挨個去通知調用方去下線這台機器。這樣整個調用鏈路就變短了,對於每個調用方來說就一次 RPC,可以確保調用的成功率很高。大部分場景下,這么做確實沒有問題,我們之前也是這么實現的,但是我們發現線上還是會偶爾會出現,因為服務提供方上線而導致調用失敗的問題。
那到底哪里出問題了呢?我后面分析了調用方請求日志跟收到關閉通知的日志,並且發現了一個線索如下:出問題請求的時間點跟收到服務提供方關閉通知的時間點很接近,只比關閉通知的時間早不到 1ms,如果再加上網絡傳輸時間的話,那服務提供方收到請求的時候,它應該正在處理關閉邏輯。這就說明服務提供方關閉的時候,並沒有正確處理關閉后接收到的新請求。

3)優雅關閉

知道了根本原因,問題就很好解決了。因為服務提供方已經開始進入關閉流程,那么很多對象就可能已經被銷毀了,關閉后再收到的請求按照正常業務請求來處理,肯定是沒法保證能處理的。所以我們可以在關閉的時候,設置一個請求“擋板”,擋板的作用就是告訴調用方,我已經開始進入關閉流程了,我不能再處理你這個請求了。

基於這個思路,我們可以這么處理:當服務提供方正在關閉,如果這之后還收到了新的業務請求,服務提供方直接返回一個特定的異常給調用方(比如 ShutdownException)。這個異常就是告訴調用方“我已經收到這個請求了,但是我正在關閉,並沒有處理這個請求”,然后調用方收到這個異常響應后,RPC 框架把這個節點從健康列表挪出,並把請求自動重試到其他節點,因為這個請求是沒有被服務提供方處理過,所以可以安全地重試到其他節點,這樣就可以實現對業務無損。

但如果只是靠等待被動調用,就會讓這個關閉過程整體有點漫長。因為有的調用方那個時刻沒有業務請求,就不能及時地通知調用方了,所以我們可以加上主動通知流程,這樣既可以保證實時性,也可以避免通知失敗的情況。

說到這里,我知道你肯定會問,那要怎么捕獲到關閉事件呢?

在我的經驗里,可以通過捕獲操作系統的進程信號來獲取,在 Java 語言里面,對應的是Runtime.addShutdownHook 方法,可以注冊關閉的鈎子。在 RPC 啟動的時候,我們提前注冊關閉鈎子,並在里面添加了兩個處理程序,一個負責開啟關閉標識,一個負責安全關閉服務對象,服務對象在關閉的時候會通知調用方下線節點。同時需要在我們調用鏈里面加上擋板處理器,當新的請求來的時候,會判斷關閉標識,如果正在關閉,則拋出特定異常。

看到這里,感覺問題已經比較好地被解決了。但細心的同學可能還會提出問題,關閉過程中已經在處理的請求會不會受到影響呢?

如果進程結束過快會造成這些請求還沒有來得及應答,同時調用方會也會拋出異常。為了盡可能地完成正在處理的請求,首先我們要把這些請求識別出來。這就好比日常生活中,我們經常看見停車場指示牌上提示還有多少剩余車位,這個是如何做到的呢?如果仔細觀察一下,你就會發現它是每進入一輛車,剩余車位就減一,每出來一輛車,剩余車位就加一。我們也可以利用這個原理在服務對象加上引用計數器,每開始處理請求之前加一,完成請求處理減一,通過該計數器我們就可以快速判斷是否有正在處理的請求。

服務對象在關閉過程中,會拒絕新的請求,同時根據引用計數器等待正在處理的請求全部結束之后才會真正關閉。但考慮到有些業務請求可能處理時間長,或者存在被掛住的情況,為了避免一直等待造成應用無法正常退出,我們可以在整個 ShutdownHook 里面,加上超時時間控制,當超過了指定時間沒有結束,則強制退出應用。超時時間我建議可以設定成10s,基本可以確保請求都處理完了。整個流程如下圖所示。
 

 

 4)總結

在 RPC 里面,關閉雖然看似不屬於 RPC 主流程,但如果我們不能處理得很好的話,可能就會導致調用方業務異常,從而需要我們加入很多額外的運維工作。一個好的關閉流程,可以確保使用我們框架的業務實現平滑的上下線,而不用擔心重啟導致的問題。

其實“優雅關閉”這個概念除了在 RPC 里面有,在很多框架里面也都挺常見的,比如像我們經常用的應用容器框架 Tomcat。Tomcat 關閉的時候也是先從外層到里層逐層進行關閉,先保證不接收新請求,然后再處理關閉前收到的請求。

14丨優雅啟動:如何避免流量打到沒有啟動完成的節點?

今天要和你分享的重點,RPC 里面的一個實用功能——啟動預熱。

1)啟動預熱

那什么叫啟動預熱呢?

簡單來說,就是讓剛啟動的服務提供方應用不承擔全部的流量,而是讓它被調用的次數隨着時間的移動慢慢增加,最終讓流量緩和地增加到跟已經運行一段時間后的水平一樣。

那在 RPC 里面,我們該怎么實現這個功能呢?

我們現在是要控制調用方發送到服務提供方的流量。我們可以先簡單地回顧下調用方發起的RPC 調用流程是怎樣的,調用方應用通過服務發現能夠獲取到服務提供方的 IP 地址,然后每次發送請求前,都需要通過負載均衡算法從連接池中選擇一個可用連接。那這樣的話,我們是不是就可以讓負載均衡在選擇連接的時候,區分一下是否是剛啟動不久的應用?對於剛啟動的應用,我們可以讓它被選擇到的概率特別低,但這個概率會隨着時間的推移慢慢變大,從而實現一個動態增加流量的過程。

現在方案有了,我們就可以考慮具體實現了。
首先對於調用方來說,我們要知道服務提供方啟動的時間,這個怎么獲取呢?我這里給出兩種方法,一種是服務提供方在啟動的時候,把自己啟動的時間告訴注冊中心;另外一種就是注冊中心收到的服務提供方的請求注冊時間。這兩個時間我認為都可以,不過可能你會猶豫我們該怎么確保所有機器的日期時間是一樣的?這其實不用太關心,因為整個預熱過程的時間是一個粗略值,即使機器之間的日期時間存在 1 分鍾的誤差也不影響,並且在真實環境中機器都會默認開啟 NTP 時間同步功能,來保證所有機器時間的一致性。
不管你是選擇哪個時間,最終的結果就是,調用方通過服務發現,除了可以拿到 IP 列表,還可以拿到對應的啟動時間。我們需要把這個時間作用在負載均衡上,在[第 11 講] 我們介紹過一種基於權重的負載均衡,但是這個權重是由服務提供方設置的,屬於一個固定狀態。現在我們要讓這個權重變成動態的,並且是隨着時間的推移慢慢增加到服務提供方設定的固定值,整個過程如下圖所示:

 

通過這個小邏輯的改動,我們就可以保證當服務提供方運行時長小於預熱時間時,對服務提供方進行降權,減少被負載均衡選擇的概率,避免讓應用在啟動之初就處於高負載狀態,從而實現服務提供方在啟動后有一個預熱的過程。

看到這兒,你可能還會有另外一個疑問,就是當我在大批量重啟服務提供方的時候,會不會導致沒有重啟的機器因為扛的流量太大而出現問題?

關於這個問題,我是這么考慮的。當你大批量重啟服務提供方的時候,對於調用方來說,這些剛重啟的機器權重基本是一樣的,也就是說這些機器被選中的概率是一樣的,大家都是一樣得低,也就不存在權重區分的問題了。但是對於那些沒有重啟過的應用提供方來說,它們被負載均衡選中的概率是相對較高的,但是我們可以通過[第 11 講] 學到的自適應負載的方法平緩地切換,所以也是沒有問題的。
啟動預熱更多是從調用方的角度出發,去解決服務提供方應用冷啟動的問題,讓調用方的請求量通過一個時間窗口過渡,慢慢達到一個正常水平,從而實現平滑上線。但對於服務提供方本身來說,有沒有相關方案可以實現這種效果呢?
當然有,這也是我今天要分享的另一個重點,和熱啟動息息相關,那就是延遲暴露。
2)延遲暴露

我們應用啟動的時候都是通過 main 入口,然后順序加載各種相關依賴的類。以 Spring 應用啟動為例,在加載的過程中,Spring 容器會順序加載 Spring Bean,如果某個 Bean 是 RPC 服務的話,我們不光要把它注冊到 Spring-BeanFactory 里面去,還要把這個 Bean 對應的接口注冊到注冊中心。注冊中心在收到新上線的服務提供方地址的時候,會把這個地址推送到調用方應用內存中;當調用方收到這個服務提供方地址的時候,就會去建立連接發請求。

但這時候是不是存在服務提供方可能並沒有啟動完成的情況?因為服務提供方應用可能還在加載其它的 Bean。對於調用方來說,只要獲取到了服務提供方的 IP,就有可能發起 RPC 調用,但如果這時候服務提供方沒有啟動完成的話,就會導致調用失敗,從而使業務受損。 

那有什么辦法可以避免這種情況嗎?

在解決問題前,我們先看下出現上述問題的根本原因。這是因為服務提供方應用在沒有啟動完成的時候,調用方的請求就過來了,而調用方請求過來的原因是,服務提供方應用在啟動過程中把解析到的 RPC 服務注冊到了注冊中心,這就導致在后續加載沒有完成的情況下服務提供方的地址就被服務調用方感知到了。

這樣的話,其實我們就可以把接口注冊到注冊中心的時間挪到應用啟動完成后。具體的做法就是在應用啟動加載、解析 Bean 的時候,如果遇到了 RPC 服務的 Bean,只先把這個 Bean 注冊到 Spring-BeanFactory 里面去,而並不把這個 Bean 對應的接口注冊到注冊中心,只有等應用啟動完成后,才把接口注冊到注冊中心用於服務發現,從而實現讓服務調用方延遲獲取到服務提供方地址。
這樣是可以保證應用在啟動完后才開始接入流量的,但其實這樣做,我們還是沒有實現最開始的目標。因為這時候應用雖然啟動完成了,但並沒有執行相關的業務代碼,所以 JVM 內存里面還是冷的。如果這時候大量請求過來,還是會導致整個應用在高負載模式下運行,從而導致不能及時地返回請求結果。而且在實際業務中,一個服務的內部業務邏輯一般會依賴其它資源的,比如緩存數據。如果我們能在服務正式提供服務前,先完成緩存的初始化操作,而不是等請求來了之后才去加載,我們就可以降低重啟后第一次請求出錯的概率。
那具體怎么實現呢?
我們還是需要利用服務提供方把接口注冊到注冊中心的那段時間。我們可以在服務提供方應用啟動后,接口注冊到注冊中心前,預留一個 Hook 過程,讓用戶可以實現可擴展的Hook 邏輯。用戶可以在 Hook 里面模擬調用邏輯,從而使 JVM 指令能夠預熱起來,並且用戶也可以在 Hook 里面事先預加載一些資源,只有等所有的資源都加載完成后,最后才把接口注冊到注冊中心。整個應用啟動過程如下圖所示:
3)總結
包括[第 11 講] 在內,到今天為止,我們就已經把整個 RPC 里面的啟停機流程都講完了。就像前面說過的那樣,雖然啟停機流程看起來不屬於 RPC 主流程,但是如果你能在RPC 里面把這些“微小”的工作做好,就可以讓你的技術團隊感受到更多的微服務帶來的好處。另外,我們今天的兩大重點——啟動預熱與延遲暴露,它們並不是 RPC 的專屬功能,我們在開發其它系統時,也可以利用這兩點來減少冷啟動對業務的影響。

15丨熔斷限流:業務如何實現自我保護

1)為什么需要自我保護?

我在開篇詞中說過,RPC 是解決分布式系統通信問題的一大利器,而分布式系統的一大特點就是高並發,所以說 RPC 也會面臨高並發的場景。在這樣的情況下,我們提供服務的每個服務節點就都可能由於訪問量過大而引起一系列的問題,比如業務處理耗時過長、CPU 飄高、頻繁 Full GC 以及服務進程直接宕機等等。但是在生產環境中,我們要保證服務的穩定性和高可用性,這時我們就需要業務進行自我保護,從而保證在高訪問量、高並發的場景下,應用系統依然穩定,服務依然高可用。
2)那么在使用 RPC 時,業務又如何實現自我保護呢?
最常見的方式就是限流了,簡單有效,但 RPC 框架的自我保護方式可不只有限流,並且RPC 框架的限流方式可以是多種多樣的。我們可以將 RPC 框架拆開來分析,RPC 調用包括服務端和調用端,調用端向服務端發起調用。下面我就分享一下服務端與調用端分別是如何進行自我保護的。
3)服務端的自我保護
我們先看服務端,舉個例子,假如我們要發布一個 RPC 服務,作為服務端接收調用端發送過來的請求,這時服務端的某個節點負載壓力過高了,我們該如何保護這個節點?

 

這個問題還是很好解決的,既然負載壓力高,那就不讓它再接收太多的請求就好了,等接收和處理的請求數量下來后,這個節點的負載壓力自然就下來了。

那么就是限流吧?是的,在 RPC 調用中服務端的自我保護策略就是限流,那你有沒有想過我們是如何實現限流的呢?是在服務端的業務邏輯中做限流嗎?有沒有更優雅的方式?

限流是一個比較通用的功能,我們可以在 RPC 框架中集成限流的功能,讓使用方自己去配置限流閾值;我們還可以在服務端添加限流邏輯,當調用端發送請求過來時,服務端在執行業務邏輯之前先執行限流邏輯,如果發現訪問量過大並且超出了限流的閾值,就讓服務端直接拋回給調用端一個限流異常,否則就執行正常的業務邏輯。
 

 

那服務端的限流邏輯又該如何實現呢?

方式有很多,比如最簡單的計數器,還有可以做到平滑限流的滑動窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最為常用。上述這幾種限流算法我就不一一講解了,資料很多,不太清楚的話自行查閱下就可以了。

我們可以假設下這樣一個場景:我發布了一個服務,提供給多個應用的調用方去調用,這時有一個應用的調用方發送過來的請求流量要比其它的應用大很多,這時我們就應該對這個應用下的調用端發送過來的請求流量進行限流。所以說我們在做限流的時候要考慮應用級別的維度,甚至是 IP 級別的維度,這樣做不僅可以讓我們對一個應用下的調用端發送過來的請求流量做限流,還可以對一個 IP 發送過來的請求流量做限流。

這時你可能會想,使用方該如何配置應用維度以及 IP 維度的限流呢?在代碼中配置是不是不大方便?我之前說過,RPC 框架真正強大的地方在於它的治理功能,而治理功能大多都需要依賴一個注冊中心或者配置中心,我們可以通過 RPC 治理的管理端進行配置,再通過注冊中心或者配置中心將限流閾值的配置下發到服務提供方的每個節點上,實現動態配置。
看到這兒,你有沒有發現,在服務端實現限流,配置的限流閾值是作用在每個服務節點上的。比如說我配置的閾值是每秒 1000 次請求,那么就是指一台機器每秒處理 1000 次請求;如果我的服務集群擁有 10 個服務節點,那么我提供的服務限流閾值在最理想的情況下就是每秒 10000 次。
 
接着看這樣一個場景:我提供了一個服務,而這個服務的業務邏輯依賴的是 MySQL 數據庫,由於 MySQL 數據庫的性能限制,我們是需要對其進行保護。假如在 MySQL 處理業務邏輯中,SQL 語句的能力是每秒 10000 次,那么我們提供的服務處理的訪問量就不能超過每秒 10000 次,而我們的服務有 10 個節點,這時我們配置的限流閾值應該是每秒 1000次。那如果之后因為某種需求我們對這個服務擴容了呢?擴容到 20 個節點,我們是不是就要把限流閾值調整到每秒 500 次呢?這樣操作每次都要自己去計算,重新配置,顯然太麻煩了。
我們可以讓 RPC 框架自己去計算,當注冊中心或配置中心將限流閾值配置下發的時候,我們可以將總服務節點數也下發給服務節點,之后由服務節點自己計算限流閾值,這樣就解決問題了吧?
解決了一部分,還有一個問題存在,那就是在實際情況下,一個服務節點所接收到的訪問量並不是絕對均勻的,比如有 20 個節點,而每個節點限流的閾值是 500,其中有的節點訪問量已經達到閾值了,但有的節點可能在這一秒內的訪問量是 450,這時調用端發送過來的總調用量還沒有達到 10000 次,但可能也會被限流,這樣是不是就不精確了?那有沒有比較精確的限流方式呢?
我剛才講解的限流方式之所以不精確,是因為限流邏輯是服務集群下的每個節點獨立去執行的,是一種單機的限流方式,而且每個服務節點所接收到的流量並不是絕對均勻的。
我們可以提供一個專門的限流服務,讓每個節點都依賴一個限流服務,當請求流量打過來時,服務節點觸發限流邏輯,調用這個限流服務來判斷是否到達了限流閾值。我們甚至可以將限流邏輯放在調用端,調用端在發出請求時先觸發限流邏輯,調用限流服務,如果請求量已經到達了限流閾值,請求都不需要發出去,直接返回給動態代理一個限流異常即可。
這種限流方式可以讓整個服務集群的限流變得更加精確,但也由於依賴了一個限流服務,它在性能和耗時上與單機的限流方式相比是有很大劣勢的。至於要選擇哪種限流方式,就要結合具體的應用場景進行選擇了。
4)調用端的自我保護
剛才我講解了服務端如何進行自我保護,最簡單有效的方式就是限流。那么調用端呢?調用端是否需要自我保護呢?
舉個例子,假如我要發布一個服務 B,而服務 B 又依賴服務 C,當一個服務 A 來調用服務B 時,服務 B 的業務邏輯調用服務 C,而這時服務 C 響應超時了,由於服務 B 依賴服務C,C 超時直接導致 B 的業務邏輯一直等待,而這個時候服務 A 在頻繁地調用服務 B,服務 B 就可能會因為堆積大量的請求而導致服務宕機。
由此可見,服務 B 調用服務 C,服務 C 執行業務邏輯出現異常時,會影響到服務 B,甚至可能會引起服務 B 宕機。這還只是 A->B->C 的情況,試想一下 A->B->C->D->......呢?在整個調用鏈中,只要中間有一個服務出現問題,都可能會引起上游的所有服務出現一系列的問題,甚至會引起整個調用鏈的服務都宕機,這是非常恐怖的。所以說,在一個服務作為調用端調用另外一個服務時,為了防止被調用的服務出現問題而影響到作為調用端的這個服務,這個服務也需要進行自我保護。而最有效的自我保護方式就是熔斷。
所以說,在一個服務作為調用端調用另外一個服務時,為了防止被調用的服務出現問題而影響到作為調用端的這個服務,這個服務也需要進行自我保護。而最有效的自我保護方式就是熔斷。

 

我們可以先了解下熔斷機制。

熔斷器的工作機制主要是關閉、打開和半打開這三個狀態之間的切換。在正常情況下,熔斷器是關閉的;當調用端調用下游服務出現異常時,熔斷器會收集異常指標信息進行計算,當達到熔斷條件時熔斷器打開,這時調用端再發起請求是會直接被熔斷器攔截,並快速地執行失敗邏輯;當熔斷器打開一段時間后,會轉為半打開狀態,這時熔斷器允許調用端發送一個請求給服務端,如果這次請求能夠正常地得到服務端的響應,則將狀態置為關閉狀態,否則設置為打開。

了解完熔斷機制,你就會發現,在業務邏輯中加入熔斷器其實是不夠優雅的。那么在 RPC框架中,我們該如何整合熔斷器呢?

熔斷機制主要是保護調用端,調用端在發出請求的時候會先經過熔斷器。我們可以回想下RPC 的調用流程:

 

你看圖的話,有沒有想到在哪個步驟整合熔斷器會比較合適呢?

我的建議是動態代理,因為在 RPC 調用的流程中,動態代理是 RPC 調用的第一個關口。在發出請求時先經過熔斷器,如果狀態是閉合則正常發出請求,如果狀態是打開則執行熔斷器的失敗策略。

5)總結

今天我們主要講解了 RPC 框架是如何實現業務的自我保護。
服務端主要是通過限流來進行自我保護,我們在實現限流時要考慮到應用和 IP 級別,方便我們在服務治理的時候,對部分訪問量特別大的應用進行合理的限流;服務端的限流閾值配置都是作用於單機的,而在有些場景下,例如對整個服務設置限流閾值,服務進行擴容時,限流的配置並不方便,我們可以在注冊中心或配置中心下發限流閾值配置的時候,將總服務節點數也下發給服務節點,讓 RPC 框架自己去計算限流閾值;我們還可以讓 RPC 框架的限流模塊依賴一個專門的限流服務,對服務設置限流閾值進行精准地控制,但是這種方式依賴了限流服務,相比單機的限流方式,在性能和耗時上有劣勢。
調用端可以通過熔斷機制進行自我保護,防止調用下游服務出現異常,或者耗時過長影響調用端的業務邏輯,RPC 框架可以在動態代理的邏輯中去整合熔斷器,實現 RPC 框架的熔斷功能。

16丨業務分組:如何隔離流量?

那說起突發流量,限流固然是一種手段,但其實面對復雜的業務以及高並發場景時,我們還有別的手段,可以最大限度地保障業務無損,那就是隔離流量。這也是我今天重點要和你分享的內容,接下來我們就一起看看分組在 RPC 中的應用。

1)為什么需要分組

在我們的日常開發中,我們不都提倡讓用戶使用起來越簡單越好嗎?如果在接口上再加一個分組維度去管理,不就讓事情變復雜了嗎?

同樣的道理,我們用在 RPC 治理上也是一樣的。假設你是一個服務提供方應用的負責人,在早期業務量不大的情況下,應用之間的調用關系並不會復雜,請求量也不會很大,我們的應用有足夠的能力扛住日常的所有流量。我們並不需要花太多的時間去治理調用請求過來的流量,我們通常會選擇最簡單的方法,就是把服務實例統一管理,把所有的請求都用一個共享的“大池子”來處理。這就類似於“簡單道路時期”,服務調用方跟服務提供方之間的調用拓撲如下圖所示:無隔離調用拓撲
 

 

后期因為業務發展豐富了,調用你接口的調用方就會越來越多,流量也會漸漸多起來。可能某一天,一個“爆炸式驚喜”就來了。其中一個調用方的流量突然激增,讓你整個集群瞬間處於高負載運行,進而影響到其它調用方,導致它們的整體可用率下降。而這時候作為應用負責人的你,那就得變身“救火隊長”了,要想盡各種辦法來保證應用的穩定。

在經過一系列的救火操作后,我們肯定要去想更好的應對辦法。那回到問題的根本去看,關鍵就在於,早期為了管理方便,我們把接口都放到了同一個分組下面,所有的服務實例是以一個整體對外提供能力的。

但后期因為業務發展,這種粗暴的管理模式已經不適用了,這就好比“汽車來了,我們的交通網也得抓緊建設”一樣,讓人車分流。此時,道路上的人和車就好比我們應用的調用方,我們可以嘗試把應用提供方這個大池子划分出不同規格的小池子,再分配給不同的調用方,而不同小池子之間的隔離帶,就是我們在 RPC 里面所說的分組,它可以實現流量隔離。

2)怎么實現分組

現在分組是怎么回事我們搞清楚了,那放到 RPC 里我們該怎么實現呢?

既然是要求不同的調用方應用能拿到的池子內容不同,那我們就要回想下服務發現了,因為在 RPC 流程里,能影響到調用方獲取服務節點的邏輯就是它了。

在[第 08 講] 我們說過,服務調用方是通過接口名去注冊中心找到所有的服務節點來完成服務發現的,那換到這里的話,這樣做其實並不合適,因為這樣調用方會拿到所有的服務節點。因此為了實現分組隔離邏輯,我們需要重新改造下服務發現的邏輯,調用方去獲取服務節點的時候除了要帶着接口名,還需要另外加一個分組參數,相應的服務提供方在注冊的時候也要帶上分組參數。

通過改造后的分組邏輯,我們可以把服務提供方所有的實例分成若干組,每一個分組可以提供給單個或者多個不同的調用方來調用。那怎么分組好呢,有沒有統一的標准?

坦白講,這個分組並沒有一個可衡量的標准,但我自己總結了一個規則可以供你參考,就是按照應用重要級別划分。

非核心應用不要跟核心應用分在同一個組,核心應用之間應該做好隔離,一個重要的原則就是保障核心應用不受影響。比如提供給電商下單過程中用的商品信息接口,我們肯定是需要獨立出一個單獨分組,避免受其它調用方污染的。有了分組之后,我們的服務調用方跟服務提供方之間的調用拓撲就如下圖所示:
分組調用拓撲通過分組的方式隔離調用方的流量,從而避免因為一個調用方出現流量激增而影響其它調用方的可用率。對服務提供方來說,這種方式是我們日常治理服務過程中一個高頻使用的手段,那通過這種分組進行流量隔離,對調用方應用會不會有影響呢?
3)如何實現高可用?
分組隔離后,單個調用方在發 RPC 請求的時候可選擇的服務節點數相比沒有分組前減少了,那對於單個調用方來說,出錯的概率就升高了。比如一個集中交換機設備突然壞了,而這個調用方的所有服務節點都在這個交換機下面,在這種情況下對於服務調用方來說,它的請求無論如何也到達不了服務提供方,從而導致這個調用方業務受損。
 
那有沒有更高可用一點的方案呢?回到我們前面說的那個馬路例子上,正常情況下我們是必須讓車在車道行駛,人在人行道上行走。但當人行道或者車道出現搶修的時候,在條件允許的情況下,我們一般都是允許對方借道行駛一段時間,直到道路完全恢復。
我們同樣可以把這個特性用到我們的 RPC 中,要怎么實現呢?
 
在前面我們也說了,調用方應用服務發現的時候,除了帶上對應的接口名,還需要帶上一個特定分組名,所以對於調用方來說,它是拿不到其它分組的服務節點的,那這樣的話調用方就沒法建立起連接發請求了。
因此問題的核心就變成了調用方要拿到其它分組的服務節點,但是又不能拿到所有的服務節點,否則分組就沒有意義了。一個最簡單的辦法就是,允許調用方可以配置多個分組。但這樣的話,這些節點對於調用方來說就都是一樣的了,調用方可以隨意選擇獲取到的所有節點發送請求,這樣就又失去了分組隔離的意義,並且還沒有實現我們想要的“借道”的效果。
所以我們還需要把配置的分組區分下主次分組,只有在主分組上的節點都不可用的情況下才去選擇次分組節點;只要主分組里面的節點恢復正常,我們就必須把流量都切換到主節點上,整個切換過程對於應用層完全透明,從而在一定程度上保障調用方應用的高可用。
4)總結
今天我們通過一個道路划分的案例,引出了在 RPC 里面我們可以通過分組的方式人為地給不同的調用方划分出不同的小集群,從而實現調用方流量隔離的效果,保障我們的核心業務不受非核心業務的干擾。但我們在考慮問題的時候,不能顧此失彼,不能因為新加一個的功能而影響到原有系統的穩定性。
其實我們不僅可以通過分組把服務提供方划分成不同規模的小集群,我們還可以利用分組完成一個接口多種實現的功能。正常情況下,為了方便我們自己管理服務,我一般都會建議每個接口完成的功能盡量保證唯一。但在有些特殊場景下,兩個接口也會完全一樣,只是具體實現上有那么一點不同,那么我們就可以在服務提供方應用里面同時暴露兩個相同接口,但只是接口分組不一樣罷了。

答疑課堂丨基礎篇與進階篇思考題答案合集

第二講思考題:在 RPC 里面,我們是怎么實現請求跟響應關聯的?

首先我們要弄清楚為什么要把請求與響應關聯。這是因為在 RPC 調用過程中,調用端會向服務端發送請求消息,之后它還會收到服務端發送回來的響應消息,但這兩個操作並不是同步進行的。在高並發的情況下,調用端可能會在某一時刻向服務端連續發送很多條消息之后,才會陸續收到服務端發送回來的各個響應消息,這時調用端需要一種手段來區分這些響應消息分別對應的是之前的哪條請求消息,所以我們說 RPC 在發送消息時要請求跟響應關聯。

解決這個問題不難,只要調用端在收到響應消息之后,從響應消息中讀取到一個標識,告訴調用端,這是哪條請求消息的響應消息就可以了。在這一講中,你會發現我們設計的私有協議都會有消息 ID,這個消息 ID 的作用就是起到請求跟響應關聯的作用。調用端為每一個消息生成一個唯一的消息 ID,它收到服務端發送回來的響應消息如果是同一消息 ID,那么調用端就可以認為,這條響應消息是之前那條請求消息的響應消息。

第五講思考題:如果沒有動態代理幫我們完成方法調用攔截,用戶該怎么完成 RPC 調用?

這個問題我們可以參考下 gRPC 框架。gRPC 框架中就沒有使用動態代理,它是通過代碼生成的方式生成 Service 存根,當然這個 Service 存根起到的作用和 RPC 框架中的動態代理是一樣的。

gRPC 框架用代碼生成的 Service 存根來代替動態代理主要是為了實現多語言的客戶端,因為有些語言是不支持動態代理的,比如 C++、go 等,但缺點也是顯而易見的。如果你使用過 gRPC,你會發現這種代碼生成 Service 存根的方式與動態代理相比還是很麻煩的,並不如動態代理的方式使用起來方便、透明。

第六講思考題:在 gRPC 調用的時候,我們有一個關鍵步驟就是把對象轉成可傳輸的二進制,但是在 gRPC 里面,我們並沒有直接轉成二進制數組,而是返回一個 InputStream,你知道這樣做的好處是什么嗎?

RPC 調用在底層傳輸過程中也是需要使用 Stream 的,直接返回一個 InputStream 而不是二進制數組,可以避免數據的拷貝。

第八講思考題:目前服務提供者上線后會自動注冊到注冊中心,服務調用方會自動感知到新增的實例,並且流量會很快打到該新增的實例。如果我想把某些服務提供者實例的流量切走,除了下線實例,你有沒有想到其它更便捷的辦法呢?

解決這個問題的方法還是有很多的,比如留言中提到的改變服務提供者實例的權重,將權重調整為 0,或者通過路由的方式也可以。

但解決這個問題最便捷的方式還是使用動態分組,在[第 16 講] 中我講解了業務分組的概念,通過業務分組來實現流量隔離。如果業務分組是動態的,我們就可以在管理平台動態地自由調整,那是不是就可以實現動態地流量切換了呢?這個問題我們還會在高級篇中詳解,期待一下。

第十二講思考題:在整個 RPC 調用的流程中,異常重試發生在哪個環節?

在回答這個問題之前,我們先回想下這一講中講過的內容。我在講 RPC 為什么需要異常重試時我說過,如果在發出請求時恰好網絡出現問題了,導致我們的請求失敗,我們可能需要進行異常重試。從這一點我們可以看出,異常重試的操作是要在調用端進行的。因為如果在調用端發出請求時恰好網絡出現問題導致請求失敗,那么這個請求很可能還沒到達服務端,服務端當然就沒辦法去處理重試了。

另外,我還講過,我們需要在所有發起重試、負載均衡選擇節點的時候,去掉重試之前出現過問題的那個節點,以保證重試的成功率。由此可見異常重試的操作應該發生在負載均衡之前,在發起重試的時候,會調用負載均衡插件來選擇一個服務節點,在調用負載均衡插件時我們要告訴負載均衡需要刨除哪些有問題的服務節點。

在整個 RPC 調用的過程中,從動態代理到負載均衡之間還有一系列的操作,如果你研究過開源的 RPC 框架,你會發現在調用端發送請求消息之前還會經過過濾鏈,對請求消息進行層層的過濾處理,之后才會通過負載均衡選擇服務節點,發送請求消息,而異常重試操作就發生在過濾鏈處理之后,調用負載均衡選擇服務節點之前,這樣的重試是可以減少很多重復操作的。

第十四講思考題:在啟動預熱那部分,我們特意提到過一個問題,就是“當大批量重啟服務提供方的時候,會導致請求大概率發到沒有重啟的機器上,這時服務提供方有可能扛不住”,不知道你是怎么看待這個問題的,是否有好的解決方案呢?

我們可以考慮在非流量高峰的時候重啟服務,將影響降到最低;也可以考慮分批次重啟,控制好每批重啟的服務節點的數量,當一批服務節點的權重與訪問量都到正常水平時,再去重啟下一批服務節點。

第十五講思考題:在使用 RPC 的過程中業務要實現自我保護,針對這個問題你是否還有其他的解決方案?

通過這一講我們知道,在 RPC 調用中無論服務端還是調用端都需要自我保護,服務端自我保護的最簡單有效的方式是“限流”,調用端則可以通過“熔斷”機制來進行自我保護。

除了“熔斷”和“限流”外,相信你一定聽過“降級”這個詞。簡單來說就是當一個服務處理大量的請求達到一定壓力的時候,我們可以讓這個服務在處理請求時減少些非必要的功能,從而降低這個服務的壓力。

還有就是我們可以通過服務治理,降低一個服務節點的權重來減輕某一方服務節點的請求壓力,達到保護這個服務節點的目的。

十六講思考題:在我們的實際工作中,測試人員和開發人員的工作一般都是並行的,這就導致一個問題經常出現:開發人員在開發過程中可能需要啟動自身的應用,而測試人員為了能驗證功能,會在測試環境中部署同樣的應用。如果開發人員和測試人員用的接口分組名剛好一樣,在這種情況下,就可能會干擾其它正在聯調的調用方進行功能驗證,進而影響整體的工作效率。不知道面對這種情況,你有什么好辦法嗎?

我們可以考慮配置不同的注冊中心,開發人員將自己的服務注冊到注冊中心 A 上,而測試人員可以將自己的服務注冊到測試專屬的注冊中心 B 上,這樣測試人員在驗證功能的時候,調用端會從注冊中心 B 上拉取服務節點,開發人員重啟自己的服務是影響不到測試人員的。
如果你使用過或者了解 k8s 的話,你一定知道“命名空間”的概念,RPC 框架如果支持“命名空間”,也是可以解決這一問題的。

17丨異步RPC:壓榨單機吞吐量

1)如何提升單機吞吐量

在我運營 RPC 的過程中,“如何提升吞吐量”是我與業務團隊經常討論的問題。

記得之前業務團隊反饋過這樣一個問題:我們的 TPS 始終上不去,壓測的時候 CPU 壓到40%~50% 就再也壓不上去了,TPS 也不會提高,問我們這里有沒有什么解決方案可以提升業務的吞吐量?

之后我是看了下他們服務的業務邏輯,發現他們的業務邏輯在執行較為耗時的業務邏輯的基礎上,又同步調用了好幾個其它的服務。由於這幾個服務的耗時較長,才導致這個服務的業務邏輯耗時也長,CPU 大部分的時間都在等待,並沒有得到充分地利用,因此 CPU 的利用率和服務的吞吐量當然上不去了。

2)那是什么影響到了 RPC 調用的吞吐量呢?

在使用 RPC 的過程中,談到性能和吞吐量,我們的第一反應就是選擇一款高性能、高吞吐量的 RPC 框架,那影響到 RPC 調用的吞吐量的根本原因是什么呢?

其實根本原因就是由於處理 RPC 請求比較耗時,並且 CPU 大部分的時間都在等待而沒有去計算,從而導致 CPU 的利用率不夠。這就好比一個人在干活,但他沒有規划好時間,並且有很長一段時間都在閑着,當然也就完不成太多工作了。

那么導致 RPC 請求比較耗時的原因主要是在於 RPC 框架本身嗎?事實上除非在網絡比較慢或者使用方使用不當的情況下,否則,在大多數情況下,刨除業務邏輯處理的耗時時間,RPC 本身處理請求的效率就算在比較差的情況下也不過是毫秒級的。可以說 RPC 請求的耗時大部分都是業務耗時,比如業務邏輯中有訪問數據庫執行慢 SQL 的操作。所以說,在大多數情況下,影響到 RPC 調用的吞吐量的原因也就是業務邏輯處理慢了,CPU 大部分時間都在等待資源。

弄明白了原因,咱們就可以解決問題了,該如何去提升單機吞吐量?

這並不是一個新話題,比如現在我們經常提到的響應式開發,就是為了能夠提升業務處理的吞吐量。要提升吞吐量,其實關鍵就兩個字:“異步”。我們的 RPC 框架要做到完全異步化,實現全異步 RPC。試想一下,如果我們每次發送一個異步請求,發送請求過后請求即刻就結束了,之后業務邏輯全部異步執行,結果異步通知,這樣可以增加多么可觀的吞吐量?

3)調用端如何異步?

說到異步,我們最常用的方式就是返回 Future 對象的 Future 方式,或者入參為 Callback對象的回調方式,而 Future 方式可以說是最簡單的一種異步方式了。我們發起一次異步請求並且從請求上下文中拿到一個 Future,之后我們就可以調用 Future 的 get 方法獲取結果。

就比如剛才我提到的業務團隊的那個問題,他們的業務邏輯中調用了好幾個其它的服務,這時如果是同步調用,假設調用了 4 個服務,每個服務耗時 10 毫秒,那么業務邏輯執行完至少要耗時 40 毫秒。

那如果采用 Future 方式呢?

連續發送 4 次異步請求並且拿到 4 個 Future,由於是異步調用,這段時間的耗時幾乎可以忽略不計,之后我們統一調用這幾個 Future 的 get 方法。這樣一來的話,業務邏輯執行完的時間在理想的情況下是多少毫秒呢?沒錯,10 毫秒,耗時整整縮短到了原來的四分之一,也就是說,我們的吞吐量有可能提升 4 倍!

 

那 RPC 框架的 Future 方式異步又該如何實現呢?

通過基礎篇的學習,我們了解到,一次 RPC 調用的本質就是調用端向服務端發送一條請求消息,服務端收到消息后進行處理,處理之后響應給調用端一條響應消息,調用端收到響應消息之后再進行處理,最后將最終的返回值返回給動態代理。

這里我們可以看到,對於調用端來說,向服務端發送請求消息與接收服務端發送過來的響應消息,這兩個處理過程是兩個完全獨立的過程,這兩個過程甚至在大多數情況下都不在一個線程中進行。那么是不是說 RPC 框架的調用端,對於 RPC 調用的處理邏輯,內部實現就是異步的呢?

不錯,對於 RPC 框架,無論是同步調用還是異步調用,調用端的內部實現都是異步的。

通過[第 02 講] 我們知道,調用端發送的每條消息都一個唯一的消息標識,實際上調用端向服務端發送請求消息之前會先創建一個 Future,並會存儲這個消息標識與這個 Future的映射,動態代理所獲得的返回值最終就是從這個 Future 中獲取的;當收到服務端響應的消息時,調用端會根據響應消息的唯一標識,通過之前存儲的映射找到對應的 Future,將結果注入給那個 Future,再進行一系列的處理邏輯,最后動態代理從 Future 中獲得到正確的返回值。
 
所謂的同步調用,不過是 RPC 框架在調用端的處理邏輯中主動執行了這個 Future 的 get方法,讓動態代理等待返回值;而異步調用則是 RPC 框架沒有主動執行這個 Future 的get 方法,用戶可以從請求上下文中得到這個 Future,自己決定什么時候執行這個 Future的 get 方法。

 

現在你應該很清楚 RPC 框架是如何實現 Future 方式的異步了。

4) 如何做到 RPC 調用全異步?
剛才我講解了 Future 方式的異步,Future 方式異步可以說是調用端異步的一種方式,那么服務端呢?服務端是否需要異步,有什么實現方式?
通過基礎篇的學習,我們了解到 RPC 服務端接收到請求的二進制消息之后會根據協議進行拆包解包,之后將完整的消息進行解碼並反序列化,獲得到入參參數之后再通過反射執行業務邏輯。那你有沒有想過,在生產環境中這些操作都在哪個線程中執行呢?是在一個線程中執行嗎?

當然不會在一個,對二進制消息數據包拆解包的處理是一定要在處理網絡 IO 的線程中,如果網絡通信框架使用的是 Netty 框架,那么對二進制包的處理是在 IO 線程中,而解碼與反序列化的過程也往往在 IO 線程中處理,那服務端的業務邏輯呢?也應該在 IO 線程中處理嗎?原則上是不應該的,業務邏輯應該交給專門的業務線程池處理,以防止由於業務邏輯處理得過慢而影響到網絡 IO 的處理。

這時問題就來了,我們配置的業務線程池的線程數都是有限制的,在我運營 RPC 的經驗中,業務線程池的線程數一般只會配置到 200,因為在大多數情況下線程數配置到 200 還不夠用就說明業務邏輯該優化了。那么如果碰到特殊的業務場景呢?讓配置的業務線程池完全打滿了,比如這樣一個場景。

我這里啟動一個服務,業務邏輯處理得就是比較慢,當訪問量逐漸變大時,業務線程池很容易就被打滿了,吞吐量很不理想,並且這時 CPU 的利用率也很低。

對於這個問題,你有沒有想到什么解決辦法呢?是不是會馬上想到調大業務線程池的線程數?那這樣可以嗎?有沒有更好的解決方式呢?

我想服務端業務處理邏輯異步是個好方法。

調大業務線程池的線程數,的確勉強可以解決這個問題,但是對於 RPC 框架來說,往往都會有多個服務共用一個線程池的情況,即使調大業務線程池,比較耗時的服務很可能還會影響到其它的服務。所以最佳的解決辦法是能夠讓業務線程池盡快地釋放,那么我們就需要RPC 框架能夠支持服務端業務邏輯異步處理,這對提高服務的吞吐量有很重要的意義。

那服務端如何支持業務邏輯異步呢?

這是個比較難處理的問題,因為服務端執行完業務邏輯之后,要對返回值進行序列化並且編碼,將消息響應給調用端,但如果是異步處理,業務邏輯觸發異步之后方法就執行完了,來不及將真正的結果進行序列化並編碼之后響應給調用端。

這時我們就需要 RPC 框架提供一種回調方式,讓業務邏輯可以異步處理,處理完之后調用RPC 框架的回調接口,將最終的結果通過回調的方式響應給調用端。

說到服務端支持業務邏輯異步處理,結合我剛才講解的 Future 方式異步,你有沒有想到更好的處理方式呢?其實我們可以讓 RPC 框架支持 CompletableFuture,實現 RPC 調用在調用端與服務端之間完全異步。

CompletableFuture 是 Java8 原生支持的。試想一下,假如 RPC 框架能夠支持CompletableFuture,我現在發布一個 RPC 服務,服務接口定義的返回值是CompletableFuture 對象,整個調用過程會分為這樣幾步:

服務調用方發起 RPC 調用,直接拿到返回值 CompletableFuture 對象,之后就不需要任何額外的與 RPC 框架相關的操作了(如我剛才講解 Future 方式時需要通過請求上下文獲取 Future 的操作),直接就可以進行異步處理;

在服務端的業務邏輯中創建一個返回值 CompletableFuture 對象,之后服務端真正的業務邏輯完全可以在一個線程池中異步處理,業務邏輯完成之后再調用這個CompletableFuture 對象的 complete 方法,完成異步通知;

調用端在收到服務端發送過來的響應之后,RPC 框架再自動地調用調用端拿到的那個返回值 CompletableFuture 對象的 complete 方法,這樣一次異步調用就完成了。

通過對 CompletableFuture 的支持,RPC 框架可以真正地做到在調用端與服務端之間完全異步,同時提升了調用端與服務端的兩端的單機吞吐量,並且 CompletableFuture 是Java8 原生支持,業務邏輯中沒有任何代碼入侵性,這是不是很酷炫了?

5)總結

其實,RPC 框架也可以有其它的異步策略,比如集成 RxJava,再比如 gRPC 的StreamObserver 入參對象,但 CompletableFuture 是 Java8 原生提供的,無代碼入侵性,並且在使用上更加方便。如果是 Java 開發,讓 RPC 框架支持 CompletableFuture 可以說是最佳的異步解決方案。

18丨安全體系:如何建立可靠的安全體系?

19丨分布式環境下如何快速定位問題?

20丨詳解時鍾輪在RPC中的應用

21丨流量回放:保障業務技術升級的神器

1)流量回訪可以做什么

我們可以換一種思路,我可以先把線上一段時間內的請求參數和響應結果保存下來,然后把這些請求參數在新改造的應用里重新請求一遍,最后比對一下改造前后的響應結果是否一致,這就間接達到了使用線上流量測試的效果。有了線上的請求參數和響應結果后,我們再結合持續集成過程,就可以讓我們改動后的代碼隨時用線上流量進行驗證,這就跟我錄制球賽視頻一樣,只要我想看,我隨時都可以拿出來重新看一遍。

2)RPC 怎么支持流量回放?

那在實際工作中,我們該怎么實現流量回放呢?

我們常見的方案有很多,比如像 TcpCopy、Nginx 等。但在線上環境要使用這些工具的時候,我們還得需要找運維團隊幫我們把應用安裝到應用實例里面,然后再按照你的需求給配置好才能使用,整個過程繁瑣而且總數重復做無用功,那有沒有更好的辦法呢?尤其是在應用使用了 RPC 的情況下。

在前面我們不止一次說過,RPC 是用來完成應用之間通信的,換句話就是說應用之間的所有請求響應都會經過 RPC。

既然所有的請求都會經過 RPC,那么我們在 RPC 里面是不是就可以很方便地拿到每次請求的出入參數?拿到這些出入參數后,我們只要把這些出入參數旁錄下來,並把這些旁錄結果用異步的方式發送到一個固定的地方保存起來,這樣就完成了流量回放里面的錄制功能。

相對其它現成的流量回放方案,我們在 RPC 里面內置流量回放功能,使用起來會更加方便,並且我們還可以做更多定制,比如在線啟停、方法級別錄制等個性化需求。

22丨動態分組:超高效實現秒級擴縮容

23丨如何在沒有接口的情況下進行RPC調用?

24丨如何在線上環境里兼容多種RPC協議?

特別放送丨談談我所經歷過的RPC

結束語丨學會從優秀項目的源代碼中挖掘知識

 


免責聲明!

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



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