如何編寫高性能的 RPC 框架

RPC Benchmark Round 1 中余爆,Turbo 性能炸裂表現(xiàn)強悍戴差,并且在 listUser 這一項目中庐杨,取得了 10x dubbo 性能的好成績衅檀。本文將介紹 Turbo 強悍性能背后的原理招刨,并探討如何編寫高性能的 RPC 框架。

過早的優(yōu)化是萬惡之源它浅?

這句話是 The Art of Computer Programming 作者脆栋,圖領獎得主 Donald Knuth 大神說的双肤。不過對于框架設計者而言,這句話并不正確谎倔。在設計一款高性能的基礎框架時,必須始終重視性能優(yōu)化淘衙,并將性能測試貫穿于整個設計開發(fā)過程中传藏。這方面做到極致的類庫有 Disruptor JCTools Agrona DSL-JSON 等等,這幾個高性能類庫都堅持一個原則:不了解性能的外部類庫堅決不用彤守,如果現(xiàn)有的類庫不能滿足性能要求毯侦,那就重新設計一個。作為 Turbo 的設計者具垫,我也盡量堅持這一原則侈离,努力做到 Benchmark 驅動開發(fā)。

JMH 讓 Benchmark 驅動開發(fā)成為可能

在 JMH 出現(xiàn)之前筝蚕,要對某個類庫進行微基準性能測試是一件非常困難的事情卦碾。很難保證公平的測試條件铺坞,預熱次數(shù)難以確定,預熱效果也不好觀察洲胖。JMH 的出現(xiàn)讓性能測試變得 標準化 簡單化济榨,也讓 Benchmark 驅動開發(fā)成為可能。Turbo 在開發(fā)過程中用 JMH 進行了充分的 Benchmark绿映,以確定核心環(huán)節(jié)的性能開銷擒滑,選擇合適的實現(xiàn)方案。更多關于 JMH 的介紹請參考下面的鏈接:

RPC 的主要流程

  1. 客戶端 獲取到 UserService 接口的 Refer: userServiceRefer
  2. 客戶端 調用 userServiceRefer.verifyUser(email, pwd)
  3. 客戶端 獲取到 請求方法 和 請求數(shù)據(jù)
  4. 客戶端 把 請求方法 和 請求數(shù)據(jù) 序列化為 傳輸數(shù)據(jù)
  5. 進行網絡傳輸
  6. 服務端 獲取到 傳輸數(shù)據(jù)
  7. 服務端 反序列化獲取到 請求方法 和 請求數(shù)據(jù)
  8. 服務端 獲取到 UserService 的 Invoker: userServiceInvoker
  9. 服務端 userServiceInvoker 調用 userServiceImpl.verifyUser(email, pwd) 獲取到 響應結果
  10. 服務端 把 響應結果 序列化為 傳輸數(shù)據(jù)
  11. 進行網絡傳輸
  12. 客戶端 接收到 傳輸數(shù)據(jù)
  13. 客戶端 反序列化獲取到 響應結果
  14. 客戶端 userServiceRefer.verifyUser(email, pwd) 返回 響應結果

整個流程中對性能影響比較大的環(huán)節(jié)有:序列化[4, 7, 10, 13]叉弦,方法調用[2, 3, 8, 9, 14]丐一,網絡傳輸[5, 6, 11, 12]。本文后續(xù)內容將著重介紹這3個部分淹冰。

序列化方案

Java 世界最常用的幾款高性能序列化方案有 Kryo Protostuff FST Jackson Fastjson库车。只需要進行一次 Benchmark,然后從這5種序列化方案中選出性能最高的那個就行了樱拴。DSL-JSON 使用起來過于繁瑣柠衍,不在考慮之列。Colfer Protocol Thrift 因為必須預先定義描述文件晶乔,使用起來太麻煩拧略,所以不在考慮之列。至于 Java 自帶的序列化方案瘪弓,早就因為性能問題被大家所拋棄垫蛆,所以也不考慮。下面的表格列出了在考慮之列的5種序列化方案的性能腺怯。

  1. User 序列化+反序列化 性能
framework thrpt (ops/ms) size
protostuff 1654 240
kryo 1288 296
fst 1101 263
jackson 959 385
fastjson 603 378
  1. 包含15個 UserPage 序列化+反序列化 性能
