手写lru缓存因轻量精准可控:避免第三方包冗余功能与过粗锁粒度,用map+自定义双向链表优化性能,需原子更新访问顺序、正确处理并发、边界值及指针重连。

为什么不用第三方包而要手写 LRU 缓存
因为多数场景下你真正需要的只是一个带容量限制、能按访问顺序淘汰的老化容器,而不是带分布式、持久化、统计埋点的“企业级缓存”。手写 LRU 能精确控制行为:比如是否允许 nil 值、键比较方式、并发安全粒度、是否在 Get 时更新顺序。第三方包(如 github.com/hashicorp/golang-lru)默认做线程安全,但锁粒度可能比你需要的粗,反而拖慢高频单 goroutine 场景。
-
sync.Map不适合直接改造成 LRU——它不维护访问顺序,也无法按需驱逐 - 用
list.List+map[interface{}]*list.Element是标准解法,但要注意list.Element.Value是interface{},类型擦除会带来运行时断言开销 - 如果键是字符串且值是固定结构体,用
map[string]*entry+ 自定义双向链表节点(避免接口转换)性能更好
Get 和 Put 必须原子更新访问顺序
常见错误是把“查 map”和“挪到链表头”拆成两步,中间被其他 goroutine 干扰,导致顺序错乱或 panic。正确做法是在同一临界区内完成查找、移动、返回三件事。如果你加了读写锁(sync.RWMutex),Get 用读锁不够——因为要修改链表结构,必须用写锁;或者改用单个 sync.Mutex 更稳妥。
- 不要在
Get中先delete再Put——这会触发两次哈希计算和内存分配 - 移动节点时别只改
next/prev,还要确保原位置的前后节点正确重连,否则链表断裂后Remove会 panic - 当缓存满时,
Put要先从链表尾删节点,再从 map 删除对应键,顺序不能反,否则删完 map 后链表节点还挂着脏引用
如何安全支持并发读写
最简方案是整个结构体配一个 sync.Mutex,对绝大多数中小流量服务够用。别过早优化成读写分离——因为 Get 实际要写链表,所谓“读多写少”在这里不成立。如果你真遇到锁瓶颈,优先考虑分片(shard):把一个大缓存拆成多个小缓存(比如 32 个),用键哈希路由,每个配独立锁。这时注意分片数别设成 2 的幂次(如 16、32),避免哈希碰撞集中;推荐用质数(如 37)。
- 不要用
sync/atomic操作指针来绕过锁——list.Element的字段不是原子可读写的,会导致数据竞争 - 测试并发安全不能只靠压测,要跑
go test -race,尤其关注map读写和list结构修改是否在同一锁保护下 - 如果业务允许“短暂不一致”,比如容忍几毫秒内旧值未被移到头部,可用
sync.Pool缓存链表节点减少 GC,但别缓存用户数据本身
边界情况比想象中多:空值、重复键、零容量
很多人忽略 Put("k", nil) 该怎么处理。Go 的 map 允许 nil 值,但 list.List 的 PushFront(nil) 是合法的,而后续 Get 返回 nil 时你无法区分是“没命中”还是“命中但存的是 nil”。建议统一禁止 nil 值,或在 entry 结构体里加 valid bool 字段显式标记。
立即学习“go语言免费学习笔记(深入)”;
- 容量为 0 时,
Put应直接丢弃,Get永远返回未命中——但别 panic,这是合法配置(用于临时关闭缓存) - 键类型如果是结构体,确保实现了可比性(字段都可比较),否则 map 操作会编译失败;若不可比(含 slice/map/func),得自己实现哈希和等价函数
- 测试时一定要覆盖 “Put 超限 → Get 最老项 → Put 新项 → 再 Get 最老项” 这条路径,容易漏掉尾节点未正确更新
prev导致循环引用
最难调的永远不是主干逻辑,而是删节点时对前后指针的四行更新代码——少一行或顺序错,缓存就悄悄开始返回错误结果,日志还完全看不出问题。










