要怎樣才能夠完美的編寫高效能的RPC框架

爛豬皮發表於2018-04-20

RPC 的主要流程

  1. 客戶端 獲取到 UserService 介面的 Refer: userServiceRefer
  2. 客戶端 呼叫 userServiceRefer.verifyUser(email, pwd)
  3. 客戶端 獲取到 請求方法 和 請求資料
  4. 客戶端 把 請求方法 和 請求資料 序列化為 傳輸資料
  5. 進行網路傳輸
  6. 服務端 獲取到 傳輸資料
  7. 服務端 反序列化獲取到 請求方法 和 請求資料
  8. 服務端 獲取到 UserService 的 Invoker: userServiceInvoker
  9. 服務端 userServiceInvoker 呼叫 userServiceImpl.verifyUser(email, pwd) 獲取到 響應結果
  10. 服務端 把 響應結果 序列化為 傳輸資料
  11. 進行網路傳輸
  12. 客戶端 接收到 傳輸資料
  13. 客戶端 反序列化獲取到 響應結果
  14. 客戶端 userServiceRefer.verifyUser(email, pwd) 返回 響應結果

整個流程中對效能影響比較大的環節有:序列化[4, 7, 10, 13],方法呼叫[2, 3, 8, 9, 14],網路傳輸[5, 6, 11, 12]。本文後續內容將著重介紹這3個部分。

序列化方案

Java 世界最常用的幾款高效能序列化方案有 Kryo Protostuff FST Jackson Fastjson。只需要進行一次 Benchmark,然後從這5種序列化方案中選出效能最高的那個就行了。DSL-JSON 使用起來過於繁瑣,不在考慮之列。Colfer Protocol Thrift 因為必須預先定義描述檔案,使用起來太麻煩,所以不在考慮之列。至於 Java 自帶的序列化方案,早就因為效能問題被大家所拋棄,所以也不考慮。下面的表格列出了在考慮之列的5種序列化方案的效能。

User 序列化+反序列化 效能

framework thrpt (ops/ms) size

protostuff1654240
kryo1288296
fst1101263
jackson959385
fastjson603378

包含15個 UserPage 序列化+反序列化 效能

framework thrpt (ops/ms) size

kryo1432080
fst1183495
protostuff983920
jackson715711
fastjson405606

從這個 benchmark 中可以得出明確的結論:二進位制協議的 protostuff kryo fst 要比文字協議的 jackson fastjson 有明顯優勢;文字協議中,jackson(開啟了afterburner) 要比 fastjson 有明顯的優勢。

無法確定的是:3個二進位制協議到底哪個更好一些,畢竟 速度 和 size 對於 RPC 都很重要。直觀上 kryo 或許是最佳選擇,而且 kryo 也廣受各大型系統的青睞。不過最終還是決定把這3個類庫都留作備選,通過整合傳輸模組後的 Benchmark 來決定選用哪個。

framework existUser (ops/ms) createUser (ops/ms) getUser (ops/ms) listUser (ops/ms)

protostuff103.9289.5083.3321.17
kryo99.2376.7173.8925.68
fst102.3376.2478.8123.30

最終的結果也還是各有千秋難以抉擇,所以 Turbo 保留了 protostuff 和 kryo 的實現,並允許使用者自行替換為自己的實現。

方法呼叫

可用的 動態方法呼叫 方案有:Reflection ClassGeneration MethodHandle。Reflection 是最古老的技術,據說效能不佳。ClassGeneration 動態類生成,從原理上說應該是跟直接呼叫一樣的效能。MethodHandle 是從 Java 7 開始出現的技術,據說能達到跟直接呼叫一樣的效能。實際結果如下:

type thrpt (ops/us)

direct1062
javassist920
methodHandle430
reflection337

結論非常明顯:使用類生成技術的 javassist 跟直接呼叫幾乎一樣的效能,就用 javassist 了。

MethodHandle 表現並沒有宣傳的那麼好,怎麼回事?原來 MethodHandle 只有在明確知道呼叫 引數數量 引數型別 的情況下才能呼叫高效能的 invokeExact(Object... args),所以它並不適合作為動態呼叫的方案。