framework thrpt (ops/ms) size
kryo 143 2080
fst 118 3495
protostuff 98 3920
jackson 71 5711
fastjson 40 5606

從這個 benchmark 中可以得出明確的結論:二進制協(xié)議的 protostuff kryo fst 要比文本協(xié)議的 jackson fastjson 有明顯優(yōu)勢袱饭;文本協(xié)議中,jackson(開啟了afterburner) 要比 fastjson 有明顯的優(yōu)勢呛占。

無法確定的是:3個二進制協(xié)議到底哪個更好一些虑乖,畢竟 速度 和 size 對于 RPC 都很重要。直觀上 kryo 或許是最佳選擇晾虑,而且 kryo 也廣受各大型系統(tǒng)的青睞疹味。不過最終還是決定把這3個類庫都留作備選,通過集成傳輸模塊后的 Benchmark 來決定選用哪個帜篇。

framework exist op/ms create op/ms get op/ms list op/ms
proto 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 的實現(xiàn),并允許用戶自行替換為自己的實現(xiàn)笙隙。

方法調用

可用的 動態(tài)方法調用 方案有:Reflection ClassGeneration MethodHandle洪灯。Reflection 是最古老的技術,據(jù)說性能不佳竟痰。ClassGeneration 動態(tài)類生成签钩,從原理上說應該是跟直接調用一樣的性能掏呼。MethodHandle 是從 Java 7 開始出現(xiàn)的技術,據(jù)說能達到跟直接調用一樣的性能铅檩。實際結果如下:

type thrpt (ops/us)
direct 1062
javassist 920
methodHandle 430
reflection 337

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

MethodHandle 表現(xiàn)并沒有宣傳的那么好昧旨,怎么回事岭接?原來 MethodHandle 只有在明確知道調用 參數(shù)數(shù)量 參數(shù)類型 的情況下才能調用高性能的 invokeExact(Object... args),所以它并不適合作為動態(tài)調用的方案臼予。

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: https://docs.oracle.com/javase/7/docs/api/java/lang/invoke/MethodHandle.html

網絡傳輸

Netty 已經成為事實上的標準,所有主流的項目現(xiàn)在使用的都是 Netty啃沪。Mina Grizzly 已經失去市場粘拾,所以也就不用考慮了。還好也不至于這么無聊创千,Aeron 的閃亮登場讓 Netty 多了一個有力的競爭對手缰雇。Aeron 是一個可靠高效的 UDP 單播 UDP 多播和 IPC 消息傳遞工具。性能是消息傳遞中的關鍵追驴。Aeron 的設計旨在達到 高吞吐量 低開銷 和 低延遲械哟。實際效果到底如何呢?很遺憾殿雪,在 RPC Benchmark Round 1 中的表現(xiàn)一般暇咆。跟他們開發(fā)團隊溝通后,最終確認其無法對超過 64k 的消息進行 zero-copy 處理丙曙,我覺得這可能是 Aeron 表現(xiàn)不佳的一個原因爸业。Aeron 或許更適合 微小消息 極端低延遲 的場景,而不適用于更加通用的 RPC 場景亏镰。所以暫時還沒有出現(xiàn)能夠跟 Netty 一爭高下的通用網絡傳輸框架扯旷,現(xiàn)階段 Netty 依然是 RPC 系統(tǒng)的最佳選擇。

  • existUser 判斷某個 email 是否存在
framework thrpt (ops/ms) avgt (ms) p90 (ms) p99 (ms)
turbo-rpc 107.05 0.28 0.40 0.87
netty 99.81 0.32 0.40 0.52
jupiter 73.07 0.44 0.66 1.49
undertow 70.38 0.45 1.16 2.17
turbo-rest 68.49 0.44 1.17 2.15
undertow-async 62.65 0.49 1.14 2.41
dubbo-kryo 57.35 0.53 0.67 1.02
rapidoid 52.96 0.61 1.32 2.51
dubbo 52.12 0.54 0.67 0.92
motan 44.96 0.71 1.15 2.47
aeron 43.46 0.90 1.32 5.10
grpc 38.97 0.84 1.07 1.31
thrift 27.25 1.59 0.16 64.87
hprose 26.24 1.26 1.53 2.01
springwebflux 22.39 1.42 2.27 3.19
springboot 12.54 1.68 2.38 13.63

