likes
comments
collection
share

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

作者站长头像
站长
· 阅读数 10

写作背景

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

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

问题定位

问题是由可观测平台的一条告警发现的,因为业务非常重要,有任何告警我们都不会错过。 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

内存资源快打满了,但cou资源并不高,打开 grafana 监控。 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

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

go tool pprof http://192.168.50.73:6060/debug/pprof/heap

选择 pdf 即可 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

打开 pdf 文件,线头越粗表示内占用越高。发现 NewLz4Provider 函数内存使用高,看了pulsar 包源码,数据压缩用的 lz4。 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

压缩方式有多种,于是同事对底层压缩方式做了压测。 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

虽然配置了监控 cpu、内存达到某一个阀值自动抓 pod 运行时内存、cpu 数据。瞬时波峰时间比较短,存活的对象内存分配采样很难抓到,决定重新研究下 pprof,发现 allocs 可以查看过去所有的内存分配,这里面会不会有蛛丝马迹?决定研究一番,如下图: 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

执行下面命令

go tool pprof http://192.168.50.73:6060/debug/pprof/allocs

选择 pdf 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

日志库

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

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

redis 库

redis 库历史内存分配如下: 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

研究了这两处代码调用都指向了 go 官方 json 库,排查了一波线上埋点日志发现在业务高峰期

日志打的多,日志 body 基本是中型数据。

这两处日志输出在业务高峰期和内存波动基本吻合。

猜测因为这两个基础组件底层都用了 golang json 库,json 函数序列化和反序列化内存占用比较大,在业务高峰期,造成内存波动大。

问题优化

于是决定按照下面三条优化方案快速发布上线看看效果

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

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

3、决定换一个 json 库。

json 库调研

替换 json 库我主要考虑下面 2 方面

1、编码和解码性能高;

2、兼容官方 json 库,可以做到无缝替换,代码改造范围控制在最小。

主要调研了下面几款 json 库。

json-iterator

github 地址

https://github.com/json-iterator/go

100% 兼容官方 json 库,非常友好,并且性能也很高。 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

官方性能压测结果:

github.com/json-iterat…

于是决定翻翻使用姿势,使用上可以比较方便替换官方库,没啥改动成本低。 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

jsonparser

github 地址

github.com/buger/jsonparser

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

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 地址

github.com/valyala/fastjson
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 地址

https://github.com/bytedance/sonic

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

基本是兼容官方库的,官方给出的压测结果来看比 json-iterator 性能还高,如下截图: 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

压测性能对比

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

sonic 有一个兼容性要考虑,大家注意下: 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

编码和解码性能对比

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

go test -test.bench=".*" -benchmem

编码 Marshal 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

解码 Unmarshal 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」 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+ 内存。

sonic库,TotalAlloc: 30710325328,HeapAlloc=3815240,HeapAlloc=3815240

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

std标准库,TotalAlloc: 5634131232,HeapAlloc=756392,HeapAlloc=756392

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

iterator库,TotalAlloc: 4227468600,HeapAlloc=3024600,Alloc=3024600

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

sonic 内存为什么分配这么大

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

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

预热

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

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

字符串拷贝

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

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。

config := sonic.Config{
	CopyString: false,
}.Froze()

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

测试后并没有太大区别。

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

泛型的性能优化

我们是完全解析场景,Get()+Unmarshal() 方案是用不上了。 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

意外外发现

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

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

另外发现  gin 框架也支持 sonic 了。 记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

benchmark 代码

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

std 标准库

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 := 0i < b.Ni++ {
		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 := 0i < b.Ni++ {
		json.Marshal(data)
	}
}

sonic(字节)

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 := 0i < b.Ni++ {
		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 := 0i < b.Ni++ {
		sonic.Marshal(data)
	}
}

json-iterator

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 := 0i < b.Ni++ {
		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 := 0i < b.Ni++ {
		jsonIterator.Marshal(data)
	}
}

json 库替换+上线效果

从监控来看,内存优化才是本次重点,最终决定用 json- iterator 替换官方 json 库,代码改造也非常简单。替换代码如下:

var (
	json = jsoniter.ConfigCompatibleWithStandardLibrary
)

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

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

// ConfigCompatibleWithStandardLibrary tries to be 100% compatible with standard library behavior
var ConfigCompatibleWithStandardLibrary = Config{
	EscapeHTML:             true,
	SortMapKeys:            true,
	ValidateJsonRawMessage: true,
}.Froze()

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

github.com/json-iterator/go@v1.1.12/config.go

下面是上线后优化效果

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

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

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

3、决定换一个 json 库。

记录一次线上 GO 服务 oom 排查以及内存优化思路「附GO json库调研」

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

2024.03.20 日更新

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

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))
}

上面代码结果输出如下

=== RUN   TestJson
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0xc020460320 stack=[0xc0204600000xc040460000]
fatal error: stack overflow

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

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

参考文献

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"