00 開篇詞 | 別老想着怎么用好RPC框架,你得多花時間琢磨原理
RPC 是解決分布式系統通信問題的一大利器。
- 基礎篇:重點講解 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 會被丟棄了,這樣就會導致協議體的數據是錯的。

03丨序列化:對象怎么在網絡中傳輸?
1)為什么要序列化
因為網絡傳輸的數據必須是二進制數據,所以在 RPC 調用中,對入參對象與返回值對象進行序列化與反序列化是一個必須的過程。
2)有哪些常用的序列化
(1)JDK原生序列化
JDK 自帶的序列化機制對使用者而言是非常簡單的。序列化具體的實現是由 ObjectOutputStream 完成的,而反序列化的具體實現是由 ObjectInputStream 完成的。
實際上任何一種序列化框架,核心思想就是設計一種序列化協議,將對象的類型、屬性類型、屬性值一一按照固定的格式寫到二進制字節流中來完成序列化,再按照固定的格式一一讀出對象的類型、屬性類型、屬性值,通過這些信息重新創建出一個新的對象,來完成反序列化。
(2)JSON
用 JSON 進行序列化有這樣兩個問題,你需要格外注意:
- JSON 進行序列化的額外空間開銷比較大,對於大數據量服務這意味着需要巨大的內存和磁盤開銷;
- JSON 沒有類型,但像 Java 這種強類型語言,需要通過反射統一解決,所以性能不會太好。
Hessian 是動態類型、二進制、緊湊的,並且可跨語言移植的一種序列化框架。Hessian 協議要比 JDK、JSON 更加緊湊,性能上要比 JDK、JSON 序列化高效很多,而且生成的字節數也更小。
相對於 JDK、JSON,由於 Hessian 更加高效,生成的字節數更小,有非常好的兼容性和穩定性,所以 Hessian 更加適合作為 RPC 框架遠程通信的序列化協議。
但 Hessian 本身也有問題,官方版本對 Java 里面一些常見對象的類型不支持,比如:
- Linked 系列,LinkedHashMap、LinkedHashSet 等,但是可以通過擴展CollectionDeserializer 類修復;
- Locale 類,可以通過擴展 ContextSerializerFactory 類修復;
- Byte/Short 反序列化的時候變成 Integer。
- 序列化后體積相比 JSON、Hessian 小很多;
- IDL 能清晰地描述語義,所以足以幫助並保證應用程序之間的類型不會丟失,無需類似XML 解析器;
- 序列化反序列化速度很快,不需要通過反射獲取類型;
- 消息格式升級和兼容性不錯,可以做到向后兼容。
- 不支持 null;
- ProtoStuff 不支持單純的 Map、List 集合對象,需要包在對象里面。
3)RPC 框架中如何選擇序列化?
04丨網絡通信:RPC框架在網絡通信上更傾向於哪種網絡IO模型?
1)網絡通信在 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 這么做的意義是什么呢?
當然是在用戶空間,因為對數據包的處理工作都是由應用程序來處理的,那么這里有沒有可能存在數據的拷貝操作?可能會存在,當然不是在用戶空間與內核空間之間的拷貝,是用戶空間內部內存中的拷貝處理操作。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

總的來說,其實我們可以簡單地認為 gRPC 就是采用 HTTP/2 協議,並且默認采用 PB 序列化方式的一種 RPC,它充分利用了 HTTP/2 的多路復用特性,使得我們可以在同一條鏈路上雙向發送不同的 Stream 數據,以解決 HTTP/1.X 存在的性能問題。
07丨架構設計:設計一個靈活的RPC框架
傳輸模塊
協議封裝
Spring封裝的Bootstrap 模塊
服務發現模塊
2)可拓展的架構
插件化架構。
在 RPC 框架里面,我們是怎么支持插件化架構的呢?我們可以將每個功能點抽象成一個接口,將這個接口作為插件的契約,然后把這個功能的接口與功能的實現分離,並提供接口的默認實現。
在 Java 里面,JDK 有自帶的 SPI(Service Provider Interface)服務發現機制,它可以動態地為某個接口尋找服務實現。使用 SPI 機制需要在 Classpath 下的 META-INF/services 目錄里創建一個以服務接口命名的文件,這個文件里的內容就是這個接口的具體實現類。
但在實際項目中,我們其實很少使用到 JDK 自帶的 SPI 機制,首先它不能按需加載,ServiceLoader 加載某個接口實現類的時候,會遍歷全部獲取,也就是接口的實現類得全部載入並實例化一遍,會造成不必要的浪費。另外就是擴展如果依賴其它的擴展,那就做不到自動注入和裝配,這就很難和其他框架集成,比如擴展里面依賴了一個 Spring Bean,原生的 Java SPI 就不支持。

