别让 GC 给你的缓存系统拖后腿

探讨Golang中垃圾回收(GC)的性能影响,特别是在处理大量数据时。通过对比不同数据结构的GC表现,介绍BigCache如何利用map和slice优化内存缓存,减少GC开销,适用于海量存储与大结构体场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

golang 是一门自带 GC(垃圾回收) 的语言,GC 帮助程序员从手动管理内存的繁琐工作中解放出来,不仅提升了程序员的生产效率,还保护了程序员的发际线。
天下没有免费的午餐,GC 有时候却可能成为你系统性能下降的罪魁祸首,在系统内存负载较大的情况下,对象数量很多的情况下,尤其容易发生。
本文介绍一种降低 GC 开销的的优化措施,借鉴自 bigcache,一个优秀的开源 golang 内存缓存库。

问题

我们先来看一下使用不同的方式在内存中存储 3*10^7 个元素后,一次 GC 的耗时比较情况:

func main() {
	const N = 30e6

	if len(os.Args) != 2 {
		fmt.Printf("usage: %s [1 2 3 4]\n(number selects the test)\n", os.Args[0])
		return
	}

	switch os.Args[1] {
	case "1":
		// Big map with a pointer in the value
		m := make(map[int32]*int32)
		for i := 0; i < N; i++ {
			n := int32(i)
			m[n] = &n
		}
		runtime.GC()
		fmt.Printf("With %T, GC took %s\n", m, timeGC())
		_ = m[0] // Preserve m until here, hopefully
	case "2":
		// Big map, no pointer in the value
		m := make(map[int32]int32)
		for i := 0; i < N; i++ {
			n := int32(i)
			m[n] = n
		}
		runtime.GC()
		fmt.Printf("With %T, GC took %s\n", m, timeGC())
		_ = m[0]
	case "3":
		// A slice, just for comparison to show that
		// merely holding onto millions of int32s is fine
		// if they're in a slice.
		type t struct {
			p, q int32
		}
		var s []t
		for i := 0; i < N; i++ {
			n := int32(i)
			s = append(s, t{n, n})
		}
		runtime.GC()
		fmt.Printf("With a plain slice (%T), GC took %s\n", s, timeGC())
		_ = s[0]
	case "4":
		// A slice of pointer
		type t struct {
			p, q int32
		}
		var s []*t
		for i := 0; i < N; i++ {
			n := int32(i)
			s = append(s, &t{n, n})
		}
		runtime.GC()
		fmt.Printf("With a pointer slice (%T), GC took %s\n", s, timeGC())
		_ = s[0]
	}
}

func timeGC() time.Duration {
	start := time.Now()
	runtime.GC()
	return time.Since(start)
}

分别在命令行传入 1-4 调用上述程序,在笔者电脑上的输出结果是:

With map[int32]*int32, GC took 1.000267379s
With map[int32]int32, GC took 9.477637ms
With a plain slice ([]main.t), GC took 214.495µs
With a pointer slice ([]*main.t), GC took 216.134041ms

大家可以看到,在 map 或者 slice 中存储指针还是非指针,其 GC 性能完全是两个级别。还要注意到,我们上面的程序其实 timeGC 不会真的实质性的回收什么数据,因为这时候还没有已经没有引用的数据。 GC 的消耗主要在扫描内存上了。
这里想必有人会说,GC 消耗这么大,我以后要在 map 中全部存对象,不存指针。但是我想说:朋友,too young 啊。
golang 是一门按照值传递的编程语言,什么意思呢?假设:

type SomeVeryBigStruct struct{
// 一大堆字段,总体大小几百字节
}
var m map[int]SomeVeryBigStruct
// m 中添加了超多记录
for k, v := range m {
	// v 是一个完全拷贝
}
v := m[1] // v 还是一个拷贝

可以看到,我们在索引 map 的时候,会平白无故的进行数据拷贝,对于一些大结构体,这个性能开销会是相当可观的。
如何避免高昂的 GC 开销的同时,又能避免到处拷贝数据呢?

解法: map[uint32]int64 + []byte

bigcache 利用了go 这个特性,即对于没有指针的 map,GC 过程不需要遍历该 map,来对海量内存缓存进行了优化。
其核心有两个组件:

  1. map[uint32]int64,这个保存的是 hash(key) 到 offset 的映射
  2. []byte ,以单一的字节数组来保存所有的数据,1 中的 offset 正是对应记录值在 []byte 的偏移量

插入分成两个步骤:

  1. 将 value ([]byte,可能需要对原来的对象进行合适的序列化)插入到底层 []byte (bigcache 中用一个 ByteQueue 表示),获得插入的 offset
  2. 将 key 和 offset 的映射保存起来

相应的,查询时:

  1. 使用 key 查到 offset
  2. 读取 []byte 中指定 offset 的数据返回

要注意到,因为底层是将 value 存储在一个 []byte 内,所以 bigcache 提高的接口,其 value 只能是 []byte。所以对于非 []byte 的 value,要想使用类似的优化方式,还是需要引入一个额外的序列化(插入时)和反序列化(读取后)。这样做可能会引入额外的 CPU 消耗和编码逻辑,这也是需要注意的。这种情况下,此优化对于性能的提升就需要做具体的 profiling 才可以确定。
最后大家不要忘记,[]byte 的传递,不会引起底层数据的拷贝。

总结

  1. golang 中要避免存有大量指针的 map,要不然会引来高额的 GC
  2. bigcache 通过将 value 保存在一个 []byte,保存 key 与 offset 的映射来解决
  3. 这种优化策略有其特有的适用场景:海量存储 + 大结构体,如果你存储的都是一些小结构体,不会因为按值传递代来性能负担的话,是不需要采用上述优化措施的
  4. 对于 value 不是 []byte 的场景,需要引入序列化与反序列化的逻辑,这些额外 CPU 消耗,对系统的性能影响,需要通过具体的 profiling 来确定
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值