17370845950

Go语言实现简单缓存功能_Go缓存项目入门
sync.Map 是高并发读多写少场景下实现线程安全缓存的轻量首选,支持 LoadOrStore 等原子操作,但需手动添加 TTL 和惰性过期清理,小规模场景无需第三方库。

sync.Map 实现线程安全的简单缓存

Go 标准库没有开箱即用的“缓存”类型,但 sync.Map 是最轻量、最常用的选择——它专为高并发读多写少场景设计,避免了全局锁带来的性能瓶颈。

直接用 mapsync.RWMutex 也能做,但要自己处理键存在性判断、删除逻辑、零值覆盖等问题;sync.MapLoadOrStoreCompareAndDelete 等方法天然规避了竞态风险。

  • Load 返回 (value, ok),和普通 map 一致,适合查缓存
  • Store 总是覆盖,LoadOrStore 仅在键不存在时才写入,适合初始化默认值
  • 不支持遍历或获取长度——这是有意为之的设计取舍,若需统计大小,得额外维护计数器

给缓存加过期时间:自己封装 time.Now() 判断

标准 sync.Map 不带 TTL(Time-To-Live),必须手动处理。常见做法是在 value 中嵌入过期时间戳,每次 Load 后检查是否过期,过期则 Delete 并返回未命中。

不要用后台 goroutine 定期扫描清理——小规模缓存没必要,且容易引发误删(比如刚写入就扫到了);按需惰性清理更简单可靠。

  • 存储时用 time.Now().Add(ttl) 计算过期时间,和 value 一起存进 sync.Map
  • 读取时先 Load,再比对当前时间与存储的过期时间,time.Now().After(expireTime)
  • 过期后立即 Delete,避免下次再查一遍重复判断
type CacheItem struct {
	Value    interface{}
	ExpireAt time.Time
}

func (c *Cache) Get(key string) (interface{}, bool) {
	if item, ok := c.m.Load(key); ok {
		if ci, ok := item.(CacheItem); ok {
			if time.Now().Before(ci.ExpireAt) 

{ return ci.Value, true } c.m.Delete(key) } } return nil, false }

为什么不用第三方库如 gocacheristretto

项目刚起步、QPS 不高、缓存条目几百以内时,引入这些库反而增加复杂度:要理解其驱逐策略(LRU/LFU)、配置参数(shards、buffer size)、生命周期管理(Stop/Reset),还可能带来非预期的内存占用。

ristretto 虽然高性能,但它的核心价值在百万级 QPS + GB 级缓存场景;而 gocache 抽象层多,底层仍依赖 sync.Mapmap,中间封装掩盖了真实行为,调试时更难定位问题。

  • 如果只是存 token、配置、用户基本信息,手写 50 行以内的缓存足够用
  • 若后续出现内存持续上涨,优先检查是否忘了设 TTL,而不是立刻换库
  • 真正需要替换的信号是:缓存命中率长期低于 60%,且 key 分布明显倾斜(少数 key 占 80% 流量)

测试缓存并发安全性的关键点

光跑单测不等于线程安全。很多 bug 只在高并发下暴露,比如 LoadOrStoreDelete 交错导致短暂脏读,或过期判断和删除之间被其他 goroutine 插入旧值。

go test -race 是底线,但还不够。建议写一个压力测试函数,混合执行 Get/Set/Delete,并断言命中率、无 panic、无重复初始化。

  • 并发写同一 key 时,LoadOrStore 应保证只初始化一次(适合初始化 DB 连接池等)
  • 并发读+过期删除时,确保不会返回已删除的值,也不会 panic(比如类型断言失败)
  • 避免在 Get 中直接返回指针或可变结构体——外部修改会影响缓存内容,应考虑深拷贝或只缓存不可变类型
缓存逻辑看着简单,最容易出问题的地方其实是过期判断和并发写入的组合;别省那几行代码,把时间比较和删除包进同一个原子操作里。