写作背景
最近业务高峰期自动化营销某服务内存告警频繁、偶尔 oom,该服务主要是处理大量数据(工作日每天数据几百万)执行自动化操作。
最近也没有迭代、也没有改造底层触发引擎层,难道是数据量又增加了?马上打开监控果不其然,数据量增加了不少。
问题定位
问题是由可观测平台的一条告警发现的,因为业务非常重要,有任何告警我们都不会错过。
内存资源快打满了,但cou资源并不高,打开 grafana 监控。
业务高峰期内存和 cpu 都有明显瞬时波峰。另外内存消耗板块可以看出已有 pod 重启了。
尝试用下命令抓内存数据分析下。(ip和端口是我本地模拟的,非线上ip)
1 | go复制代码go tool pprof http://192.168.50.73:6060/debug/pprof/heap |
选择 pdf 即可
打开 pdf 文件,线头越粗表示内占用越高。发现 NewLz4Provider 函数内存使用高,看了pulsar 包源码,数据压缩用的 lz4。
压缩方式有多种,于是同事对底层压缩方式做了压测。
看了下压测结果,我们并没有着急替换底层压缩方式,我们使用的内部组件对 puslar client 进行二次封装用了协程池,并发数越高内存占用也越高 ,评估下来应该没有问题(可以调整协程池降低协程数量)。
虽然配置了监控 cpu、内存达到某一个阀值自动抓 pod 运行时内存、cpu 数据。瞬时波峰时间比较短,存活的对象内存分配采样很难抓到,决定重新研究下 pprof,发现 allocs 可以查看过去所有的内存分配,这里面会不会有蛛丝马迹?决定研究一番,如下图:
执行下面命令
1 | go复制代码go tool pprof http://192.168.50.73:6060/debug/pprof/allocs |
选择 pdf
找到 pdf 文件打开,一路往下拉发现有两处历史内存分配比较高。
日志库
日志库历史内存分配如下:
redis 库
redis 库历史内存分配如下:
研究了这两处代码调用都指向了 go 官方 json 库,排查了一波线上埋点日志发现在业务高峰期
日志打的多,日志 body 基本是中型数据。
这两处日志输出在业务高峰期和内存波动基本吻合。
猜测因为这两个基础组件底层都用了 golang json 库,json 函数序列化和反序列化内存占用比较大,在业务高峰期,造成内存波动大。
问题优化
于是决定按照下面三条优化方案快速发布上线看看效果
1、减少日志输出,非必要场景去掉日志打印。
2、减少日志包大小,部分场景只打印关键字段,用于定位问题。
3、决定换一个 json 库。
json 库调研
替换 json 库我主要考虑下面 2 方面
1、编码和解码性能高;
2、兼容官方 json 库,可以做到无缝替换,代码改造范围控制在最小。
主要调研了下面几款 json 库。
json-iterator
github 地址
1 | vbnet复制代码https://github.com/json-iterator/go |
100% 兼容官方 json 库,非常友好,并且性能也很高。
官方性能压测结果:
于是决定翻翻使用姿势,使用上可以比较方便替换官方库,没啥改动成本低。
jsonparser
github 地址
1 | bash复制代码github.com/buger/jsonparser |
性能好,但只有json字符串解析为结构体/map功能,没有将结构体转为json字符串的功能。
1 | css复制代码func main() { |
通过字符匹配获取数值我觉得不好用,果断放弃了。
fastjson
github 地址
1 | bash复制代码github.com/valyala/fastjson |
1 | swift复制代码var p fastjson.Parser |
性能也很好但是只能解析JSON字符串,而没法生成JSON(即只有Unmarshal,没有Marshal)。看仓库已经很久没人维护了,也果断放弃了。
sonic(字节)
github 地址
1 | arduino复制代码https://github.com/bytedance/sonic |
基本是兼容官方库的,官方给出的压测结果来看比 json-iterator 性能还高,如下截图:
压测性能对比
由于 json-iterator 比较主流、sonic 性能最好,最后决定在 go 官方库、json-iterator、sonic之间压测做对比。
sonic 有一个兼容性要考虑,大家注意下:
编码和解码性能对比
进入压测文件目录,执行下面命令
1 | ini复制代码go test -test.bench=".*" -benchmem |
编码 Marshal
解码 Unmarshal
sonic 真的神奇,解码只分配了 4 次内存,单次耗时是标准库的 1/3;sonic 单次分配消耗内存是最高的;标准库表现就没那么惊艳了单次耗时慢,分配次数也不低;json-iterator 内存分配次数最高,单次分配内存最小,单次分配最快。
内存消耗测试
由于 sonic 内存分配这块非常牛逼,于是我决定测试下这三个包真实内存消耗(只测试了解码),重点研究下 sonic。
验证内存分配情况我比较喜欢用下面两种方案。
1、pprof;
2、runtime.ReadMemStats。
先介绍 TotalAlloc、HeapAlloc、Alloc 三个关键字区别
TotalAlloc:分配过堆内存累计字节数,随着内存分配的增加而增加,但不受 GC 影响,所以不会减少。
HeapAlloc、Alloc:已分配堆对象的字节数,随着 GC 清理而减少,
Sonic 历史分配了 29438 MB+ 内存。
1 | ini复制代码sonic库,TotalAlloc: 30710325328,HeapAlloc=3815240,HeapAlloc=3815240 |
GO 官方标准库分配了 5373 MB+ 内存,比 Sonic 分配还低。
1 | ini复制代码std标准库,TotalAlloc: 5634131232,HeapAlloc=756392,HeapAlloc=756392 |
json-iterator 分配了 4000 MB+ 内存,对比下来 json-iterator 历史分配内存是最低的。
1 | ini复制代码iterator库,TotalAlloc: 4227468600,HeapAlloc=3024600,Alloc=3024600 |
从内存分配结果来看,sonic 在整个压测过程中历史分配过的内存是最大的。垃圾回收之后内存差异不大。
sonic 内存为什么分配这么大
于是继续翻了翻官方文档,看了下面这段描述研发 sonic 背景是优化他们的 cpu 资源。参考:
github.com/bytedance/s…
看来对内存优化这块可能并没有那么好,但看了一些官方解决方案,决定尝试下。
预热
在使用 Marshal()/Unmarshal() 前运行了 Pretouch() 没有啥效果,因为我们的场景并非大模式。
字符串拷贝
翻了翻 sonic.Unmarshal() 源码,Unmarshal 使用默认 Config ConfigDefault ,CopyString 为 true 指解码器通过复制而不是引用来解码字符串值。源码如下:
1 | go复制代码var ( |
稍微改造下代码,CopyString 设置为 false。
1 | go复制代码config := sonic.Config{ |
测试后并没有太大区别。
如果你在使用过程中,ConfigDefault 不满足你的需求,sonic 支持你自定义配置,参考:sonic.Config 里面有一些你可以自定义配置 。
泛型的性能优化
我们是完全解析场景,Get()+Unmarshal() 方案是用不上了。
意外外发现
另外同事发现有一个 issue ,打包后可执行文件翻倍了(我没有亲测过)。
Execute file size is too big, can sonic be optimized when compile? · Issue #574 · bytedance/sonic · GitHub
看官方描述是为了提高 C-Go 内部调用性能,从回复来看这个 issue 目前还没有解决哦。
另外发现 gin 框架也支持 sonic 了。
benchmark 代码
下面是我写的压测代码,大家可以相互探讨下。
std 标准库
1 | less复制代码import ( |
sonic(字节)
1 | less复制代码import ( |
json-iterator
1 | less复制代码import ( |
json 库替换+上线效果
从监控来看,内存优化才是本次重点,最终决定用 json- iterator 替换官方 json 库,代码改造也非常简单。替换代码如下:
1 | kotlin复制代码var ( |
为什么会用 ConfigCompatibleWithStandardLibrary ?翻了翻源码,官方给出的是 100% 兼容标准库。
1 | csharp复制代码// ConfigCompatibleWithStandardLibrary tries to be 100% compatible with standard library behavior |
当然他也有默认的 Config,也支持自定义参数,源码位置参考:
1 | bash复制代码github.com/json-iterator/go@v1.1.12/config.go |
下面是上线后优化效果
从最近几天的监控来看,按照下面3点优化后是有效果的。
1、减少日志输出,非必要场景去掉日志打印。
2、减少日志包大小,部分场景只打印关键字段,用于定位问题。
3、决定换一个 json 库。
看监控和 pprof 采样,pod 常驻内存还是不小,后续还会持续优化。如果想了解后续优化方案关注我。
2024.03.20 日更新
使用 jsoniter 发现在嵌套情况下,会 Panic。
1 | css复制代码import ( |
上面代码结果输出如下
1 | arduino复制代码=== RUN TestJson |
使用上有一些缺陷,所以大家在替换时需谨慎。
2024.04.22更新
上文不是提过修改 puslar底层压缩方式吗?最近把压缩方式调成”ZLIB”压缩方式,上线后效果如下。
参考文献
sonic :基于 JIT 技术的开源全场景高性能 JSON 库
GitHub - bytedance/sonic: A blazingly fast JSON serializing & deserializing library
GitHub - json-iterator/go: A high-performance 100% compatible drop-in replacement of “encoding/json”
本文转载自: 掘金