Go标准库container/list+map无法实现LRU-K,因其仅维护单次访问时间戳,而LRU-K需为每个key保存最近K次访问时间戳并按第K次访问时间排序淘汰,须用双层结构(主缓存+历史队列)配合最小堆与懒删除机制。

Go 标准库的 container/list + map 无法直接拼出可用的 LRU-K,因为 LRU-K 要求每个 key 必须维护最近 K 次访问时间戳(不是单次),且淘汰逻辑依赖「第 K 次访问时间」排序——这和纯 LRU 的单链表移动完全不是一回事。
为什么不能在标准 LRU 上简单加个计数器就叫 LRU-K
常见错误是:给每个 key 加一个 accessCount int,每次 Get 就 count++,等 count >= K 就进主缓存。这看起来像 LRU-K,但实际漏掉了核心机制:
- 它没记录时间窗口——比如 key A 在 1 秒内被访问了 K 次,key B 是每小时访问一次、刚满 K 次,两者“晋升”权重应不同,但计数器无法区分
- 淘汰时无法判断「谁的第 K 次访问最久」,只能按最后访问时间淘汰,退化成 LRU-1
- 没有历史队列隔离,导致未达 K 次的访问也占主缓存空间,污染缓存池
必须拆成两个物理结构:主缓存 + 历史队列
LRU-K 不是“升级版 LRU”,而是双层结构:一层管热数据(主 LRU 链表),一层管候选数据(FIFO 历史队列)。二者完全独立,靠晋升逻辑连接:
- 主缓存:
*list.List+map[K]*list.Element,行为同标准 LRU:命中则MoveToFront,满则删Back() - 历史队列:
*list.List+map[K]*list.Element+map[K]int(计数器),只存 key,不存 value;Get未命中主缓存时,往队尾PushBack(key)并cnt[key]++ - 晋升触发点在
Get:若cnt[key] >= K,则从历史队列中Remove对应节点,再调用Put(key, value)进主缓存(value 需业务层提供或提前缓存) - 历史队列满时淘汰头部(
Front()),不是尾部——它是 FIFO,先进先出,保证老的候选尽早释放
淘汰「第 K 次访问最久」的 key 不能遍历全量
每次淘汰都扫一遍所有 key 的 history[0](即第 K 次访问时间)是 O(N),高并发下不可行。正确做法是维护一个最小堆:
立即学习“go语言免费学习笔记(深入)”;
- 堆元素为
{key string, kthTime time.Time},按kthTime升序排列 - 每次 key 的历史时间戳更新(如新增一次访问并截断到 K 个),就把新
kthTime推入堆 - 淘汰时循环
Pop堆顶,检查其kthTime是否仍等于当前 key 的history[0];不一致说明已过期,丢弃并继续 Pop——这就是懒删除 - Go 标准库
container/heap要求实现heap.Interface,重点写好Less(i, j int) bool和Swap,其他可复用
并发安全的关键不是锁多,而是锁对地方
LRU-K 比纯 LRU 多一层历史操作,锁乱套就会卡死或竞态。分层锁是底线:
- 主缓存读路径(
Get命中):只用mu.RLock(),保护cache[key]查找和ll.MoveToFront() - 主缓存写路径(
Put/RemoveOldest):用mu.Lock(),但临界区只做链表重连 + map 增删,绝不含日志、HTTP、序列化等阻塞操作 - 历史队列:单独一把
historyMu sync.RWMutex,Get未命中时先Rlock查cnt,再Lock更新计数或移出队列 - 绝对避免:在
Get中先Unlock()主锁,再去处理历史队列——中间窗口可能被另一个 goroutine 修改状态,导致晋升错乱或 double-remove
真正难的不是写对逻辑,而是让 history slice 的维护、堆的懒删除、两层锁的协作在高频访问下不出偏移——这些细节一旦松动,缓存就会开始“看似运行、实则漏命”。



