As is usual with virtual methods, source-level calls to invokeExact and invoke compile to an invokevirtual instruction. More unusually, the compiler must record the actual argument types, and may not perform method invocation conversions on the arguments. Instead, it must push them on the stack according to their own unconverted types. The method handle object itself is pushed on the stack before the arguments. The compiler then calls the method handle with a symbolic type descriptor which describes the argument and return types.
refer: docs.oracle.com/javase/7/do…

網路傳輸

Netty 已經成為事實上的標準,所有主流的專案現在使用的都是 NettyMina Grizzly 已經失去市場,所以也就不用考慮了。還好也不至於這麼無聊,Aeron 的閃亮登場讓 Netty 多了一個有力的競爭對手。

Aeron 是一個可靠高效的 UDP 單播 UDP 多播和 IPC 訊息傳遞工具。效能是訊息傳遞中的關鍵。Aeron 的設計旨在達到 高吞吐量 低開銷 和 低延遲

。實際效果到底如何呢?很遺憾,在 RPC Benchmark Round 1 中的表現一般。跟他們開發團隊溝通後,最終確認其無法對超過 64k 的訊息進行 zero-copy 處理,我覺得這可能是 Aeron 表現不佳的一個原因。Aeron 或許更適合 微小訊息 極端低延遲 的場景,而不適用於更加通用的 RPC 場景。所以暫時還沒有出現能夠跟 Netty 一爭高下的通用網路傳輸框架,現階段 Netty 依然是 RPC 系統的最佳選擇。

existUser 判斷某個 email 是否存在

framework thrpt (ops/ms) avgt (ms) p90 (ms) p99 (ms) p999 (ms)

turbo-rpc107.050.280.400.874.06
netty
99.81
0.32
0.40
0.52
1.16
jupiter73.070.440.661.492.92
undertow70.380.451.162.1732.48
turbo-rest68.490.441.172.1525.66
undertow-async62.650.491.142.4124.84
dubbo-kryo57.350.530.671.0211.65
rapidoid52.960.611.322.5125.07
dubbo52.120.540.670.923.93
motan44.960.711.152.4733.39
aeron
43.46
0.90
1.32
5.10
14.29
grpc38.970.841.071.316.06
thrift27.251.590.1664.87122.83
hprose26.241.261.532.018.34
springwebflux22.391.422.273.1917.20
springboot12.541.682.3813.6333.20

訊息格式

我們先來看一下 Dubbo 的訊息格式
public class RpcInvocation implements Invocation, Serializable {
    private String methodName;
    private Class<?>[] parameterTypes;
    private Object[] arguments;
    ...
}
複製程式碼

可以說是非常經典的設計,Client 必須告知 Server 要呼叫的 方法名稱 引數型別 引數。Server 獲取到這3個引數後,通過

方法名稱 com.alibaba.service.auth.UserService.verifyUser

引數型別 (String, String)

獲取到 Invoker,然後通過 Invoker 實際呼叫 userServiceImpl 的 verifyUser(String, String) 方法。其他的眾多 RPC 框架也都採取了這一經典設計。

但是,這是正確的做法嗎?當然不是,這種做法非常浪費空間,每次請求訊息體的大概記憶體佈局應該是下面的樣子。 public boolean verifyUser(String email, String pwd) 大致的記憶體佈局:

|com.alibaba.service.auth.UserService.verifyUser|java.lang.String,java.lang.String|實際的引數|

囉裡囉嗦的,浪費了 80 byte 來定義 方法 和 引數,並沒有比 http+json 的方式高效多少。實際的 效能測試 也證明了這一點,undertow+jackson 要比 dubbo motan 的成績都要好。

那什麼才是正確的做法?Turbo 在訊息格式上做出了非常大的改變。

public classRequestimplementsSerializable{
    private int requestId;
    private int serviceId;
    private MethodParam methodParam;
    ...
}
複製程式碼

public boolean verifyUser(String email, String pwd) 大致的記憶體佈局:

|int|int|實際的引數|

高效多了,只用了 4 byte 就做到了 方法 和 引數 的定義。大大減小了 傳輸資料 的 size,同時 int 型別的 serviceId 也降低了 Invoker 的查詢開銷。

看到這裡,有同學可能會問:那豈不是要為每個方法定義一個唯一 id ? 答案是不需要的,Turbo 解決了這一問題,詳情參考 TurboConnectService

推薦一個交流學習群:575745314 裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多:


MethodParam 簡介

