JSON库性能对比及实现探究

Posted 2023-10-09 16:01 +0800 by ZhangJie ‐ 3 min read

分享:  

本文背景

JSON是一种轻量级的数据交换格式,易于阅读、解析、生成,应用十分广泛。如今在微服务通信中,JSON也是一种常见的序列化手段,比如json-rpc或者gRPC json、pb互转。因为读写场景的不同,对JSON序列化、反序列化(或者解析)的关注点也不一样,一个通用的JSON库不一定能满足性能要求,可以看到有非常多的JSON第三方库频频向标准库发起挑战。本文将从JSON解析的不同场景入手,来说明这些场景下对JSON生成、解析的一些诉求,以及对性能方面的考量,进一步介绍下业界在这方面一些优秀的实践。

回顾JSON标准

rfc8259是目前JSON事实上的标准https://datatracker.ietf.org/doc/html/rfc8259,一个合法的JSON value必须是一个object、array、number、string,或者以下字面量false、true、null。该规范定义了JSON grammar来说明如何表示上述数据。

rfc8259标准明确提出,如果JSON数据不是在一个封闭系统中使用,在不同系统中进行交换时,字符集应该明确使用UTF-8编码。旧的JSON标准并没有指出这点,但是为了保证不同系统的正常交互,大多数系统使用的正是UTF-8编码。标准还指出在编码时不应该在头部添加BOM字符(Byte Order Mark,U+FEFE),一些JSON解析器为了尽可能保证互操作性可能会忽略被错误添加的BOM字符,而不是报错。

ps:rfc8259中还提及使用Unicode字符,Unicode是一种字符编码标准,定义了字符的唯一码点,而UTF-8是Unicode的一种可变长的具体编码方案,以对ASCII进行向后兼容。

JSON 解析器(parser)将JSON文本转换为另一种表示形式,比如go结构体struct。JSON 解析器必须接受所有符合 JSON grammar的文本,可以接受非 JSON 形式或其他扩展(比如vscode .devcontainer定义中支持注释)。解析器实现可能会对其文本的长度进行限制,也可以对数据的最大嵌套深度进行限制,也可以对数值的范围、精度进行限制。

ps:“A JSON parser MAY accept non-JSON forms or extensions.” 这句话的意思是,JSON解析器可以接受非JSON形式或扩展。也就是说,解析器可以容忍一些不符合严格JSON语法的文本,或者支持一些扩展的语法或功能。这给了解析器一定的灵活性,使其能够处理一些非标准的JSON文本或具有扩展功能的JSON文本。这样做是为了在实际应用中提供更大的灵活性和兼容性,以满足不同的需求和场景。

JSON生成器(generator)用于生成JSON文本,生成的文本必须严格符合JSON grammar。比如json.Marshal(v)将v这个数据类型序列化成JSON文本,当然还有json.MarshalIndent(v, "", "\\t"),会在name前面增加一些缩进,tab、空格等空白字符在标准中也是允许的。

**小结:通过rfc8259我们了解了JSON是用来做什么的,有效的JSON数据是什么样的,为了互操作性、灵活性JSON的解析器、生成器又可以怎么做。**下面我们将介绍一些应用场景,从一般到特殊,对应的也会对标准库实现提出一些挑战,然后进一步介绍一些业界的实践、优化。

从标准库开始

go标准库中提供了对JSON编码、解析的支持,最常用的两个函数就是json.Marshaljson.Unmarshal。标准库的设计实现,对大多数数据类型、普通的编码解析场景、易用性方面提供了很不错的支持。

在指出标准库在哪些场景下会表现欠佳之前,需要先了解下标准库在编码、解析过程中的一些实现策略、细节。

