聊聊最近线上服务 OOM 问题定位思路?

写作背景

最近业务高峰期自动化营销某服务内存告警频繁、偶尔 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 库,非常友好,并且性能也很高。

官方性能压测结果:

github.com/json-iterat...

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

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"

相关推荐
hlsd#24 分钟前
go mod 依赖管理
开发语言·后端·golang
陈大爷(有低保)28 分钟前
三层架构和MVC以及它们的融合
后端·mvc
亦世凡华、29 分钟前
【启程Golang之旅】从零开始构建可扩展的微服务架构
开发语言·经验分享·后端·golang
河西石头30 分钟前
一步一步从asp.net core mvc中访问asp.net core WebApi
后端·asp.net·mvc·.net core访问api·httpclient的使用
2401_8574396941 分钟前
SpringBoot框架在资产管理中的应用
java·spring boot·后端
怀旧66643 分钟前
spring boot 项目配置https服务
java·spring boot·后端·学习·个人开发·1024程序员节
阿华的代码王国1 小时前
【SpringMVC】——Cookie和Session机制
java·后端·spring·cookie·session·会话
小码编匠1 小时前
领域驱动设计(DDD)要点及C#示例
后端·c#·领域驱动设计
德育处主任Pro2 小时前
『Django』APIView基于类的用法
后端·python·django
哎呦没4 小时前
SpringBoot框架下的资产管理自动化
java·spring boot·后端