介绍
我们在日常开发中,常常会对JSON进行序列化和反序列化。Golang提供了encoding/json
包对JSON进行Marshal/Unmarshal
操作。但是在大规模数据场景下,该包的性能和开销确实会有点不够看。在生产环境下,JSON 序列化和反序列化会被频繁的使用到。在测试中,CPU使用率接近 10%,其中极端情况下超过 40%。因此,JSON 库的性能是提高机器利用率的关键问题。
Sonic是一款由字节跳动开发的一个全新的高性能、适用广泛的 JSON 库。在设计上借鉴了多款JSON库,同时为了实现对标准库的真正插拔式替换,Sonic使用了 JIT** (即时编译)**。
Sonic的特色
我们可以看出:Sonic是一个主打快的JSON库。
- 运行时对象绑定,无需代码生成
- 完备的 JSON 操作 API
- 快,更快,还要更快!
Sonic的设计
- 针对编解码动态汇编的函数调用开销,使用 JIT 技术在运行时组装与模式对应的字节码(汇编指令),最终将其以 Golang 函数的形式缓存在堆外内存上。
- 针对大数据和小数据共存的实际场景,使用预处理判断(字符串大小、浮点数精度等)将 SIMD 与标量指令相结合,从而实现对实际情况的最佳适应。
- 对于 Golang 语言编译优化的不足,使用 C/Clang 编写和编译核心计算函数,并且开发了一套 asm2asm 工具,将经过充分优化的 x86 汇编代码转换为 Plan9 格式,最终加载到 Golang 运行时中。
- 考虑到解析和跳过解析之间的速度差异很大, 惰性加载机制当然也在 AST 解析器中使用了,但以一种更具适应性和高效性的方式来降低多键查询的开销。
在细节上,Sonic进行了一些进一步的优化:
- 由于 Golang 中的原生汇编函数不能被内联,发现其成本甚至超过了 C 编译器的优化所带来的改善。所以在 JIT 中重新实现了一组轻量级的函数调用:
- 全局函数表 静态偏移量,用于调用指令
- 使用寄存器传递参数
Sync.Map
一开始被用来缓存编解码器,但是对于准静态(读远多于写),元素较少(通常不足几十个)的场景,它的性能并不理想,所以使用开放寻址哈希和 RCU 技术重新实现了一个高性能且并发安全的缓存。
详细设计文档可查阅introduction
安装使用
当前我使用的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还支持流式的输入输出
代码语言:javascript复制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
是什么?
我们可以通过查看源码:
代码语言: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
// 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 默认将基本类型(
struct
,map
等)编码为紧凑格式的 JSON ,除非使用json.RawMessage
orjson.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,表示搜索当前节点为对象。
// 函数签名:
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 性能
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())
}
参考
github.com/sonic