阿里妹導讀
本文為《事件CPU開銷壓降》揭榜報告,旨在解決風控系統間資訊傳遞時事件體持續膨脹導致的序列化/反序列化CPU消耗過高的問題。
本文為《事件CPU開銷壓降》揭榜報告,同時也可以泛化為通用的SOFA RPC對複雜物件序列化效能最佳化方法:在確保反序列化結果正確性100%的前提下,透過前置自定義序列化,對高頻物件進行業務字典壓縮替換,並解決SOFA RPC預設的Hessian反序列化大byte[ ]效能問題,最終達到壓縮序列化CPU消耗數倍的目的。
先上一張揭榜結果效能對比圖:最終最佳化效果序列化CPU耗時相比預設Hessian降低至的20%左右,實現目標要求的5倍提升。
(並非最佳化效果全部,詳細效能提升報告請見後文)
背景描述
風控各系統間均以事件方式進行資訊傳統,多年以來事件體持續膨脹,以交易事件為例,多個系統間傳輸平均大小為20K~50K,極端情況會出現>1M的情況。
目標要求:在保障風控引擎消費時最終事件資訊不變和 RT 不增加的前提下,將事件的序列化/反序列 CPU 消耗降低80%。
好,讓我們開始動手揭榜吧~
問題分析與最佳化方向調研
1、前置約束
由於本次揭榜問題是源於線上實踐,解法需要最終能落地、能部署上線,所以有幾個預設的約束:
-
約束1:資料正確性需要100%:即反序列化(或解壓縮)之後資料欄位值、欄位型別(含泛型簽名等)、引用關係(含迴圈引用等)應該和原來RPC反序列化一致,如果正確性無法保證,則後續最佳化無意義。
-
約束2:需要基於現有的SOFA協議:路由、限流切面、trace等都需要繼續能用。
-
約束3:對接入方的SOFA版本等不能有高版本要求:如果接入方需要必須升級到特定SOFA版本,則會對接入產生巨大障礙。例如:最新版SOFABoot支援Fury和Protobuf等(SOFARPC序列化配置),但是要求接入方都是最新SOFABoot版本,我們的安全服務對接的上游系統眾多,如果對上游有高版本要求將會成為接入阻礙。
-
約束4:接入、最佳化方式不能太複雜,需要能開箱即用。
2、最佳化方向-要解決的幾個問題:
2.1、確定可以最佳化的騰挪空間
現有RPC序列化的鏈路很簡單,流程示意:

能夠騰挪的空間並不大,大致方向即:
-
減少SOFARPC預設Hessian序列化/反序列化的消耗
-
在此過程中的額外消耗增加要遠小於前述減少的
-
抵消後能形成較大正收益
2.2、預設序列化是否能被替換、或還有最佳化空間?
-
理論上可以替換,但是考慮到RPC相容性,就有障礙了:除了介面引數,SOFA中的方法簽名等等RPC引數也都走的Hessian,路由、限流等依賴這個,還要考慮上下游版本相容性。
-
不替換:也有最佳化空間,可以嘗試換個其他框架序列化好後,拿序列化結果送給RPC讓Hessian再搞一次。
2.3、傳輸物件的內容是否還有壓縮空間?
初步肉眼觀察,感覺properties、extendData、baseInfoData這3個Map大欄位及subEvents子事件欄位較大,有一定的資料重複度。統計量化到兩個比較重要的資料:
-
Map的Key重複度很大:Key的重複度97%
-
Map的Key長度佔比很長:Key的長度比Value(只算String類的Value)還長很多,Key的長度佔比接近60%。
個人覺得這也很好理解:CTU事件對接的上游儘管很多,但是每個上游需要傳遞的引數相對是固定的。Map中還有List和Map的巢狀,以及上游可能會把一些List<DTO>也轉成Map再放到Map中。
一些考古發現:
屬性的Key中,除了常用的英文駝峰命名的Key之外,還有很多是以數字組成的String,例如"118","155"。

