1. 前言
Protocal Buffers
是google推出的一種序列化協議。由於它的編碼和解碼的速度,已經編碼后的大小控制的較好,因此它常常被用在RPC調用中,傳遞參數和結果。比如gRPC
。
Protocal Buffers
的實現非常簡單,本文將對比JSON協議,來聊聊Protocol Buffers的實現以及它高性能的秘密
2. 正篇
2.1 減少傳輸量(字段名和定界符)
汽車類在Golang中的定義
type Car struct {
Age int32 `json:"age"`
Color string `json:"color"`
Price float32 `json:"price"`
}
JSON字符串表示
{
"age": 10,
"color": "red",
"price": 15.2568983
}
1)”{” 、”}”、”[“, “]”、 雙引號、”,” 、”:” 是為了把字段與字段之間,以及字段的名稱和值分隔開。它們不是必須的。
2)字段的名稱”age”、”color”、”price”也不是必須的。
如果發送方和接收方都對對象的定義是明晰的,那么字段的名稱也不要傳遞
Protocol Buffers
對象定義
message Car {
int32 age = 1;
string color = 2;
double price = 3;
}
每個字段都有一個編號,比如在例子中,age是1,color是2,price是3
接收方只要拿到編號,就可以知道需要解析的是哪個字段,它對應的名字甚至是字段值的長度
下圖是對Protocol buffers
編碼的說明 圖1
Protocol buffers
有點TLV的意思(type-length-value)
FieldInfo
包含了存儲field_number
(字段編號), data_type
表示字段類型
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length-delimited | string, bytes, embedded messages, packed repeated fields |
3 | Start group | groups (deprecated) |
4 | End group | groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
- 對於
64-bit
32-bit
得到類data_type
,也就得到了長度 - 對於
Varint
可以在解析的過程得到value - 對於 類似
Length-delimited
稍微有點特殊,有額外的字段length
表示value字節的長度
注 Varint
是對整型的變長表示,它與ES中使用的整型壓縮算法是完全一致的。參見我的文章VINT–針對INT型的壓縮格式
由於Protocol Buffers
有type和length信息的存在,因此無需字段名稱和JSON中的”{“等定界符
2.2 減少傳輸量(整型和浮點數)
由於JSON屬於文本型協議,因此它傳輸的數據都是字符
- 對於較大的整數,var int32 age = 123456789 傳輸時會變成”123456789″ 需要消耗9個字節
- 對於浮點數,如果出現小數部分 var float32 price = 15.2568983
傳輸時,會變成”15.2568983″
在Protocol Buffers
中,int32按Varint
存儲,平均開銷不到3個字節,而float32按照固定4字節存儲,這樣一來就比JSON少了不少
2.3字段可選
Protocol Buffers
中允許指定某個字段是optional
(可選的)。如果該字段沒有值,則編碼時,這個字段不會占用任何字節。
在一些語言的JSON庫包中,如果解碼時,該字段在JSON字符串中不存在,則會直接報錯。
2.4 解碼時的優勢
2.4.1 跳過數據結構
JSON 是一個沒有 header 的格式。因為沒有 header,JSON 需要掃描每個字節才可以定位到所需的字段上。中間可能要掃過很多不需要處理的字段。
message PbTestWriteObject {
repeated string field1 = 1;
message Field2 {
repeated string field1 = 1;
repeated string field2 = 2;
repeated string field3 = 3;
}
Field2 field2 = 2;
string field3 = 3;
}
message PbTestReadObject {
string field3 = 3;
}
消息用 PbTestWriteObject 來編碼,然后用 PbTestReadObject 來解碼。field1 和 field2 的內容應該被跳過。
這是一個非常極端的例子,回顧圖1中的示例,在Protocol Buffers
中除了Varint
類型,其余類型,都能直接得到長度信息,因此可以直接跳過不需要解析的字節,效率大大提高
2.4.2 字符串的處理
對於string類型的數據,JSON一般而言還需要支持unicode
和UTF8 2種編碼
對於Golang,string本身就是UTF8編碼的字節,因此在解碼時,直接做memcopy
就行
3. 總結
編解碼數字的時候,JSON 仍然是非常慢的。Jsoniter 把這個差距從 10 倍縮小到了 3 倍多一些。
JSON 最差的情況是下面幾種:
- 跳過非常長的字符串:和字符串長度線性相關。
- 解碼 double 字段:Protobuf 優勢明顯,是 Jsoniter的 3.27 倍,是 Jackson 的 13.75 倍。
- 編碼 double 字段:如果不能接受只保留 6 位小數,Protobuf 是 Jackson 的 12.71 倍。如果接受精度損失,Protobuf 是 Jsoniter 的 1.96 倍。
- 解碼整數:Protobuf 是 Jsoniter 的 2.64 倍,是 Jackson 的 8.51 倍。
如果你的生產環境中的 JSON 沒有那么多的 double 字段,都是字符串占大頭,那么基本上來說替換成 Protobuf 也就是僅僅比 Jsoniter 提高一點點,肯定在 2 倍之內。如果不幸的話,沒准 Protobuf 還要更慢一點。
在Protocol Buffers
在極端場景下對JSON的速度優勢,可以達到5倍左右,但是它本身與Gzip
等比較,不算是一種壓縮算法。它可以被表述為更為緊湊的序列化協議。對於針對它序列化的結果,再使用其它壓縮算法進行一步壓縮。
4. 代碼參考
對於不同類型字段的序列化(編碼)主要在
table_marshal.go 中的typeMarshaler
函數
針對 32-bit
的編碼
func appendFixedS32Ptr(b []byte, ptr pointer, wiretag uint64, _ bool) ([]byte, error) {
p := ptr.getInt32Ptr()
if p == nil {
return b, nil
}
b = appendVarint(b, wiretag)
b = appendFixed32(b, uint32(*p))
return b, nil
}
針對 string 的編碼
func appendStringValue(b []byte, ptr pointer, wiretag uint64, _ bool) ([]byte, error) {
v := *ptr.toString()
b = appendVarint(b, wiretag) //
b = appendVarint(b, uint64(len(v)))
b = append(b, v...)
return b, nil
}