大家好,我是阿蒙。
最近在重构公司的一个高频特征服务,QPS 大概在 6w 左右。为了抗住峰值,我们在前级加了一层本地缓存。结果刚上线测试环境,P99 延迟就直接偶发性飙到 200ms+。拉出 pprof 一看,CPU 全被 runtime.gcDrain 给吃了。
今天就直接拉出来聊聊:在海量数据下,Go 本地缓存究竟怎么选,以及如何避免把 GC 玩崩。
一、 痛点:为什么 map[string]*Item 会把 GC 拖垮?
很多刚接触 Go 的同学做缓存,起手就是一个 sync.Map 或者 map[string]*Item 加一把读写锁。数据量在几万级别的时候风平浪静,一旦数据量冲到几百万,GC 就会教你做人。
Go 的 GC 采用的是并发三色标记清除算法。GC 标记阶段需要扫描堆上的对象,如果你的 map 里塞满了指针(包括 string 底层的指针、结构体内部的指针),GC 就会顺着这些指针一个个递归去扫。
从 Go 1.5 开始,如果一个 map 的 key 和 value 都不包含指针(比如 map[int]int 或 map[uint64][32]byte),GC 就会直接跳过对这个 map 内部 bucket 的递归扫描。
我们写个简单的 benchmark 看下差距:
package main
import (
"fmt"
"runtime"
"testing"
"time"
)
// 包含指针的结构
type ItemPtr struct {
data *string
}
// 模拟测试不同结构的 GC 扫描耗时
func BenchmarkGCWithPointer(b *testing.B) {
const count = 5_000_000
m := make(map[int]*ItemPtr, count)
str := "benchmark_test_data"
for i := 0; i < count; i++ {
m[i] = &ItemPtr{data: &str}
}
runtime.GC()
b.ResetTimer()
for i := 0; i < b.N; i++ {
t0 := time.Now()
runtime.GC()
b.ReportMetric(float64(time.Since(t0).Microseconds()), "gc_us")
}
}
func BenchmarkGCWithoutPointer(b *testing.B) {
const count = 5_000_000
// 完全没有指针
m := make(map[uint64][24]byte, count)
var val [24]byte
for i := uint64(0); i < count; i++ {
m[i] = val
}
runtime.GC()
b.ResetTimer()
for i := 0; i < b.N; i++ {
t0 := time.Now()
runtime.GC()
b.ReportMetric(float64(time.Since(t0).Microseconds()), "gc_us")
}
}
跑出来的数字非常残暴:
goos: darwin
goarch: arm64
BenchmarkGCWithPointer-8 5 184521000 ns/op 184521 gc_us
BenchmarkGCWithoutPointer-8 100 11234000 ns/op 11234 gc_us
500 万数据下,带指针的 map 一次 runtime.GC() 耗时接近 184ms,而完全无指针的 map 只有 11ms。差了 16 倍!在延迟敏感的服务里,180ms 的 STW + 并发扫描足以让你的 P99 彻底穿孔。
二、 主流本地缓存方案横评
为了绕过这个 GC 陷阱,社区有很多解决方案,核心思路无非两个:避开指针扫描、控制锁竞争。
1. sync.Map / go-cache
- 原理:标准的内置方案,或加读写锁的 map。
- 缺点:不防 GC;没有淘汰策略(
sync.Map无法设置容量上限,内存容易爆;go-cache定期清理需要全量扫描)。 - 适用场景:只存几千到小几万条数据,比如配置项、元数据缓存。
2. BigCache
- 原理:
- Sharding(分片锁)减少锁竞争。
- 核心大招:底层开辟一个巨大的
[]byte环形队列(RingBuffer),所有的 key 和 value 序列化后打包塞进[]byte。 - 索引只存一个
map[uint64]uint32,key 是哈希后的 64 位整型,value 是在 byte 数组里的 offset 偏移量。 - 整个 map 没有任何指针类型,GC 直接把这几百万条记录当成一大块内存跳过扫描。
- 缺点:数据出入必须序列化和反序列化;淘汰策略只支持 FIFO,不支持 LRU/LFU。
3. FreeCache
- 原理: 同样通过零指针规避 GC,它把内存切成 512 个 segment,每个 segment 预分配环形缓冲区。
- 优点:支持严格的 LRU 淘汰,内存上限控制非常精准。
- 缺点:也是要序列化,写入稍微重一点。
4. Ristretto (Dgraph 出品)
- 原理:主打极高命中率的 TinyLFU 淘汰算法,同时异步处理读写 buffers,吞吐极高。
- 注意点:写是异步的(Put 完立刻 Get 可能会偶发 Miss),底层依然持有较多对象,在超千万级 key 场景下,GC 压力比 BigCache 略大。
三、 压测数据对比 (Benchmark)
用 testing.B 对主流的 BigCache 与原生带锁 map 进行简单的并发 Set/Get 压测对比(模拟千万级条目,开启 GODEBUG=gctrace=1):
BenchmarkNativeMap_Set-8 1000000 1320 ns/op 216 B/op 3 allocs/op
BenchmarkBigCache_Set-8 5000000 290 ns/op 48 B/op 1 allocs/op
BenchmarkNativeMap_Get-8 3000000 410 ns/op 0 B/op 0 allocs/op
BenchmarkBigCache_Get-8 6000000 195 ns/op 24 B/op 1 allocs/op
从数据可以看到:
- 写入性能:BigCache 得益于分片加无指针分配,耗时只有原生 map + Mutex 的 1/4 左右。
- GC Trace 日志:
- 原生 Map 在写入 1000 万条数据时,gc 阶段
mark耗时经常跑出wall clock 40~60ms。 - BigCache 写入 1000 万条数据时,GC 扫描耗时稳定在
1~2ms左右,平稳得像一条直线。
- 原生 Map 在写入 1000 万条数据时,gc 阶段
四、 实战:BigCache 的极简封装
使用 BigCache 时,一定要注意序列化开销。尽量避免用 encoding/json,否则省下来的 GC 时间全被 JSON 序列化反噬了。推荐用 Protobuf、Msgpack,或者直接手动对齐字节。
示例代码:
package main
import (
"context"
"fmt"
"time"
"github.com/allegro/bigcache/v3"
)
type LocalCache struct {
cache *bigcache.BigCache
}
func NewLocalCache(maxMemoryMB int) (*LocalCache, error) {
// 配置 BigCache: 避开 GC 扫描的关键是正确配置 Shards 和内存上限
config := bigcache.Config{
Shards: 1024, // 分片数,必须是 2 的幂次,降低分片锁竞争
LifeWindow: 10 * time.Minute, // 过期时间
CleanWindow: 1 * time.Minute, // 清理周期
MaxEntriesInWindow: 1000 * 10 * 60, // 预估生命周期内的元素总量
MaxEntrySize: 500, // 预估单条 entry 大小(字节)
HardMaxCacheSize: maxMemoryMB, // 内存硬限制 (MB)
OnRemove: nil,
}
bc, err := bigcache.New(context.Background(), config)
if err != nil {
return nil, err
}
return &LocalCache{cache: bc}, nil
}
func (c *LocalCache) Set(key string, val []byte) error {
return c.cache.Set(key, val)
}
func (c *LocalCache) Get(key string) ([]byte, error) {
return c.cache.Get(key)
}
func main() {
c, err := NewLocalCache(512) // 512 MB 限制
if err != nil {
panic(err)
}
key := "user_token_12345"
val := []byte("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
_ = c.Set(key, val)
data, _ := c.Get(key)
fmt.Printf("Get key: %s, val: %s\n", key, string(data))
}
五、 GC 调优实践建议
如果你由于业务原因无法重构为 BigCache / FreeCache 这类无指针缓存,可以从以下几个方向调优:
- 结构扁平化(Flattening):
把
map[string]*User改成map[uint64]User。避免指针引用,少一个指针,GC 就少扫一层。 - 利用
GOMEMLIMIT(Go 1.19+): 以前我们只能调GOGC=100(按比例触发),容易导致内存利用不充分或者频繁 GC。现在强烈建议设置GOMEMLIMIT(如设为容器内存上限的 80%~85%),把GOGC适当调大(比如GOGC=200或300)。在内存充裕时尽量少触发 GC,到了限额附近平滑回收。 - 内存预分配:
写 map 或者 slice 时,必须带上预估容量
make(map[K]V, hint),避免频繁扩容引起的内存拷贝和旧空间释放。
总结
选型其实很明确,不要盲目上复杂方案:
- 千万级 Key、追求极限延迟:无脑选 BigCache(FIFO 淘汰)或 FreeCache(LRU 淘汰),序列化走快速库。
- 需要高级淘汰算法(TinyLFU)、命中率要求高:选 Ristretto。
- 十万以下 Key:直接原生
sync.Map或RWMutex + map就足够了,没必要为了避开 GC 额外引入序列化的 CPU 损耗。
性能优化先打 pprof,拿 benchmark 数字说话,别靠直觉猜。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