目測是隨著歷史發展、接入方越來越多,前面大佬也注意到了Key的大小問題,並對常用的Key進行了字串對映壓縮最佳化,這已經是在EventDTO這個Map的泛型定義的基礎上最好的優化了,在一定程度上遏制了事件屬性變大的程度。
本次最佳化方向類似,但是不改變原物件,只在序列化中作額外動作。
3、最終最佳化方案
在確保反序列化結果正確性100%的前提下:
-
步驟1:在SOFA RPC預設序列化前置自定義序列化。 -
步驟2:在步驟1過程中,對高頻String壓縮替換。 -
步驟3:解決SOFA RPC預設的Hessian反序列化大byte[ ]效能問題。

最佳化詳細步驟
1、把複雜物件變為簡單物件
主要思路:把複雜物件(DTO+Map)變為簡單物件(byte[ ])。轉換完成後,再將byte[ ]透過現有的RPC框架傳送接收。
這一步驟的主要工作,就是選一個高效的序列化框架,然後接入進來,寫個Util類。這裡經過簡單對比,選擇了螞蟻自己的Fury框架,封裝一個簡單的Util類,提供序列化和反序列化方法:
Fury Util
publicclassZipFuryUtil{
staticfinal ThreadLocal<Fury> furyThreadLocal = new ThreadLocal<>();
/**
* 序列化操作
*
* @param obj
* @return
*/
publicstaticbyte[] serializeObjectToByteArray(Object obj) {
byte[] serializedBytes = getFury().serialize(obj);
return serializedBytes;
}
/**
* 反序列化操作
*
* @param bytes
* @return
*/
publicstatic EventDTO deserializeByteArrayToEvent(byte[] bytes){
EventDTO event = (EventDTO) getFury().deserialize(bytes);
return event;
}
static ThreadSafeFury threadSafeFury = null;
publicstatic ThreadSafeFury getFury(){
if (threadSafeFury == null) {
synchronized (ZipFuryPlusUtil.class) {
if (threadSafeFury == null) {
threadSafeFury = new ThreadPoolFury(
classLoader -> {
Fury fury = Fury.builder()
.withRefTracking(true)
.withCompatibleMode(CompatibleMode.SCHEMA_CONSISTENT)
.build();
fury.registerSerializer(JSONArray.class, new ArrayListSerializer(fury));
fury.registerSerializer(JSONObject.class, new HashMapSerializer(fury));
fury.registerSerializer(Map.class, new HashMapSerializer(fury));
fury.registerSerializer(Collection.class, new ArrayListSerializer(fury));
fury.register(BaseDTO.class);
fury.register(EventDTO.class);
fury.register(FastEventDTO.class);
return fury;
},
100,
100,
300,
TimeUnit.SECONDS);
}
}
}
return threadSafeFury;
}
}
技術選型備註:
-
也測試過其他一些框架,例如Kryo框架:CPU比Fury消耗高30%左右,可以深度定製化改造Kryo原始碼,效能向Fury靠攏,但是這種自定義以後將難以維護(可能不相容Kryo新版)。
-
是不是可以自己再實現一個:實現一個新的序列化框架並不是本揭榜的核心,不如直接站在巨人肩膀上。
另外,有個前提假設:SOFA RPC對簡單型別傳輸更加高效(備註:這個假設的方向沒錯,但是後來測試發現Hessian反序列化時有個坑,導致差點翻車,詳見第3步最佳化)。
這步驟完成後,效果就有了:直接降低70%(不過別高興的太早,有坑)