MethodParam 才是 Turbo 效能炸裂的真正原因。其基本原理是利用 ClassGeneration 對每個 Method 都生成一個MethodParam 類,用於對方法引數的封裝。這樣做的好處有:

  1. 減少基本資料型別的 裝箱 拆箱 開銷
  2. 序列化時可以省略掉很多型別描述,大大減小 傳輸訊息 的 size
  3. 使 Invoker 可以高效呼叫 被代理類 的方法
  4. 統一 RPC 和 REST 的資料模型,簡化 序列化 反序列化 實現
  5. 大大加快 json 格式資料 反序列化 速度
//方法 test(long id, int value) 將會生成下面的 MethodParam 類:	 
public class TestService_test_2_MethodParam implements MethodParam {
    private long id;
    private int value;
	 
    public long $param0() { return this.id; }
    public int $param1() { return this.value; }

    //... getters and setters
	 
    publicTestService_test_2_MethodParam(long id, int value){
        this.id = id;
        this.value= value;
    }
}
複製程式碼
複製程式碼

序列化的進一步優化

大部分 RPC 框架的 序列化 反序列化 過程都需要一箇中間的 bytes
  • 序列化過程:User > bytes > ByteBuf
  • 反序列化過程:ByteBuf > bytes > User

Turbo 砍掉了中間的 bytes,直接操作 ByteBuf,實現了 序列化 反序列化 的 zero-copy,大大減少了 記憶體分配 記憶體複製 的開銷。具體實現請參考 ProtostuffSerializerCodec

對於已知型別和已知欄位,Turbo 都儘量採用 手工序列化 手工反序列化 的方式來處理,以進一步減少效能開銷。

ObjectPool

常見的幾個 ObjectPool 實現效能都很差,反而很容易成為效能瓶頸。Stormpot 效能強悍,不過存在偶爾死鎖的問題,而且作者也停止維護了。HikariCP 效能不錯,不過其本身是一款資料庫連線池,用作 ObjectPool 並不稱手。我的建議是儘量避免使用 ObjectPool,轉而使用替代技術。更重要的是 Netty 的 Channel 是執行緒安全的,並不需要使用 ObjectPool 來管理。只需要一個簡單的容器來儲存 Channel,用的時候使用 負載均衡策略 選出一個 Channel 出來就行了。

framework thrpt (ops/us)

ThreadLocal685.418
Stormpot272.934
HikariCP139.126
SegmentLock19.415
Vibur4.668
CommonsPool21.107
CommonsPool0.276

基礎類庫優化

除了上述的關鍵流程優化,Turbo 還做了大量基礎類庫的優化

  • AtomicMuiltInteger 多個 int 的原子性操作
  • ConcurrentArrayList 無鎖併發 List 實現,比 CopyOnWriteArrayList 的寫入開銷低,O(1) vs O(n)
  • ConcurrentIntToObjectArrayMap 以 int 陣列為底層實現的無鎖併發 Map,讀多寫少情況下接近直接訪問欄位的效能,讀多寫多情況下是 ConcurrentHashMap 效能的 5x
  • ConcurrentIntegerSequencer 快速序號生成器,併發環境下是 AtomicInteger 效能的10x
  • ObjectId 全域性唯一 id 生成器,是 Java 自帶 UUID 效能的 200x
  • HexUtils 查表 + 批量操作,是 Netty 和 Guava 實現的 2x~5x
  • URLEncodeUtils 基於 HexUtils 實現,是 Java 和 Commons 實現的 2x,Guava 實現的 1.1x (Guava 只有 urlEncode 實現,無 urlDecode 實現)
  • ByteBufUtils 實現了高效的 ZigZag 寫入操作,最高可達通常實現的 4x

上面的內容僅介紹了作者認為重要的東西,更多內容請直接檢視 Turbo 原始碼

不足之處

  • 有很多優化是毫無價值的,Donald Knuth 大神說得很對
  • 強制必須使用 CompletableFuture 作為返回值導致了一些效能開銷
  • 濫用 ClassGeneration,而且並沒有考慮類的解除安裝,這方面需要改進
  • 實現了 UnsafeStringUtils,這是個危險的黑魔法實現,需要重新思考下
  • 對效能的追求有點走火入魔,導致了很多地方的設計過於複雜


相關文章