写作背景
最近业务高峰期自动化营销某服务内存告警频繁、偶尔 oom,该服务主要是处理大量数据(工作日每天数据几百万)执行自动化操作。
最近也没有迭代、也没有改造底层触发引擎层,难道是数据量又增加了?马上打开监控果不其然,数据量增加了不少。
问题定位
问题是由可观测平台的一条告警发现的,因为业务非常重要,有任何告警我们都不会错过。
内存资源快打满了,但cou资源并不高,打开 grafana 监控。
业务高峰期内存和 cpu 都有明显瞬时波峰。另外内存消耗板块可以看出已有 pod 重启了。
尝试用下命令抓内存数据分析下。(ip和端口是我本地模拟的,非线上ip)
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 可以查看过去所有的内存分配,这里面会不会有蛛丝马迹?决定研究一番,如下图:
执行下面命令
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 地址
vbnet
https://github.com/json-iterator/go
100% 兼容官方 json 库,非常友好,并且性能也很高。
官方性能压测结果:
于是决定翻翻使用姿势,使用上可以比较方便替换官方库,没啥动成本低。
jsonparser
github 地址
bash
github.com/buger/jsonparser
性能好,但只有json字符串解析为结构体/map功能,没有将结构体转为json字符串的功能。
css
func main() {
data := []byte(`{
"person": {
"name": {
"first": "Leonid",
"last": "Bugaev",
"fullName": "Leonid Bugaev"
},
"github": {
"handle": "buger",
"followers": 109
},
"avatars": [
{ "url": "https://avatars1.githubusercontent.com/u/14009?v=3&s=460", "type": "thumbnail" }
]
},
"company": {
"name": "Acme"
}
}`)
val, tp, offset, err := jsonparser.Get(data, "person", "github", "handle")
if err != nil {
panic(err)
}
fmt.Println(string(val))
fmt.Println(tp)
fmt.Println(offset)
}
通过字符匹配获取数值我觉得不好用,果断放弃了。
fastjson
github 地址
bash
github.com/valyala/fastjson
swift
var p fastjson.Parser
v, err := p.Parse(`{
"str": "bar",
"int": 123,
"float": 1.23,
"bool": true,
"arr": [1, "foo", {}]
}`)
if err != nil {
panic(err)
}
fmt.Printf("foo=%s\n", v.GetStringBytes("str"))
性能也很好但是只能解析JSON字符串,而没法生成JSON(即只有Unmarshal,没有Marshal)。看仓库已经很久没人维护了,也果断放弃了。
sonic(字节)
github 地址
arduino
https://github.com/bytedance/sonic
基本是兼容官方库的,官方给出的压测结果来看比 json-iterator 性能还高,如下截图:
参考地址:github.com/bytedance/s...
压测性能对比
由于 json-iterator 比较主流、sonic 性能最好,最后决定在 go 官方库、json-iterator、sonic之间压测做对比。
sonic 有一个兼容性要考虑,大家注意下:
编码和解码性能对比
进入压测文件目录,执行下面命令
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+ 内存。
ini
sonic库,TotalAlloc: 30710325328,HeapAlloc=3815240,HeapAlloc=3815240
GO 官方标准库分配了 5373 MB+ 内存,比 Sonic 分配还低。
ini
std标准库,TotalAlloc: 5634131232,HeapAlloc=756392,HeapAlloc=756392
json-iterator 分配了 4000 MB+ 内存,对比下来 json-iterator 历史分配内存是最低的。
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 指解码器通过复制而不是引用来解码字符串值。源码如下:
go
var (
// ConfigDefault is the default config of APIs, aiming at efficiency and safty.
ConfigDefault = Config{}.Froze()
// ConfigStd is the standard config of APIs, aiming at being compatible with 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 = Config{
NoQuoteTextMarshaler: true,
NoValidateJSONMarshaler: true,
}.Froze()
)
func Unmarshal(buf []byte, val interface{}) error {
return ConfigDefault.Unmarshal(buf, val)
}
稍微改造下代码,CopyString 设置为 false。
go
config := sonic.Config{
CopyString: false,
}.Froze()
err := config.Unmarshal(mediumFixture, &data)
if err != nil {
panic(err)
}
测试后并没有太大区别。
如果你在使用过程中,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 标准库
less
import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"testing"
)
func BenchmarkUnmarshalStdStruct(b *testing.B) {
b.N = n
b.ReportAllocs()
// TODO 如果仅压测可以去掉下面这3行代码
//g.Go(func() error {
// return http.ListenAndServe("192.168.50.73:6060", nil)
//})
var (
m runtime.MemStats
data MediumPayload
)
for i := 0; i < b.N; i++ {
json.Unmarshal(mediumFixture, &data)
}
runtime.ReadMemStats(&m)
fmt.Printf("std 标准库TotalAlloc: %d,HeapAlloc=%d,HeapAlloc=%d\n", m.TotalAlloc, m.HeapAlloc, m.Alloc)
// TODO 如果仅压测可以去掉下面这几行代码
//if err := g.Wait(); err != nil {
// panic(err)
//}
}
func BenchmarkMarshalStd(b *testing.B) {
b.N = n
b.ReportAllocs()
var data MediumPayload
json.Unmarshal(mediumFixture, &data)
for i := 0; i < b.N; i++ {
json.Marshal(data)
}
}
sonic(字节)
less
import (
"fmt"
"github.com/bytedance/sonic"
"net/http"
_ "net/http/pprof"
"runtime"
"testing"
)
func BenchmarkUnmarshalSonic(b *testing.B) {
b.N = n
b.ReportAllocs()
// TODO 如果仅压测可以去掉下面这3行代码
//g.Go(func() error {
// return http.ListenAndServe("192.168.50.73:6060", nil)
//})
var (
m runtime.MemStats
data MediumPayload
)
for i := 0; i < b.N; i++ {
sonic.Unmarshal(mediumFixture, &data)
}
runtime.ReadMemStats(&m)
fmt.Printf("Sonic 标准库TotalAlloc: %d,HeapAlloc=%d,HeapAlloc=%d\n", m.TotalAlloc, m.HeapAlloc, m.Alloc)
// TODO 如果仅压测可以去掉下面这几行代码
//if err := g.Wait(); err != nil {
// panic(err)
//}
}
func BenchmarkMarshalSonic(b *testing.B) {
b.N = n
b.ReportAllocs()
var data MediumPayload
sonic.Unmarshal(mediumFixture, &data)
for i := 0; i < b.N; i++ {
sonic.Marshal(data)
}
}
json-iterator
less
import (
"fmt"
jsoniter "github.com/json-iterator/go"
"golang.org/x/sync/errgroup"
"net/http"
_ "net/http/pprof"
"runtime"
"testing"
)
var jsonIterator = jsoniter.ConfigCompatibleWithStandardLibrary
var (
n = 11000000
g errgroup.Group
)
func BenchmarkUnmarshalJsoniter(b *testing.B) {
b.N = n
b.ReportAllocs()
// TODO 如果仅压测可以去掉下面这3行代码
//g.Go(func() error {
// return http.ListenAndServe("192.168.50.73:6060", nil)
//})
var (
m runtime.MemStats
data MediumPayload
)
for i := 0; i < b.N; i++ {
jsonIterator.Unmarshal(mediumFixture, &data)
}
runtime.ReadMemStats(&m)
fmt.Printf("iterator 标准库TotalAlloc: %d,HeapAlloc=%d,HeapAlloc=%d\n", m.TotalAlloc, m.HeapAlloc, m.Alloc)
// TODO 如果仅压测可以去掉下面这几行代码
//if err := g.Wait(); err != nil {
// panic(err)
//}
}
func BenchmarkMarshalJsoniter(b *testing.B) {
b.N = n
b.ReportAllocs()
var data MediumPayload
jsonIterator.Unmarshal(mediumFixture, &data)
for i := 0; i < b.N; i++ {
jsonIterator.Marshal(data)
}
}
json 库替换+上线效果
从监控来看,内存优化才是本次重点,最终决定用 json- iterator 替换官方 json 库,代码改造也非常简单。替换代码如下:
kotlin
var (
json = jsoniter.ConfigCompatibleWithStandardLibrary
)
var data YourStruct
out, err := json.Marshal(data)
json.Unmarshal(out, &data)
为什么会用 ConfigCompatibleWithStandardLibrary ?翻了翻源码,官方给出的是 100% 兼容标准库。
csharp
// ConfigCompatibleWithStandardLibrary tries to be 100% compatible with standard library behavior
var ConfigCompatibleWithStandardLibrary = Config{
EscapeHTML: true,
SortMapKeys: true,
ValidateJsonRawMessage: true,
}.Froze()
当然他也有默认的 Config,也支持自定义参数,源码位置参考:
bash
github.com/json-iterator/go@v1.1.12/config.go
下面是上线后优化效果
从最近1天的监控来看,按照下面3点优化后是有效果的。
1、减少日志输出,非必要场景去掉日志打印。
2、减少日志包大小,部分场景只打印关键字段,用于定位问题。
3、决定换一个 json 库。
看监控和 pprof 采样,pod 常驻内存还是不小,后续还会持续优化。如果想了解后续优化方案关注我。
参考文献
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"