导读:Google 的 Protocol Buffers 在数据编码的效率上似乎被神化了,一直流传性能是 JSON 等本文格式 5 倍以上,本文通过代码测试来比较 JSON 与 PB 具体的性能差别到底是多少。作者陶文,转载请注明来自高可用架构「ArchNotes」
陶文,技术极简主义者。认为好的技术是应该是对开发者友好的。一直致力于用技术改进研发效率和开发者体验。jsoniter [4] 作者,jsoniter 就来自于要不要用 Thrift 替代 JSON 的思考。我认为通过引入 IDL 和高效率的编解码库,可以让 HTTP + JSON 这样对开发者体验有好处的技术长久地生存下去。
拿 JSON 衬托 Protobuf 的文章真的太多了,经常可以看到文章中写道:“快来用 Protobuf 吧,JSON 太慢啦”。但是 Protobuf 真的有那么牛么?我想从 JSON 切换到 Protobuf 怎么也得快一倍吧,要不然对不起付出的切换成本?
然而,DSL-JSON 居然声称在 Java 语言里, JSON 可以和那些二进制的编解码格式性能不相上下 [1]!
这太让人惊讶了!虽然你可能会说,咱们能不用苹果和梨来做比较了么,两个东西根本用途完全不一样,用 Protobuf 是冲着跨语言无歧义的 IDL 的去的,才不仅仅是因为性能!这个我同意,但是仍然有那么多人盲目相信 Protobuf 一定会快很多,因此我觉得还是有必要通过本文彻底终结一下这个关于速度的传说。
DSL-JSON 的博客里只给了他们的测试结论,但是没有给出任何原因,以及优化的细节。这很难让人信服数据是真实的。你要说 JSON 比二进制格式更快,真的是很反直觉的事情。稍微琢磨一下这个问题,就可以列出好几个 Protobuf 应该更快的理由:
如果字段大部分是字符串,占到决定性因素的因素可能是字符串拷贝的速度,而不是解析的速度。在这个评测 [2] 中,我们看到不少库的性能是非常接近的。这是因为测试数据中大部分是由字符串构成的。
影响解析速度的决定性因素是分支的数量。因为分支的存在,解析仍然是一个本质上串行的过程。虽然 Protobuf 里没有 [] 或者 {},但是仍然有类似的分支代码的存在。如果没有这些分支的存在,解析不过就是一个 memcpy 的操作而已。只有 Parabix 这样的技术才有革命性的意义,而 Protobuf 相比 JSON 只是改良而非革命。
也许 Protobuf 是一个理论上更快的格式,但是实现它的库并不一定就更快。这取决于优化做得好不好,如果有不必要的内存分配或者重复读取,实际的速度未必就快。
有多个 benchmark 都把 DSL-JSON 列到前三名里,有时甚至比其他的二进制编码更快。经过我仔细分析,原因出在了这些 benchmark 对于测试数据的构成选择上。因为构造测试数据很麻烦,所以一般评测只会对相同的测试数据,去测不同的库的实现。这样就使得结果是严重倾向于某种类型输入的。比如 [3] 选择的测试数据的结构是这样的
点击图片可以放大浏览
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_int
库 | 相比 Jackson | ns/op |
Protobuf | 8.20 | 22124.431 |
Thrift | 6.6 | 27232.761 |
Jsoniter | 6.45 | 28131.009 |
DSL-JSON | 4.48 | 40472.032 |
Fastjson | 2.1 | 86555.965 |
Jackson | 1 | 181357.349 |
从结果上看,似乎优势非常明显。但是因为只有 1 个整数字段,所以可能整数解析的成本没有占到大头。所以,我们把测试调整对象调整为 10 个整数字段。再比比看
点击图片可以放大浏览
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_int_fields
库 | 相比 Jackson | ns/op |
Protobuf | 8.51 | 71067.990 |
Thrift | 2.98 | 202921.616 |
Jsoniter | 3.22 | 187654.012 |
DSL-JSON | 1.43 | 422839.151 |
Fastjson | 1.4 | 432494.654 |
Jackson | 1 | 604894.752 |
点击图片可以放大浏览
整数是直接从输入的字节里计算出来的,公式是 value = (value << 3) + (value << 1) + ind; 相比读出字符串,然后调用 Integer.valueOf ,这个实现只遍历了一遍输入,同时也避免了内存分配。
Jsoniter 在这个基础上做了循环展开
点击图片可以放大浏览
库 | 相比 Jackson | ns/op |
Protobuf | 2.9 | 121027.285 |
Thrift | 0.17 | 2128221.323 |
Jsoniter | 2.02 | 173912.732 |
DSL-JSON | 2.18 | 161038.645 |
Fastjson | 0.81 | 431348.853 |
Jackson | 1 | 351430.04 |