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

为什么不用 container/list 直接实现 LRU?
因为 container/list 的节点指针无法直接映射到哈希表中的键,每次 Get 后想把节点移到队首,得先从链表里找到它——而链表查节点是 O(n)。LRU 要求所有操作 O(1),必须让哈希表值直接存链表节点指针,且节点自带前后指针和 key/val 字段。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 自己定义双向链表节点结构,包含
key、value、prev、next - 用
map[interface{}]*node做哈希索引,值是节点指针,不是数据副本 - 别复用
container/list.Element.Value存业务数据——类型擦除后取值要断言,易 panic 且无法直接更新节点位置
sync.RWMutex 锁粒度怎么设才不拖慢并发读?
缓存本质是读多写少,但若整个 cache 结构共用一把 sync.RWMutex,高并发 Get 仍会排队抢读锁(Go runtime 对 RWMutex 的读锁也有轻量竞争开销)。更糟的是,某些实现把 Get + Touch(移至队首)全包在写锁里,彻底串行化。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
-
Get只读 map 和节点字段:用RWMutex.RLock(),且确保读取过程不触发内存分配或函数调用 -
Touch和Put涉及链表结构调整:必须升级为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.Time,Get 时比较 time.Now().After(entry.expireAt)。问题在于:time.Now() 是系统时钟,可能被 NTP 调整、手动修改,导致大量 key 突然“提前过期”或“永不释放”。而且每次比较都调用系统调用,性能有损。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 改用单调时钟:记录 entry 创建时的
time.Since(startTime)(startTime = time.Now()),后续用time.Since(startTime) > ttl判断,完全避开系统时间跳变 - 过期检查只在
Get和Put时做,不要后台 goroutine 定期扫描——既难保证及时性,又增加调度负担 - 如果业务允许,干脆不做自动过期,把 TTL 逻辑交给上层(比如 HTTP 缓存头),本地缓存只负责容量淘汰
最麻烦的从来不是写对 LRU 链表,而是怎么让它的行为在并发、内存、时间三个维度上都不意外。尤其当它开始影响下游超时或内存 OOM 时,问题往往藏在锁粒度、时钟假设或 size 估算这些“看起来不重要”的地方。










