直接套用 container/list 实现 LRU-K 容易出错,因其仅提供双向链表,缺乏键值索引与频次统计能力;LRU-K 需维护 map[string][]time.Time 记录每 key 最近 K 次访问时间戳,并按第 K 次访问时间淘汰,而非最后一次。

为什么直接套用标准库的 container/list 实现 LRU-K 容易出错
因为 container/list 只提供双向链表,不带键值索引和访问频次统计能力。LRU-K 的核心是「记录每个 key 最近 K 次访问的时间戳」,不是简单地把最久未用的踢掉——你得维护一个 map[key][]time.Time,还要按时间戳排序裁剪。如果只用链表模拟,很容易漏掉某次访问、重复插入时间戳,或在淘汰时误删高频 key。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用
map[string][]time.Time存访问历史,每次Get时追加当前时间,并用sort.Slice或双端队列逻辑截断到最多 K 个(推荐用切片 +append+copy避免频繁分配) - 淘汰策略不是看「最后一次访问」,而是看「第 K 次访问距今多久」:取
accesses[len(accesses)-K](需确保长度 ≥ K),越小越该淘汰 - 别在
Get里做全量重排——只 append + 截断;全量排序留到淘汰前(且仅对候选集做)
lruk.Cache 结构体必须包含哪些字段才能支持并发安全
单纯加 sync.RWMutex 不够。LRU-K 的读写热点在访问历史更新和淘汰决策,而淘汰往往需要遍历所有 key 的访问序列。如果锁整个 map,高并发下会严重阻塞 Get。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用
sync.Map存key → *entry,其中entry是含sync.Mutex的结构体,封装自己的访问历史切片——实现「分 key 锁」 -
entry中的accesses切片要预分配容量(如make([]time.Time, 0, K)),避免扩容导致指针失效或竞争 - 淘汰操作(
evict)仍需全局锁,但只在缓存满时触发,且应限制单次淘汰数量(如每次最多删 1–3 个),避免长时持有锁
如何判断某个 key 是否该被淘汰:K=2 时的典型错误计算
常见错误是直接比较 accesses[0] 或 accesses[len(accesses)-1],但 LRU-K 要求的是「倒数第 K 次访问时间」。当 K=2 且某 key 只被访问过 1 次,它不该参与淘汰(历史不足 K 次);若访问过 3 次,有效参考点是 accesses[1](索引从 0 开始,倒数第 2 是 len-2)。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 淘汰前先过滤:
if len(e.accesses) - 正确取参考时间:
ref := e.accesses[len(e.accesses)-K] - 用
time.Since(ref)算「距第 K 次访问多久」,值越大越冷,优先淘汰 - 注意:Go 的
time.Time比较可直接用,无需转 Unix 时间戳
为什么不用第三方库(如 github.com/hashicorp/golang-lru)而手写 LRU-K
因为那些库基本只实现 LRU / LFU / ARC,没有 LRU-K。HashiCorp 的 lru 库连 K=1(即纯 LRU)都靠 container/list + map 手搓,更别说维护多时间戳。你要加 K 维度,就得改底层存储模型——不如自己定义 entry 和淘汰逻辑,反而更可控。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 不要试图 patch 第三方 LRU 库:它们的淘汰钩子(如
OnEvicted)只暴露 key/val,拿不到访问历史 - 如果项目已用
bigcache或ristretto,它们也不支持 LRU-K;强行混用会导致语义错乱(比如你以为在用 LRU-K,实际走的是 LFU 路径) - 手写时把
K设为常量参数(非运行时可变),避免在entry中存动态大小切片带来的 GC 压力
真正难的不是实现 K 次记录,而是让「淘汰时的扫描」不拖慢正常读写——这要求你把访问历史的更新、截断、排序完全隔离到单个 entry 内部,且淘汰只扫「最近可能变冷」的候选集(比如按上次淘汰后写入时间分桶),而不是全量遍历。这点多数教程会跳过。


