這時,整個架構就變成了一個微內核架構,我們將每個功能點抽象成一個接口,將這個接口作為插件的契約,然后把這個功能的接口與功能的實現分離並提供接口的默認實現。
這樣的架構相比之前的架構,有很多優勢。首先它的可擴展性很好,實現了開閉原則,用戶可以非常方便地通過插件擴展實現自己的功能,而且不需要修改核心功能的本身;其次就是保持了核心包的精簡,依賴外部包少,這樣可以有效減少開發人員引入 RPC 導致的包版本沖突問題。
08丨服務發現:到底是要CP還是AP?
1)為什么需要服務發現?

- 服務注冊:在服務提供方啟動的時候,將對外暴露的接口注冊到注冊中心之中,注冊中心將這個服務節點的 IP 和接口保存下來。
- 服務訂閱:在服務調用方啟動的時候,去注冊中心查找並訂閱服務提供方的 IP,然后緩存到本地,並用於后續的遠程調用。

- 如果這個 IP 端口下線了,服務調用者能否及時摘除服務節點呢?
- 如果在之前已經上線了一部分服務節點,這時我突然對這個服務進行擴容,那么新上線的服務節點能否及時接收到流量呢?

- 搭建負載均衡設備或 TCP/IP 四層代理,需求額外成本;
- 請求流量都經過負載均衡設備,多經過一次網絡傳輸,會額外浪費些性能;
- 負載均衡添加節點和摘除節點,一般都要手動添加,當大批量擴容和下線時,會有大量的人工操作和生效延遲;
- 我們在服務治理的時候,需要更靈活的負載均衡策略,目前的負載均衡設備的算法還滿足不了靈活的需求。

- 服務平台管理端先在 ZooKeeper 中創建一個服務根路徑,可以根據接口名命名(例如:/service/com.demo.xxService),在這個路徑再創建服務提供方目錄與服務調用方目錄(例如:provider、consumer),分別用來存儲服務提供方的節點信息和服務調用方的節點信息。
- 當服務提供方發起注冊時,會在服務提供方目錄中創建一個臨時節點,節點中存儲該服務提供方的注冊信息。
- 當服務調用方發起訂閱時,則在服務調用方目錄中創建一個臨時節點,節點中存儲該服務調用方的信息,同時服務調用方 watch 該服務的服務提供方目錄(/service/com.demo.xxService/provider)中所有的服務節點數據。
- 當服務提供方目錄下有節點數據發生變更時,ZooKeeper 就會通知給發起訂閱的服務調用方。

當有服務上線,注冊中心節點收到注冊請求,服務列表數據發生變化,會生成一個消息,推送給消息總線,每個消息都有整體遞增的版本。
消息總線會主動推送消息到各個注冊中心,同時注冊中心也會定時拉取消息。對於獲取到消息的在消息回放模塊里面回放,只接受大於本地版本號的消息,小於本地版本號的消息直接丟棄,從而實現最終一致性。
消費者訂閱可以從注冊中心內存拿到指定接口的全部服務實例,並緩存到消費者的內存里面。
為了性能,這里采用了兩級緩存,注冊中心和消費者的內存緩存,通過異步推拉模式來確保最終一致性。
09丨健康檢測:這個節點都掛了,為啥還要瘋狂發請求?
1)怎么保證選擇出來的連接一定是可用的呢?

為了進一步了解事情的真相,我查看了問題時間點的監控和日志,在案發現場發現了這樣幾個線索:
- 通過日志發現請求確實會一直打到這台有問題的機器上,因為我看到日志里有很多超時的異常信息。
- 從監控上看,這台機器還是有一些成功的請求,這說明當時調用方跟服務之間的網絡連接沒有斷開。因為如果連接斷開之后,RPC 框架會把這個節點標識為“不健康”,不會被選出來用於發業務請求。
- 深入進去看異常日志,我發現調用方到目標機器的定時心跳會有間歇性失敗。
- 從目標機器的監控上可以看到該機器的網絡指標有異常,出問題時間點 TCP 重傳數比正常高 10 倍以上。