这里简单总结一下:

  • 标准库json.Marshal的过程,使用了大量的反射操作,比如确定map k、v的类型信息,struct字段的类型信息,匿名嵌套及字段的可见性分析,struct jsontag规则处理,而且是通过反射递归展开json.Marshal(v)中v的类型信息,才能知道如何encode,最后才是根据v及其内部各个组成部分对应的typeEncoder来完成encode输出。encode的过程中虽然它使用了一些caching(缓存)、pooling(池化)技术,但是前面的反射开销确实是比较大的,尤其是数据类型复杂、数据量比较大的时候。

    想了解详细过程的话,可以参考这篇总结,会对这个过程中的开销有更清晰的认识:https://www.notion.so/hitzhangjie/JSON-d278399b8092470985cbc423830115fb?pvs=4

  • 标准库json.Unmarshal的过程,和json.Marshal的过程相比,其中涉及到的一些要点大差不差,这里就不展开了。

see: https://sourcegraph.com/github.com/golang/go@go1.19.10/-/tree/src/encoding/json

ps:反射的开销主要在哪里?

reflection trades performance for very dynamic and flexible code by pushing work from compile time to runtime. The runtime costs include indirect calls, type inspection, value conversions, and dynamic dispatch. But used judiciously, these costs can be worth it for certain programs.

我的个人看法是,标准库作为一种支持更广泛场景下的实现,使用反射并不是一件坏事,它牺牲了一定的性能来保证了运行时的更大的灵活性,而不用像某些三方库一样去做一些极致的优化,比如bytedance/sonic只支持amd64架构。

Well,但是当我们知道自己要做什么时,还是可以去做一些更加“极致”的优化的。比如bytedance团队知道自己用的是什么类型的机器,借助SIMD等一系列优化,单是JSON相关的序列化、反序列化优化带来的收益,性能提升了、CPU开销下降了,算下来为公司节省了几十万核,这种优化就是很值得的。

下面就带着这种“优化”的思路去看看“不同场景”下该如何优化来达到期望的效果。

细说业界实践

先列举几个不错的第三方实现。

projectrepositoryfunctions
fastjsonhttps://github.com/valyala/fastjsonparser
jsonparserhttps://github.com/buger/jsonparserparser
jsoniterhttps://github.com/simon-engledew/jsoniterparser
simdjsonhttps://github.com/simdjson/simdjsonparser
simdjson-gohttps://github.com/minio/simdjson-goparser
rapidjsonhttps://github.com/Tencent/rapidjsonparser
easyjsonhttps://github.com/mailru/easyjsonparser+generator
json-iterator/gohttps://github.com/json-iterator/goparser+generator
bytedance/sonichttps://github.com/bytedance/sonicparser+generator
segmentio/encodinhttps://github.com/segmentio/encodingparser+generator
goccy/go-jsonhttps://github.com/goccy/go-jsonparser+generator

fastjson

fastjson parse+get操作,和标准库encoding/json的unmarshal相比,效率是后者的15x,see benchmark

fastjson实现上消除了reflection,解析过程中也完全不需要schema,也不需要像其他某些三方库一样通过code generate来生成schema,它是怎么做的呢?

  • 它解析JSON文本的过程,非常暴力直接,跳过空白字符直接对JSON value按照object、array、string、number、true、false、null进行解析,解析完后并不做任何“类型转换”的操作。比如object内部的字段名和值直接作为一个kvpairs数组存起来,并不关心它的类型是什么样的。
  • 当真正去获取某个字段值时,调用方就知道类型是什么了,此时调用对应的GetInt或者GetString等包含类型信息的helper函数,函数内部将对应的string转换为具体的类型。

通过上面这种方法,彻底消除了反射。虽然parse+get的效率比标准库unmarshal效率高,但是还是要看具体场景,是否用起来方便,是否真的care性能。

另外,fastjson虽然提供了快速的解析操作,但是没有提供快速的编码操作,尽管它提供了value.MarshalTo方法,但是这个并不是大家日常编码时需要的将任意类型编码为JSON文本的操作。

关于快速的编码操作,fastjson建议通过valyala/quicktemplate来执行快速编码。我看了下,它实际上是通过模板引擎来完成这个编码操作,但是要业务开发针对要marshal的自定义类型写好对应的go模板,有这个功夫,我还不如将自定义类型实现Marshaler接口了,干嘛非得用模板呢?同样可以避免大量反射,还不用调试go模板。

jsonparser

