LRU 按最近使用顺序选择淘汰对象,而不是按历史热度、存放年龄或业务重要程度选择。有界缓存需要腾出空间时,精确的最近最少使用策略会移除最后一次有效访问发生得最早的条目。它利用的是时间局部性,既不保证命中有价值的数据,也不保证留下来的数据仍然新鲜。

沿访问顺序找出淘汰对象

假设缓存只能存三个条目,并按从旧到新的方向列出。依次加载 A、B、C 后,顺序为 A、B、C;再次读取 A,顺序变成 B、C、A;此时加载 D,就淘汰 B,留下 C、A、D。普通字典即使保留插入顺序,也不会因为某个条目被读取就自动调整它的位置。

实现必须明确哪些操作算作一次访问。常见精确方案把查找表与双向链表结合起来,以较低成本完成查找、移到最近端和从最旧端移除。维护顺序需要额外元数据,也让缓存命中变成状态更新;并发实现还必须协调这些更新,不能把读取缓存理解成永远没有写入开销。

精确顺序与采样近似不是同一契约

Redis 使用近似 LRU:抽取候选并维护较好的淘汰候选池,而不是为每个键保存完整的精确访问顺序。采样数量可以配置,更多采样用额外计算换取更接近精确 LRU 的结果。它不承诺下面小模型的逐个淘汰顺序,也不能由此推导所有淘汰操作都有固定耗时。

候选资格同样重要。Redis 的 allkeys-lru 可以淘汰任意键,volatile-lru 则只考虑带过期信息的键。设置了有效期,并不保证键在截止时间之前不会被淘汰;如果没有符合条件的带过期时间的键,volatile 策略在内存压力处理上会表现得像 noeviction。

容量约束不是新鲜度约束

限制三个条目,不等于限制三千字节。不同键、值和维护结构占用不同空间,按数量限制的缓存仍可能持有很大的对象。Python 的 lru_cache 会持有参数与返回值的引用,直到条目被淘汰或缓存被清空。选择容量单位时,应对应真正需要控制的资源,而不是只看条目数是否稳定。

最近访问也不能说明源数据有没有变化。频繁读取一个过时答案,反而可能让它一直留在缓存中。有效期、显式失效和版本化键分别处理新鲜度问题;淘汰规则既不是一致性协议,也不是备份策略,不能因为缓存命中就跳过业务对数据有效性的判断。

坏掉的样子

一次扫描大量只用一遍的条目,可能挤掉本来有用的工作集。容量为三时循环访问四个不同键,即使这些键都在被反复请求,也可能每次都未命中。LRU 不会因为过去访问次数很多就永久保留一个键。LFU、插入前的准入策略或调整容量可能适合某些负载,但不能仅凭“读多”就自动选出正确方案。

应在有代表性的时间段一起观察命中、未命中、淘汰、保留字节数以及未命中的代价。很高的命中率也可能掩盖少量特别昂贵的回源;单看淘汰数量,不知道被移除的数据是否有价值。修改策略前,先还原实际访问轨迹,再解释缓存为什么做出了这些选择。

缓存命中还必须允许安全复用。带副作用的函数,或每次调用都必须创建独立可变对象的函数,不适合直接做记忆化。Python 的 lru_cache 能保持多线程下内部结构一致,但同时发生的未命中仍可能让被包装函数执行多次。线程安全不等于抑制同一项工作被重复执行。

快速验证

将代码保存为 lru_check.py,用 Python 3.10 以上版本执行 python3 lru_check.py。实验已在 Python 3.14.4 上运行,只使用标准库:OrderedDict 展示精确访问顺序,真正的 functools 装饰器另行验证记忆化行为。不启动 Redis,不发送网络请求,也不写文件。

from collections import OrderedDict
from functools import lru_cache


def access(cache, key, capacity):
    assert capacity > 0
    hit = key in cache
    evicted = None
    if hit:
        cache.move_to_end(key)
    else:
        cache[key] = key.lower()
        if len(cache) > capacity:
            evicted, _ = cache.popitem(last=False)
    return hit, evicted, tuple(cache)


cache = OrderedDict()
observed = [access(cache, key, 3) for key in "ABCAD"]
assert observed == [
    (False, None, ("A",)),
    (False, None, ("A", "B")),
    (False, None, ("A", "B", "C")),
    (True, None, ("B", "C", "A")),
    (False, "B", ("C", "A", "D")),
]
print("ABCAD: touching A makes B the victim; final oldest -> newest:", tuple(cache))

for key in "XYZ":
    access(cache, key, 3)
assert tuple(cache) == ("X", "Y", "Z")
assert access(cache, "A", 3)[0] is False
print("a one-time XYZ scan displaces the old working set")

cache.clear()
hits = sum(access(cache, key, 3)[0] for key in "ABCD" * 3)
assert hits == 0
print("capacity 3, repeated ABCD: 0 hits in 12 accesses")

origin = {"A": 1, "B": 2, "C": 3}
loads = []


@lru_cache(maxsize=2)
def read(key):
    loads.append(key)
    return origin[key]


assert [read(key) for key in "ABACB"] == [1, 2, 1, 3, 2]
assert loads == ["A", "B", "C", "B"]
assert tuple(read.cache_info()) == (1, 4, 2, 2)
print("functools trace:", read.cache_info(), "origin reads:", loads)
origin["B"] = 20
assert read("B") == 2
read.cache_clear()
assert read("B") == 20
assert tuple(read.cache_info()) == (0, 1, 2, 1)
print("a recent cached B stays stale until explicit invalidation")

第一组断言检查每一步的顺序,而不只是最后的容量:读取 A 后,B 成为下一次淘汰对象。随后一次性访问 X、Y、Z,会挤走原工作集;再循环访问 A、B、C、D,容量为三的缓存十二次访问全部未命中。结果说明“这些键经常出现”并不足以保证 LRU 有收益,还取决于它们之间隔了多少其他访问。

装饰器包装的函数读取一份内存中的源数据。A、B、A、C、B 的轨迹得到一次命中、四次未命中和两个保留条目;记录的源读取列表明确指出哪些调用实际重新计算。把源数据 B 从二改为二十后,缓存仍返回二,直到执行 cache_clear 才读到二十;清空操作也会重置统计。

这些是确定性的正确性检查,不是延迟或内存基准。实验没有执行 Redis 采样淘汰、有效期处理、并发未命中、按字节限制容量或生产访问分布。先用轨迹理解规则,再针对真实缓存实现和真实工作负载验证,不能把小模型的结果直接当成生产命中率承诺。

延伸阅读

来源

  • Python functools,LRU 记忆化、统计、对象引用、失效与并发调用边界,已核验 2026-09-08。
  • Python collections,OrderedDict 重排与端点移除,已核验 2026-09-08。
  • Redis 键淘汰,近似 LRU、采样与候选资格,已核验 2026-09-08。