消息格式

我們先來看一下 Dubbo 的消息格式

public class RpcInvocation implements Invocation, Serializable {
    private String methodName;
    private Class<?>[] parameterTypes;
    private Object[] arguments;
    ...
}

可以說是非常經典的設計索抓,Client 必須告知 Server 要調用的 方法名稱 參數(shù)類型 參數(shù)钧忽。Server 獲取到這3個參數(shù)后,通過 方法名稱 com.alibaba.service.auth.UserService.verifyUser
參數(shù)類型 (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|實際的參數(shù)|

啰里啰嗦的奈揍,浪費了 80 byte 來定義 方法 和 參數(shù)曲尸,并沒有比 http+json 的方式高效多少。實際的 性能測試 也證明了這一點男翰,undertow+jackson 要比 dubbo motan 的成績都要好另患。

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

public class Request implements Serializable {
    private int requestId;
    private int serviceId;
    private MethodParam methodParam;
    ...
}

大致的內存布局:

public boolean verifyUser(String email, String pwd);
|int|int|實際的參數(shù)|

高效多了昆箕,只用了 4 byte 就做到了 方法 和 參數(shù) 的定義。大大減小了 傳輸數(shù)據(jù) 的 size租冠,同時 int 類型的 serviceId 也降低了 Invoker 的查找開銷鹏倘。

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

MethodParam 簡介

MethodParam 才是 Turbo 性能炸裂的真正原因捏题。其基本原理是利用 ClassGeneration 對每個 Method 都生成一個 MethodParam 類,用于對方法參數(shù)的封裝肉渴。這樣做的好處有:

  1. 減少基本數(shù)據(jù)類型的 裝箱 拆箱 開銷
  2. 序列化時可以省略掉很多類型描述公荧,大大減小 傳輸消息 的 size
  3. 使 Invoker 可以高效調用 被代理類 的方法
  4. 統(tǒng)一 RPC 和 REST 的數(shù)據(jù)模型,簡化 序列化 反序列化 實現(xiàn)
  5. 大大加快 json 格式數(shù)據(jù) 反序列化 速度
//方法 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
     
    public TestService_test_2_MethodParam(long id, int value) {
        this.id = id;
        this.value= value;
    }
}

序列化的進一步優(yōu)化

大部分 RPC 框架的 序列化 反序列化 過程都需要一個中間的 bytes

序列化過程:User > bytes > ByteBuf
反序列化過程:ByteBuf > bytes > User

Turbo 砍掉了中間的 bytes同规,直接操作 ByteBuf循狰,實現(xiàn)了 序列化 反序列化 的 zero-copy,大大減少了 內存分配 內存復制 的開銷券勺。具體實現(xiàn)請參考 ProtostuffSerializerCodec晤揣。

對于已知類型和已知字段,Turbo 都盡量采用 手工序列化 手工反序列化 的方式來處理朱灿,以進一步減少性能開銷昧识。

ObjectPool

常見的幾個 ObjectPool 實現(xiàn)性能都很差,反而很容易成為性能瓶頸盗扒。Stormpot 性能強悍跪楞,不過存在偶爾死鎖的問題,而且作者也停止維護了侣灶。HikariCP 性能不錯甸祭,不過其本身是一款數(shù)據(jù)庫連接池,用作 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

基礎類庫優(yōu)化

除了上述的關鍵流程優(yōu)化寨典,Turbo 還做了大量基礎類庫的優(yōu)化

  • AtomicMuiltInteger 多個 int 的原子性操作
  • ConcurrentArrayList 無鎖并發(fā) List 實現(xiàn)氛雪,比 CopyOnWriteArrayList 的寫入開銷低,O(1) vs O(n)
  • ConcurrentIntToObjectArrayMap 以 int 數(shù)組為底層實現(xiàn)的無鎖并發(fā) Map耸成,讀多寫少情況下接近直接訪問字段的性能报亩,讀多寫多情況下是 ConcurrentHashMap 性能的 5x
  • ConcurrentIntegerSequencer 快速序號生成器,并發(fā)環(huán)境下是 AtomicInteger 性能的10x
  • ObjectId 全局唯一 id 生成器井氢,是 Java 自帶 UUID 性能的 200x
  • HexUtils 查表 + 批量操作弦追,是 Netty 和 Guava 實現(xiàn)的 2x~5x
  • URLEncodeUtils 基于 HexUtils 實現(xiàn),是 Java 和 Commons 實現(xiàn)的 2x花竞,Guava 實現(xiàn)的 1.1x (Guava 只有 urlEncode 實現(xiàn)劲件,無 urlDecode 實現(xiàn))
  • ByteBufUtils 實現(xiàn)了高效的 ZigZag 寫入操作,最高可達通常實現(xiàn)的 4x