只支持parser,号称效率是标准库encoding/json的10x,那么它为什么这么快呢?没fastjson快 😆

  • 它不依赖于encoding/json、reflection或interface{},唯一真正的包依赖是bytes。
  • 它在字节级别上操作JSON payload,为您提供指向原始数据结构的指针:无需内存分配。
  • 没有自动类型转换,默认一切都是[]byte,但它提供了值类型,您可以通过helper函数自行转换。
  • 它不解析完整的记录,只解析您指定的键。可以通过jsonpath来访问JSON中嵌套在内部的元素信息。

看下来,它的实现思路和fastjson类似。

jsoniter

这个库的实现思路是,避免解析完整的JSON文本,而是遍历JSON数据的过程中path和指定的jsonpath匹配时才开始将当前path对应的jsonvalue进行decode。

在这个遍历过程中没有使用反射,也没有decode不必要的value,前面的几个实现方案是解析了对应的value的,尽管只是kvpairs的形式,不过开销也不大。

实际decode感兴趣的部分时还是使用了标准库encoding/json.Decoder的实现,这里面哈还是会走到反射部分。所以很难说这个库实现性能有多高。如果是数据量比较大,只解析其中部分数据时还是优势的。

import (
    "encoding/json"
    "github.com/simon-engledew/jsoniter"
)

func main() {
    var found any
    matcher := jsoniter.Matcher("some", 0, "nested", "structure") // .some[0].nested.structure

    d := json.NewDecoder(os.Stdin)
    err := jsoniter.Iterate(d, func(path []json.Token) error {
        if matcher(path) {
            return d.Decode(&found)                               // decode感兴趣的部分数据
        }
        return nil
    })
}

simdjson(-go)

simdjson是C++版本的实现,simdjson-go是用go重写后的版本,整体实现思路差不多,所以合在一起说了。simdjson是Daniel Lemire 和 Geoff Langdale实现的一个JSON解析库,它广泛使用了SIMD指令操作来获得高效的JSON解析操作,号称解析1GB JSON数据只需要1秒钟。

simdjson-go是使用go语言重写后的版本,它的性能大概是c++版本实现的40%~60%,是标准库性能的10x。

ps:大名鼎鼎的clickhouse就使用了simdjson,可以在这里看到更多使用simdjson的知名项目https://github.com/simdjson/simdjson#real-world-usage。

那么simdjson或者simdjson-go为什么会在解析的时候有这么高的性能呢?最主要的技术已经在名字中了,SIMD?

2019年QCon大会上Daniel Lemire做了一个分享 Parsing JSON Really Quickly: Lessons Learned,其中提到了JSON解析过程中的主要任务,以及在实现时可能会遇到的一些挑战。作者还发表过一篇论文 Parsing Gigabytes of JSON per Second,感兴趣也可以看下。

JSON解析中的主要任务

  • 读取完整的JSON内容
  • 检查是否是一个有效的JSON
  • 检查Unicode编码
  • 解析number
  • 构建JSON DOM(document-object-model)

实现JSON解析时要注意

  • 考虑分支预测失效的影响,避免难以预测的分支

    分享中给出了一个示例代码,无判断分支时每次迭代3cycle,加了奇偶判断分支每次迭代增加到了15cycle,通过branchless programming消除奇偶判断分支每次迭代重新回到了4cycle。为什么会给这个例子,因为在JSON解析时需要对字符{}[]:,“等进行分类,而分类是基于相等判断的,需要在设计实现时避免分支判断。

  • 使用更宽字长,避免1个字节1个字节处理,很慢

  • 如果可能(硬件支持的话),使用SIMD

    目前在大多数现代主流ARM、x64处理器上都支持SIMD,最初Pentium支持SIMD是为了更好地对多媒体(声音)进行处理,现代处理器增加了位宽更大的寄存器(128-bit、256-bit、512-bit),也增加了一些高效的指令,比如一次性做32个表查询。那这个和JSON解析有什么关系呢?JSON解析中需要对字符进行分类,如分类成{}[]:,",通过巧妙的表设计,可以一次性对很多字符进行分类,而且代码还能避免不必要的分支预测。

    老的x64(Intel、AMD)平台可以用SSE2…SSE4.2(128-bit),主流的x64(Intel、AMD)可以用AVX、AVX2(256-bit),最新的x64(Intel)可以用AVX-512(512-bit),其他平台可以自行检索下。

  • 避免内存(对象)分配,这个很好理解了

    JSON解析库解析过程中解析出的object、array等最终都会转换成一个个的内存对象,如何合理地减少或者避免内存分配,就很重要,尤其是JSON数据量比较大的时候这个内存开销问题就会比较明显。对于支持GC的语言,因为GC也会导致CPU开销。

  • 对解析性能做benchmark,并进行合理的优化

    比如在后续不停的优化、维护过程中,需要注意做好benchmark,一旦发现性能下降,就应该当做BUG来跟进。

