sync.map 不适合当缓存用,因其不支持过期、容量限制、高效遍历和淘汰回调;真正可用的缓存需具备 get/set、ttl 过期、lru 淘汰三要素。

为什么不用 sync.Map 直接当缓存用?
很多人一想“线程安全的内存缓存”,立刻想到 sync.Map,但它不是为缓存设计的:不支持过期、不支持容量限制、遍历效率低、无法触发淘汰回调。真拿来存 session 或临时 token,不出三天就会内存泄漏或 OOM。
真正可用的简易缓存,至少得有三样东西:get/set 接口、TTL 过期控制、LRU 或类似淘汰策略。Go 标准库没提供,但自己搭一层很轻量。
-
sync.Map适合高频写+稀疏读的场景(比如注册表),不适合缓存这种读多写少、需定时清理的用途 - 如果只用
map + sync.RWMutex,自己管过期逻辑,容易在Get时发现过期却不敢删(怕并发冲突),导致脏数据堆积 - 推荐组合:
map存数据 +sync.RWMutex控制访问 + 单独 goroutine 定期扫描清理(或惰性过期 + 写时淘汰)
如何实现带 TTL 和 LRU 淘汰的 Cache 结构?
不必从零造轮子。用 container/list 维护访问顺序,map 做 O(1) 查找,再加个 sync.RWMutex 保并发安全——50 行内能跑起来。
关键点不在数据结构,而在“什么时候删”:TTL 判断必须在 Get 时做(惰性),否则后台 goroutine 扫描可能漏删;淘汰则放在 Set 时检查容量,避免缓存无限膨胀。
- 每个缓存项封装成 struct:
value interface{}+expireAt int64(Unix 时间戳)+*list.Element(用于 LRU 链表定位) -
Get先查 map,命中后判断expireAt ,过期就 <code>delete并返回空,不 panic 不报错 -
Set时若超出容量(比如 1000 条),先从 LRU 尾部弹出最久未用项并从 map 删除,再插入新项到链表头部
time.AfterFunc 能不能用来逐个 key 做过期?
不能。它会为每个 key 启一个 timer,10 万个 key 就是 10 万个 goroutine 和 timer,调度开销爆炸,且无法回收已删除 key 对应的 timer。
真正轻量的做法只有两种:惰性过期(Get 时检查)、或单 ticker 定期批量扫描(比如每秒扫 100 个随机 key)。后者适合对延迟不敏感的场景,前者更常用也更稳妥。
- 别写
time.AfterFunc(expireDur, func(){ delete(m, key) })—— 这是典型反模式 - 如果业务允许少量过期数据残留(比如配置缓存容忍 1 秒延迟),惰性过期足够;否则加一个低频
time.Ticker扫描map中的expireAt字段 - 扫描时务必用
sync.RWMutex.RLock()读,避免阻塞写操作;删的时候再换Lock()
如何避免缓存击穿和并发写覆盖?
当多个 goroutine 同时 Get 一个不存在的 key,又同时触发 Set,最后只该有一个写入生效。标准 map + mutex 只能串行化,但吞吐低;需要“首次加载锁”机制。
做法很简单:用 sync.Map 存正在加载中的 *sync.Once,key 相同则共用同一个 Once。这样即使 100 个协程并发查同一个 miss key,也只执行一次加载逻辑。
- 结构体里加字段:
loading sync.Map(key 是 string,value 是*sync.Once) -
Get没命中时,先loading.LoadOrStore(key, &sync.Once{}),再调.Do(...)加载真实数据 - 注意:Once 本身不带超时,如果加载函数卡住,整个 key 就永久阻塞,所以加载逻辑必须带 context 控制
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











