记录一次线上 GO 服务 oom 排查以及内存优化思路「附G

写作背景

最近业务高峰期自动化营销某服务内存告警频繁、偶尔 oom,该服务主要是处理大量数据(工作日每天数据几百万)执行自动化操作。

最近也没有迭代、也没有改造底层触发引擎层,难道是数据量又增加了?马上打开监控果不其然,数据量增加了不少。

问题定位

问题是由可观测平台的一条告警发现的,因为业务非常重要,有任何告警我们都不会错过。
image.png

内存资源快打满了,但cou资源并不高,打开 grafana 监控。
image.png

业务高峰期内存和 cpu 都有明显瞬时波峰。另外内存消耗板块可以看出已有 pod 重启了。

尝试用下命令抓内存数据分析下。(ip和端口是我本地模拟的,非线上ip)

1
go复制代码go tool pprof http://192.168.50.73:6060/debug/pprof/heap

选择 pdf 即可
image.png

打开 pdf 文件,线头越粗表示内占用越高。发现 NewLz4Provider 函数内存使用高,看了pulsar 包源码,数据压缩用的 lz4。
image.png

压缩方式有多种,于是同事对底层压缩方式做了压测。
image.png

看了下压测结果,我们并没有着急替换底层压缩方式,我们使用的内部组件对 puslar client 进行二次封装用了协程池,并发数越高内存占用也越高 ,评估下来应该没有问题(可以调整协程池降低协程数量)。

虽然配置了监控 cpu、内存达到某一个阀值自动抓 pod 运行时内存、cpu 数据。瞬时波峰时间比较短,存活的对象内存分配采样很难抓到,决定重新研究下 pprof,发现 allocs 可以查看过去所有的内存分配,这里面会不会有蛛丝马迹?决定研究一番,如下图:
image.png

执行下面命令

1
go复制代码go tool pprof http://192.168.50.73:6060/debug/pprof/allocs

选择 pdf
image.png

找到 pdf 文件打开,一路往下拉发现有两处历史内存分配比较高。

日志库

日志库历史内存分配如下:

image.png

redis 库

redis 库历史内存分配如下:
image.png

研究了这两处代码调用都指向了 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 库,非常友好,并且性能也很高。
image.png

官方性能压测结果:

github.com/json-iterat…

于是决定翻翻使用姿势,使用上可以比较方便替换官方库,没啥改动成本低。
image.png

jsonparser

github 地址

1
bash复制代码github.com/buger/jsonparser

image.png

性能好,但只有json字符串解析为结构体/map功能,没有将结构体转为json字符串的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 地址

1
bash复制代码github.com/valyala/fastjson
1
2
3
4
5
6
7
8
9
10
11
12
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 地址

1
arduino复制代码https://github.com/bytedance/sonic

image.png

基本是兼容官方库的,官方给出的压测结果来看比 json-iterator 性能还高,如下截图:
image.png

参考地址:github.com/bytedance/s…

压测性能对比

由于 json-iterator 比较主流、sonic 性能最好,最后决定在 go 官方库、json-iterator、sonic之间压测做对比。

sonic 有一个兼容性要考虑,大家注意下:
image.png

编码和解码性能对比

进入压测文件目录,执行下面命令

1
ini复制代码go test -test.bench=".*" -benchmem

编码 Marshal
image.png

解码 Unmarshal
image.png
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

image.png

GO 官方标准库分配了 5373 MB+ 内存,比 Sonic 分配还低。

1
ini复制代码std标准库,TotalAlloc: 5634131232,HeapAlloc=756392,HeapAlloc=756392

image.png

json-iterator 分配了 4000 MB+ 内存,对比下来 json-iterator 历史分配内存是最低的。

1
ini复制代码iterator库,TotalAlloc: 4227468600,HeapAlloc=3024600,Alloc=3024600

image.png

从内存分配结果来看,sonic 在整个压测过程中历史分配过的内存是最大的。垃圾回收之后内存差异不大。

sonic 内存为什么分配这么大