从0到1实现simdjson

分享最后给出了几个examples,分别介绍了SIMD技术在UTF-8编码检测、字符分类、检测转义字符(检测、移除转义字符、确定字符串范围)的应用,并不是那么好理解,建议多看几遍分享好好体会下,看了也不一定能理解那些branchless programming的写法,分享人明确指出这些玩意是经过lots of hard work总结出来的计算式。

到这里,整个JSON文档的结构就可以非常高效地解析出来,没有任何分支判断。分享最后提到了将字符串转换为number时的一点复杂性、计算开销。man 3 strtod,strtod是一个库函数实现将字符串转换为浮点数double的逻辑,性能的话:吞吐量90MB/s、每个字节耗时38cycles、每个浮点数转换大约有10次分支预测失败。在simdjson作者看来这个转换太慢了,所以自己实现了相关的转换逻辑。

最后,不同平台有不同SIMD指令及对应的实现细节,simdjson会检测当前平台,并通过runtime dispatch选择使用匹配的实现代码。将上述这些思路放在一起,就是simdjson的全部核心思想了。

simdjson的benchmark数据显示,其性能吊打yyjson、rapidjson、json for m. c++等其他json parser。但是它只支持parser,不支持generator,后面再介绍go标准库encoding/json的平替方案 bytedance/sonic 时我们会继续详细介绍下sonic的一些优化思路,当然SIMD相关的部分就可以省略掉了。

小结:读者可能看到这里,可能恍然大悟,也可能仍旧一头雾水,感觉让自己迷惑的东西越来越多了?那是正常的,懂的越多之后会发现自己不懂的也越来越多。作者个人水平有限,也不打算在此补充更多SIMD的内容,读者感兴趣的话可以自行去学习。

rapidjson

rapidjson虽然号称支持parser+generator,肯定是支持parser的,但是对于generator的支持是比较有限的,它和前面的个别JSON库有点类似,就是你得parse完后再修改再将这部分解析后的DOM生成为JSON数据。和我们日常应用时理解的将自定义数据结构、类型编码为JSON数据是有差异的。鉴于此,前面表格里没有将其归为parser+generator这一类。

从simdjson的benchmark数据来看,虽然其性能吊打rapidjson,但是从实现思路上来看,rapidjson其实也是考虑使用了SIMD技术(仅限于SSE, SSE4.2?)来加速的,但是可能没有simdjson做的完善。

下面是rapidjson项目介绍中,主要提到下面几点:

  • 支持SAX和DOM两种风格的API;

  • 它的性能近似strlen(),也支持SSE/SSE4.2加速;

  • 不依赖外部库Boost,也不依赖STL;

  • 内存友好,对于大多数32/64位机器,每个JSON值占用精确的16字节(不包括字符串)。默认情况下,它使用快速内存分配器,并且解析器在解析过程中紧凑地分配内存;

  • 对Unicode支持友好。它内部支持UTF-8、UTF-16、UTF-32(LE和BE)以及它们的检测、验证和转码;

对性能方面的优化细节,我们就不再想想展开看了,毕竟是被simdjson吊打了 😆

easyjson

