阿里妹指南
本文作者从评价标准、功能评价、性能评价等多个方面分析了三方图书馆的优势,并给出了更为务实的建议。 (后台回复【Java单元测试实践】可以获得电子书)
介绍
为了方便朋友们的理解,我对文章中提到的术语和术语的解释进行了总结。 请放心阅读。 欢迎大家一起讨论交流!
你真的了解Go标准库吗?
问题1:标准库可以反序列化普通字符串吗? 下面的代码会报错吗?
var s string
err := json.Unmarshal([]byte(`"Hello, world!"`), &s)
assert.NoError(t, err)
fmt.Println(s)
// 输出:
// Hello, world!
解决方案:其实标准库解析不仅支持对象和数组,还支持字符串、数字、布尔值和空值,但需要注意的是,上述字符串中的双引号不能缺失,否则会不是合法的json序列,返回错误。
问题2:如果结构体的json标签定义与key大小不一致,反序列化能否成功?
cert := struct {
Username string `json:"username"`
Password string `json:"password"`
}{}
err = json.Unmarshal([]byte(`{"UserName":"root","passWord":"123456"}`), &cert)
if err != nil {
fmt.Println("err =", err)
} else {
fmt.Println("username =", cert.Username)
fmt.Println("password =", cert.Password)
}
// 输出:
// username = root
// password = 123456
解决方案:如果遇到大小写问题,标准库会尝试转换大小写,即如果某个key与结构体中的定义不同,但忽略大小写后是一样的,仍然可以赋值到田野。
为什么要使用第三方库以及标准库的缺点是什么?
Go json标准库/json[1]已经提供了一个足够舒适的json处理工具,受到Go开发者的广泛好评,但是仍然存在两个问题:
基于以上考虑,业务会根据使用场景、降低成本和效益的要求,引入合适的第三方库。
哪些三方库强?
首先,思考从提问开始:
以下是我收集整理的一些开源第三方库。 也欢迎感兴趣的小伙伴一起交流补充!
库名
星级数 (2023.04.19)
社区维护
(/json)[2]
✔️
✔️
不适用
(/)[3]
✔️
✔️
❌
1.9k
贫穷的
GJson (/gjson)[4]
✔️
✔️
❌
12.1k
更好的
(错误/)[5]
✔️
✔️
❌
5k
贫穷的
(json-/go)[6]
✔️
✔️
部分兼容
12.1k
贫穷的
(goccy/go-json)[7]
✔️
✔️
✔️
2.2k
更好的
(/)[8]
✔️
✔️
❌
4.1k
贫穷的
索尼克(/索尼克)[9]
✔️
✔️
✔️
4.1k
更好的
评判标准
评判标准包括三个维度:
从功能划分上,根据主流json库API,分为三种使用方式:
功能评价
特征分析
绩效评估
以下是性能评估中使用的各个包的版本。 具体测试代码请参考[10]。
为什么GJson库没有被审核?
GJson在单键搜索的场景下有很大的优势。 这是因为它的搜索是通过lazy-load实现的,巧妙地跳过了传值,有效减少了很多不必要的解析,但实际上,跳过也是一种轻量级的解析,实际上是在处理json控制字符“[”,“{ “, ETC; 然而,在多键查找方面,Gjson 的表现比标准库更糟糕,这是其跳过机制的副作用:同路径查找的重复开销。 对于它的使用场景,简单地评价定性编解码器和通用编解码器对GJson来说是不公平的。
根据样本json的关键数量和深度,分为三个级别:
在评估过程中,不仅要注意区分通用编解码和定型编解码,还要考虑并发条件下的性能。 测试代码示例如下:
func BenchmarkEncoder_Generic_StdLib(b *testing.B) {
_, _ = json.Marshal(_GenericValue)
b.SetBytes(int64(len(TwitterJson)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(_GenericValue)
}
}
func BenchmarkEncoder_Binding_StdLib(b *testing.B) {
_, _ = json.Marshal(&_BindingValue)
b.SetBytes(int64(len(TwitterJson)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = json.Marshal(&_BindingValue)
}
}
func BenchmarkEncoder_Parallel_Generic_StdLib(b *testing.B) {
_, _ = json.Marshal(_GenericValue)
b.SetBytes(int64(len(TwitterJson)))
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _ = json.Marshal(_GenericValue)
}
})
}
func BenchmarkEncoder_Parallel_Binding_StdLib(b *testing.B) {
_, _ = json.Marshal(&_BindingValue)
b.SetBytes(int64(len(TwitterJson)))
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _ = json.Marshal(&_BindingValue)
}
})
}
具体指标数据及统计结果参见文献[14]。 总体结论如下:
常见的优化思路有哪些?
定型编解码器
对于一些刻板的编解码器场景,许多操作实际上并不需要在“运行时”执行。 例如,业务模型中确定某个json key的值必须是类型。 其实这个对象对应的json值(true/false)可以在序列化阶段直接输出,不需要检查这个对象的具体类型。 其核心思想是优化数据处理逻辑,将模型解释与数据处理逻辑分离,让前者提前固定下来,从而消除反射,提高性能。
动态功能组装
只需使用函数组装模式即可。 具体做法是将结构体解释为逐字段的编解码函数,然后组装缓存为整个对象对应的编解码器,然后在运行时加载处理json,减少了性能损失成本反射降至最低。
这个优化能一劳永逸吗?
一点也不。 因为这样的实现会转化为大量的接口封装和函数调用栈。 实际测试中会发现,随着json数据量级的增大,由于调用接口涉及到动态寻址,导致汇编函数无法内联,函数调用的性能很差(没有-by) -注册参数传递),而函数调用的开销也成倍增加。
减少函数调用
为了避免动态汇编的函数调用开销,目前业界主要有两种实现方式:code-gen(代码生成)和JIT(即时编译)。
代码生成
就用代码生成的思想吧。 这种实现方式对于库开发者来说实现起来比较简单,并且性能较高; 但随之而来的是模式依赖性和便利性的丧失,增加了业务代码的维护成本和局限性,并且无法实现同样是代码生成的秒级热更新。 之所以json库的受众并不广。
准时生产
即时编译将编译过程移至程序的加载或第一个解析阶段。 只需要提供json对应的结构体类型信息,就可以一次性编译生成对应的编解码器,通常会以函数的形式缓存在堆外内存中,方便后期高效执行。
// 函数缓存
type cache struct {
functions map[*rtype]function
lock sync.Mutex
}
var (
global = func() [caches]*cache {
var caches [caches]*cache
for idx := range caches {
caches[idx] = &cache{functions: make(map[*rtype]function, 4)}
}
return caches
}()
)
func load(typ *rtype) (function, bool) {
do, ok := global[uintptr(unsafe.Pointer(typ))%caches].functions[typ]
return do, ok
}
func save(typ *rtype, do function) {
cache := global[uintptr(unsafe.Pointer(typ))%caches]
cache.lock.Lock()
cache.functions[typ] = do
cache.lock.Unlock()
}
通用编解码器
其实通用编解码性能差不仅仅是因为没有,因为你可以对比一下C++的json库,比如[15],它的解析方式都是通用的,但是性能还是很好的;
Go标准库的通用解析性能较差,因为它使用map[]{}作为json的编解码对象。 这实际上是一个糟糕的选择,原因如下:
如果使用更接近json AST的数据结构来描述,不仅可以简化转换过程,甚至可以实现lazy-load。
多路编码缓冲器
通过使用sync.Pool重用之前编码的缓冲区,可以有效减少内存分配的次数。
type buffer struct {
data []byte
}
var bufPool = sync.Pool{
New: func() interface{} {
return &buffer{data: make([]byte, 0, 1024)}
},
}
// 复用缓冲区
buf := bufPool.Get().(*buffer)
data:= encode(buf.data)
newBuf := make([]byte, len(data))
copy(newBuf, buf)
buf.data = data
bufPool.Put(buf)
Sonic 库为什么性能好?
原理研究
Sonic基于汇编开发,充分利用矢量化(SIMD)指令、优化内存布局、按需解析等关键技术,极大提升了序列化和反序列化的性能。
其优化思路可以分为离线和在线:
为什么不使用CGO?
虽然使用CGO实现起来比较容易,但是CGO在调用c代码时引入了调度、切换线程栈等开销,会造成很大(某些场景高达20倍)的性能损失,并且无法深度优化代码。
单指令多数据流
什么是 SIMD?
SIMD(--Multi-Data Data )是一种利用一个控制器控制多个处理器,同时对一组数据中的每个数据进行相同操作,从而实现空间并行的方法。技术。 例如:X86的SSE或AVX2指令集,以及ARM的NEON指令集。 它作为一组特殊的 CPU 指令用于并行处理矢量数据。 目前大多数CPU都支持,广泛应用于图像处理和大数据计算,当然在json处理中也很有用。
SIMD在json处理中解决什么问题?
用于json文本的处理和计算。 其中一些问题业界已经有了比较成熟、高效的解决方案,比如浮点数转字符串的算法Ryu、整数转字符串的查表方法等; 对于有些问题,逻辑比较简单,但是可能面临大规模文本,比如json的\quote处理、空白字符跳过等,还需要一些技术手段来提高处理能力,而SIMD是一种并行的技术大规模数据的处理。 -go[16]在大型json场景(>100KB)很有竞争力。 然而,对于一些极小或不规则的字符串,SIMD所需的额外加载操作会导致性能下降。 因此,针对大数据和小数据并存的实际场景,使用预设的条件来判断(字符串大小、浮点精度等),结合SIMD和标量指令,以达到最佳的适应性。
为了提高执行效率,Sonic中的一些关键计算函数采用C语言编写,并使用Clang编译; 然而,由于 Clang 编译 x86 程序集,因此它编译 plan9 程序集; 如何嵌入优化后的程序集成为一个问题,因此为了在Byte中调用Clang编译的程序集,开发了一个内部工具tools/来将x86程序集转换为plan9。
即时编译
Sonic借鉴了组装各类处理函数的实现方式。 针对编解码器动态组装的函数调用开销,采用JIT技术在运行时组装模式对应的操作码(asm),最终以函数的形式缓存在堆外内存中。 由于编译后的编解码函数是集成函数,因此在保证灵活性的同时可以大大减少函数调用; 对于已知json对应结构的业务场景,在线JIT组装比较耗时,会导致耗时较高,也可以提前生成组装后的字节码。
RCU缓存
为了提高Codec Cache的加载速度,可以将每个结构体对应的序列化/反序列化字节码缓存起来,以后直接调用,以减少运行时汇编操作的执行次数(缓存足够大时只需执行一次)。 Sync.Map最初是用来缓存编解码器的,但是对于准静态(读多于写)和元素较少(通常不超过几十个)的场景,其性能并不理想,所以使用“”开放寻址哈希+RCU技术”重新实现了高性能且同时安全的缓存。
定制 AST
对于通用编解码,基于map开销较高的考虑,Sonic实现了更符合json结构的树状AST; 它用自定义通用通用数据容器 sonic-ast 取代了 Go,从而提高了性能。
使用node {type, , }来表示任意json数据节点,结合树和数组结构来描述节点之间的层次关系。 对于部分解析,考虑到解析和跳过之间巨大的速度差距,在 AST 解析器中加入了延迟加载机制,以更加自适应和高效的方式减少多键查询的开销。
type Node struct {
v int64
t types.ValueType
p unsafe.Pointer
}
如何实现部分解析?
sonic-ast 实现了一个有状态的、可扩展的 json 解析过程。 当用户获取到key时,使用skip计算,轻轻跳过要获取的key之前的json文本; 对于key后面的json节点,不直接进行解析过程; 只保存用户真正需要的密钥。 完全解析了。
如何解决同一路径查找重复开销的问题?
子节点的skip处理过程增加了一步,记录跳过的json的key、起始位、结束位,并指定一个Raw-JSON类型的节点保存,这样第二次skip就可以直接根据Node ,解决了相同路径查找带来的重复开销问题。 同时,sonic-ast支持更新、插入和序列化节点,甚至支持将任何Go类型转换为节点并保存。
函数调用优化
商业实践
适用场景
由于Sonic对json操作进行了优化,因此在json操作的cpu开销占比较大的业务场景中,好处会更加明显。 如网关、转发和入口服务等。
快速试用
评估切换是否值得,以验证 Sonic 将以较少侵入的方式为您的服务带来性能改进。 推荐使用-/[17]工具库。 内部实现是向被hook的函数地址写入跳转指令,直接跳转到新的函数地址。
使用方法:在main函数入口处,hook当前使用的json库函数作为Sonic中的等效函数。 hook是在函数层面,因此可以详细验证具体函数的性能提升; 当人们不信任某些功能,或者他们有更好的性能或更稳定的实现时,他们也可以使用Sonic来实现某些功能。 但需要注意的是,尚未在生产环境中进行验证,建议仅用于测试。 请记住,在线更改时,必须满足“可监控”、“可灰化”、“可回滚”三个原则。
import "github.com/brahma-adshonor/gohook"
func main() {
// 在main函数的入口hook当前使用的json库(如encoding/json)
gohook.Hook(json.Marshal, sonic.Marshal, nil)
gohook.Hook(json.Unmarshal, sonic.Unmarshal, nil)
}
收益
截至2022年1月,Sonic已应用于抖音、今日头条等服务,节省数十万核字节。 下图是使用Sonic(sonic:基于JIT技术的开源全场景高性能JSON库)后,一个Byte服务在高峰时段占用CPU核数的对比。 在生产环境中,Sonic也验证了良好的效益,高峰时段使用的核心数减少了近三分之一:
同时,我们将某在线服务的HTTP查询接口的JSON序列化从标准库切换到Sonic库后,在相同QPS水平下CPU利用率下降了近3个点,效果还不错。
使用说明HTML
标准库中默认启用了html,但Sonic由于性能损失而没有默认启用。
func TestEncode(t *testing.T) {
data:= map[string]string{"&&": "<>"}
// 标准库
var w1 = bytes.NewBuffer(nil)
enc1 := json.NewEncoder(w1)
err := enc1.Encode(data)
assert.NoError(t, err)
// Sonic 库
var w2 = bytes.NewBuffer(nil)
enc2 := encoder.NewStreamEncoder(w2)
err = enc2.Encode(data)
assert.NoError(t, err)
fmt.Printf("%v%v", w1.String(), w2.String())
}
// 运行结果:
{"\u0026\u0026":"\u003c\u003e"}
{"&&":"<>"}
如果需要,可以通过以下方法启用:
import "github.com/bytedance/sonic/encoder"
v := map[string]string{"&&":"<>"}
ret, err := encoder.Encode(v, EscapeHTML) // ret == `{"\u0026\u0026":{"X":" \u003e"}}`
enc := encoder.NewStreamEncoder(w)
enc.SetEscapeHTML(true)
err := enc.Encode(obj)
大模式问题
由于 Sonic 使用 -asm 作为 JIT 汇编器,不太适合运行时编译,因此第一次运行大型模式可能会导致请求超时甚至处理 OOM。 为了获得更好的稳定性,对于大模式或内存紧张的应用程序,建议在 ()/() 之前使用 ()。
import (
"reflect"
"github.com/bytedance/sonic"
"github.com/bytedance/sonic/option"
)
func init() {
var v HugeStruct
// For most large types (nesting depth <= option.DefaultMaxInlineDepth)
err := sonic.Pretouch(reflect.TypeOf(v))
// with more CompileOption...
err := sonic.Pretouch(reflect.TypeOf(v),
// If the type is too deep nesting (nesting depth > option.DefaultMaxInlineDepth),
// you can set compile recursive loops in Pretouch for better stability in JIT.
option.WithCompileRecursiveDepth(loop),
// For a large nested struct, try to set a smaller depth to reduce compiling time.
option.WithCompileMaxInlineDepth(depth),
)
}
按键排序
默认情况下,Sonic 在序列化时不对键进行排序。 json的规范与顺序无关,但如果需要json有序,可以在序列化时选择排序配置,这会带来10%的性能损失。 排序方法如下:
import "github.com/bytedance/sonic"
import "github.com/bytedance/sonic/encoder"
// Binding map only
m := map[string]interface{}{}
v, err := encoder.Encode(m, encoder.SortMapKeys)
// Or ast.Node.SortKeys() before marshal
var root := sonic.Get(JSON)
err := root.SortKeys()
目前不支持arm架构
现象:Mac M1编译失败。 解决方法请参考sonic-[19]。 编译时添加以下参数:=amd64可以解决编译失败的问题,但仍然无法支持本地Debug操作;
官方在[20]中表示,由于内部实现原因,这个问题确实很难解决,但仍有望在Sonic V2的大版本中得到支持。
总结
综上所述,业务选型需要根据具体情况、业务使用场景以及不同领域的发展趋势进行,综合考虑各种因素。 最适合的生意就是最好的! 例如:如果业务只是简单的解析http请求返回的json字符串的一些字段,并且字段都已经确定,并且偶尔需要搜索功能,那么Gjson是一个不错的选择。
以下是一些个人观点,仅供参考:
参考:
[1]
[2]
[3]
[4]
[5]
[6]
[7]
[8]
[9]
[10]
[11]
[12]
[13]
[14]
[15]
[16]
[17]
[19]#
[20]
阿里云开发者社区,千万开发者的选择
阿里云开发者社区,拥有百万优质技术内容、数千门免费系统课程、丰富的体验场景、活跃的社区活动、行业专家分享交流,欢迎点击【阅读原文】加入我们。