手写 LRU 缓存:O(1) 双向链表与 6 个真实坑

格 格达软件 2026-11-04 0 次阅读

线上有个商品详情接口,单机 QPS 6000,DB 顶不住,第一反应是加本地缓存。很多人的第一版长这样:

var cache = make(map[string][]byte)
var mu sync.Mutex

func Get(key string) []byte {
	mu.Lock()
	defer mu.Unlock()
	return cache[key]
}

func Set(key string, v []byte) {
	mu.Lock()
	defer mu.Unlock()
	cache[key] = v
}

上线三天,容器被 OOMKilled。key 空间是「商品 ID × 站点 × 语言」,一天能碰到几百万个组合,value 序列化后 10~30KB,内存只增不减。

有人改成「超过 10 万条就整个清空」,结果更糟:每隔几十分钟来一次全量击穿,DB 瞬间被打到 3 万 QPS。需要的是「有上限 + 淘汰时挑最不该留的那个」,这就是 LRU 要解决的问题。下面手写一遍,然后把踩到的坑一个个列出来。

为什么是哈希表 + 双向链表

LRU 要对两个操作都保持 O(1):

  1. 按 key 找到值 —— 只有哈希表能做到。
  2. 把刚访问的节点挪到「最新」端,从「最旧」端淘汰 —— 要能在 O(1) 时间把一个节点从链表中间摘出来,并且知道前后是谁。

数组不行,中间删除要搬移元素,O(n)。单链表不行,摘除一个节点需要它的前驱,从头找是 O(n)。双向链表可以,节点自己就存了 prev/next。

关键的一点,也是最多人写错的一点:哈希表里存的必须是指向链表节点的指针,不能是值本身。map[K]V 只能拿到值,拿不到节点,也就没法 O(1) 摘除。

结构大概长这样,head 侧是最近使用,tail 侧是最久未使用:

head <-> [A] <-> [B] <-> [C] <-> tail
         ↑ 最新            ↑ 最旧,满了就淘汰它
map: "A" -> *nodeA, "B" -> *nodeB, "C" -> *nodeC

用两个哨兵节点(head/tail)可以省掉所有「链表为空」「插到头部」「删除尾节点」的空指针判断,这是双向链表实现里最省事的一个技巧。

手写实现:Go 泛型版

完整代码,可以直接跑,链表用哨兵,map 存节点指针。

package lru

import "sync"

type entry[K comparable, V any] struct {
	key        K
	val        V
	prev, next *entry[K, V]
}

type Cache[K comparable, V any] struct {
	mu       sync.Mutex
	capacity int
	items    map[K]*entry[K, V]
	head     *entry[K, V] // 哨兵,head.next 是最近使用的
	tail     *entry[K, V] // 哨兵,tail.prev 是最久未使用的
}

func New[K comparable, V any](capacity int) *Cache[K, V] {
	if capacity <= 0 {
		panic("lru: capacity must be positive")
	}
	c := &Cache[K, V]{
		capacity: capacity,
		items:    make(map[K]*entry[K, V], capacity),
		head:     &entry[K, V]{},
		tail:     &entry[K, V]{},
	}
	c.head.next = c.tail
	c.tail.prev = c.head
	return c
}

// unlink 把节点从链表摘出来,不碰 map
func (c *Cache[K, V]) unlink(e *entry[K, V]) {
	e.prev.next = e.next
	e.next.prev = e.prev
}

// pushFront 把节点插到 head 后面,表示「刚刚被用过」
func (c *Cache[K, V]) pushFront(e *entry[K, V]) {
	e.next = c.head.next
	e.prev = c.head
	c.head.next.prev = e
	c.head.next = e
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()

	e, ok := c.items[key]
	if !ok {
		var zero V
		return zero, false
	}
	// 命中就要挪到头部,否则整个结构退化成 FIFO
	c.unlink(e)
	c.pushFront(e)
	return e.val, true
}

func (c *Cache[K, V]) Put(key K, val V) {
	c.mu.Lock()
	defer c.mu.Unlock()

	// 已存在的 key 必须复用原节点,不能 new 一个新的
	if e, ok := c.items[key]; ok {
		e.val = val
		c.unlink(e)
		c.pushFront(e)
		return
	}

	e := &entry[K, V]{key: key, val: val}
	c.items[key] = e
	c.pushFront(e)

	// 先插入再判断,更新路径上不会算错
	if len(c.items) > c.capacity {
		oldest := c.tail.prev
		c.unlink(oldest)
		delete(c.items, oldest.key)
		// 断开引用,value 是大对象时能让 GC 早点回收
		oldest.prev, oldest.next = nil, nil
	}
}

