并发安全 LRU 缓存¶
30 秒版(开场)¶
LRU = 最近最少使用淘汰。经典实现:哈希表 + 双向链表,Get/Put 的期望/平均复杂度为 O(1)。Go 用
map+container/list,并发版加sync.Mutex包住“查 map + 改链表”的完整临界区。讲解关键词:MoveToFront、超容量删 Back、更新已存在 key。
3 分钟版(精讲深度)¶
- 是什么:容量固定,满时淘汰最久未访问的 key。
- 为什么:本地缓存、DB 查询缓存、热点数据 — 高频编码练习(LeetCode 146)。
- 怎么做:
map[key]*list.Element定位节点;链表头=最新、尾=最旧;Get 命中则MoveToFront;Put 新 key 则PushFront,超 cap 删Back并从 map 移除。
10 分钟版(白板步骤)¶
flowchart LR
subgraph map
K1[key A] --> N1
K2[key B] --> N2
end
subgraph list
HEAD --> N1 --> N2 --> TAIL
end
手写顺序(建议讲解)
- 定义
entry{key, value}— 删尾时需要 key 从 map 移除 Cache{cap, mu, items map, order *list.List}Get:不存在 return false;存在MoveToFront返回值Put:已存在则更新 +MoveToFront;否则PushFront,若Len>cap删Back- 加
mu.Lock/defer Unlock
复杂度
| 操作 | 时间 | 空间 |
|---|---|---|
| Get | 平均 O(1) | — |
| Put | 平均 O(1) | O(capacity) |
生产场景¶
- 进程内缓存(配合 TTL 可扩展为 LRU+过期时间)
- 注意:单机缓存;分布式用 Redis
- 高 QPS 时 Mutex 成瓶颈 → 分片 LRU(按 key hash 多把锁)
排查与工具¶
go test -race ./lru/...- benchmark 对比
sync.Map(不适合 LRU 顺序语义)
架构取舍¶
| 方案 | 适用 |
|---|---|
| Mutex + 单 LRU | 编码练习 / 中等 QPS |
| 分片 LRU | 高并发读写在同一进程 |
| Redis LRU | 多实例共享 |
深挖问答¶
- 为何链表 + map? → map O(1) 找节点,list O(1) 移动/删除,数组做不到。
- Get 要 MoveToFront 吗? → 要,否则「刚读过的」会被误淘汰。
- 能用单链表吗? → 删尾/前驱需要 O(n),双向链表 O(1)。
- RWMutex 行吗? → Put 会改结构,读也 MoveToFront,通常仍用 Mutex;只读且不移位才可 RWMutex。
反模式与事故¶
- 删尾时 忘记 delete map → 内存泄漏、逻辑错误
list.Element存 value 不存 key → 删尾无法定位 map 键- 无界 map 不做 LRU → OOM
代码示例¶
完整实现见 examples/senior/lru/lru.go:
func (c *Cache) Put(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
if elem, ok := c.items[key]; ok {
elem.Value.(*entry).value = value
c.order.MoveToFront(elem)
return
}
elem := c.order.PushFront(&entry{key: key, value: value})
c.items[key] = elem
if c.order.Len() > c.cap {
back := c.order.Back()
c.order.Remove(back)
delete(c.items, back.Value.(*entry).key)
}
}
cd examples/senior && go test ./lru/...