- 健康狀態:建立連接成功,並且心跳探活也一直成功;
- 亞健康狀態:建立連接成功,但是心跳請求連續失敗;
- 死亡狀態:建立連接失敗。
- 調用方每個接口的調用頻次不一樣,有的接口可能 1 秒內調用上百次,有的接口可能半個小時才會調用一次,所以我們不能把簡單的把總失敗的次數當作判斷條件。
- 服務的接口響應時間也是不一樣的,有的接口可能 1ms,有的接口可能是 10s,所以我們也不能把 TPS 至來當作判斷條件。
5)總結
10丨路由策略:怎么讓請求按照設定的規則發到不同的節點上?
1)為什么選擇路由策略
2)如何實現路由策略
我們可以重新回到調用方發起 RPC 調用的流程。在 RPC 發起真實請求的時候,有一個步驟就是從服務提供方節點集合里面選擇一個合適的節點(就是我們常說的負載均衡),那我們是不是可以在選擇節點前加上“篩選邏輯”,把符合我們要求的節點篩選出來。那這個篩選的規則是什么呢?就是我們前面說的灰度過程中要驗證的規則。
舉個具體例子你可能就明白了,比如我們要求新上線的節點只允許某個 IP 可以調用,那我們的注冊中心會把這條規則下發到服務調用方。在調用方收到規則后,在選擇具體要發請求的節點前,會先通過篩選規則過濾節點集合,按照這個例子的邏輯,最后會過濾出一個節點,這個節點就是我們剛才新上線的節點。通過這樣的改造,RPC 調用流程就變成了這樣:
這個篩選過程在我們的 RPC 里面有一個專業名詞,就是“路由策略”,而上面例子里面的路由策略是我們常見的 IP 路由策略,用於限制可以調用服務提供方的 IP。使用了 IP 路由策略后,整個集群的調用拓撲如下圖所示:
3)參數路由

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

這個問題其實挺好解決的,我們當時給出的方案是:在治理平台上調低這幾台機器的權重,這樣的話,訪問的流量自然就減少了。
但業務接着反饋了,說:當他們發現服務可用率降低的時候,業務請求已經受到影響了,這時再如此解決,需要時間啊,那這段時間里業務可能已經有損失了。緊接着他們就提出了需求,問:RPC 框架有沒有什么智能負載的機制?能否及時地自動控制服務節點接收到的訪問量?
看到這兒,你有沒有想到什么好的處理方案呢?接下來,我們就以這個問題為背景,一起看看 RPC 框架的負載均衡。
2)負載均衡

我剛才介紹的負載均衡主要還是應用在 Web 服務上,Web 服務的域名綁定負載均衡的地址,通過負載均衡將用戶的請求分發到一個個后端服務上。
3)RPC中的負載均衡
那 RPC 框架中的負載均衡是不是也是如此呢?和我上面講的負載均衡,你覺得會有區別嗎?
RPC 負載均衡策略一般包括隨機權重、Hash、輪詢。當然,這還是主要看 RPC 框架自身的實現。其中的隨機權重策略應該是我們最常用的一種了,通過隨機算法,我們基本可以保證每個節點接收到的請求流量是均勻的;同時我們還可以通過控制節點權重的方式,來進行流量控制。比如我們默認每個節點的權重都是 100,但當我們把其中的一個節點的權重設置成 50 時,它接收到的流量就是其他節點的 1/2。
4)如何設計自適應的負載均衡
我剛才講過,RPC 的負載均衡完全由 RPC 框架自身實現,服務調用者發起請求時,會通過配置的負載均衡插件,自主地選擇服務節點。那是不是只要調用者知道每個服務節點處理請求的能力,再根據服務處理節點處理請求的能力來判斷要打給它多少流量就可以了?當一個服務節點負載過高或響應過慢時,就少給它發送請求,反之則多給它發送請求。
那服務調用者節點又該如何判定一個服務節點的處理能力呢?
這里我們可以采用一種打分的策略,服務調用者收集與之建立長連接的每個服務節點的指標數據,如服務節點的負載指標、CPU 核數、內存大小、請求處理的耗時指標(如請求平均耗時、TP99、TP999)、服務節點的狀態指標(如正常、亞健康)。通過這些指標,計算出一個分數,比如總分 10 分,如果 CPU 負載達到 70%,就減它 3 分,當然了,減 3 分只是個類比,需要減多少分是需要一個計算策略的。
我們又該如果根據這些指標來打分呢?