上面的內容僅介紹了作者認為重要的東西左胞,更多內容請直接查看 Turbo 源碼

不足之處

  • 有很多優(yōu)化是毫無價值的,Donald Knuth 大神說得很對
  • 強制必須使用 CompletableFuture 作為返回值導致了一些性能開銷
  • 濫用 ClassGeneration举户,而且并沒有考慮類的卸載烤宙,這方面需要改進
  • 實現(xiàn)了 UnsafeStringUtils,這是個危險的黑魔法實現(xiàn)俭嘁,需要重新思考下
  • 對性能的追求有點走火入魔躺枕,導致了很多地方的設計過于復雜
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市供填,隨后出現(xiàn)的幾起案子拐云,更是在濱河造成了極大的恐慌,老刑警劉巖近她,帶你破解...
    沈念sama閱讀 211,561評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件叉瘩,死亡現(xiàn)場離奇詭異,居然都是意外死亡粘捎,警方通過查閱死者的電腦和手機薇缅,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,218評論 3 385
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來攒磨,“玉大人泳桦,你說我怎么就攤上這事∶溏郑” “怎么了灸撰?”我有些...
    開封第一講書人閱讀 157,162評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我浮毯,道長完疫,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,470評論 1 283
  • 正文 為了忘掉前任亲轨,我火速辦了婚禮趋惨,結果婚禮上,老公的妹妹穿的比我還像新娘惦蚊。我一直安慰自己器虾,他們只是感情好,可當我...
    茶點故事閱讀 65,550評論 6 385
  • 文/花漫 我一把揭開白布蹦锋。 她就那樣靜靜地躺著兆沙,像睡著了一般。 火紅的嫁衣襯著肌膚如雪莉掂。 梳的紋絲不亂的頭發(fā)上葛圃,一...
    開封第一講書人閱讀 49,806評論 1 290
  • 那天,我揣著相機與錄音憎妙,去河邊找鬼库正。 笑死,一個胖子當著我的面吹牛厘唾,可吹牛的內容都是我干的褥符。 我是一名探鬼主播,決...
    沈念sama閱讀 38,951評論 3 407
  • 文/蒼蘭香墨 我猛地睜開眼抚垃,長吁一口氣:“原來是場噩夢啊……” “哼喷楣!你這毒婦竟也來了?” 一聲冷哼從身側響起鹤树,我...
    開封第一講書人閱讀 37,712評論 0 266
  • 序言:老撾萬榮一對情侶失蹤铣焊,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后罕伯,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體曲伊,經...
    沈念sama閱讀 44,166評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,510評論 2 327
  • 正文 我和宋清朗相戀三年追他,在試婚紗的時候發(fā)現(xiàn)自己被綠了熊昌。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,643評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡湿酸,死狀恐怖婿屹,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情推溃,我是刑警寧澤昂利,帶...
    沈念sama閱讀 34,306評論 4 330
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響蜂奸,放射性物質發(fā)生泄漏犁苏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,930評論 3 313
  • 文/蒙蒙 一扩所、第九天 我趴在偏房一處隱蔽的房頂上張望围详。 院中可真熱鬧,春花似錦祖屏、人聲如沸助赞。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,745評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽雹食。三九已至,卻和暖如春期丰,著一層夾襖步出監(jiān)牢的瞬間群叶,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,983評論 1 266
  • 我被黑心中介騙來泰國打工钝荡, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留街立,地道東北人。 一個月前我還...
    沈念sama閱讀 46,351評論 2 360
  • 正文 我出身青樓埠通,卻偏偏與公主長得像赎离,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子植阴,可洞房花燭夜當晚...
    茶點故事閱讀 43,509評論 2 348

推薦閱讀更多精彩內容