不能用 sync.map 做滑动淘汰,因其不维护访问顺序、range 遍历无序、无法原子完成「查 key → 移到链表头」,导致淘汰随机、内存持续增长至 oom;它仅适用于开关、白名单等几乎不变的只读场景。

为什么不能用 sync.Map 做滑动淘汰
sync.Map 根本不维护访问顺序,Range 遍历无序,也没法原子完成「查 key → 移到链表头」这个最小操作单元。你写个 LoadOrStore 看似能读写,但后续没法把命中项挪到“最近使用”位置——结果就是淘汰完全随机,缓存项越积越多,压测几小时就 OOM。
它只适合存开关、白名单这类几乎不变的只读数据;只要带「滑动」「访问频次」「过期」任一需求,就必须换掉。
lru.Cache + 懒检查过期是唯一可控路径
第三方库 github.com/hashicorp/golang-lru/v2 的 lru.Cache 本身不带 TTL,但结构干净、无全局锁、支持并发读写。加滑动淘汰只需两步:
-
Set时存value和expireAt time.Time字段(别用int64时间戳,易出时区/纳秒精度问题) -
Get时先取值,再立刻判断expireAt.Before(time.Now()),过期就Delete并返回未命中
注意:绝不自动在 Get 里删 key 后返回 nil, false——并发 Get 可能同时触发删除,导致日志刷屏、监控误报。
高频字符串键下容易踩的坑
字符串键看似简单,但实际高频场景下几个细节极易翻车:
- 每次
time.Now()调用都有开销,短生命周期缓存(比如单次请求内用完就丢)建议用毫秒级int64存时间差,而非完整time.Time - map 的 key 是字符串,但 value 必须是
*list.Element,不是值本身——漏掉这点会导致Get后无法快速移到链表头 - 插入新元素时,必须先
list.PushFront,再把返回的*list.Element写进 map,顺序反了会丢数据 - 淘汰最久未用项时,要从
list.Back()取,再用对应 key 从 map 中删,两步缺一不可
真要手写,锁粒度别迷信 RWMutex
sync.RWMutex 在读多写少场景下确实能提升吞吐,但注意:list.MoveToFront 和 list.Remove 都会修改链表结构,哪怕只是“移动”,sync.RWMutex 的读锁也不够。所以实际没法只用读锁保护 Get。
更现实的做法是:所有公开方法(Get/Put/Remove)都用 sync.Mutex,简单可靠。若真有高并发读需求,可考虑分片(shard),比如按 key 哈希到 8 个独立 LRU 实例 + 8 把锁。
最容易被忽略的是:把 sync.Mutex 放在结构体字段里却忘了导出(首字母小写),导致外部无法 lock;或者在 Put 里先删旧 entry 再插新 entry,中间没锁住,被其他 goroutine 插入打断。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