easyjson提供了一个代码生成工具,它分析指定包中的struct定义并为之生成json.Marshaler接口的实现方法(MarshalJSON、UnmarshalJSON),代码生成工具代码生成阶段用到反射来获得待编码元素的类型,但是生成代码中就完全消除反射操作了,因此性能也会比较高。

据提供的benchmark测试显示,性能能达到标准库的4~5x。由于它需要用代码生成来额外生成代码,为了方便生成代码,可以在类型定义上增加//go:generate,构建时先执行go generate ./...再执行go build

但是它毕竟要额外生成代码,但是也有可能某些情况下,生成工具不知道如何生成代码,比如 interface{}

json-iterator/go

json-iterator/go是另一个标准库encoding/json的平替,默认配置下它的编码、解析性能已经是标准库的好几倍,还可以配置成高性能模式,如允许使用6digits来编码float允许损失精度。

那么这个库做了哪些优化呢?作为一个标准库的平替,应该可以多给些关注,看下它的benchmark数据:

ns/opallocation bytesallocation times
std decode35510 ns/op1960 B/op99 allocs/op
easyjson decode8499 ns/op160 B/op4 allocs/op
jsoniter decode5623 ns/op160 B/op3 allocs/op
std encode2213 ns/op712 B/op5 allocs/op
easyjson encode883 ns/op576 B/op3 allocs/op
jsoniter encode837 ns/op384 B/op4 allocs/op

那它是如何做的呢?看代码的结构的话,别说,bytedance/sonic跟这个还有点像……ok,回来继续看。

see:https://sourcegraph.com/github.com/json-iterator/go@71ac16282d122fdd1e3a6d3e7f79b79b4cc3b50e/-/blob/config.go?L296:26&popover=pinned

see:https://sourcegraph.com/github.com/json-iterator/go@71ac16282d122fdd1e3a6d3e7f79b79b4cc3b50e/-/blob/reflect.go?L87:23-87:31

看了下,和segmentio/encoding/json有点类似,尽可能消除反射逻辑、缓存技术、池化技术之类的优化。但是这里的测试应该没有那么充分,它的性能应该和segmentio/encoding/json差不多了太多。

bytedance/sonic

该JSON库支持parser+generator,可以作为go标准库encoding/json的平替,API方面和标准库提供的接口一样也比较友好,对JSON标准的支持层面也OK。支持和标准库对齐的默认配置、对齐标准库模式、快速模式等,不同的模式有不同的控制选项来决定是否跳过一些排序、转义之类的操作。

sonic可以作为go标准库encoding/json的一个平替(至少在amd64平台上是可以),不仅如此,它还号称是在全场景中表现优异。开发者提到,此前很难找到支持全场景、并且在支持全场景中性能均保持top3的json库,这也是开发者最终开发bytedance/sonic的一个起因。

bytedance/sonic有一篇非常不错的介绍性文章,see: 基于 JIT 技术的开源全场景高性能 JSON 库。其中提到了所谓的全场景的概念:

  • 泛型(generic)编解码:JSON 没有对应的 schema,只能依据自描述语义将读取到的 value 解释为对应语言的运行时对象,例如:JSON object 转化为 Go any, interface{}, map[string]interface{};
  • 定型(binding)编解码:JSON 有对应的 schema(Go strcut),可以同时结合模型定义与 JSON 语法,将读取到的 value 绑定到对应的模型字段上去,同时完成数据解析与校验;
  • 查找(get)& 修改(set) :指定某种规则的查找路径(一般是 key 与 index 的集合),只对需要的那部分 JSON value 进行查找或者修改。

读者可以将前面的一些json库的应用场景与上面提到的情景进行下对应,总结下某些场景下的一些优化手段。对于sonic而言,它在改进性能方面,有哪些比较亮眼的地方呢?

bytedance/sonic: A blazingly fast JSON serializing & deserializing library, accelerated by JIT (just-in-time compiling) and SIMD (single-instruction-multiple-data).