- 添加服務指標收集器,並將其作為插件,默認有運行時狀態指標收集器、請求耗時指標收集器。
- 運行時狀態指標收集器收集服務節點 CPU 核數、CPU 負載以及內存等指標,在服務調用者與服務提供者的心跳數據中獲取。
- 請求耗時指標收集器收集請求耗時數據,如平均耗時、TP99、TP999 等。
- 可以配置開啟哪些指標收集器,並設置這些參考指標的指標權重,再根據指標數據和指標權重來綜合打分。
- 通過服務節點的綜合打分與節點的權重,最終計算出節點的最終權重,之后服務調用者會根據隨機權重的策略,來選擇服務節點。
12丨異常重試:在約定時間內安全可靠地重試
1)為什么要異常重試
網絡抖動導致請求失敗。
2)RPC 框架的重試機制
在使用 RPC 框架的時候,我們要確保被調用的服務的業務邏輯是冪等的,這樣我們才能考慮根據事件情況開啟 RPC 框架的異常重試功能。這一點你要格外注意,這算是一個高頻誤區了。
3)何在約定時間內安全可靠地重試?
連續的異常重試可能會出現一種不可靠的情況,那就是連續的異常重試並且每次處理的請求時間比較長,最終會導致請求處理的時間過長,超出用戶設置的超時時間。
13丨優雅關閉:如何避免服務停機帶來的業務損失?
1)關閉為什么會有問題
在重啟服務的過程中,RPC 怎么做到讓調用方系統不出問題呢?
在服務重啟的時候,對於調用方來說,這時候可能會存在以下幾種情況:
- 調用方發請求前,目標服務已經下線。對於調用方來說,跟目標節點的連接會斷開,這時候調用方可以立馬感知到,並且在其健康列表里面會把這個節點挪掉,自然也就不會被負載均衡選中。
- 調用方發請求的時候,目標服務正在關閉,但調用方並不知道它正在關閉,而且兩者之間的連接也沒斷開,所以這個節點還會存在健康列表里面,因此該節點就有一定概率會被負載均衡選中。

這樣不就可以實現不通過“人肉”的方式,從而達到一種自動化方式,但這么做就能完全保證實現無損上下線嗎?
3)優雅關閉
知道了根本原因,問題就很好解決了。因為服務提供方已經開始進入關閉流程,那么很多對象就可能已經被銷毀了,關閉后再收到的請求按照正常業務請求來處理,肯定是沒法保證能處理的。所以我們可以在關閉的時候,設置一個請求“擋板”,擋板的作用就是告訴調用方,我已經開始進入關閉流程了,我不能再處理你這個請求了。
基於這個思路,我們可以這么處理:當服務提供方正在關閉,如果這之后還收到了新的業務請求,服務提供方直接返回一個特定的異常給調用方(比如 ShutdownException)。這個異常就是告訴調用方“我已經收到這個請求了,但是我正在關閉,並沒有處理這個請求”,然后調用方收到這個異常響應后,RPC 框架把這個節點從健康列表挪出,並把請求自動重試到其他節點,因為這個請求是沒有被服務提供方處理過,所以可以安全地重試到其他節點,這樣就可以實現對業務無損。
但如果只是靠等待被動調用,就會讓這個關閉過程整體有點漫長。因為有的調用方那個時刻沒有業務請求,就不能及時地通知調用方了,所以我們可以加上主動通知流程,這樣既可以保證實時性,也可以避免通知失敗的情況。
說到這里,我知道你肯定會問,那要怎么捕獲到關閉事件呢?
在我的經驗里,可以通過捕獲操作系統的進程信號來獲取,在 Java 語言里面,對應的是Runtime.addShutdownHook 方法,可以注冊關閉的鈎子。在 RPC 啟動的時候,我們提前注冊關閉鈎子,並在里面添加了兩個處理程序,一個負責開啟關閉標識,一個負責安全關閉服務對象,服務對象在關閉的時候會通知調用方下線節點。同時需要在我們調用鏈里面加上擋板處理器,當新的請求來的時候,會判斷關閉標識,如果正在關閉,則拋出特定異常。
看到這里,感覺問題已經比較好地被解決了。但細心的同學可能還會提出問題,關閉過程中已經在處理的請求會不會受到影響呢?
如果進程結束過快會造成這些請求還沒有來得及應答,同時調用方會也會拋出異常。為了盡可能地完成正在處理的請求,首先我們要把這些請求識別出來。這就好比日常生活中,我們經常看見停車場指示牌上提示還有多少剩余車位,這個是如何做到的呢?如果仔細觀察一下,你就會發現它是每進入一輛車,剩余車位就減一,每出來一輛車,剩余車位就加一。我們也可以利用這個原理在服務對象加上引用計數器,每開始處理請求之前加一,完成請求處理減一,通過該計數器我們就可以快速判斷是否有正在處理的請求。

