导读: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.048 |
点击图片可以放大浏览
这段代码的意思是比较令人费解的。不知道哪里就做了数字到字符串的转换了。过程是这样的,假设输入了19823,会被分解为 19 和 823 两部分。然后有一个 `DIGITS` 的查找表,根据这个表把 19 翻译为 "19",把 823 翻译为 "823"。其中 "823" 并不是三个byte分开来存的,而是把 bit 放到了一个integer里,然后在 writeBuf 的时候通过位移把对应的三个byte解开的
点击图片可以放大浏览
这个实现比 JDK 自带的 Integer.toString 更快。因为查找表预先计算好了,节省了运行时的计算成本。
点击图片可以放大浏览
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_double_fields
库 | 相比 Jackson | ns/op |
Protobuf | 13.75 | 92447.958 |
Thrift | 7.30 | 174052.307 |
Jsoniter | 4.2 | 302453.323 |
Jsoniter (base64) | 3.25 | 390812.895 |
DSL-JSON | 2.53 | 502287.602 |
Fastjson | 1.2 | 1055454.855 |
Jackson | 1 | 1271311.735 |
点击图片可以放大浏览
浮点数被去掉了点,存成了 long 类型,然后再除以对应的 10 的倍数。如果输入是 3.1415,则会变成 31415/10000。
库 | 相比 Jackson | ns/op |
Protobuf | 12.71 | 143346.157 |
Thrift | 0.87 | 2093533.015 |
Jsoniter (6 digits) | 6.5 | 280252.226 |
Jsoniter (base64) | 6.68 | 272843.205 |
DSL-JSON | 1.23 | 1483965.621 |
Fastjson | 1.06 | 1722392.219 |
Jackson | 1 | 1822478.053 |
点击图片可以放大浏览
到目前来看,我们可以说 JSON 不是为数字设计的。如果你使用的是 Jackson,切换到 Protobuf 的话可以把数字的处理速度提高 10 倍。然而 DSL-Json 做的优化可以把这个性能差距大幅缩小,解码在 3x ~ 4x 之间,编码在 1.3x ~ 2x 之间(前提是牺牲 double 的编码精度)。
Base64FloatSupport.enableEncodersAndDecoders();
点击图片可以放大浏览
对于 0.123456789 就变成了 "OWNfmt03P78"
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_1_string_field
库 | 相比 Jackson | ns/op |
Protobuf | 2.52 | 68666.658 |
Thrift | 2.74 | 63139.324 |
Jsoniter | 5.78 | 29887.361 |
DSL-JSON | 5.32 | 32458.030 |
Fastjson | 1.71 | 101107.721 |
Jackson | 1 | 172747.146 |
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_5_string_fields
库 | 相比 Jackson | ns/op |
Protobuf | 1.3 | 276972.857 |
Thrift | 1.44 | 250016.572 |
Jsoniter | 2.5 | 143807.401 |
DSL-JSON | 2.41 | 149261.728 |
Fastjson | 1.39 | 259296.397 |
Jackson | 1 | 359868.351 |
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_10_string_fields
库 | 相比 Jackson | ns/op |
Protobuf | 1.22 | 462167.920 |
Thrift | 1.12 | 503725.605 |
Jsoniter | 2.04 | 277531.128 |
DSL-JSON | 1.84 | 307569.103 |
Fastjson | 1.18 | 477492.445 |
Jackson | 1 | 564942.726 |
点击图片可以放大浏览
这个实现比 Hashmap 来说,仅仅是稍微略快而已。DSL-JSON 的实现是先 hash,然后也是类似的分发的方式:
点击图片可以放大浏览
使用的 hash 算法是 FNV-1a。
DYNAMIC_MODE_AND_MATCH_FIELD_STRICTLY,它可以产生下面这样的严格匹配的代码:
点击图片可以放大浏览
即便是严格匹配,速度上也是有保证的。DSL-JSON 也有选项,可以在 hash 匹配之后额外加一次字符串 equals 检查。
库 | 相比 Jackson | ns/op |
Jsoniter (hash mode) | 2.13 | 274949.346 |
Jsoniter (strict mode) | 1.95 | 300524.989 |
DSL-JSON (hash mode) | 1.91 | 305812.208 |
DSL-JSON (strict mode) | 1.71 | 343203.344 |
Jackson | 1 | 585421.314 |
库 | 相比 Jackson | ns/op |
Protobuf | 1.22 | 57502.775 |
Thrift | 0.86 | 137094.627 |
Jsoniter | 2.06 | 57081.756 |
DSL-JSON | 2.46 | 47890.664 |
Fastjson | 0.92 | 127421.715 |
Jackson | 1 | 117604.479 |
库 | 相比 Jackson | ns/op |
Protobuf | 1.68 | 127933.179 |
Thrift | 0.46 | 467818.566 |
Jsoniter | 2.54 | 84702.001 |
DSL-JSON | 2.68 | 80211.517 |
Fastjson | 0.98 | 219373.346 |
Jackson | 1 | 214802.686 |
库 | 相比 Jackson | ns/op |
Protobuf | 1.72 | 194371.476 |
Thrift | 0.38 | 888230.783 |
Jsoniter | 2.59 | 129305.086 |
DSL-JSON | 2.56 | 130379.967 |
Fastjson | 1.06 | 315267.365 |
Jackson | 1 | 334297.953 |
点击图片可以放大浏览
可以看到我们把 "field1": 作为一个整体写出去了。如果我们知道字段是非空的,则可以进一步的把字符串的双引号也一起合并写出去。
点击图片可以放大浏览
从对象的编解码的 benchmark 结果可以看出,Protobuf 在这个方面仅仅比 Jackson 略微强一些,而比 DSL-Json 要慢。
设置 [packed=true]
库 | 相比 Jackson | ns/op |
Protobuf | 2.92 | 249888.105 |
Thrift | 3.63 | 201439.691 |
Jsoniter | 2.97 | 245837.298 |
DSL-JSON | 1.97 | 370897.998 |
Fastjson | 0.89 | 820099.921 |
Jackson | 1 | 730450.607 |
点击图片可以放大浏览
对于成员比较少的情况,这样搞可以避免数组的扩容带来的内存拷贝。
库 | 相比 Jackson | ns/op |
Protobuf | 1.35 | 159337.360 |
Thrift | 0.45 | 472555.572 |
Jsoniter | 1.9 | 112770.811 |
DSL-JSON | 2.19 | 97998.250 |
Fastjson | 0.66 | 323194.122 |
Jackson | 1 | 214409.223 |
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_object_list
库 | 相比 Jackson | ns/op |
Protobuf | 1.26 | 1118704.310 |
Thrift | 1.3 | 1078278.555 |
Jsoniter | 2.91 | 483304.365 |
DSL-JSON | 2.22 | 635179.183 |
Fastjson | 1.12 | 1260390.104 |
Jackson | 1 | 1407116.476 |
库 | 相比 Jackson | ns/op |
Protobuf | 2.22 | 328219.768 |
Thrift | 0.38 | 1885052.964 |
Jsoniter | 3.63 | 200420.923 |
DSL-JSON | 3.87 | 187964.594 |
Fastjson | 0.85 | 857771.520 |
Jackson | 1 | 727582.950 |
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_double_array
库 | 相比 Jackson | ns/op |
Protobuf | 5.18 | 207503.316 |
Thrift | 6.12 | 175678.703 |
Jsoniter | 4.83 | 222818.772 |
Jsoniter (base64) | 3.63 | 296262.142 |
DSL-JSON | 2.8 | 383549.289 |
Fastjson | 0.58 | 1866460.535 |
Jackson | 1 | 1075423.265 |
点击图片可以放大浏览
这避免了数组扩容的开销。
库 | 相比 Jackson | ns/op |
Protobuf | 15.63 | 107760.788 |
Thrift | 0.54 | 3125678.472 |
Jsoniter (6 digits) | 6.74 | 249945.866 |
Jsoniter (base64) | 7.11 | 236991.658 |
DSL-JSON | 1.14 | 1478332.248 |
Fastjson | 1.08 | 1562377.465 |
Jackson | 1 | 1684935.837 |
https://github.com/json-iterator/java-benchmark/tree/master/src/main/java/com/jsoniter/benchmark/with_long_string
库 | 相比 Jackson | ns/op |
Protobuf | 1.85 | 173680.548 |
Thrift | 2.29 | 140635.170 |
Jsoniter | 2.4 | 134067.924 |
DSL-JSON | 2.27 | 141419.108 |
Fastjson | 1.14 | 281061.212 |
Jackson | 1 | 321406.155 |
点击图片可以放大浏览
这个捷径里规避了处理转义字符和utf8字符串的成本。
点击图片可以放大浏览
使用这个虽然被废弃,但是还没有被删除的构造函数,我们可以使用 Arrays.copyOfRange 来直接构造 java.lang.String 了。然而,在测试之后,发现这个实现方式并没有比 DSL-JSON 的实现更快。
库 | 相比 Jackson | ns/op |
Protobuf | 0.96 | 262077.921 |
Thrift | 0.99 | 252140.935 |
Jsoniter | 1.5 | 166381.978 |
DSL-JSON | 1.38 | 181008.120 |
Fastjson | 0.74 | 339919.707 |
Jackson | 1 | 250431.354 |
库 | 相比 Jackson | ns/op |
Protobuf | 5.05 | 152194.483 |
Thrift | 5.43 | 141467.209 |
Jsoniter | 3.75 | 204704.100 |
DSL-JSON | 2.51 | 305784.845 |
Fastjson | 0.4 | 1949277.734 |
Jackson | 1 | 768840.597 |
场景 | Protobuf V.S. Jackson | Protobuf V.S. Jsoniter | Jsoniter V.S Jackson |
Decode Integer | 8.51 | 2.64 | 3.22 |
Encode Integer | 2.9 | 1.44 | 2.02 |
Decode Double | 13.75 | 3.27 | 4.2 |
Encode Double | 12.71 | 1.96 (只保留小数点后6位) | 6.5 |
Decode Object | 1.22 | 0.6 | 2.04 |
Encode Object | 1.72 | 0.67 | 2.59 |
Decode Integer List | 2.92 | 0.98 | 2.97 |
Encode Integer List | 1.35 | 0.71 | 1.9 |
Decode Object List | 1.26 | 0.43 | 2.91 |
Encode Object List | 2.22 | 0.61 | 3.63 |
Decode Double Array | 5.18 | 1.47 | 4.83 |
Encode Double Array | 15.63 | 2.32 (只保留小数点后6位) | 6.74 |
Decode String | 1.85 | 0.77 | 2.4 |
Encode String | 0.96 | 0.63 | 1.5 |
Skip Structure | 5.05 | 1.35 | 3.75 |
如果你的生产环境中的 JSON 没有那么多的 double 字段,都是字符串占大头,那么基本上来说替换成 Protobuf 也就是仅仅比 Jsoniter 提高一点点,肯定在 2 倍之内。如果不幸的话,没准 Protobuf 还要更慢一点。
相关链接
https://blog.dsl-platform.com/improving-java-json-speed/
https://github.com/fabienrenaud/java-json-benchmark
https://github.com/eishay/jvm-serializers/wiki
http://jsoniter.com/index.cn.html
推荐阅读
模块化还是微服务 - 为什么说大部分团队微服务化都走入了陷阱
容器与持久化存储:容器的开源分布式存储方案选型
Redis架构之防雪崩设计:网站不宕机背后的兵法
本文作者陶文,转载请注明出处,技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。
高可用架构
改变互联网的构建方式
长按二维码 关注「高可用架构」公众号