先简要说下sonic针对不同场景的大致优化思路,根据笔者认为的idea重要性程度做个排序:

  • simd并行处理:在对json中字符进行分类、字符串转义、编解码、确定字符串范围、校验等方面,使用SIMD进行数据级并行处理,这个在simdjson中已经有明确的效果了。另外,开发者发现对于较短的json数据使用SIMD得不偿失,所以会综合json数据长度来决定是走标量或向量处理的方式。

  • jit编解码函数:定性编解码,标准库中会通过反射来递归获取schema中不同成员的encoder func,然后在进行序列化、反序列化时会以函数调用的形式来逐个调用上述func。sonic开发者发现这里的函数调用开销很大,尤其是旧版本的go函数参数传递方式比较低效。他们通过JIT将需要用到的encoder funcs组合成一个函数体,省去了函数调用的开销。

  • llvm编译优化:go编译器对编译优化做的没llvm好,clang中已经集成了llvm,为了利用llvm的优化能力,有一些核心函数是用c编写的,然后再通过clang进行编译生成优化后的汇编,然后这部分汇编go编译器是不认识的,sonic开发者又提供了一个asm2asm的工具将这些汇编转换为plan9汇编,最终由go编译器编译。

  • 缓存优化:上述encoder funcs是可以缓存处理的,但是缓存的东西不多、直接使用sync.Map来缓存时发现性能比较差,所以开发者使用RCU机制实现了一个缓存。

  • 内存分配优化:对于json中一些不需要转义处理的字符串,可以避免拷贝字符串,也考虑了一些其他的池化技术。

  • 热点代码路径:尽量消除反射。

  • 其他,避免重复解析,;

sonic的性能优化,主要源自SIMD和JIT,它的优势在介绍simdjson的时候重点介绍过了,这里就不再展开。

另外说到全场景,sonic API层面也做了比较好的支持,像范型编解码、定性编解码大家用的比较多了,不再赘述。对于按需查找&修改这种场景,sonic也支持了指定jsonpath来按需获取需要的那部分数据,如root, _ := sonic.GetFromString(jsondata, paths...)

  • 如果这个root是基本数据类型,那就可以通过root.Int(), root.Bool(), ...等函数直接取到值,
  • 如果这个root是object类型,
    • 没有schema时,可以通过root.Map()将其转换为一个map;
    • 有schema时,可以通过root.Raw()拿到原始字符串数据后再去unmarshal;

ps:我们可以通过root对数据进行修改,然后可以再Marshal为json。

为了更好地支持对json数据进行操作,sonic提供了一个新的数据容器ast.Node,它是对json进行解析后生成的,比如对某个jsonpath对应的数据解析后生成的,好处是继续查找其下面的字段时,不用对之前的jsonpath进行重复解析。另外,它是一个比any或者map更好用的数据容器。

segmentio/encoding

内部实现使用了一些unsafe操作(无类型代码、指针运算等等)来尽可能避免使用反射,使用反射通常是序列化过程中CPU占用高、内存开销大的重要原因。这个包致力于实现零不必要的动态内存分配,并且热点代码路径中尽量避免使用反射包。

以json.Marshal(v)为例,来看下做了哪些优化?

  • 构建encoderCache sync.Map的时候,它没有使用reflect.Type来作为key,而是使用了typeid(reflect.Type),实际上是一个实现类型的地址,通过这种方式减轻了后续查询type对应的encoder的开销;
  • map的keys是否排序,提供了一个选项进行控制,而不是像标准库那样有各种各样的排序,排序前获得map中的所有key、value还是通过反射的,所以这个排序的前置准备以及排序本身都有开销的,不能忽略;
  • struct的fields排序也省略掉了,这个把struct及其内部嵌套struct通过反射获取index信息进行排序的逻辑也比较啰嗦,这里也省掉了;
  • html转义也是可以控制的,这个和标准库一样都有选项进行控制,算不上什么明显优化;

所以,segmentio/encoding/json库是通过消除一些不必要的反射以及其他一些优化技术来改善了解析、编码的性能,可以作为标准库的平替。实测其性能是优于标准库的,但是没有sonic好。

goccy/go-json

