背景
JSON/XML不好嗎?
好,再沒有一種序列化方案能像JSON和XML一樣流行,自由、方便,擁有強大的表達力和跨平臺能力。是通用數據傳輸格式的默認首選。不過隨着數據量的增加和性能要求的提升,這種自由與通用帶來的性能問題也不容忽視。
JSON和XML使用字符串表示所有的數據,對於非字符數據來說,字面量表達會佔用很多額外的存儲空間,並且會嚴重受到數值大小和精度的影響。 一個32位浮點數 1234.5678 在內存中佔用 4 bytes 空間,如果存儲爲 utf8 ,則需要佔用 9 bytes空間,在JS這樣使用utf16表達字符串的環境中,需要佔用 18 bytes空間。 使用正則表達式進行數據解析,在面對非字符數據時顯得十分低效,不僅要耗費大量的運算解析數據結構,還要將字面量轉換成對應的數據類型。
在面對海量數據時,這種格式本身就能夠成爲整個系統的IO與計算瓶頸,甚至直接overflow。
JSON/XML之外還有什麼?
衆多的序列化方案中,按照存儲方案,可分爲字符串存儲和二進制存儲,字符串存儲是可讀的,但是由於以上問題,這裏只考慮二進制存儲。二進制存儲中可分爲需要IDL和不需要IDL,或分爲自描述與非自描述(反序列化是否需要IDL)。
需要IDL的使用過程:
使用該方案所定義的IDL語法編寫schema
使用該方案提供的編譯器將schema編譯成生產方和消費方所用語言的代碼(類或者模塊)
數據生產方引用該代碼,根據其接口,構建數據,並序列化
消費方引用該代碼,根據其接口讀取數據
不需要IDL的使用過程:
生產方與消費方通過文檔約定數據結構
生產方序列化
消費方反序列化
etc.
protocol buffers
gRPC所用的傳輸協議,二進制存儲,需要IDL,非自描述
高壓縮率,表達性極強,在Google系產品中使用廣泛
flat buffers
Google推出序列化方案,二進制存儲,需要IDL,非自描述(自描述方案不跨平臺)
高性能,體積小,支持string、number、boolean
avro
Hadoop使用的序列化方案,將二進制方案和字符串方案的優勢結合起來,僅序列化過程需要IDL,自描述
然而場景受限,沒有成熟的JS實現,不適合Web環境,這裏不做對比
Thrift
Facebook的方案,二進制存儲,需要IDL,非自描述
基本上只集成在RPC中使用,這裏不做對比
DIMBIN
針對多維數組設計的序列化方案,二進制存儲,不需要IDL,自描述
高性能,體積小,支持string、number、boolean
優化原理
空間優化原理
使用數值類型而非字面量來保存數值,本身就能節約一筆十分可觀的空間。 protocol buffer爲了實現更高的壓縮率,使用varint去壓縮數值。(不過下面的測試表明,可以使用gzip的環境中,這種方案沒有幫助)
時間優化原理
二進制格式用通過特定位置來記錄數據結構以及每個節點數據的偏移量,省去了從字符串中解析數據結構所耗費的時間,避免了長字符串帶來的性能問題,在GC語言中,也大大減少了中間垃圾的產生。
在可以進行內存直接操作的環境中(包括JS),還可以通過內存偏移量直接讀取數據,而避免進行復制操作,也避免開闢額外的內存空間。DIMBIN和flatbuffers都使用了這種理念來優化數據存儲性能。在JS環境中,通過建立DataView或者TypedArray來從內存段中提取數據的耗時基本上可以忽略不計。
二進制方案中存儲字符串需要額外的邏輯進行UTF8編解碼,性能和體積不如JSON這樣的字符串格式。
DIMBIN是什麼?
我們的數據可視化場景中經常涉及百萬甚至千萬條數據的實時更新,爲解決JSON的性能問題,我們使用內存偏移量操作的思路,開發了DIMBIN作爲序列化方案,並基於其上設計了許多針對web端數據處理的傳輸格式。
作爲一種簡單直白的優化思路,DIMBIN已經成爲我們數據傳輸的標準方案,保持絕對的精簡與高效。
我們剛剛將DIMBIN開源,貢獻給社區,希望能爲大家帶來一個比 JSON/protocol/flatbuffers 更輕、更快、對Web更友好的解決方案。
方案對比
針對Web/JS環境中的使用,我們選擇 JSON、protocol buffers、flat buffers、DIMBIN 四種方案,從七個方面進行對比。
工程化
Protocolbuffers 和 flatbuffers 代表Google所倡導的完整的workflow。嚴格、規範、統一、面向IDL,爲多端協作所設計,針對python/java/c++。通過IDL生成代碼,多平臺/多語言使用一致的開發流程。如果團隊採用這種工作流,那麼這種方案更便於管理,多端協作和接口更迭都更可控。
但是如果離開了這套工程結構,則顯得相對繁雜。
JSON/XML 和 DIMBIN 是中立的,不需要IDL,不對工程化方案和技術選型作假設或限制。可以只通過文檔規範接口,也可以自行添加schema約束。
部署/編碼複雜度
Protocolbuffers 和 flatbuffers 須在項目設計的早期階段加入,並作爲工作流中的關鍵環節。如果出於性能優化目的而加入,會對項目架構造成較大影響。
JSON基本是所有平臺的基礎設施,無部署成本。
DIMBIN只需要安裝一個軟件包,但是需要數據結構扁平化,如果數據結構無法被扁平化,將無法從中受益。
在JS中使用時:
使用JSON序列化反序列化的代碼行數基本在5以內
使用DIMBIN則10行左右
使用protocol需要單獨編寫schema(proto)文件,引入編譯出的幾百行代碼,序列化和反序列化時,需要通過面向對象風格的接口操作每個節點的數據(數據結構上的每個節點都是一個對象)
使用flatbuffer需要單獨編寫schema(fbs)文件,引入編譯出的幾百行代碼,序列化過程需要通過狀態機風格的接口處理每個節點,手動轉換並放入每個節點的數據,書寫體驗比較磨人;反序列化過程通過對象操作接口讀取每個節點的數據
性能(JS環境)
Protocol官網聲稱性能高於JSON,該測試數據顯然不是JS端的,我們的測試表明其JS端性相對於JSON更差(數據量大的時候差的多)。
所有的二進制方案處理字符串的過程都是類似的:需要將js中的utf16先解碼成unicode,再編碼成utf8,寫入buffer,並記錄每個字符串的數據地址。該過程性能消耗較大,而且如果不使用varint(protocol buffers)的話,體積也沒有任何優勢。
在處理字符串數據時,JSON的性能總是最好的,序列化性能 JSON > DIMBIN > flatbuffers > proto,反序列化 JSON > proto > DIMBIN > flatbuffers
處理數值數據時 Flatbuffers 和 DIMBIN 性能優勢明顯,
對於扁平化數值數據的序列化性能 DIMBIN > flatbuffers > JSON > proto,
反序列化 DIMBIN > flatbuffers >十萬倍> JSON > proto
體積
使用字符串與數值混合結構或者純數值時,protocol < DIMBIN < flat < JSON 使用純字符串時,JSON最小,二進制方案都比較大
Gzip之後,DIMBIN和flat的體積最小且基本一致,protocol反而沒有優勢,猜測可能是varint的副作用。
表達力
Protocol 爲強類型語言而設計,所支持的類型比JSON要豐富的多,數據結構也可以十分複雜; Flatbuffers 支持 數值/布爾值/字符串 三種基本類型,結構與JSON類似; DIMBIN 支持 數值/布爾值/字符串 三種基本類型,目前只支持多維數組的結構(暫不支持也不鼓勵使用鍵值對),更復雜的結構需要在其上封裝。
自由度
JSON和DIMBIN都是自描述的,(弱類型語言中)不需要schema,用戶可以動態生成數據結構和數據類型,生產方和消費方之間約定好即可,如果需要類型檢查則需要在上層封裝。
Protocolbuffers 和 flatbuffers 必須在編碼前先寫好IDL並生成對應的代碼,接口修改則需修改IDL並重新生成代碼、部署到生產端和消費端、再基於其上進行編碼。
Protocolbuffers的C++和java實現中有自描述的特性,可以嵌入.proto文件,但是依然需要編譯一個頂層接口來描述這個“自描述的內嵌數據”,基本沒有實用性,其文檔中也說Google內部從來沒有這樣用過(不符合IDL的設計原則)。
flatbuffers 有一個自描述版本的分支(flexbuffers),試驗階段,無JS支持,無相關文檔。
多語言支持
Protocolbuffers 和 flatbuffers 服務端與客戶端語言支持都非常完整。兩者優先針對C++/Java(android)/Python開發,JS端缺少一部分高級功能,無完整文檔,需要自己研究example和生成的代碼,不過代碼不長,註釋覆蓋完整。
JSON基本上所有編程語言都有對應的工具。
DIMBIN針對JS/TS開發和優化,目前提供c#版本,c++、wasm、java和python的支持在計劃中。
用例(僅測試JS環境)
我們生成一份典型的數據,使用扁平化和非扁平化兩種結構,使用JSON、DIMBIN、protocol和flat buffers來實現相同的功能,對比各種方案的性能、體積以及便捷程度。
測試數據
我們生成兩個版本的測試數據:非扁平化(多層鍵值對結構)數據和等效的扁平化(多維數組)數據
考慮到字符串處理的特殊性,在測試時我們分開測試了 字符串/數值混合數據、純字符串數據,和純數值數據
// 非扁平化數據
export const data = {
items: [
{
position: [0, 0, 0],
index: 0,
info: {
a: 'text text text...',
b: 10.12,
},
},
// * 200,000 個
],
}
// 等效的扁平化數據
export const flattedData = {
positions: [0, 0, 0, 0, 0, 1, ...],
indices: [0, 1, ...],
info_a: ['text text text', 'text', ...],
info_b: [10.12, 12.04, ...],
}
JSON
序列化
const jsonSerialize = () => {
return JSON.stringify(data)
}
反序列化
const jsonParse = str => {
const _data = JSON.parse(str)
let _read = null
// 由於flat buffers的讀取操作是延後的,因此這裏需要主動讀取數據來保證測試的公平性
const len = _data.items.length
for (let i = 0; i < len; i++) {
const item = _data.items[i]
_read = item.info.a
_read = item.info.b
_read = item.index
_read = item.position
}
}
DIMBIN
序列化
import DIMBIN from 'src/dimbin'
const dimbinSerialize = () => {
return DIMBIN.serialize([
new Float32Array(flattedData.positions),
new Int32Array(flattedData.indices),
DIMBIN.stringsSerialize(flattedData.info_a),
new Float32Array(flattedData.info_b),
])
}
反序列化
const dimbinParse = buffer => {
const dim = DIMBIN.parse(buffer)
const result = {
positions: dim[0],
indices: dim[1],
info_a: DIMBIN.stringsParse(dim[2]),
info_b: dim[3],
}
}
DIMBIN目前僅支持多維數組,不能處理樹狀數據結構,這裏不做對比。
Protocol Buffers
schema
首先需要按照proto3語法編寫schema
syntax = "proto3";
message Info {
string a = 1;
float b = 2;
}
message Item {
repeated float position = 1;
int32 index = 2;
Info info = 3;
}
message Data {
repeated Item items = 1;
}
message FlattedData {
repeated float positions = 1;
repeated int32 indices = 2;
repeated string info_a = 3;
repeated float info_b = 4;
}
編譯成js
使用 protoc 編譯器將schema編譯成JS模塊
./lib/protoc-3.8.0-osx-x86_64/bin/protoc ./src/data.proto --js_out=import_style=commonjs,,binary:./src/generated
序列化
// 引入編譯好的JS模塊
const messages = require('src/generated/src/data_pb.js')
const protoSerialize = () => {
// 頂層節點
const pbData = new messages.Data()
data.items.forEach(item => {
// 節點
const pbInfo = new messages.Info()
// 節點寫入數據
pbInfo.setA(item.info.a)
pbInfo.setB(item.info.b)
// 子級節點
const pbItem = new messages.Item()
pbItem.setInfo(pbInfo)
pbItem.setIndex(item.index)
pbItem.setPositionList(item.position)
pbData.addItems(pbItem)
})
// 序列化
const buffer = pbData.serializeBinary()
return buffer
// 扁平化方案:
// const pbData = new messages.FlattedData()
// pbData.setPositionsList(flattedData.positions)
// pbData.setIndicesList(flattedData.indices)
// pbData.setInfoAList(flattedData.info_a)
// pbData.setInfoBList(flattedData.info_b)
// const buffer = pbData.serializeBinary()
// return buffer
}
反序列化
// 引入編譯好的JS模塊
const messages = require('src/generated/src/data_pb.js')
const protoParse = buffer => {
const _data = messages.Data.deserializeBinary(buffer)
let _read = null
const items = _data.getItemsList()
for (let i = 0; i < items.length; i++) {
const item = items[i]
const info = item.getInfo()
_read = info.getA()
_read = info.getB()
_read = item.getIndex()
_read = item.getPositionList()
}
// 扁平化方案:
// const _data = messages.FlattedData.deserializeBinary(buffer)
// // 讀數據(避免延遲讀取帶來的標定誤差)
// let _read = null
// _read = _data.getPositionsList()
// _read = _data.getIndicesList()
// _read = _data.getInfoAList()
// _read = _data.getInfoBList()
}
Flat buffers
schema
首先需要按照proto3語法編寫schema
table Info {
a: string;
b: float;
}
table Item {
position: [float];
index: int;
info: Info;
}
table Data {
items: [Item];
}
table FlattedData {
positions:[float];
indices:[int];
info_a:[string];
info_b:[float];
}
編譯成js
./lib/flatbuffers-1.11.0/flatc -o ./src/generated/ --js --binary ./src/data.fbs
序列化
// 首先引入基礎庫
const flatbuffers = require('flatbuffers').flatbuffers
// 然後引入編譯出的JS模塊
const tables = require('src/generated/data_generated.js')
const flatbufferSerialize = () => {
const builder = new flatbuffers.Builder(0)
const items = []
data.items.forEach(item => {
let a = null
// 字符串處理
if (item.info.a) {
a = builder.createString(item.info.a)
}
// 開始操作 info 節點
tables.Info.startInfo(builder)
// 添加數值
item.info.a && tables.Info.addA(builder, a)
tables.Info.addB(builder, item.info.b)
// 完成操作info節點
const fbInfo = tables.Info.endInfo(builder)
// 數組處理
let position = null
if (item.position) {
position = tables.Item.createPositionVector(builder, item.position)
}
// 開始操作item節點
tables.Item.startItem(builder)
// 寫入數據
item.position && tables.Item.addPosition(builder, position)
item.index && tables.Item.addIndex(builder, item.index)
tables.Item.addInfo(builder, fbInfo)
// 完成info節點
const fbItem = tables.Item.endItem(builder)
items.push(fbItem)
})
// 數組處理
const pbItems = tables.Data.createItemsVector(builder, items)
// 開始操作data節點
tables.Data.startData(builder)
// 寫入數據
tables.Data.addItems(builder, pbItems)
// 完成操作
const fbData = tables.Data.endData(builder)
// 完成所有操作
builder.finish(fbData)
// 輸出
// @NOTE 這個buffer是有偏移量的
// return builder.asUint8Array().buffer
return builder.asUint8Array().slice().buffer
// 扁平化方案:
// const builder = new flatbuffers.Builder(0)
// const pbPositions = tables.FlattedData.createPositionsVector(builder, flattedData.positions)
// const pbIndices = tables.FlattedData.createIndicesVector(builder, flattedData.indices)
// const pbInfoB = tables.FlattedData.createInfoBVector(builder, flattedData.info_b)
// const infoAs = []
// for (let i = 0; i < flattedData.info_a.length; i++) {
// const str = flattedData.info_a[i]
// if (str) {
// const a = builder.createString(str)
// infoAs.push(a)
// }
// }
// const pbInfoA = tables.FlattedData.createInfoAVector(builder, infoAs)
// tables.FlattedData.startFlattedData(builder)
// tables.FlattedData.addPositions(builder, pbPositions)
// tables.FlattedData.addIndices(builder, pbIndices)
// tables.FlattedData.addInfoA(builder, pbInfoA)
// tables.FlattedData.addInfoB(builder, pbInfoB)
// const fbData = tables.FlattedData.endFlattedData(builder)
// builder.finish(fbData)
// // 這個buffer是有偏移量的
// return builder.asUint8Array().slice().buffer
// // return builder.asUint8Array().buffer
}
反序列化
// 首先引入基礎庫
const flatbuffers = require('flatbuffers').flatbuffers
// 然後引入編譯出的JS模塊
const tables = require('src/generated/data_generated.js')
const flatbufferParse = buffer => {
buffer = new Uint8Array(buffer)
buffer = new flatbuffers.ByteBuffer(buffer)
const _data = tables.Data.getRootAsData(buffer)
// 讀數據(flatbuffer在解析時並不讀取數據,因此這裏需要主動讀)
let _read = null
const len = _data.itemsLength()
for (let i = 0; i < len; i++) {
const item = _data.items(i)
const info = item.info()
_read = info.a()
_read = info.b()
_read = item.index()
_read = item.positionArray()
}
// 扁平化方案:
// buffer = new Uint8Array(buffer)
// buffer = new flatbuffers.ByteBuffer(buffer)
// const _data = tables.FlattedData.getRootAsFlattedData(buffer)
// // 讀數據(flatbuffer是使用get函數延遲讀取的,因此這裏需要主動讀取數據)
// let _read = null
// _read = _data.positionsArray()
// _read = _data.indicesArray()
// _read = _data.infoBArray()
// const len = _data.infoALength()
// for (let i = 0; i < len; i++) {
// _read = _data.infoA(i)
// }
}
Flatbuffers 對字符串的解析性能較差,當數據中的字符串佔比較高時,其整體序列化性能、解析性能和體積都不如JSON,對於純數值數據,相對於JSON優勢明顯。其狀態機一般的接口設計對於複雜數據結構的構建比較繁瑣。
性能指標
測試環境:15' MBP mid 2015,2.2 GHz Intel Core i7,16 GB 1600 MHz DDR3,macOS 10.14.3,Chrome 75
測試數據:上面例子中的數據,200,000條,字符串使用 UUID*2
測試方式:運行10次取平均值,GZip使用默認配置 gzip ./*
單位:時間 ms,體積 Mb
字符串在數據中的佔比、單個字符串的長度,以及字符串中unicode的數值大小,都會對測試造成影響。
由於DIMBIN針對扁平化數據而設計,因此非扁平化數據只測試了JSON/protocol/flatbuffers
序列化性能
反序列化性能
空間佔用
選型建議
從測試結果來看,如果你的場景對性能有較高要求,將數據扁平化總是明智的原則。
數據量小、快速迭代、包含大量字符串數據,使用JSON,方便快捷;
數據量小、接口穩定、靜態語言主導、多語言協作、集成IDL、依賴gPRC,考慮 protocol buffers。
數據量大、接口穩定、靜態語言主導、集成IDL、數據無法扁平化,考慮 flat buffers。
數據量大、快速迭代、性能要求高、數據可以扁平化,不希望使用重量級工具或修改工程結構,考慮DIMBIN。