sync.Map 不适合做 LRU 缓存,因其无访问顺序记录、不支持容量限制与淘汰策略,底层为无序分片哈希表,Range 遍历不反映最近使用情况,易导致缓存击穿、雪崩及内存失控。

为什么 sync.Map 不适合做 LRU 缓存
因为 sync.Map 没有访问顺序记录能力,也不支持容量限制和淘汰策略。它只保证并发读写安全,底层是分片哈希表 + 延迟初始化,Range 遍历无序且不反映最近使用情况。
常见错误现象:sync.Map 被误用后,缓存“击穿”或“雪崩”时完全不可控——明明设置了 1000 条上限,实际内存占用翻倍;或者刚写入的 key 突然查不到,其实是被其他 goroutine 并发覆盖或 GC 干扰了。
- LRU 必须维护双向链表(或等效结构)来追踪访问时序
- 淘汰动作必须原子地更新链表 + map,不能拆成两步操作
- 读操作要触发“移到头部”,写操作要判断是否已存在并调整位置
手写并发安全 LRU 的核心三件套
不是靠锁整个结构体,而是把热点操作拆开:用 sync.RWMutex 保护链表操作,用普通 map 存数据,再加一个 sync.Mutex 控制淘汰临界区。这样读多写少场景下性能更稳。
典型结构体字段:
立即学习“go语言免费学习笔记(深入)”;
type LRUCache struct {
mu sync.RWMutex
evictMu sync.Mutex
cache map[string]*list.Element
list *list.List
capacity int
}
关键点:
-
cache是map[string]*list.Element,不是map[string]interface{}—— 直接存指针避免二次查找 -
*list.Element的Value字段必须是自定义结构体(含 key + value),否则淘汰时无法反查 key - 所有对外方法(
Get/Put/Del)开头统一加mu.RLock()或mu.Lock(),绝不裸奔
Get 方法里最容易漏掉的并发陷阱
看似只是查 map + 移动链表节点,但有两个隐性竞争条件:
- 查到元素后,
list.MoveToFront()前可能已被其他 goroutineDel掉,导致 panic: “move of nil element” - 读取
element.Value后,值可能正被另一个Put覆盖,造成脏读(如果 Value 是指针或大结构体)
正确做法:
func (c *LRUCache) Get(key string) (value interface{}, ok bool) {
c.mu.RLock()
elem, exists := c.cache[key]
if !exists {
c.mu.RUnlock()
return nil, false
}
// 必须在持有 RLock 期间完成 MoveToFront,否则 elem 可能失效
c.list.MoveToFront(elem)
c.mu.RUnlock()
// 此时 elem.Value 已稳定,安全解包
entry := elem.Value.(*entry)
return entry.value, true
}
注意:MoveToFront 必须在 RUnlock 前调用,且不能换成先 RUnlock 再 Lock —— 那样会丢失“访问即更新时序”的语义。
容量超限时的淘汰逻辑为什么不能只靠 list.Len()
list.Len() 是 O(1),但它只告诉你当前节点数,不等于缓存真实大小。如果你存的是大对象(比如 []byte 或 struct{}),内存压力其实在 value 本身,而不是链表节点数。
所以生产环境常用两种方案:
- 按条目数限流(简单,适合 key-value 都小且均匀的场景)——此时
if c.list.Len() > c.capacity可用 - 按估算内存用量限流(需预估每个 value 占用,配合 runtime.ReadMemStats 做周期采样)——这时淘汰要走独立 goroutine + channel 控制节奏,避免阻塞主请求
容易踩的坑:Del 时只删 map 不删 list.Element,或反过来,导致内存泄漏;还有在 evictMu 里调用了可能阻塞的操作(如 HTTP 请求、数据库查询),拖垮整个缓存吞吐。
复杂点在于:淘汰不是“删得越快越好”,而是要平衡延迟、内存精度和 goroutine 开销。多数业务其实卡死在“忘了在 Put 里检查是否已存在 key”,导致重复插入链表,最终 list.Len() 失真 —— 这个 bug 往往压测一周才暴露。










