要怎樣纔可以完美的編寫高性能的RPC框架

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個部分。html

序列化方案

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

User 序列化+反序列化 性能

framework thrpt (ops/ms) sizegit

protostuff 1654 240
kryo 1288 296
fst 1101 263
jackson 959 385
fastjson 603 378

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

framework thrpt (ops/ms) sizegithub

kryo 143 2080
fst 118 3495
protostuff 98 3920
jackson 71 5711
fastjson 40 5606

從這個 benchmark 中能夠得出明確的結論:二進制協議的 protostuff kryo fst 要比文本協議的 jackson fastjson 有明顯優點;文本協議中,jackson(開啓了afterburner) 要比 fastjson 有明顯的優點。web

沒法肯定的是:3個二進制協議到底哪一個更好一些,畢竟 速度 和 size 對於 RPC 都很重要。直觀上 kryo 或許是最佳選擇,並且 kryo 也廣受各大型系統的青睞。不過最終仍是決定把這3個類庫都留做備選,經過集成傳輸模塊後的 Benchmark 來決定選用哪一個。spring

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

protostuff 103.92 89.50 83.33 21.17
kryo 99.23 76.71 73.89 25.68
fst 102.33 76.24 78.81 23.30

最終的結果也仍是各有千秋難以抉擇,因此 Turbo 保留了 protostuff 和 kryo 的實現,並容許用戶自行替換爲本身的實現。數據庫

方法調用

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

type thrpt (ops/us)json

direct 1062
javassist 920
methodHandle 430
reflection 337

結論很是明顯:使用類生成技術的 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-rpc 107.05 0.28 0.40 0.87 4.06
netty
99.81
0.32
0.40
0.52
1.16
jupiter 73.07 0.44 0.66 1.49 2.92
undertow 70.38 0.45 1.16 2.17 32.48
turbo-rest 68.49 0.44 1.17 2.15 25.66
undertow-async 62.65 0.49 1.14 2.41 24.84
dubbo-kryo 57.35 0.53 0.67 1.02 11.65
rapidoid 52.96 0.61 1.32 2.51 25.07
dubbo 52.12 0.54 0.67 0.92 3.93
motan 44.96 0.71 1.15 2.47 33.39
aeron
43.46
0.90
1.32
5.10
14.29
grpc 38.97 0.84 1.07 1.31 6.06
thrift 27.25 1.59 0.16 64.87 122.83
hprose 26.24 1.26 1.53 2.01 8.34
springwebflux 22.39 1.42 2.27 3.19 17.20
springboot 12.54 1.68 2.38 13.63 33.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)

ThreadLocal 685.418
Stormpot 272.934
HikariCP 139.126
SegmentLock 19.415
Vibur 4.668
CommonsPool2 1.107
CommonsPool 0.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,這是個危險的黑魔法實現,須要從新思考下
  • 對性能的追求有點走火入魔,致使了不少地方的設計過於複雜
相關文章
相關標籤/搜索