正確使用 protobuf 的姿勢
Protobuf 總結
用途
Protobuf 是 google 出品的序列化框架,可跨平臺、跨語言使用,擴充套件性良好。與 XML, JSON 等序列化框架相同,Protobuf 廣泛的應用於資料儲存,網路傳輸,RPC 呼叫等環境。
序列化: 將 資料結構或物件轉換成二進位制串的過程
反序列化:將在序列化過程中所生成的二進位制串轉換成資料結構或者物件的過程
筆者認為序列化和反序列化可理解為上學的時候偷偷傳的「紙條」,小 A 和 小 B 之間提前約定某種規則,小 A 按照規則寫紙條的過程就是「序列化」,小 B 接到後按照規則翻譯紙條上的內容的過程就是「反序列化」
序列化原理分析
優勢
效能方面
- 體積小:Protobuf 中使用了多種編碼(Varint、Zigzag),序列化後,資料大小可縮小 3 倍
- 傳輸速度快:頻寬相同的情況下,體積小的傳輸速度更快
- 序列化速度快:直接把物件和位元組資料做轉換
使用方面
-
使用簡單,維護成本低:僅需要維護一份 .proto 檔案,protoc 編譯器支援多種平臺程式碼的生成
-
向後相容好:支援欄位的增加和刪除,筆者認為 json 亦可支援
-
安全性:protobuf 編碼後的資料比 json 編碼的資料更難分析
-
適用性: 不適用於對基於文字的標記文件(如 HTML)建模,但在傳輸資料量大 & 網路環境不穩定
原理
-
Protocol Buffer 將訊息裡的每個欄位進行編碼後,再利用 T - L - V 方式進行資料儲存。
-
Tag - Length - Value, 標識 - 長度 -欄位值儲存方式
不需要分隔符就能分割欄位,減少分割符使用
採用 Varint & Zigzag 編碼方式,儲存空間利用率高
沒有設定欄位值的欄位,不需要編碼(儲存或傳輸過程中資料是完全不存在的),相應欄位在解碼的時候才會被設定預設值
-
-
Protocol Buffer 對於不同資料型別,採用不同的序列化方式(編碼方式 & 資料儲存方式)
-
Varint 編碼方式
變長編碼方式,用位元組表示數字,值越小的數字,使用越少的位元組數表示,通過減少表示數字的位元組數從而進行資料壓縮。
- 對於 int32 型別的數字,一般需要 4 個位元組表示,若使用 Varint 編碼,對於很小的 int32 型別數字,可以用 1 個位元組表示,但很大的數字需要 5 個位元組表示。
- 在計算機內,負數的符號位為數字的最高位,會被計算機理解為很大的整數,一定需要 5 個 byte,所以 protobuf 中又引入了 Zigzag 編碼。
-
Zigzag 編碼方式
變長編碼方式,使用無符號數來表示有符號數字,使得絕對值小的數字都可以採用比較少子節來表示。特別是對錶示負數的資料能更好地進行資料壓縮
-
-
Ptotocol Buffer 對於資料編碼方式和T - L -V 資料儲存方式 ,使得序列化後體積更小
使用建議
-
欄位標識號(Field_Number)儘量使用 1-15,且不要跳動使用
tag 裡的 Field_Number 欄位是需要佔用位元組空間的。如果 Field_Number 大於 16, Field_Number 的編碼就會佔用 2 個位元組, 那麼 Tag 在編碼時也就會佔用更多的位元組。
-
若需要使用的欄位值出現負數,優先使用 sint32/sint64
採用 sint32/ sint64 資料型別表示負數時,會優先使用 Zigzag 編碼再採用 Varint 編碼,更加有效的壓縮資料。
測試結果分析
在分析過原理之後,深入思考下 protobuf 是不是在任何使用場景下都是合適的?可以考慮如下幾種場景:
- 如果欄位大部分都是字串,佔到決定性因素應該是字串拷貝速度,而不是解析速度。
- 影響解析速度的決定性因素是分支的數量,上述建議欄位標識號不要超過 15。因為分支的存在,解析仍然是一個序列的過程。
- 理論和實踐並不一定完全儲存一致。Protobuf 是一個理論上更快的格式,但是實現它的庫並不一定就更快。而是取決於優化做得好不好,比如是否有不必要記憶體分配或者重複讀取。
網上整理的測評結果
測評結果整理自 Protobuf 效能到底有沒有比 JSON 快 5 倍,整理的是 Jackson 和 Protobuf 的效能對比,詳情可點選檢視:
測評方式 | 測評結果(Protobuf vs Jackson) |
---|---|
整數解碼 | 8.51 倍 |
整數編碼 | 2.9 倍 |
double 解碼 | 13.75 倍 |
double 編碼 | 12.71 倍 |
1 個欄位的物件解碼 | 2.5 倍 |
5 個欄位的物件解碼 | 1.3 倍 |
10 個欄位的物件解碼 | 1.22 倍 |
1 個欄位的物件編碼 | 1.22 倍 |
5 個欄位的物件編碼 | 1.68 倍 |
10 個欄位的物件編碼 | 1.72 倍 |
整數列表解碼 | 2.92 倍 |
整數列表編碼 | 1.35 倍 |
物件列表解碼 | 1.26 倍 |
物件列表編碼 | 2.22 倍 |
double 陣列解碼 | 5.18 倍 |
double 陣列編碼 | 15.63 倍 |
長字串解碼 | 1.85 倍 |
長字串編碼 | 0.96 倍,Jackson 比 Protobuf 略快 |
Jackson 是 Java 程式中用的最多的 JSON 解析器,benchmark 中開啟了 AfterBurner 的加速特性(筆者不懂 Java 不知道這是用來做什麼的)
自測資料
本著實踐的精神筆者用 golang 寫了一版本 protobuf VS json VS CustomWay(自定義格式)編碼解碼的測試,測試結果如下:
-
protobuf 格式
message Content { string identifier = 1; string resourceLocator = 2; string bucket = 3; int64 time = 4; }
-
json 格式
type JContent struct { Identifier string `json:"Identifier"` ResourceLocator string `json:"ResourceLocator,omitempty"` Bucket string `json:"Bucket"` Time int64 `json:"Time,omitempty"` }
-
自定義格式
field1 field2 field3 field4
注意: 按照空格分隔符分割 4 個欄位,編碼為拼接 4 個欄位,解碼為分割 4 個欄位
測試結果:
goos: darwin
goarch: amd64
pkg: protobuf/benchmark
BenchmarkMarshalByProtoBuf-4 10000000 181 ns/op
BenchmarkUnmarshalByProtoBuf-4 10000000 158 ns/op
BenchmarkMarshalByCustomWay-4 20000000 85.0 ns/op
BenchmarkUnmarshalByCustumWay-4 10000000 143 ns/op
BenchmarkMarshalByJson-4 2000000 844 ns/op
BenchmarkUnmarshalByJson-4 1000000 2520 ns/op
PASS
ok protobuf/benchmark 12.141s
測試程式碼:
package test
import (
"encoding/json"
"strings"
"testing"
proto "github.com/golang/protobuf/proto"
)
var (
f = []string{
"shanghai",
"master",
"shanghai/chongming",
"1541388122",
}
content = Content{
Identifier: "shanghai",
ResourceLocator: "shanghai/chongming",
Bucket: "master",
Time: 1541388122,
}
jContent = JContent{
Identifier: "shanghai",
ResourceLocator: "shanghai/chongming",
Bucket: "master",
Time: 1541388122,
}
)
type customWay string
func (s *customWay) Marshal(fields []string) {
s1 := strings.Join(fields, " ")
*s = customWay(s1)
}
func (s *customWay) Unmarshal() []string {
return strings.Split(string(*s), " ")
}
type JContent struct {
Identifier string `json:"Identifier"`
ResourceLocator string `json:"ResourceLocator,omitempty"`
Bucket string `json:"Bucket"`
Time int64 `json:"Time,omitempty"`
}
func BenchmarkMarshalByProtoBuf(b *testing.B) {
for i := 0; i < b.N; i++ {
proto.Marshal(&content)
}
}
func BenchmarkUnmarshalByProtoBuf(b *testing.B) {
bytes, _ := proto.Marshal(&content)
result := Content{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
proto.Unmarshal(bytes, &result)
}
}
func BenchmarkMarshalByCustomWay(b *testing.B) {
var sc customWay
for i := 0; i < b.N; i++ {
sc.Marshal(f)
}
}
func BenchmarkUnmarshalByCustumWay(b *testing.B) {
var sc customWay
sc.Marshal(f)
b.ResetTimer()
for i := 0; i < b.N; i++ {
sc.Unmarshal()
}
}
func BenchmarkMarshalByJson(b *testing.B) {
for i := 0; i < b.N; i++ {
json.Marshal(jContent)
}
}
func BenchmarkUnmarshalByJson(b *testing.B) {
bytes, _ := json.Marshal(jContent)
result := JContent{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
json.Unmarshal(bytes, &result)
}
}
Proto3 區別於 Proto2 的使用
- 在第一行非空非註釋行,必須寫:
syntax = "proto3";
- 欄位規則移除 「required」,並把 「optional」改為 「singular」
- 「repeated」欄位預設使用 paced 編碼
- 移除 default 選項
- 列舉型別的第一個欄位必須要為 0
- 移除對擴充套件的支援,新增 Any 型別, Any 型別是用來替代 proto2 中的擴充套件的
- 增加了 JSON 對映特性