4)總結
在 RPC 里面,關閉雖然看似不屬於 RPC 主流程,但如果我們不能處理得很好的話,可能就會導致調用方業務異常,從而需要我們加入很多額外的運維工作。一個好的關閉流程,可以確保使用我們框架的業務實現平滑的上下線,而不用擔心重啟導致的問題。
其實“優雅關閉”這個概念除了在 RPC 里面有,在很多框架里面也都挺常見的,比如像我們經常用的應用容器框架 Tomcat。Tomcat 關閉的時候也是先從外層到里層逐層進行關閉,先保證不接收新請求,然后再處理關閉前收到的請求。
14丨優雅啟動:如何避免流量打到沒有啟動完成的節點?
今天要和你分享的重點,RPC 里面的一個實用功能——啟動預熱。
1)啟動預熱
那什么叫啟動預熱呢?
簡單來說,就是讓剛啟動的服務提供方應用不承擔全部的流量,而是讓它被調用的次數隨着時間的移動慢慢增加,最終讓流量緩和地增加到跟已經運行一段時間后的水平一樣。
那在 RPC 里面,我們該怎么實現這個功能呢?
我們現在是要控制調用方發送到服務提供方的流量。我們可以先簡單地回顧下調用方發起的RPC 調用流程是怎樣的,調用方應用通過服務發現能夠獲取到服務提供方的 IP 地址,然后每次發送請求前,都需要通過負載均衡算法從連接池中選擇一個可用連接。那這樣的話,我們是不是就可以讓負載均衡在選擇連接的時候,區分一下是否是剛啟動不久的應用?對於剛啟動的應用,我們可以讓它被選擇到的概率特別低,但這個概率會隨着時間的推移慢慢變大,從而實現一個動態增加流量的過程。

通過這個小邏輯的改動,我們就可以保證當服務提供方運行時長小於預熱時間時,對服務提供方進行降權,減少被負載均衡選擇的概率,避免讓應用在啟動之初就處於高負載狀態,從而實現服務提供方在啟動后有一個預熱的過程。
看到這兒,你可能還會有另外一個疑問,就是當我在大批量重啟服務提供方的時候,會不會導致沒有重啟的機器因為扛的流量太大而出現問題?
我們應用啟動的時候都是通過 main 入口,然后順序加載各種相關依賴的類。以 Spring 應用啟動為例,在加載的過程中,Spring 容器會順序加載 Spring Bean,如果某個 Bean 是 RPC 服務的話,我們不光要把它注冊到 Spring-BeanFactory 里面去,還要把這個 Bean 對應的接口注冊到注冊中心。注冊中心在收到新上線的服務提供方地址的時候,會把這個地址推送到調用方應用內存中;當調用方收到這個服務提供方地址的時候,就會去建立連接發請求。
但這時候是不是存在服務提供方可能並沒有啟動完成的情況?因為服務提供方應用可能還在加載其它的 Bean。對於調用方來說,只要獲取到了服務提供方的 IP,就有可能發起 RPC 調用,但如果這時候服務提供方沒有啟動完成的話,就會導致調用失敗,從而使業務受損。
那有什么辦法可以避免這種情況嗎?
在解決問題前,我們先看下出現上述問題的根本原因。這是因為服務提供方應用在沒有啟動完成的時候,調用方的請求就過來了,而調用方請求過來的原因是,服務提供方應用在啟動過程中把解析到的 RPC 服務注冊到了注冊中心,這就導致在后續加載沒有完成的情況下服務提供方的地址就被服務調用方感知到了。

