Sonic: Go语言的超级JSON库,解析与编码速度狂飙

2023年 8月 28日 101.3k 0

介绍

我们在日常开发中,常常会对JSON进行序列化和反序列化。Golang提供了encoding/json包对JSON进行Marshal/Unmarshal操作。但是在大规模数据场景下,该包的性能和开销确实会有点不够看。在生产环境下,JSON 序列化和反序列化会被频繁的使用到。在测试中,CPU使用率接近 10%,其中极端情况下超过 40%。因此,JSON 库的性能是提高机器利用率的关键问题。

Sonic是一款由字节跳动开发的一个全新的高性能、适用广泛的 JSON 库。在设计上借鉴了多款JSON库,同时为了实现对标准库的真正插拔式替换,Sonic使用了 JIT[1] (即时编译) 。

Sonic的特色

我们可以看出:Sonic是一个主打快的JSON库。

  • 运行时对象绑定,无需代码生成
  • 完备的 JSON 操作 API
  • 快,更快,还要更快!

Sonic的设计

  • 针对编解码动态汇编的函数调用开销,使用 JIT 技术在运行时组装与模式对应的字节码(汇编指令) ,最终将其以 Golang 函数的形式缓存在堆外内存上。
  • 针对大数据和小数据共存的实际场景,使用预处理判断(字符串大小、浮点数精度等)将 SIMD 与标量指令相结合,从而实现对实际情况的最佳适应。
  • 对于 Golang 语言编译优化的不足,使用 C/Clang 编写和编译核心计算函数,并且开发了一套 asm2asm[2] 工具,将经过充分优化的 x86 汇编代码转换为 Plan9 格式,最终加载到 Golang 运行时中。
  • 考虑到解析和跳过解析之间的速度差异很大, 惰性加载机制当然也在 AST 解析器中使用了,但以一种更具适应性和高效性的方式来降低多键查询的开销。
  • 在细节上,Sonic进行了一些进一步的优化:

  • 由于 Golang 中的原生汇编函数不能被内联,发现其成本甚至超过了 C 编译器的优化所带来的改善。所以在 JIT 中重新实现了一组轻量级的函数调用:
  • 全局函数表+静态偏移量,用于调用指令

    使用寄存器传递参数

  • Sync.Map 一开始被用来缓存编解码器,但是对于准静态(读远多于写),元素较少(通常不足几十个)的场景,它的性能并不理想,所以使用开放寻址哈希和 RCU 技术重新实现了一个高性能且并发安全的缓存。
  • 安装使用

    当前我使用的go version是1.21。

    官方建议的版本:

    • Go 1.16~1.21
    • Linux / MacOS / Windows(需要 Go1.17 以上)
    • Amd64 架构
    # 下载sonic依赖
    $ go get github.com/bytedance/sonic

    基本使用

    sonic提供了许多功能。本文仅列举其中较为有特色的功能。感兴趣的同学可以去看一下官方的examples

    序列化/反序列化

    sonic的使用类似于标准包encoding/json包的使用.

    func base() {
     m := map[string]interface{}{
      "name": "z3",
      "age":  20,
     }
     
     // sonic序列化
     byt, err := sonic.Marshal(&m)
     if err != nil {
      log.Println(err)
     }
     fmt.Printf("json: %+vn", string(byt))
    
     // sonic反序列化
     um := make(map[string]interface{})
     err = sonic.Unmarshal(byt, &um)
     if err != nil {
      log.Println(err)
     }
     fmt.Printf("unjson: %+vn", um)
    }
    
    // print
    // json: {"name":"z3","age":20}
    // unjson: map[age:20 name:z3]

    sonic还支持流式的输入输出

    Sonic 支持解码 io.Reader 中输入的 json,或将对象编码为 json 后输出至 io.Writer,以处理多个值并减少内存消耗

    func base() {
     m := map[string]interface{}{
      "name": "z3",
      "age":  20,
     }
    
     // 流式io编解码
     // 编码
     var encbuf bytes.Buffer
     enc := sonic.ConfigDefault.NewEncoder(&encbuf)
     if err := enc.Encode(m); err != nil {
      log.Fatal(err)
     } else {
      fmt.Printf("cutomize encoder: %+v", encbuf.String())
     }
    
     // 解码
     var decbuf bytes.Buffer
     decbuf.WriteString(encbuf.String())
     clear(m)
    
     dec := sonic.ConfigDefault.NewDecoder(&decbuf)
     if err := dec.Decode(&m); err != nil {
      log.Fatal(err)
     } else {
      fmt.Printf("cutomize decoder: %+vn", m)
     }
    }
    
    // print
    // cutomize encoder: {"name":"z3","age":20}
    // cutomize decoder: map[age:20 name:z3]

    配置

    在上面的自定义流式编码解码器,细心的朋友可能看到我们创建编码器和解码器的时候,是通过sonic.ConfigDefault.NewEncoder() / sonic.ConfigDefault.NewDecoder()这两个函数进行调用的。那么sonic.ConfigDefault是什么?

    我们可以通过查看源码:

    var (
        // ConfigDefault is the default config of APIs, aiming at efficiency and safty.
        // ConfigDefault api的默认配置,针对效率和安全。
        ConfigDefault = Config{}.Froze()
     
        // ConfigStd is the standard config of APIs, aiming at being compatible with encoding/json.
        // ConfigStd是api的标准配置,旨在与encoding/json兼容。
        ConfigStd = Config{
            EscapeHTML : true,
            SortMapKeys: true,
            CompactMarshaler: true,
            CopyString : true,
            ValidateString : true,
        }.Froze()
     
        // ConfigFastest is the fastest config of APIs, aiming at speed.
        // ConfigFastest是api的最快配置,旨在提高速度。
        ConfigFastest = Config{
            NoQuoteTextMarshaler: true,
        }.Froze()
    )

    sonic提供了三种常用的Config配置。这些配置中对一些场景已经预定义好了对应的Config。

    其实我们使用的sonic.Marshal()函数就是调用了默认的ConfigDefault

    // Marshal returns the JSON encoding bytes of v.
    func Marshal(val interface{}) ([]byte, error) {
        return ConfigDefault.Marshal(val)
    }
    
    // Unmarshal parses the JSON-encoded data and stores the result in the value pointed to by v.
    // NOTICE: This API copies given buffer by default,
    // if you want to pass JSON more efficiently, use UnmarshalString instead.
    func Unmarshal(buf []byte, val interface{}) error {
        return ConfigDefault.Unmarshal(buf, val)
    }

    但是在一些场景下我们不满足于sonic预定义的三个Config。此时我们可以定义自己的Config进行个性化的编码和解码。

    首先先看一下Config的结构。

    // Config is a combination of sonic/encoder.Options and sonic/decoder.Options
    type Config struct {
        // EscapeHTML indicates encoder to escape all HTML characters 
        // after serializing into JSON (see https://pkg.go.dev/encoding/json#HTMLEscape).
        // WARNING: This hurts performance A LOT, USE WITH CARE.
        EscapeHTML                    bool
        // SortMapKeys indicates encoder that the keys of a map needs to be sorted 
        // before serializing into JSON.
        // WARNING: This hurts performance A LOT, USE WITH CARE.
        SortMapKeys                   bool
        // CompactMarshaler indicates encoder that the output JSON from json.Marshaler 
        // is always compact and needs no validation 
        CompactMarshaler              bool
        // NoQuoteTextMarshaler indicates encoder that the output text from encoding.TextMarshaler 
        // is always escaped string and needs no quoting
        NoQuoteTextMarshaler          bool
        // NoNullSliceOrMap indicates encoder that all empty Array or Object are encoded as '[]' or '{}',
        // instead of 'null'
        NoNullSliceOrMap              bool
        // UseInt64 indicates decoder to unmarshal an integer into an interface{} as an
        // int64 instead of as a float64.
        UseInt64                      bool
        // UseNumber indicates decoder to unmarshal a number into an interface{} as a
        // json.Number instead of as a float64.
        UseNumber                     bool
        // UseUnicodeErrors indicates decoder to return an error when encounter invalid
        // UTF-8 escape sequences.
        UseUnicodeErrors              bool
        // DisallowUnknownFields indicates decoder to return an error when the destination
        // is a struct and the input contains object keys which do not match any
        // non-ignored, exported fields in the destination.
        DisallowUnknownFields         bool
        // CopyString indicates decoder to decode string values by copying instead of referring.
        CopyString                    bool
        // ValidateString indicates decoder and encoder to valid string values: decoder will return errors 
        // when unescaped control chars(u0000-u001f) in the string value of JSON.
        ValidateString                    bool
    }

    由于字段较多。笔者就选择几个字段进行演示,其他字段使用方式都是一致。

    假设我们希望对JSON序列化按照key进行排序以及将JSON编码成紧凑的格式。我们可以配置Config进行Marshal操作

    func base() {
     snc := sonic.Config{
      CompactMarshaler: true,
      SortMapKeys:      true,
     }.Froze()
    
     snc.Marshal(obj)
    }

    考虑到排序带来的性能损失(约 10% ), sonic 默认不会启用这个功能。

    Sonic 默认将基本类型( struct , map 等)编码为紧凑格式的 JSON ,除非使用 json.RawMessage or json.Marshaler 进行编码:sonic 确保输出的 JSON 合法,但出于性能考虑,不会加工成紧凑格式。我们提供选项 encoder.CompactMarshaler 来添加此过程,

    Ast.Node

    sonic提供了Ast.Node的功能。Sonic/ast.Node 是完全独立的 JSON 抽象语法树库。它实现了序列化和反序列化,并提供了获取和修改通用数据的鲁棒的 API。

    先来简单介绍一下Ast.Node:ast.Node 通常指的是编程语言中的抽象语法树(Abstract Syntax Tree)节点。抽象语法树是编程语言代码在编译器中的内部表示,它以树状结构展现代码的语法结构,便于编译器进行语法分析、语义分析、优化等操作。

    在很多编程语言的编译器或解释器实现中,抽象语法树中的每个元素(节点)都会有对应的数据结构表示,通常这些数据结构会被称为 ast.Node 或类似的名字。每个 ast.Node 表示源代码中的一个语法结构,如表达式、语句、函数声明等。

    抽象语法树的节点可以包含以下信息:

    • 节点的类型:例如表达式、语句、函数调用等。
    • 节点的内容:节点所代表的源代码的内容。
    • 子节点:一些节点可能包含子节点,这些子节点也是抽象语法树的节点,用于构建更复杂的语法结构。
    • 属性:一些节点可能会包含附加的属性,如变量名、操作符类型等。

    我们通过几个案例理解一下Ast.Node的使用。

    准备数据

    data := `{"name": "z3","info":{"num": [11,22,33]}}`

    将数据转换为Ast.Node

    通过传入bytes或者string返回一个Ast.Node。其中你可以指定path获取JSON中的子路径元素。

    每个路径参数必须是整数或者字符串

    • 整数是目标索引(>=0),表示以数组形式搜索当前节点。
    • 字符串为目标key,表示搜索当前节点为对象。
    // 函数签名:
    func Get(src []byte, path ...interface{}) (ast.Node, error) {
        return GetFromString(string(src), path...)
    }
    // GetFromString与Get相同,只是src是字符串,这样可以减少不必要的内存拷贝。
    func GetFromString(src string, path ...interface{}) (ast.Node, error) {
        return ast.NewSearcher(src).GetByPath(path...)
    }

    获取当前节点的完整数据

    func base() {
     data := `{"name": "z3","info":{"num": [11,22,33]}}`
    
     // no path return all json string
     root, err := sonic.GetFromString(data)
     if err != nil {
      log.Panic(err)
     }
     if raw, err := root.Raw(); err != nil {
      log.Panic(err)
     } else {
      log.Println(raw)
     }
    }
    
    // print
    // 2023/08/26 17:15:52 {"name": "z3","info":{"num": [11,22,33]}}

    根据path或者索引获取数据

    func base() {
     data := `{"name": "z3","info":{"num": [11,22,33]}}`
    
     // no path return all json string
     root, err := sonic.GetFromString(data)
     if err != nil {
      log.Panic(err)
     }
    
        // according to path(根据key,查询当前node下的元素)
     if path, err := root.GetByPath("name").Raw(); err != nil {
      log.Panic(err)
     } else {
      log.Println(path)
     }
    
     // indexOrget (同时提供index和key进行索引和key的匹配)
     if path, err := root.IndexOrGet(1, "info").Raw(); err != nil {
      log.Panic(err)
     } else {
      log.Println(path)
     }
    
        // index (按照index进行查找当前node下的元素)
        // root.Index(1).Index(0).Raw()意味着
        // root.Index(1) == "info"
        // root.Index(1).Index(0) == "num"
        // root.Index(1).Index(0).Raw() == "[11,22,33]"
     if path, err := root.Index(1).Index(0).Raw(); err != nil {
      log.Panic(err)
     } else {
      log.Println(path)
     }
    
    }
    
    // print
    // 2023/08/26 17:17:49 "z3"
    // 2023/08/26 17:17:49 {"num": [11,22,33]}
    // 2023/08/26 17:17:49 [11,22,33]

    Ast.Node支持链式调用。故我们可以从root node节点,根据path路径向下搜索指定的元素。

    index和key混用

    user := root.GetByPath("statuses", 3, "user")  // === root.Get("status").Index(3).Get("user")

    根据path进行修改数据

    func base() {
     data := `{"name": "z3","info":{"num": [11,22,33]}}`
    
     // no path return all json string
     root, err := sonic.GetFromString(data)
     if err != nil {
      log.Panic(err)
     }
    
     // according to path
     if path, err := root.GetByPath("name").Raw(); err != nil {
      log.Panic(err)
     } else {
      log.Println(path)
     }
    
     // indexOrget (同时提供index和key进行索引和key的匹配)
     if path, err := root.IndexOrGet(1, "info").Raw(); err != nil {
      log.Panic(err)
     } else {
      log.Println(path)
     }
    
     // index
     if path, err := root.Index(1).Index(0).Raw(); err != nil {
      log.Panic(err)
     } else {
      log.Println(path)
     }
    
     // set
        // ast提供了很多go类型转换node的函数
     if _, err := root.Index(1).SetByIndex(0, ast.NewArray([]ast.Node{
      ast.NewNumber("101"),
      ast.NewNumber("202"),
     })); err != nil {
      log.Panic(err)
     }
    
     raw, _ := root.Raw()
     log.Println(raw)
    }
    
    // print
    // 2023/08/26 17:23:55 "z3"
    // 2023/08/26 17:23:55 {"num": [11,22,33]}
    // 2023/08/26 17:23:55 [11,22,33]
    // 2023/08/26 17:23:55 {"name":"z3","info":{"num":[101,202]}}

    序列化

    func base() {
     
     data := `{"name": "z3","info":{"num": [11,22,33]}}`
    
     // no path return all json string
     root, err := sonic.GetFromString(data)
     if err != nil {
      log.Panic(err)
     }
     bts, _ := root.MarshalJSON()
     log.Println("Ast.Node(Marshal): ", string(bts))
    
     btes, _ := json.Marshal(&root)
     log.Println("encoding/json (Marshal): ", string(btes))
    
    }
    
    // print
    // 2023/08/26 17:39:06 Ast.Node(Marshal):  {"name": "z3","info":{"num": [11,22,33]}}    
    // 2023/08/26 17:39:06 encoding/json (Marshal):  {"name":"z3","info":{"num":[11,22,33]}}

    ⚠: 使用json.Marshal() (必须传递指向节点的指针)

    API

    Ast.Node提供了许多有特色的API,感兴趣的朋友可以去试一下。

    • 合法性检查:Check(), Error(), Valid(), Exist()
    • 索引:Index(), Get(), IndexPair(), IndexOrGet(), GetByPath()
    • 转换至 go 内置类型:Int64(), Float64(), String(), Number(), Bool(), Map[UseNumber|UseNode](), Array[UseNumber|UseNode](), Interface[UseNumber|UseNode]()
    • go 类型打包:NewRaw(), NewNumber(), NewNull(), NewBool(), NewString(), NewObject(), NewArray()
    • 迭代:Values(), Properties(), ForEach(), SortKeys()
    • 修改:Set(), SetByIndex(), Add()

    最佳实践

    预热

    由于 Sonic 使用 golang-asm 作为 JIT 汇编器,这个库并不适用于运行时编译,第一次运行一个大型模式可能会导致请求超时甚至进程内存溢出。为了更好地稳定性,我们建议在运行大型模式或在内存有限的应用中,在使用 Marshal()/Unmarshal() 前运行 Pretouch()。

    拷贝字符串

    当解码 没有转义字符的字符串时, sonic 会从原始的 JSON 缓冲区内引用而不是复制到新的一个缓冲区中。这对 CPU 的性能方面很有帮助,但是可能因此在解码后对象仍在使用的时候将整个 JSON 缓冲区保留在内存中。实践中我们发现,通过引用 JSON 缓冲区引入的额外内存通常是解码后对象的 20% 至 80% ,一旦应用长期保留这些对象(如缓存以备重用),服务器所使用的内存可能会增加。我们提供了选项 decoder.CopyString() 供用户选择,不引用 JSON 缓冲区。这可能在一定程度上降低 CPU 性能

    func base() {
        // 在sonic.Config中进行配置
     snc := sonic.Config{
      CopyString: true,
     }.Froze()
    }

    传递字符串还是字节数组

    为了和 encoding/json 保持一致,我们提供了传递 []byte 作为参数的 API ,但考虑到安全性,字符串到字节的复制是同时进行的,这在原始 JSON 非常大时可能会导致性能损失。因此,你可以使用 UnmarshalString() 和 GetFromString() 来传递字符串,只要你的原始数据是字符串,或零拷贝类型转换对于你的字节数组是安全的。我们也提供了 MarshalString() 的 API ,以便对编码的 JSON 字节数组进行零拷贝类型转换,因为 sonic 输出的字节始终是重复并且唯一的,所以这样是安全的。

    零拷贝类型转换是一种技术,它允许你在不进行实际数据复制的情况下,将一种数据类型转换为另一种数据类型。这种转换通过操作原始内存块的指针和切片来实现,避免了额外的数据复制,从而提高性能并减少内存开销.

    需要注意的是,零拷贝类型转换虽然可以提高性能,但也可能引入一些安全和可维护性的问题,特别是当直接操作指针或内存映射时。

    性能优化

    在 完全解析的场景下, Unmarshal() 表现得比 Get()+Node.Interface() 更好。

    func base() {
     data := `{"name": "z3","info":{"num": [11,22,33]}}`
    
     // complete parsing
     m := map[string]interface{}{}
     sonic.Unmarshal([]byte(data), &m)
    
    }

    但是如果你只有特定 JSON的部分模式,你可以将 Get() 和 Unmarshal() 结合使用:

    func base() {
     data := `{"name": "z3","info":{"num": [11,22,33]}}`
    
     // complete parsing
     m := map[string]interface{}{}
     sonic.Unmarshal([]byte(data), &m)
    
     // partial parsing
     clear(m)
     node, err := sonic.GetFromString(data, "info", "num", 1)
     if err != nil {
      panic(err)
     }
     log.Println(node.Raw())
    }

    原文链接

    https://juejin.cn/post/7271429136659791907

    相关资料

    [1]https://en.wikipedia.org/wiki/Jit: https://en.wikipedia.org%2Fwiki%2FJit

    [2]https://github.com/chenzhuoyu/asm2asm: https://github.com%2Fchenzhuoyu%2Fasm2asm

    本文转载自微信公众号「 程序员升级打怪之旅」,作者「 Shyunn&王中阳Go」,可以通过以下二维码关注。

    转载本文请联系「 程序员升级打怪之旅」公众号。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论