这个库是后来发现的,它和bytedance/sonic在全场景支持方面有的一拼,泛型编解码、定性编解码、按需查找、流式都支持,按需修改不支持。goccy/go-json也是可以直接作为标准库的平替的,它做了哪些方面的优化呢,这个在其项目README里面有比较清晰的介绍。

主要包括如下这些优化:

  • buffer复用:https://github.com/goccy/go-json/#buffer-reuse

  • 移除反射:https://github.com/goccy/go-json/#elimination-of-reflection

  • encoder部分

    • 避免Marshal参数逃逸:https://github.com/goccy/go-json/#do-not-escape-arguments-of-marshal

    • 预编译的操作码序列进行编码:https://github.com/goccy/go-json/#encoding-using-opcode-sequence

      这点上也是为了消除大量的函数调用开销,和bytedance/sonic中使用JIT构造编解码函数的目的是一样的。

    • 上述操作码序列优化:https://github.com/goccy/go-json/#opcode-sequence-optimization

    • 递归调用JMP代替CALL:https://github.com/goccy/go-json/#change-recursive-call-from-call-to-jmp

    • 根据typeptr查信息,从map到slice:https://github.com/goccy/go-json/#dispatch-by-typeptr-from-map-to-slice

  • decoder部分:

    • 根据typeptr查信息,从map到slice:https://github.com/goccy/go-json/#dispatch-by-typeptr-from-map-to-slice-1
    • 更快的结束符检测:https://github.com/goccy/go-json/#faster-termination-character-inspection-using-nul-character
    • 边界检查移除:https://github.com/goccy/go-json/#use-boundary-check-elimination
    • 使用bitmap检测struct字段是否存在:https://github.com/goccy/go-json/#checking-the-existence-of-fields-of-struct-using-bitmaps
  • 其他未列出的优化:https://github.com/goccy/go-json/#others

评价下的话:这个库在有schema的时候,性能和bytedance/sonic有一拼,更好或者逊色一点,但是无schema的时候比bytedance/sonic要逊色不少,比标准库好一点点。

本文总结

本文首先介绍了下JSON标准,介绍了下JSON parser+generator在标准范围内的一些腾挪空间,然后我们列举了当前性能比较有优势的一些JSON库实现,并对它们属于parser、generator进行了分类,也指出了哪些库可以作为go标准库的平替方案。我们还比较详细地分析了各个JSON库的优化思路,其中重点介绍了simdjson这个被大量优秀开源项目使用的实现,以及针对go语言的bytedance/sonic这个在字节广泛使用的实现。从中我们认识到,JSON的使用场景比较多样化,泛型模式、有固定schema的模式、按需解析的模式,甚至还有对齐进行修改后再序列化的诉求,要实现一个支持全场景的方案本身就不简单,而且还要做到sonic开发者团队说的那样全场景top3的程度。

目前,从效果上来看,sonic确实做的不错,但是它受限于amd64平台,继续支持其他平台可能并非sonic开发者能支持的,所以goccy/go-json的方案也值得借鉴下,虽然其在泛型模式下表现一般,但是其在有schema模式下已经可以实现和bytedance/sonic JIT优化后的差不多的效果了,看来goccy/go-json也可以有进一步优化战胜bytedance/sonic的空间

参考文献

  1. JSON, https://en.wikipedia.org/wiki/JSON
  2. Introducing JSON, https://www.json.org/json-en.html
  3. The JavaScript Object Notation Data Interchange Format, https://datatracker.ietf.org/doc/html/rfc8259
  4. Parsing Gigabytes of JSON per Second, https://r.jordan.im/download/technology/langdale2019.pdf
  5. Parsing JSON Really Quickly: Lessons Learned, https://www.youtube.com/watch?v=wlvKAT7SZIQ
  6. rapidjson, https://github.com/Tencent/rapidjson
  7. rapidjson features, https://github.com/Tencent/rapidjson/blob/master/doc/features.md
  8. bytesonic/json, https://segmentfault.com/a/1190000044004731
  9. goccy/go-json, https://github.com/goccy/go-json
  10. benchmark, https://github.com/hitzhangjie/codemaster/blob/master/serialization/json_benchmark