15丨熔斷限流:業務如何實現自我保護
1)為什么需要自我保護?

這個問題還是很好解決的,既然負載壓力高,那就不讓它再接收太多的請求就好了,等接收和處理的請求數量下來后,這個節點的負載壓力自然就下來了。
那么就是限流吧?是的,在 RPC 調用中服務端的自我保護策略就是限流,那你有沒有想過我們是如何實現限流的呢?是在服務端的業務邏輯中做限流嗎?有沒有更優雅的方式?

那服務端的限流邏輯又該如何實現呢?
方式有很多,比如最簡單的計數器,還有可以做到平滑限流的滑動窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最為常用。上述這幾種限流算法我就不一一講解了,資料很多,不太清楚的話自行查閱下就可以了。
我們可以假設下這樣一個場景:我發布了一個服務,提供給多個應用的調用方去調用,這時有一個應用的調用方發送過來的請求流量要比其它的應用大很多,這時我們就應該對這個應用下的調用端發送過來的請求流量進行限流。所以說我們在做限流的時候要考慮應用級別的維度,甚至是 IP 級別的維度,這樣做不僅可以讓我們對一個應用下的調用端發送過來的請求流量做限流,還可以對一個 IP 發送過來的請求流量做限流。


我們可以先了解下熔斷機制。
熔斷器的工作機制主要是關閉、打開和半打開這三個狀態之間的切換。在正常情況下,熔斷器是關閉的;當調用端調用下游服務出現異常時,熔斷器會收集異常指標信息進行計算,當達到熔斷條件時熔斷器打開,這時調用端再發起請求是會直接被熔斷器攔截,並快速地執行失敗邏輯;當熔斷器打開一段時間后,會轉為半打開狀態,這時熔斷器允許調用端發送一個請求給服務端,如果這次請求能夠正常地得到服務端的響應,則將狀態置為關閉狀態,否則設置為打開。
了解完熔斷機制,你就會發現,在業務邏輯中加入熔斷器其實是不夠優雅的。那么在 RPC框架中,我們該如何整合熔斷器呢?

你看圖的話,有沒有想到在哪個步驟整合熔斷器會比較合適呢?
我的建議是動態代理,因為在 RPC 調用的流程中,動態代理是 RPC 調用的第一個關口。在發出請求時先經過熔斷器,如果狀態是閉合則正常發出請求,如果狀態是打開則執行熔斷器的失敗策略。
5)總結
16丨業務分組:如何隔離流量?
那說起突發流量,限流固然是一種手段,但其實面對復雜的業務以及高並發場景時,我們還有別的手段,可以最大限度地保障業務無損,那就是隔離流量。這也是我今天重點要和你分享的內容,接下來我們就一起看看分組在 RPC 中的應用。
1)為什么需要分組
在我們的日常開發中,我們不都提倡讓用戶使用起來越簡單越好嗎?如果在接口上再加一個分組維度去管理,不就讓事情變復雜了嗎?