2、物件內容進行業務壓縮
主要方法:生成一份全域性資料字典。在序列化過程中“注入”一個業務邏輯:遇到字典中的物件,替換為字典的Short索引。
說明:
-
字典資料來源:目前只針對測試20條資料簡單統計,真正上線需要人工校對一下並確定字典順序。
-
正確性100%:如果線上流量的key不在字典中,也可按原樣正常序列化,無正確性問題。
-
使用Short索引,較節省空間:僅用正數位支援32768個字典物件(前面問題分析統計不重複Key為591個,加上部分可列舉的Value值不超過2000個)。
-
資料壓縮替換除了對Key適用,也對Value適用:觀察Value中無Short,不會衝突,如有,可取消對Value的壓縮,影響不大。
-
此項技術應用範圍:其實不止適用於特定Fury框架,其他的如Kryo、Hessian,甚至FastJson等,此思路和方法都是適用的。
關鍵技術:不改變原Map物件,不生成新Map物件,只在byte[ ]結果生成之前“偷偷”修改。能夠克服下面兩種替換方式的缺點。
-
如果修改原Map物件,缺點:① 序列化結束後還得修改回去,否則會引發嚴重資料正確性問題。② 對Map的修改,會產生多次map的put操作甚至resize操作,額外增加很多耗時。③ 修改原物件本身就是額外增加對所有Key遍歷一遍,而且還需要處理好迴圈引用問題。
-
生成新的Map物件來儲存一個臨時的壓縮後結果,缺點基本同上,也需要進行多次put操作增加不必要耗時。
詳細步驟如下:
2.1、生成Key字典:
複用簽名Key統計的程式碼,得到所有字典Key:
獲取字典Key的程式碼
package com.alipay.securityservice.decision.util;
import com.alibaba.fastjson.JSON;
import com.alipay.ctu.service.event.model.EventDTO;
import org.junit.Test;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
publicclassZipUtilTools {
int totalCount = 0;
int countKey = 0;
int countValue = 0;
long lengthKey = 0;
long lengthContent = 0;
publicstatic boolean isStringAscii(String str) {
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) > 127) {
returnfalse;
}
}
returntrue;
}
privatevoidaddKeySet(Set<String> keySet, Map map, int depth, String path) {
if (depth > 3) {
return;
}
for (Object key : map.keySet()) {
if (key instanceof String) {
countKey++;
String keyStr = (String) key;
if (!(keyStr.length() == 16 && keyStr.startsWith("2088"))) {
if (isStringAscii(keyStr)) {
keySet.add(keyStr);
}
}
totalCount++;
lengthKey += keyStr.length();
}
Object value = map.get(key);
// 如果Value也進入壓縮字典
//if (value instanceof String) {
// String str = (String) value;
// if (str.length() <= 32 && (!str.startsWith("2088"))) {
// if (isStringAscii(str)) {
// keySet.add(str);
// }
// }
// totalCount++;
// lengthKey += str.length();
//}
if (value instanceof String) {
countValue++;
lengthContent += ((String) value).length();
}
if (value instanceof List) {
List list = (List) value;
for (Object o : list) {
if (o instanceof Map) {
addKeySet(keySet, (Map) o, depth + 1, path + "." + key);
}
}
}
if (value instanceof Map) {
addKeySet(keySet, (Map) map.get(key), depth + 1, path + "." + key);
}
}
}
@Test
publicvoidfindMapKeys() {
Set<String> keySet = new HashSet<>();
for (String eventId : EventCatagory.eventCatagory.keySet()) {
EventDTO event = EventCatagory.eventCatagory.get(eventId);
addKeySet(keySet, event.getExtendData(), 0, "");
addKeySet(keySet, event.getBaseInfoData(), 0, "");
addKeySet(keySet, event.getEventProperties(), 0, "");
for (EventDTO subEvent : event.getSubEvents()) {
addKeySet(keySet, subEvent.getExtendData(), 0, "");
addKeySet(keySet, subEvent.getBaseInfoData(), 0, "");
addKeySet(keySet, subEvent.getEventProperties(), 0, "");
}
}
System.out.println("不重複Key數量: " + keySet.size());
System.out.println("所有Key數量: " + countKey);
System.out.println("所有Key String長度: " + lengthKey);
System.out.println("所有Value String長度: " + lengthContent);
System.out.println(JSON.toJSONString(keySet));
}
}
根據結果,搞一個簡單的固定的字典:
EventDict 字典類完整程式碼
/*
* Ant Group
* Copyright (c) 2004-2024 All Rights Reserved.
*/
package com.alipay.securityservice.decision.util;
import java.util.HashMap;
import java.util.Map;
publicclassEventDict{
publicstaticfinal Map<String, Short> DICT_STRING;
privatestaticfinalint MAX_KEY_SIZE = 32;
static {
DICT_STRING = new HashMap<>(eventDictStr.length * 2);
for (short i = 0; i < eventDictStr.length; i++) {
DICT_STRING.put(eventDictStr[i], i);
}
}
/**
* 壓縮替換
*
* @param oldKey
* @return
*/
publicstatic Object zip(Object oldKey){
if (oldKey instanceof String) {
String str = (String) oldKey;
if (str.length() >= MAX_KEY_SIZE) {
return oldKey;
}
Short index = DICT_STRING.get(str);
if (index != null) {
return index;
}
}
return oldKey;
}
/**
* 解壓縮替換
*
* @param key
* @return
*/
publicstatic Object unZip(Object key){
if (key instanceof Short) {
int index = (Short) key;
if (index < 0 || index >= eventDictStr.length) {
return key;
}
return eventDictStr[index];
}
return key;
}
/**
* 順序需要固定,後續有新的追加。生產環境可以搞成動態快取名單
*/
staticfinal String[] eventDictStr = new String[] {"事件屬性Key1","事件屬性Key2" ,……};
}
2.2、序列化過程中進行字典替換
只在序列化過程中,在寫入最終二進位制流之前,進行字典查詢與替換,不對原物件有任何修改。
這個操作過程中需要對map進行get操作,有一點點額外開銷,對比收益來講,還是很值得的(取決於字典的覆蓋度,線上業務Key應該能覆蓋統計90%以上,所以很好)。
一些小最佳化:map進行get操作查字典之前,先判斷字串長度(目前設定為32,超過則不進行字典判斷:肯定不在,節省額外計算hashCode的消耗 )。
壓縮替換示例:
原始資料Map:
{"serviceMethodName":"invoke","other":"很長很長的字串"}
資料字典
1:serviceMethodName2: invoke3: other
壓縮Map序列化二進位制結構示意:
[Map物件,泛型<String,String>][i,1][i,2][i,3][S,很長很長的字串]
(泛型描述不變)
對比預設Map序列化二進位制結構示意:
[Map物件,泛型<String,String>][S,serviceMethodName][S,invoke][S,other][S,很長很長的字串]
放個簡單的測試感受一下:

