Go每日一库之186:sonic(高性能JSON库)

2023-09-30 09:07:32 浏览数 (1)

介绍

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

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

Sonic的特色

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

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

Sonic的设计

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

在细节上,Sonic进行了一些进一步的优化:

  1. 由于 Golang 中的原生汇编函数不能被内联,发现其成本甚至超过了 C 编译器的优化所带来的改善。所以在 JIT 中重新实现了一组轻量级的函数调用:
  • 全局函数表 静态偏移量,用于调用指令
  • 使用寄存器传递参数
  1. Sync.Map 一开始被用来缓存编解码器,但是对于准静态(读远多于写),元素较少(通常不足几十个)的场景,它的性能并不理想,所以使用开放寻址哈希和 RCU 技术重新实现了一个高性能且并发安全的缓存。

详细设计文档可查阅introduction

安装使用

当前我使用的go version是1.21

官方建议的版本:

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

基本使用

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

序列化/反序列化

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

代码语言:javascript复制
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,以处理多个值并减少内存消耗

代码语言:javascript复制
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是什么?

我们可以通过查看源码:

代码语言:javascript复制
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

代码语言:javascript复制
// 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的结构。

代码语言:javascript复制
// 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操作

代码语言:javascript复制
func base() {
	snc := sonic.Config{
		CompactMarshaler: true,
		SortMapKeys:      true,
	}.Froze()

	snc.Marshal(obj)
}

考虑到排序带来的性能损失(约 10% ), sonic 默认不会启用这个功能。 Sonic 默认将基本类型( structmap 等)编码为紧凑格式的 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的使用。

准备数据

代码语言:javascript复制
data := `{"name": "z3","info":{"num": [11,22,33]}}`

将数据转换为Ast.Node

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

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

  • 整数是目标索引(>=0),表示以数组形式搜索当前节点。
  • 字符串为目标key,表示搜索当前节点为对象。
代码语言:javascript复制
// 函数签名:
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...)
}

获取当前节点的完整数据

代码语言:javascript复制
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或者索引获取数据

代码语言:javascript复制
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混用

代码语言:javascript复制
user := root.GetByPath("statuses", 3, "user")  // === root.Get("status").Index(3).Get("user")

根据path进行修改数据

代码语言:javascript复制
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]}}

序列化

代码语言:javascript复制
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 性能

代码语言:javascript复制
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() 更好。

代码语言:javascript复制
func base() {
	data := `{"name": "z3","info":{"num": [11,22,33]}}`

	// complete parsing
	m := map[string]interface{}{}
	sonic.Unmarshal([]byte(data), &m)

}

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

代码语言:javascript复制
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())
}

参考

github.com/sonic

0 人点赞