结论:container/list+map实现LRU能跑通但不适用于生产环境,因其堆分配频繁导致GC压力大、内存浪费严重、无法预分配节点池、并发性能差且不感知系统内存水位。

直接说结论:用 container/list + map 实现基础 LRU 能跑通,但线上高频服务中大概率会出问题——不是逻辑错,而是内存水位失控、GC 压力大、并发下性能抖动。
为什么 container/list 不适合生产环境的 LRU
它封装了太多间接层:list.Element 是 runtime 分配的堆对象,每次 PushFront 或 MoveToFront 都触发一次小对象分配;高并发下 GC 扫描压力陡增;实测在 10k QPS 场景中,list 的节点操作耗时比手写结构体高 3–4 倍。
-
list.Element内部含两个指针 + interface{} 字段,至少 32 字节(64 位系统),而实际业务 key/value 往往很小(如stringID +bool),内存浪费明显 - 无法预分配节点池,
sync.Pool对list.Element无效——它的类型是私有且不可导出的 - 读写都要加锁时,
list的方法本身不带并发安全保证,必须外层加sync.RWMutex,锁粒度粗,易成瓶颈
手写 node 结构体 + sync.Pool 的关键写法
核心是把链表节点变成可复用的值类型,避开 runtime 分配。
- 定义
type node struct { key, value any; prev, next *node },所有字段显式声明,不依赖 interface{} - 用
sync.Pool管理*node:初始化时pool = &sync.Pool{New: func() any { return &node{} }} -
map[any]*node直接存指针,Get/Put 时从 pool 取、用完后 reset 字段再放回,避免 GC 扫描 - 淘汰逻辑里不要只删一个节点:满容量时批量淘汰(如 10%),减少后续频繁触发淘汰的开销
Put 操作前必须检查系统内存水位
只看 Go 堆内存(runtime.ReadMemStats)完全不够——CGO 分配、mmap 映射、内核 page cache 都不计入,但都会挤占可用内存。
立即学习“go语言免费学习笔记(深入)”;
- Linux 下读
/proc/meminfo的MemAvailable字段,macOS 用host_statistics - 每 2 秒采样一次,缓存到本地变量,避免每次
Put都 syscall - 写死阈值很危险:建议设动态
memThreshold,负载高时自动下调(比如从 512MB → 256MB),让缓存主动收缩 - 检查失败时,不是 panic,而是调用
evictByLRU(0.3)主动淘汰 30% 最久未用项,保服务可用性
Expirable LRU 的过期检查不能只靠 Get 时惰性删除
惰性删除(access 时才清理)会导致过期 key 长期滞留,尤其当某些 key 写入后就再也不被访问——内存泄漏风险真实存在。
- 单独起 goroutine 定期扫描(如每 30 秒),用
time.Now().After(expireAt)判断,批量清理 - 扫描范围控制在当前缓存 size 的 5% 以内,避免单次扫描阻塞太久
- 过期时间存为绝对时间戳(
time.Time),别存 TTL,避免多次计算和时钟漂移误差 - 注意:
expirable.NewLRU底层仍是container/list,同样有上面提到的性能缺陷,仅适合低频场景
真正难的不是写对 Get 和 Put 的逻辑,而是让缓存感知系统真实压力、在 GC 和内存之间做取舍、并在并发与延迟间找平衡点——这些细节不落地,再“正确”的 LRU 也只是玩具。


