func (c *Cache[K, V]) Len() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return len(c.items)
}

一个最小测试,覆盖「命中会改变淘汰顺序」这件事:

func TestEvict(t *testing.T) {
	c := New[string, int](2)
	c.Put("a", 1)
	c.Put("b", 2)
	c.Get("a")    // a 变成最近使用
	c.Put("c", 3) // 应该淘汰 b 而不是 a
	if _, ok := c.Get("b"); ok {
		t.Fatal("b should be evicted")
	}
	if v, _ := c.Get("a"); v != 1 {
		t.Fatal("a should survive")
	}
}

需要 Go 1.18+。老项目把泛型换成 interface{},逻辑一字不改,只是取值要类型断言。

Java 其实不用手写,LinkedHashMap 构造函数第三个参数 accessOrder=true 就是维护访问顺序的双向链表:

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    public LRUCache(int capacity) {
        super(capacity, 0.75f, true); // true = 按访问顺序排序
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

但 LinkedHashMap 不是线程安全的。套 Collections.synchronizedMap 之后每次 get 都要抢同一把锁,热点 key 上会直接打成瓶颈,见坑四。

坑一:map 里存值,淘汰时摘不掉节点

最典型的第一版错误是 m map[string]string,或者 m map[string]*Value(值类型指针,不是链表节点)。淘汰的时候你手里只有 key,没有节点,只能从头遍历链表找前驱,淘汰从 O(1) 变成 O(n)。

容量 10 万、QPS 6000 的话,每次淘汰扫 10 万个节点是几百微秒量级,而且这一下正好发生在 Put 的锁里,其他所有请求跟着排队,延迟毛刺就是这么来的。

还有一个变体:map 存节点,但 Put 更新已有 key 时 new 了一个新节点。旧节点还挂在链表上,于是同一个 key 在链表里出现两次,内存泄漏;淘汰时删的 key 又对不上,删了个寂寞。Put 必须先查 map,命中就复用节点。

坑二:Get 命中不挪位置,LRU 变 FIFO

把 Get 里的 unlink + pushFront 删掉,代码照样编译、照样跑,如果你的测试只覆盖了「不存在的 key 返回 false」,单测全绿。但淘汰顺序变成了纯先进先出。

后果是热点 key 会被后来的冷 key 按插入顺序挤掉,命中率明显下降。差距多大取决于访问分布,在 Zipf(α≈1) 的模拟里通常是十几个到二十几个百分点。建议拿一份真实访问日志,用同一个 key 序列分别喂给 FIFO 和 LRU 实现,把命中率打出来对比 —— 这比看代码可靠得多,也能顺便验证你线上容量到底该设多少。

坑三:边界条件

  • capacity <= 0:别静默变成「永不缓存」,直接 panic。我在测试环境见过容量从配置读成 0 的服务,缓存全程不生效,压测数据好看得离谱,上线才炸。
  • 淘汰时机:先插入再判断 len > capacity,还是先判断再插入?前者简单且正确,最多瞬时超一个;后者在「更新已有 key」的路径上很容易把容量算错,提前淘汰一个不该淘汰的。
  • 容量为 1:head.next 和 tail.prev 是同一个节点。哨兵写法天然正确,用 if-else 判断空表/单节点的手写版本这里最容易崩。
  • 淘汰后断引用:oldest.prev, oldest.next = nil, nil 不是必须,但在大 value 场景能让 GC 早点回收,也能避免调试时顺着指针翻出一堆本该释放的对象。

坑四:并发下的锁粒度

上面整个 Cache 一把 sync.Mutex。我在 8 核机器上用 b.RunParallel、读写比 9:1 压了下(key 是 16 字节字符串),单锁版本大概每秒几百万次操作,看着还行;但只要 Get 里多一点点工作(value 是结构体要拷贝、或者临界区里还做了序列化),数字立刻掉一个量级,因为所有核都在抢同一把锁。换 sync.RWMutex 没用 —— LRU 的 Get 会改链表,本质是写操作。

真正有效的是分片,每片独立加锁:

type ShardedCache[V any] struct {
	shards []*Cache[string, V]
	mask   uint64
}

func NewSharded[V any](capacity, shards int) *ShardedCache[V] {
	// shards 必须是 2 的幂,才能用位与代替取模
	if shards&(shards-1) != 0 {
		panic("shards must be power of two")
	}
	per := capacity / shards
	if per == 0 {
		per = 1
	}
	s := &ShardedCache[V]{
		shards: make([]*Cache[string, V], shards),
		mask:   uint64(shards - 1),
	}
	for i := range s.shards {
		s.shards[i] = New[string, V](per)
	}
	return s
}

func (s *ShardedCache[V]) shard(key string) *Cache[string, V] {
	// FNV-1a,够快;别在这里用 md5/sha,哈希比缓存访问本身还贵
	h := uint64(14695981039346656037)
	for i := 0; i < len(key); i++ {
		h ^= uint64(key[i])
		h *= 1099511628211
	}
	return s.shards[h&s.mask]
}

func (s *ShardedCache[V]) Get(key string) (V, bool) { return s.shard(key).Get(key) }
func (s *ShardedCache[V]) Put(key string, val V)      { s.shard(key).Put(key, val) }

代价要说清楚:总容量变成 per * shards,各分片独立淘汰。如果 key 分布倾斜(比如某几个热门商品占了 30% 流量),它们落在同一片,仍然会争锁。分片数 16、32 比较常见,再往上单核收益递减,而且每片都淘汰不满、容量碎片化更明显。

坑五:按条数淘汰,不按内存淘汰

10 万条 × 平均 20KB ≈ 2GB。但容器 limit 可能只有 1GB,而且 P99 的 value 是 200KB,不是平均值。你按条数算得好好的,OOMKilled 照样来。

要么加权重按字节淘汰:给 Put 加一个 weight 参数(通常就是 len(serialized)),维护 curWeight,淘汰循环改成 for c.curWeight > c.maxWeight。要么更省事:设一个 value 大小上限,超了直接不进缓存。

const maxValueSize = 64 << 10 // 64KB

func (s *ShardedCache[[]byte]) PutBytes(key string, v []byte) {
	if len(v) > maxValueSize {
		return // 大对象不进缓存,否则会把一堆小热 key 全冲掉
	}
	s.Put(key, v)
}

这一行判断的收益,通常比把容量从 10 万调到 20 万大得多。大对象进 LRU 会挤掉大量小热 key,这是 LRU 的经典缺陷,后面还会说到。

坑六:LRU 挡不住一次性扫描

LRU 的前提假设是「最近用过的还会再用」。爬虫、后台导出、运营全表扫这类流量不满足这个假设:它们塞进来几万个只用一次的 key,把真正的热数据全挤出去。现象是命中率周期性掉到接近 0,DB 出现规律性尖刺,而 CPU、QPS 都正常,很难查。

这不是实现 bug,是算法选型问题。三个方向:

方案 思路 适合 代价
Segmented LRU(groupcache) 分 probation / protected 两段,新 key 先进 probation,被二次访问才升级 冷热分层明显 实现复杂度翻倍,两段容量比例要调
LRU-K / 2Q 记录最近 K 次访问,次数不够不进主缓存 扫描流量多 维护额外历史队列,内存开销上升
TinyLFU(ristretto) Count-Min Sketch 统计频率,准入时和待淘汰者比频率 高频长尾、命中率优先 有统计误差,sketch 要定期衰减

经验是:单机、纯本地、几十万条以内,手写 LRU 够用。一旦命中率被扫描流量打崩,先试 value 大小限制 + 业务层按 key 前缀分流(把会扫描的维度单独放一个实例或干脆不缓存),再考虑换算法。上 TinyLFU 之前先量清楚命中率掉了多少 —— 很多时候问题不在淘汰算法,在 key 的设计,比如把不该拼的维度拼进了 cache key。

坑七:把内部结构返回出去

Go 里如果把 *entry 返回给调用方,对方随手改了 e.val 或者 e.next,链表就断了,之后所有淘汰都算错。这类 bug 要等到容量满了才暴露,而且现场看着像随机的。所以 Get 签名用 (V, bool),返回值拷贝。

如果 V 本身是指针、map、slice,拷贝的只是引用,调用方改内容缓存里也跟着变。要么约定缓存内部对象只读,要么 Put 时深拷贝。深拷贝的代价通常是单次访问耗时的 2~3 倍,自己权衡 —— 我一般选约定只读,然后在 code review 里盯着。

什么时候别自己写

现成的轮子已经够用了。Go 有 github.com/hashicorp/golang-lru/v2(同时提供 LRU 和 2Q,泛型接口,跟上面的 New/Get/Put 几乎一样)、github.com/dgraph-io/ristretto(TinyLFU + 采样淘汰,命中率一般更好,但内部有异步 goroutine,关闭时要 Wait())。Java 生产环境基本用 Caffeine(W-TinyLFU)。Python 是 collections.OrderedDict 配 move_to_end,或者直接用 functools.lru_cache。

自己写的理由只有两个:需要按字节权重淘汰,或者需要和已有的存储、序列化逻辑贴得很紧。其余情况用库,把精力留给命中率监控 —— 一个没有命中率指标的缓存,本质上就是个内存泄漏。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。