发现生产环境 Go 的 GC 次数特别频繁,平均每分钟 12 次左右,偶尔来个流量尖刺,每分钟 GC 次数超过了 20 次,同时发现,服务器内存是 1G,但是实际只使用了 0.05G,只使用了 5%,这就非常不合理了。
先看一下优化后的结果,GC 次数从原来的 12 次减少到 2 次,内存占用从原来的 0.05G 增加到 0.2G 左右,GC 次数减少,内存占用增加,这是符合预期的
主要是通过 2 个参数来进行优化:SetGCPercent
和 SetMemoryLimit
1、SetGCPercent
比较常见的是通过调整 GC 的步调,以调整 GC 的触发频率。可以通过设置 GOGC 或者在代码中设置 debug.SetGCPercent() 来达到,效果是相同的。
这个参数控制的是触发 GC 的阈值,是一个百分比。当 Go 新创建对象占用内存大小,除以,上次 GC 结束后保留下来的对象占用内存大小,所得到的比值大于设置的阈值时,就会触发 GC,默认值是100。也就是说,默认情况下,当目前占用内存是上次 GC 结束后占用内存的一倍时触发 GC。
举个例子,假设上次 GC 后,驻留内存是 100MB,若该值设置的是 100,那么下次内存达到 200MB 时候就会触发 GC,若设置 200,那么下次内存达到 300MB 的时候就会触发 GC。
下面通过代码来说明。消费者从队列中读取序列化的数据,将其反序列化到一个临时的中间数据结构中,将变形后的数据写入map中保存。
package main
import (
"encoding/json"
"fmt"
"runtime"
"sync"
)
// 用于解码数据的临时结构
type QMessage struct {
ID uint64 `json:"id,omitempty"`
Body QMessageBody `json:"body,omitempty"`
}
type QMessageBody struct {
Field1 string `json:"field_1,omitempty"`
Field2 int `json:"field_2,omitempty"`
}
// 常驻于内存的数据结构
type Message struct {
id uint64
field1 string
field2 int
}
// 内存数据缓存,里面存放了千万级的词条信息
var buffer = make(map[int]Message)
func main() {
wg := &sync.WaitGroup{}
q := make(chan string)
wg.Add(2)
go producer(q, wg)
go consumer(q, wg)
wg.Wait()
PrintMemUsage()
}
// 模拟生产者,产生两千万词条数据
func producer(q chan string, wg *sync.WaitGroup) {
for i := 0; i < 20000000; i++ {
q <- `{"id":123456, "body":{"field1": "123", "field2": 456}}`
}
close(q)
wg.Done()
}
// 模拟消费者,消费并反序列化词条数据,使用中间临时数据结构进行数据变形,并将最后的结果存储
func consumer(q chan string, wg *sync.WaitGroup) {
idx := 0
for data := range q {
idx++
qtmp := QMessage{}
json.Unmarshal([]byte(data), &qtmp)
tmp := Message{
id: qtmp.ID,
field1: qtmp.Body.Field1,
field2: qtmp.Body.Field2,
}
buffer[idx] = tmp
}
wg.Done()
}
// 以下是打印内存监控数据的工具函数,与业务逻辑无关
func PrintMemUsage() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
// For info on each, see: https://golang.org/pkg/runtime/#MemStats
fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc))
fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
fmt.Printf("\tNumGC = %v", m.NumGC)
fmt.Printf("\tSTW = %v\n", m.PauseTotalNs / 1000)
}
func bToMb(b uint64) uint64 {
return b / 1024 / 1024
}
把打印结果转为表格:
GOGC | Alloc (MiB) | TotalAlloc (MiB) | Sys (MiB) | NumGC | STW |
---|---|---|---|---|---|
-1 | 9318 | 9318 | 9598 | 0 | 0 |
12 | 1508 | 9317 | 2899 | 251 | 11320 |
25 | 1537 | 9318 | 2909 | 131 | 5980 |
50 | 1655 | 9318 | 3397 | 66 | 3242 |
100 | 4237 | 9318 | 4348 | 31 | 1503 |
200 | 3987 | 9318 | 4808 | 17 | 709 |
可以看出当把 GOGC 的值分别设置为 12、25、50、100、200 时,占用的内存逐步增加,GC 次数逐步减少,STW 时间也逐步减少。
很好理解,因为 GC 频次减少了,很多对象回收推迟了,占用内存就增多了,优点是 GC 次数和 STW 时间都减少了,以空间换时间。
从上面可以看出,SetGCPercent 控制着GC的运行频率。当 SetGCPercent 值设置的较小时,GC 运行就频繁一些;当 SetGCPercent 的值设置的较大时,GC运行就不那么频繁,但要承担内存分配接近资源上限的风险。
2、SetMemoryLimit
为了解决内存分配超过资源上限的风险,需要配合使用另一个参数 SetMemoryLimit
。
一旦设定了 SetMemoryLimit,当 Go 堆大小达到 MemoryLimit 减去非堆内存后的值时,GC 就会被触发,即使手动关闭了GC(GOGC=off),也会被触发。这个特性最直接解决的就是 oom-killed 这个问题。
但如果 Go 应用的 live heap object 超过了 soft memory limit 但还尚未被 kill,那么此时 GC 会被持续触发,但为了保证在这种情况下业务依然能继续进行,GC 最多只会使用 50% 的 CPU,以保证业务处理依然能够得到 CPU 资源。
3、总结
本文介绍了通过 SetGCPercent
和 SetMemoryLimit
对 Go 应用 GC 调优:
-
SetGCPercent
设置的是 GC 触发的百分比,而不是一个具体值; -
SetMemoryLimit
设置了 soft memory limit 的最大值,达到该值时会触发 GC; - 若同时设置这俩值,即使 soft memory 没有达到 MemoryLimit,也有可能触发 GC,因为在没达到 MemoryLimit 阈值的情况下,还是遵循 GOGC 决定要不要进行垃圾回收。