直接使用 github.com/hashicorp/golang-lru/v2 最稳妥,它已解决并发、生命周期同步、淘汰边界等所有易错点;手写 container/list + map 极易因指针不同步、并发修改、淘汰顺序错误导致 panic 或数据静默丢失。

直接用 github.com/hashicorp/golang-lru/v2 是最稳妥的选择,它已解决并发、生命周期同步、淘汰边界等所有易错点;自己手写 container/list + map 极大概率在高并发或容量临界时 panic 或漏删。
为什么别自己实现基础 LRU 结构体
手写容易在三个关键点崩掉:
-
map和list.Element生命周期不同步:比如list.Remove(e)后仍保留e.Value引用,后续访问可能解引用 nil 指针 - 并发
Get同一个 key 时,多个 goroutine 同时调MoveToFront,而list.Element的prev/next指针被并发修改,触发 panic: “assignment to entry in nil map” - 淘汰逻辑顺序错误:先
delete(m, key)再list.Remove(e),会导致 map 中残留 key 指向已释放节点,后续Get返回零值但不报错,数据静默丢失
golang-lru/v2 的 New 和 Add 怎么用才不出错
它默认线程安全,但行为依赖你传入的类型和调用时机:
- 泛型参数必须显式指定,例如
lru.New[string, int](128),不能省略或写成any—— 否则Get返回any,类型断言失败时静默返回零值 -
Add(key, value)是覆盖写入:若 key 已存在,会更新 value 并重置访问序,无需手动Remove再Add - 容量满时自动淘汰
list.Back()对应项(最久未使用),这个行为不可配置,也不触发回调,除非你传入onEvicted函数 - 不要在
onEvicted回调里做阻塞操作(如 HTTP 请求),它运行在持有锁的上下文中,会拖慢所有缓存操作
Resize 动态调大缓存时要注意什么
Resize 看似简单,但实际是“复制重建”,不是原地扩容:
- 调用
cache.Resize(256)会新建一个容量为 256 的内部链表,把当前存活 key-value 按访问序迁移过去,旧结构被 GC - 迁移过程持有写锁,期间所有
Get和Add都会阻塞,若缓存已有上万条目,Resize 耗时可达毫秒级 - 它不会保留被踢出的旧 key 的访问序:新缓存中,刚迁移进来的 key 全部视为“最近使用”,历史访问热度清零
- 如果你依赖访问热度做降级判断(比如只对访问频次 >3 的 key 做异步预热),Resize 后该逻辑会失效
真正轻量且够用的最小可行封装
多数业务不需要 ARC 或 2Q,只要一个带驱逐通知、能跑在 HTTP handler 里的 LRU,这样封就够了:
type UserCache struct {
cache *lru.Cache[string, *User]
}
func NewUserCache(size int) *UserCache {
return &UserCache{
cache: lru.New[string, *User](size),
}
}
func (u *UserCache) Get(id string) (*User, bool) {
v, ok := u.cache.Get(id)
if !ok {
// 可在此处触发一次加载并 Add,但注意避免缓存击穿
}
return v, ok
}
func (u *UserCache) Put(id string, user *User) {
u.cache.Add(id, user)
}
重点不在代码行数,而在它避开了所有 list.Element 手动管理、sync.Mutex 锁粒度误设、以及泛型类型擦除带来的断言陷阱——这些才是“轻量”真正的成本所在。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











