Golang实战:基于LRU算法的简易本地缓存库

1次阅读

不用 container/list 实现 lru 是因为其节点无法直接映射哈希表键,查找节点为 o(n),不满足 o(1) 要求;需自定义含 key/val/prev/next 的节点结构,map 值存节点指针,并避免类型擦除与锁粒度不当等问题。

Golang实战:基于LRU算法的简易本地缓存库

为什么不用 container/list 直接实现 LRU?

因为 container/list 的节点指针无法直接映射到哈希表中的键,每次 Get 后想把节点移到队首,得先从链表里找到它——而链表查节点是 O(n)。LRU 要求所有操作 O(1),必须让哈希表值直接存链表节点指针,且节点自带前后指针和 key/val 字段。

实操建议:

立即学习go语言免费学习笔记(深入)”;

  • 自己定义双向链表节点结构,包含 keyvalueprevnext
  • map[Interface{}]*node 做哈希索引,值是节点指针,不是数据副本
  • 别复用 container/list.Element.Value 存业务数据——类型擦除后取值要断言,易 panic 且无法直接更新节点位置

sync.RWMutex 锁粒度怎么设才不拖慢并发读?

缓存本质是读多写少,但若整个 cache 结构共用一把 sync.RWMutex,高并发 Get 仍会排队抢读锁(go runtime 对 RWMutex 的读锁也有轻量竞争开销)。更糟的是,某些实现把 Get + Touch(移至队首)全包在写锁里,彻底串行化。

实操建议:

立即学习go语言免费学习笔记(深入)”;

  • Get 只读 map 和节点字段:用 RWMutex.RLock(),且确保读取过程不触发内存分配或函数调用
  • TouchPut 涉及链表结构调整:必须升级为 Lock(),但应尽量缩短临界区——只做指针重连,不碰 value 数据本身
  • 别在锁内做用户回调(如过期回调函数),否则可能卡住整个缓存

淘汰策略里「容量」和「字节大小」到底该按什么算?

多数简易 LRU 库只按元素个数限容,比如 maxEntries: 1000。但实际中,一个 String 值可能占几 MB,另一个只占 12 字节,按数量淘汰会导致内存失控。而按字节淘汰又引入计算成本和精度问题(如 unsafe.Sizeof 不算底层数据,reflect.Value.Size() 不适用 interface{})。

实操建议:

立即学习go语言免费学习笔记(深入)”;

  • 对外暴露 OnEvict func(key interface{}, value interface{}) 回调,由使用者自行统计和决策——比如传入一个原子计数器,在回调里减去该 value 的估算大小
  • 避免在 Put 时实时计算 value 占用内存,Go 没有稳定可靠的运行时对象大小 API
  • 如果真要硬控内存,建议配合 runtime.ReadMemStats 周期性采样 + 后台 goroutine 触发清理,而不是每次 Put 都检查

为什么 time.Time 做过期时间反而容易出错?

常见做法是每个 entry 存一个 expireAt time.TimeGet 时比较 time.Now().After(entry.expireAt)。问题在于:time.Now() 是系统时钟,可能被 NTP 调整、手动修改,导致大量 key 突然“提前过期”或“永不释放”。而且每次比较都调用系统调用,性能有损。

实操建议:

立即学习go语言免费学习笔记(深入)”;

  • 改用单调时钟:记录 entry 创建时的 time.Since(startTime)startTime = time.Now()),后续用 time.Since(startTime) > ttl 判断,完全避开系统时间跳变
  • 过期检查只在 GetPut 时做,不要后台 goroutine 定期扫描——既难保证及时性,又增加调度负担
  • 如果业务允许,干脆不做自动过期,把 TTL 逻辑交给上层(比如 http 缓存头),本地缓存只负责容量淘汰

最麻烦的从来不是写对 LRU 链表,而是怎么让它的行为在并发、内存、时间三个维度上都不意外。尤其当它开始影响下游超时或内存 OOM 时,问题往往藏在锁粒度、时钟假设或 size 估算这些“看起来不重要”的地方。

text=ZqhQzanResources