主要程式碼:
序列化呼叫入口
private void generalJavaWrite(Fury fury, MemoryBuffer buffer, Map map) {
ClassResolver classResolver = fury.getClassResolver();
RefResolver refResolver = fury.getRefResolver();
Set<Entry> entrySet = map.entrySet();
for (Map.Entry entry : entrySet) {
Object key = EventDict.zip(entry.getKey());
Object value = EventDict.zip(entry.getValue());
writeKeyJavaRefOptimized(
fury, classResolver, refResolver, buffer, key, keyClassInfoWriteCache);
writeJavaRefOptimized(
fury, classResolver, refResolver, buffer, value, valueClassInfoWriteCache);
}
}
EventDict.zip 查詢字典
publicstaticObject zip(Object oldKey) {
if (oldKey instanceofString) {
String str = (String) oldKey;
if (str.length() >= MAX_KEY_SIZE) {
return oldKey;
}
Short index = DICT_STRING.get(str);
if (index != null) {
return index;
}
}
return oldKey;
}
2.3、反序列化過程,還原
說明:
自定義map反序列化器,遇到Short,即查詢壓縮字典,將查詢結果放入真實Map,無二次轉換。
小技巧:查詢壓縮字典的時候,根據Short可以作為ArrayList的index查詢,相對Map.get更高效,幾乎無效能額外開銷。
這就是2.2的反過程,比較簡單,請直接看程式碼即可:
呼叫字典入口
privatevoidgeneralJavaRead(Fury fury, MemoryBuffer buffer, Map map, int size) {
for (int i = 0; i < size; i++) {
Object key = fury.readRef(buffer, keyClassInfoReadCache);
Object value = fury.readRef(buffer, valueClassInfoReadCache);
key = EventDict.unZip(key);
value = EventDict.unZip(value);
map.put(key, value);
}
}
EventDict.unZip 字典還原
public static Object unZip(Object key) {
if (key instanceof Short) {
int index = (Short) key;
if (index < 0 || index >= eventDictStr.length) {
return key;
}
return eventDictStr[index];
}
return key;
}
此階段完成後,效果更加明顯了:效果較上一步驟再降低30%

3、SOFA RPC:Hessian最佳化
完成上面兩個步驟之後,一切看起來還算順利,效果也很明顯。
但是,總感覺少了點什麼?是的,這沒有在RPC環境下實際測試啊。
興致沖沖的部署到聯調環境,發現時間消耗有一點縮減,但並沒有按預期的這個最佳化比例縮減,本著實事求是的態度,得研究研究這個耗時主要來源:
-
RPC Hessian包裹傳送與接收byte[ ]真的如預期一樣是零消耗嗎?
-
RPC中還有大量SOFA中介軟體的切面/工具,如awatch、guardian等等,這個基本是跟RPC呼叫相關固定的,暫時不在我們本最佳化範圍內。
對於第一個問題,自己搭建一個測試對比環境,透過火焰圖發現:Hessian2的反序列化耗時佔比有40%多,相當於原有最佳化耗時消耗要翻倍了,當看到這個結果時,第一感覺:天啦嚕,前面的嘗試都白費了。