于是继续翻了翻官方文档,看了下面这段描述研发 sonic 背景是优化他们的 cpu 资源。参考:
github.com/bytedance/s…

image.png

看来对内存优化这块可能并没有那么好,但看了一些官方解决方案,决定尝试下。

预热

在使用 Marshal()/Unmarshal() 前运行了 Pretouch() 没有啥效果,因为我们的场景并非大模式。

image.png

字符串拷贝

image.png

翻了翻 sonic.Unmarshal() 源码,Unmarshal 使用默认 Config ConfigDefault ,CopyString 为 true 指解码器通过复制而不是引用来解码字符串值。源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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。

1
2
3
4
5
6
7
8
go复制代码config := sonic.Config{
CopyString: false,
}.Froze()

err := config.Unmarshal(mediumFixture, &data)
if err != nil {
panic(err)
}

测试后并没有太大区别。

如果你在使用过程中,ConfigDefault 不满足你的需求,sonic 支持你自定义配置,参考:sonic.Config 里面有一些你可以自定义配置 。

泛型的性能优化

我们是完全解析场景,Get()+Unmarshal() 方案是用不上了。
image.png

意外外发现

另外同事发现有一个 issue ,打包后可执行文件翻倍了(我没有亲测过)。
Execute file size is too big, can sonic be optimized when compile? · Issue #574 · bytedance/sonic · GitHub

image.png

image.png

看官方描述是为了提高 C-Go 内部调用性能,从回复来看这个 issue 目前还没有解决哦。

另外发现 gin 框架也支持 sonic 了。
image.png

benchmark 代码

下面是我写的压测代码,大家可以相互探讨下。

std 标准库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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(字节)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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 库,代码改造也非常简单。替换代码如下:

1
2
3
4
5
6
7
kotlin复制代码var (
json = jsoniter.ConfigCompatibleWithStandardLibrary
)

var data YourStruct
out, err := json.Marshal(data)
json.Unmarshal(out, &data)

为什么会用 ConfigCompatibleWithStandardLibrary ?翻了翻源码,官方给出的是 100% 兼容标准库。

1
2
3
4
5
6
csharp复制代码// ConfigCompatibleWithStandardLibrary tries to be 100% compatible with standard library behavior
var ConfigCompatibleWithStandardLibrary = Config{
EscapeHTML:             true,
SortMapKeys:            true,
ValidateJsonRawMessage: true,
}.Froze()

当然他也有默认的 Config,也支持自定义参数,源码位置参考:

1
bash复制代码github.com/json-iterator/go@v1.1.12/config.go

下面是上线后优化效果

从最近几天的监控来看,按照下面3点优化后是有效果的。

1、减少日志输出,非必要场景去掉日志打印。

2、减少日志包大小,部分场景只打印关键字段,用于定位问题。

3、决定换一个 json 库。

企业微信截图_89804e93-e8e5-43ae-aa96-54d2a14758c5.png

看监控和 pprof 采样,pod 常驻内存还是不小,后续还会持续优化。如果想了解后续优化方案关注我。

2024.03.20 日更新

使用 jsoniter 发现在嵌套情况下,会 Panic。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
css复制代码import (
"fmt"
jsoniter "github.com/json-iterator/go"
"testing"
)

var (
json = jsoniter.ConfigCompatibleWithStandardLibrary
)

type A struct {
B *B
}

type B struct {
A *A
}

func TestJson(t *testing.T) {
var a = A{}
var b = B{}
a.B = &b
b.A = &a

bb, _ := json.Marshal(a)
fmt.Println(string(bb))
}

上面代码结果输出如下

1
2
3
4
5
6
7
arduino复制代码=== RUN   TestJson
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc020460320 stack=[0xc020460000, 0xc040460000]
fatal error: stack overflow

runtime stack:
runtime.throw({0x139be44?, 0x1624d40?})

使用上有一些缺陷,所以大家在替换时需谨慎。

2024.04.22更新

上文不是提过修改 puslar底层压缩方式吗?最近把压缩方式调成”ZLIB”压缩方式,上线后效果如下。
image.png

参考文献

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”

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%