后期因為業務發展豐富了,調用你接口的調用方就會越來越多,流量也會漸漸多起來。可能某一天,一個“爆炸式驚喜”就來了。其中一個調用方的流量突然激增,讓你整個集群瞬間處於高負載運行,進而影響到其它調用方,導致它們的整體可用率下降。而這時候作為應用負責人的你,那就得變身“救火隊長”了,要想盡各種辦法來保證應用的穩定。
在經過一系列的救火操作后,我們肯定要去想更好的應對辦法。那回到問題的根本去看,關鍵就在於,早期為了管理方便,我們把接口都放到了同一個分組下面,所有的服務實例是以一個整體對外提供能力的。
但后期因為業務發展,這種粗暴的管理模式已經不適用了,這就好比“汽車來了,我們的交通網也得抓緊建設”一樣,讓人車分流。此時,道路上的人和車就好比我們應用的調用方,我們可以嘗試把應用提供方這個大池子划分出不同規格的小池子,再分配給不同的調用方,而不同小池子之間的隔離帶,就是我們在 RPC 里面所說的分組,它可以實現流量隔離。
2)怎么實現分組
現在分組是怎么回事我們搞清楚了,那放到 RPC 里我們該怎么實現呢?
既然是要求不同的調用方應用能拿到的池子內容不同,那我們就要回想下服務發現了,因為在 RPC 流程里,能影響到調用方獲取服務節點的邏輯就是它了。
在[第 08 講] 我們說過,服務調用方是通過接口名去注冊中心找到所有的服務節點來完成服務發現的,那換到這里的話,這樣做其實並不合適,因為這樣調用方會拿到所有的服務節點。因此為了實現分組隔離邏輯,我們需要重新改造下服務發現的邏輯,調用方去獲取服務節點的時候除了要帶着接口名,還需要另外加一個分組參數,相應的服務提供方在注冊的時候也要帶上分組參數。
通過改造后的分組邏輯,我們可以把服務提供方所有的實例分成若干組,每一個分組可以提供給單個或者多個不同的調用方來調用。那怎么分組好呢,有沒有統一的標准?
坦白講,這個分組並沒有一個可衡量的標准,但我自己總結了一個規則可以供你參考,就是按照應用重要級別划分。

答疑課堂丨基礎篇與進階篇思考題答案合集
第二講思考題:在 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 調用中無論服務端還是調用端都需要自我保護,服務端自我保護的最簡單有效的方式是“限流”,調用端則可以通過“熔斷”機制來進行自我保護。
除了“熔斷”和“限流”外,相信你一定聽過“降級”這個詞。簡單來說就是當一個服務處理大量的請求達到一定壓力的時候,我們可以讓這個服務在處理請求時減少些非必要的功能,從而降低這個服務的壓力。
還有就是我們可以通過服務治理,降低一個服務節點的權重來減輕某一方服務節點的請求壓力,達到保護這個服務節點的目的。
十六講思考題:在我們的實際工作中,測試人員和開發人員的工作一般都是並行的,這就導致一個問題經常出現:開發人員在開發過程中可能需要啟動自身的應用,而測試人員為了能驗證功能,會在測試環境中部署同樣的應用。如果開發人員和測試人員用的接口分組名剛好一樣,在這種情況下,就可能會干擾其它正在聯調的調用方進行功能驗證,進而影響整體的工作效率。不知道面對這種情況,你有什么好辦法嗎?
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 大部分時間都在等待資源。
弄明白了原因,咱們就可以解決問題了,該如何去提升單機吞吐量?
3)調用端如何異步?
說到異步,我們最常用的方式就是返回 Future 對象的 Future 方式,或者入參為 Callback對象的回調方式,而 Future 方式可以說是最簡單的一種異步方式了。我們發起一次異步請求並且從請求上下文中拿到一個 Future,之后我們就可以調用 Future 的 get 方法獲取結果。
就比如剛才我提到的業務團隊的那個問題,他們的業務邏輯中調用了好幾個其它的服務,這時如果是同步調用,假設調用了 4 個服務,每個服務耗時 10 毫秒,那么業務邏輯執行完至少要耗時 40 毫秒。
那如果采用 Future 方式呢?

那 RPC 框架的 Future 方式異步又該如何實現呢?
通過基礎篇的學習,我們了解到,一次 RPC 調用的本質就是調用端向服務端發送一條請求消息,服務端收到消息后進行處理,處理之后響應給調用端一條響應消息,調用端收到響應消息之后再進行處理,最后將最終的返回值返回給動態代理。
這里我們可以看到,對於調用端來說,向服務端發送請求消息與接收服務端發送過來的響應消息,這兩個處理過程是兩個完全獨立的過程,這兩個過程甚至在大多數情況下都不在一個線程中進行。那么是不是說 RPC 框架的調用端,對於 RPC 調用的處理邏輯,內部實現就是異步的呢?
不錯,對於 RPC 框架,無論是同步調用還是異步調用,調用端的內部實現都是異步的。

現在你應該很清楚 RPC 框架是如何實現 Future 方式的異步了。
4) 如何做到 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
結束語丨學會從優秀項目的源代碼中挖掘知識