這意味著什麼?意味著前面兩個步驟的最佳化耗時,實際需要翻一倍。

怎麼辦?(各種心理活動暫且不表)透過研究火焰圖及Hessian原始碼,發現:Hessian中對大byte[]讀取效能有問題,會對流多次緩衝-讀取-中斷,並會產生額外的Stream物件作為中轉。
解決方法:讀Hessian原始碼、諮詢RPC同學、除錯Hessian原始碼瞭解機制等等,找到可能的突破點,改造它!
3.1、改造Hessian反序列化
關鍵技術提升點:自定義Hessian反序列化,只讀取一次緩衝直接生成byte[]。
具體方法:改動Hessian2Input的readObject( )方法,修改其對byte塊('b分塊'和'B終塊'型別)的處理,處理好記憶體buffer和緩衝流指標。
直接上程式碼吧:
修改後的1821-1914行 readObject方法
case'b':
case'B': {
_isLastChunk = tag == 'B';
_chunkLength = (read() << 8) + read();
ByteArrayOutputStream bos=null;
while (!_isLastChunk){
if (bos == null) {
bos = new ByteArrayOutputStream();
}
byte[] temp = newbyte[_chunkLength];
int i = 0;
//處理完記憶體裡的buffer
while (_offset < _length && i < _chunkLength) {
temp[i] = _buffer[_offset++];
i++;
}
int needRead = _chunkLength - i;
if (needRead > 0) {
_is.read(temp, i, needRead);
}
bos.write(temp);
//讀下一個塊
int code = read();
switch (code){
case'b':
_isLastChunk = false;
_chunkLength = (read() << 8) + read();
break;
case'B':
_isLastChunk = true;
_chunkLength = (read() << 8) + read();
break;
case0x20: case0x21: case0x22: case0x23:
case0x24: case0x25: case0x26: case0x27:
case0x28: case0x29: case0x2a: case0x2b:
case0x2c: case0x2d: case0x2e: case0x2f:
_isLastChunk = true;
_chunkLength = code - 0x20;
break;
default:
throw expect("byte[]", code);
}
}
byte[] res = newbyte[_chunkLength];
int i = 0;
//處理完記憶體裡的buffer
while (_offset < _length && i < _chunkLength) {
res[i] = _buffer[_offset++];
i++;
}
int needRead = _chunkLength - i;
if (needRead > 0) {
_is.read(res, i, needRead);
}
if (bos!=null){
bos.write(res);
res=bos.toByteArray();
}
for (i = 0; i < res.length; i++) {
res[i] = (byte) (res[i] & 0xff);
}
_chunkLength = 0;
return res;
// 最佳化前原始程式碼
//_isLastChunk = tag == 'B';
//_chunkLength = (read() << 8) + read();
//
//int data;
//ByteArrayOutputStream bos = new ByteArrayOutputStream();
//
//while ((data = parseByte()) >= 0)
// bos.write(data);
//
//return bos.toByteArray();
}
修改前原始程式碼
case'b':
case 'B': {
_isLastChunk = tag == 'B';
_chunkLength = (read() << 8) + read();
int data;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while ((data = parseByte()) >= 0)
bos.write(data);
return bos.toByteArray();
}
此步驟效果:改造後,Hessian耗時佔比幾乎清零。整個流程中耗時壓減50%。
3.2、把自定義的改造注入到RPC中去
服務方在啟動時,可以在afterPropertiesSet( ),註冊自定義Hessian序列化器,確保高效。
註冊自定義反序列化類
CustomHessianSerializerManager.addSerializer(SofaRequest.class, new FastSofaRequestHessianSerializer(serializerFactory, genericSerializerFactory));
serializerFactory, genericSerializerFactory這兩個引數可透過反射獲取,簡單實現程式碼參考:
註冊自定義反序列化類
@Override
publicvoidafterPropertiesSet() throws Exception {
SofaRequestHessianSerializer serializer = (SofaRequestHessianSerializer) CustomHessianSerializerManager.getSerializer(
SofaRequest.class);
Class<?> clazz = serializer.getClass();
Field[] fields = clazz.getDeclaredFields();
SerializerFactory serializerFactory = null;
SerializerFactory genericSerializerFactory = null;
for (Field field : fields) {
if (!field.isAccessible()) {
field.setAccessible(true);
}
if ("serializerFactory".equals(field.getName())) {
try {
serializerFactory = (SerializerFactory) field.get(serializer);
} catch (IllegalAccessException e) {
thrownew RuntimeException(e);
}
}
if ("genericSerializerFactory".equals(field.getName())) {
try {
genericSerializerFactory = (SerializerFactory) field.get(serializer);
} catch (IllegalAccessException e) {
thrownew RuntimeException(e);
}
}
}
CustomHessianSerializerManager.addSerializer(SofaRequest.class,
new FastSofaRequestHessianSerializer(serializerFactory, genericSerializerFactory));
}
注:這個對Hessian的效能最佳化後續計劃會提交給SOFARPC的程式碼庫,如果採納合併的話,後續新版本就自動是byte[ ]讀取高效能版本了,此步驟就可以省略了。
效能最佳化報告
1、測試說明
用比較簡單的測試方法:固定次數迴圈呼叫對比,單執行緒CPU跑滿,所以可以直接以耗時對比(實際跑多次,多次之間肉眼取中位數成績)。
幾個方法說明:
測試名稱
|
最佳化專案
|
說明
|
test01_Hessian
|
原始Hessian
|
用作基準對比
|
test11_Kryo
|
Kryo序列化
|
無RPC序列化包裹,僅測試Kryo框架
|
test12_Kryo_Hessian
|
Kryo序列化 + 原始Hessian
|
|
test21_Fury
|
Fury序列化
|
無RPC序列化包裹,僅測試Fury框架
|
test22_Fury_Hessian
|
Fury序列化 + 原始Hessian
|
|
test31_FuryPlus
|
Fury序列化 + 字典壓縮
|
無RPC序列化包裹,測試Fury和字典
|
test32_FuryPlus_Hessian
|
Fury序列化 + 字典壓縮 +原始Hessian
|
|
test33_FuryPlus_FastHessian
|
Fury序列化 + 字典壓縮 +最佳化Hessian
|
最終版本
|
測試環境:
CPU: Apple M1 ProJDK: 1.8
備註:這種透過單測for迴圈的方式儘管每次都儘可能預熱,雖並不絕對嚴謹,不過也基本反映出對比趨勢。有沒有更好的測試方式?當然有,先留個伏筆。
2、單次呼叫場景:5倍效能提升
序列化+反序列化:20×1000次序列化+20×1000次反序列化測試:最終耗時壓縮至23%
反序列化效果更明細:20×1次序列化+20×1000次反序列化測試:最終耗時壓縮至17%
【20組】資料,每組序列化【1000】次,反序列化【1000】次
---------------------------------------------------------------------------
測試方法 , 資料位元組長度, 耗時ms, 耗時較預設百分比
test01_Hessian , 314693, 4114, 100.00%
test11_Kryo , 256739, 1841, 44.75%
test12_Kryo_Hessian , 256802, 2621, 63.71%
test21_Fury , 337680, 1304, 31.70%
test22_Fury_Hessian , 337743, 2162, 52.55%
test31_FuryPlus , 234287, 929, 22.58%
test32_FuryPlus_Hessian , 234347, 1638, 39.82%
test33_FuryPlus_FastHessian , 234347, 973, 23.65%

---------------------------------------------------------------------------
【20組】資料,每組序列化【1】次,反序列化【2000】次
---------------------------------------------------------------------------
測試方法 , 資料位元組長度, 耗時ms, 耗時較預設百分比
test01_Hessian , 314693, 4830, 100.00%
test11_Kryo , 256739, 1587, 32.86%
test12_Kryo_Hessian , 256802, 3132, 64.84%
test21_Fury , 337680, 1208, 25.01%
test22_Fury_Hessian , 337743, 3125, 64.70%
test31_FuryPlus , 234287, 766, 15.86%
test32_FuryPlus_Hessian , 234347, 2133, 44.16%
test33_FuryPlus_FastHessian , 234347, 842, 17.43%

3、多下游場景:額外N倍效能提升
以上測試結果,只是反映單次的序列化和反序列化對比:即假設場景是“1個呼叫端,呼叫一個下游Service”。
如果一個場景是呼叫端需要呼叫多個Service傳遞此DTO:
-
1個呼叫端,呼叫N個下游Service。只序列化1次,節省N-1次。
-
A呼叫B,B再呼叫C。如果B讀取,只有反序列化消耗。B不需要修改資料的情況下,直接將A傳入的資料再傳給C,沒有序列化消耗。
將會獲得更大的效能提升,N個額外下游額外提升N倍:原Hessian方式會觸發多次序列化。使用此方式,只需要序列化一次,得到的中間結果:byte[ ]之後傳送成本接近為0。
同時,此最佳化也適用於非RPC呼叫,例如需要將DTO序列化儲存的情況:存入Lindorm等。
4、資料傳輸大小壓縮25%-80%
資料壓縮只是本次最佳化的副產品,20組測試資料,傳輸大小壓縮至原75%左右。
由於測試資料中MapValue中字串較大,對於日常的普通小DTO(Key能大量被字典覆蓋的情況下),結果可能壓縮至20%甚至更低。
落地部署使用
1、接入步驟
如果想讓客戶端無感接入,可以繼續改HessianInput,全域性代理RPC的所有序列化操作,根據輸入型別做判斷路由是否走自己的序列化(不建議這種方式,影響面較大,這裡不做展開)。
以下為普通接入方式:
1.1、更新Jar包
將上述幾個類(序列化入口、內建字典、自定義Fury字典序列化器、Hessian反序列化byte[]最佳化類)封裝進公共類庫,服務端、呼叫端更新引入。
1.2、服務方釋出新的介面簽名
釋出服務方法引數簽名為:Object或者byte[ ],例如:int question(Object eventDTO);
如果釋出簽名為Object的引數,客戶端呼叫時傳入EventDTO這種DTO例項、或者 byte[]例項都是可以的,服務端按Object接收後,可以根據instanceof等方式判斷具體型別後決定直接使用DTO或對byte[]進行額外反序列化。需要注意一點:客戶端不能傳入純Object例項,會引發RPC報錯。
1.3、服務端註冊更新Hessian反序列化類
請參考“詳細最佳化步驟3.2”部分。
1.4、客戶端呼叫使用
比較簡單的兩種方式:
-
自行呼叫ZipFuryPlus.serializeObjectToByteArray( )方法,將DTO轉為byte[ ],然後呼叫question(Object eventDTO);
-
將序列化過程封裝進公共jar中,對客戶端提供一個question(EventDTOeventDTO)方法;
2、細節問題:如何更新詞典
目前demo為寫死固定。如需對字典的維護,簡單來講就是用jar包直接固定,或者線上應用可以透過DRM等方式推送維護,注意釋出順序:1、對字典只增加、不刪除、不修改;2、先對Server端增加,再對Client增加;
本文就不展開討論了。相信聰明的讀者能夠想出N種更新維護的姿勢。
3、其他可能最佳化展望
上述最佳化方案主要還是為了驗證並確認筆者的揭榜思路,儘管已經取得了較明顯的效能提升,這就是極限了嗎?答案肯定隨著更多的投入與打磨,還是有提升空間的,以下列出測試中發現與思考的幾點以供參考:
-
第三方框架升級:例如Fury0.9對比0.8就有一些字串寫入效能提升,相信隨著後續迭代還有最佳化空間。(甚至不侷限於Fury,其他不序列化框架隨著時間的演化可能也會有效能提升,但從本質上都適用於本方案。)
-
三方庫中的一些通用判斷等:在固定場景下,可以改部分原始碼:直接刪除部分分支邏輯、減少if和函式呼叫巢狀、預設buffer大小修改等。缺點:收益不太大,後續維護成本更高,另外有一些JIT最佳化也會透過方法內聯等方式達到此效果。
-
Fury序列化中對String是否為ASCII的判斷:isLatin()方法,根據火焰圖分析有一定效能開銷,整體佔比1%-5%。可以對固定path的value,透過內建業務字典,直接確認是否isLatin。
-
LazyMap:反序列化Map時,不即時構建Map,減少掉這部分效能,而在讀取的第一次時才構建Map。適用於只需要讀取EventDTO的基本屬性的場景,這樣反序列化效能還能提升很多。缺點-語義區別:Fury已支援,但是測試發現語義有一定區別,例如put操作,HashMap會返回oldValue,但是LazyMap不返回,所以早期Fury貌似是LazyMap繼承HashMap,新版本不繼承了。另外如果最終是要讀取Map內容,那效能本質沒區別。
-
DTO中的普通欄位:本次測試,只在序列化過程中壓縮替換了DTO中的耗時較大部分——Map的序列化。對於DTO的其他普通欄位,如果某些欄位也是相對固定的列舉Value,而在記憶體中是String,也可以按照此次最佳化方式進行字典化最佳化。
-
業務側最佳化:業務側如果能主動消減一些不必要資料傳輸,能從源頭上“壓縮”。另外目前的資料中有迴圈依賴,例如map中的某個value又引用map自身。如果能確保Map中沒有$ref,則可以在序列化框架中關閉ref檢查,預期再提升10%左右。
-
另外能不能直接在RPC預設的Hessian上面做最佳化?當然可以,方案如下:呼叫方改造一個FastHessian2Output,裡面嵌入最佳化步驟2的字典壓縮等方式進行自定義,甚至也可以遇到EventDTO這個類的時候再引用Fury搞成byte[ ],發揮空間也很大,服務方配套改造FastHessian2Input。呼叫方和服務方都用步驟3的方式替換RPC呼叫。就是侵入性影響面稍微大一些。
分享一些揭榜技巧
-
搞明白問題想要什麼:找好大致的方向,和揭榜對接人多聊聊,例如本次的榜單問題不僅僅是壓縮資料長度、而更看重的是希望壓縮CPU時間,這就基本直接排除了zip等壓縮演算法方向。
-
儘早的建立一個測試和對比環境:工欲善其事必先利其器,以便對每項改動進行資料正確性和效能對比,可以更快更好的驗證自己的想法。例如本次揭榜過程中,測試程式碼的大部分時間是透過自己搞的一系列本地單元測試,相較於在兩個程式碼庫分別寫序列化和反序列化程式碼並且提交部署,測試效率提升十倍以上。上面的揭榜過程和結果,也是經過數百次測試和開發得到的,工具的效率很重要。
-
找類庫專家多交流:除了參考各種內外網文件資料外,過程中也和Fury的作者慕白、SOFARPC的均源同學進行了多項細節探討,把自己的想法、測試進展等同步出來,中間獲得了很多有益的反饋,避免了一些彎路。同時,也希望我們實踐的一些反饋對類庫的後續發展做出一些小貢獻。
-
快速測試評估第三方框架的可修改空間和改動效果:序列化框架的改動測試,我用了一個方法——把開原始碼直接引入專案,而不是直接引入jar包。對於中間想對這些元件搞一些簡單的修改看效果,效率提升還是很好的。
-
過程無外乎就是“多調研、多實踐、多思考”:要相信問題還是有一定複雜度和難度的,順利時不要輕言成功,多想想有沒有什麼疏漏(甚至在寫ATA文章的今天,為了跑一個測試資料,發現可能有一些不嚴謹的地方又改了些測試程式碼);挫折時也不要輕言放棄,這個專案必然會遇到各種困難和挫折,從最開始就要做好堅持的心理建設,堅持肯定是勝利的必要條件之一。
後記
我們大安全技術部搞的這個揭榜活動,過程中能感受到挑戰與壓力,但是目標感也非常足。這個揭榜過程是對技術和心態的綜合鍛鍊:參與進去,勇於邁出第一步,經過上百次的嘗試,數次徘徊在懷疑、失望與堅持之間,終於捅破了那幾層窗戶紙。
今天的這個文章是站在呈現結果的角度來描述過程,並沒有完全覆蓋過程中的各種曲折探索,對於其中的不盡之處,感興趣的同學也歡迎隨時與我交流探討。
企業級雲災備與資料管理
本方案以備份 ECS 檔案為例,介紹如何部署一個簡單的雲災備環境,以滿足常見的資料保護需求。
點選閱讀原文檢視詳情。