缓存淘汰实测:一次全表扫描把 LRU 命中率从 46% 打到 38%,W-TinyLFU 守住 50% 却贵 11 倍

摘要

容量 1000、2 万 key、Zipf 0.9 的 16 万次请求里每 2 万次插一波 3000 个冷 key、3 轮实测:LRU 命中 45.84--46.07%,扫描后 5000 次掉到 38.10--41.30%;LFU 53.99--54.31%,但朴素实现每个操作 26.872--28.223 微秒,换成计数桶同命中率只要 0.519--0.606 微秒;W-TinyLFU 56.46--56.65%,扫描后仍有 49.88--53.66%。

一、LRU 的问题不是"不够聪明",是"没有记忆"

本地缓存选型,多数团队停留在两个选项:LRU 或者"加个 TTL"。LRU 的实现足够简单,OrderedDict 十行就能写完,命中率在均匀流量下也说得过去。

但它有一个结构性缺陷:只看"最近一次",不看"一共来过几次"。一次全表扫描、一次报表导出、一次爬虫回填,都会把当前热点整体挤出去。扫描的 key 之后再也不来,缓存却已经被污染了,要等真实流量把热点重新"焐热"。

这篇文章想把三件事量出来:

  1. 扫描到底能打掉多少命中率;
  2. LFU、2Q、W-TinyLFU 分别能救回多少;
  3. 救回来要付多少 CPU,以及"同一个算法换一种实现"能差多少。

二、实验设置

负载是合成的,但结构贴近真实缓存:

  • 2 万个 key,请求服从 Zipf 分布(alpha=0.9),16 万次请求,容量 1000;
  • 每 2 万次请求插入一波扫描:3000 个一次性冷 key,其中每 5 个取 1 个真正发起请求,构成 7 次突发;
  • 一轮 164200 次操作,3 轮不同随机种子;
  • 环境 Python 3.11.5 / Windows / AMD64,单进程;每个操作的开销按"总耗时 / 操作数"折算。

对比五种实现:LRU、朴素 LFU(每次线性找最小计数)、计数桶 LFU(同算法的 O(1) 均摊版本)、2Q、W-TinyLFU(1% window + SLRU + 4 位计数 sketch)。

三、命中率:LRU 垫底,W-TinyLFU 领先

策略 整体命中率 扫描后 5000 次 每操作开销
LRU 45.84--46.07% 38.10--41.30% 0.284--0.297 微秒
LFU(线性找最小) 53.99--54.31% 47.78--50.62% 26.872--28.223 微秒
LFU(计数桶) 53.99--54.31% 47.78--50.62% 0.519--0.606 微秒
2Q 52.82--52.90% 47.02--49.38% 2.230--2.328 微秒
W-TinyLFU 56.46--56.65% 49.88--53.66% 3.148--3.381 微秒

三个结论:

第一,LRU 比 LFU 低约 8 个百分点。 在 Zipf 流量下,"来过多少次"比"上次什么时候来"信息量更大,这一点和直觉一致。

第二,扫描对 LRU 的打击最直接。 每一波扫描之后,LRU 的瞬时命中率掉到 38.10--41.30%,比整体低 5--8 个百分点;W-TinyLFU 在同样的窗口里有 49.88--53.66%,只掉 3--7 个百分点。

第三,2Q 是折中点。 52.82--52.90% 的命中率介于 LFU 和 W-TinyLFU 之间,代价 2.230--2.328 微秒,比 W-TinyLFU 便宜三分之一左右。

把每一段(两次扫描之间)单独看,五者的差距并不是均匀的:稳态段 LRU 与 W-TinyLFU 只差 8 个百分点上下,扫描后的第一个窗口差得更多。原因很直接------扫描把所有策略都推到容量边界,此时"谁能把冷 key 挡在外面"才开始起作用。所以看命中率不能只看一个总分,要按阶段拆开。

四、LFU 那 28 微秒,是实现的锅

第一次跑完,LFU 的 26.872--28.223 微秒让我以为"LFU 太贵,不能用"。但看一眼实现就知道问题在哪:

python 复制代码
    def put(self, k):
        if k in self.cnt:
            return
        if len(self.cnt) >= self.cap:
            victim = min(self.cnt, key=lambda x: self.cnt[x])
            del self.cnt[victim]
        self.cnt[k] = 1

min() 每次淘汰都要扫全表,容量 1000 就是 1000 次比较。这不是 LFU 算法的成本,这是"每次淘汰做一次 O(n) 扫描"的成本。

换成分桶实现:dict[count] -> OrderedDict,再用一个 key -> count 的表直查,淘汰时从 min_count 桶里取队首,均摊 O(1):

python 复制代码
    def get(self, k):
        c = self.cnt.get(k)
        if c is None:
            return False
        self.hit += 1
        od = self.buckets[c]
        del od[k]
        if not od:
            del self.buckets[c]
            if c == self.min_count:
                self.min_count = min(self.buckets) if self.buckets else 1
        self.cnt[k] = c + 1
        self.buckets.setdefault(c + 1, OrderedDict())[k] = 1
        return True

命中率一个数都没变(53.99--54.31%),每操作开销从 26.872--28.223 微秒降到 0.519--0.606 微秒。 同一算法,两种实现,差了两个数量级。

这条比"选哪个策略"更重要:很多"某算法太慢"的结论,测的是实现而不是算法。

五、W-TinyLFU 靠"准入"而不是"淘汰"

W-TinyLFU 的结构是:1% 的 window LRU 接新对象,剩下 99% 分 probation 和 protected 两段;新对象要进主区,必须先过频率准入------比主区里最该被淘汰的那个访问次数更高才放行。

python 复制代码
    def put(self, k):
        if k in self.win or k in self.prob or k in self.prot:
            return
        self.win[k] = 1
        self.win.move_to_end(k)
        if len(self.win) <= self.wcap:
            return
        cand, _ = self.win.popitem(last=False)
        if len(self.prob) + len(self.prot) < self.pcap + max(1, self.cap - self.wcap - self.pcap):
            self.prob[cand] = 1
        elif self.sk.freq(cand) > self.sk.freq(next(iter(self.prob))):
            victim, _ = self.prob.popitem(last=False)
            self.prob[cand] = 1

关键在最后那个 elif:扫描来的冷 key 在 window 里待一下就被挤出来,频率只有 1,比不过在 probation 里反复被访问的热点,于是根本进不了主区。热点因此不会被扫描冲掉。

代价是每操作 3.148--3.381 微秒,是 LRU 的 11 倍左右;另外要维护一个 4 位计数 sketch,内存和实现复杂度都上去了。

window 取 1% 不是硬性规定,它决定"新对象有多少缓冲"。window 太小,突发写入会直接冲到准入判断,热点还没来得及被计数就被比较;window 太大,等于把主区让给了短期流量。本文没有扫这个参数,只说清它在结构里的位置------它是"准入前的缓冲区",不是"热点区的一部分"。
#mermaid-svg-pcssY9RrXLwdIma1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pcssY9RrXLwdIma1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pcssY9RrXLwdIma1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pcssY9RrXLwdIma1 .error-icon{fill:#552222;}#mermaid-svg-pcssY9RrXLwdIma1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pcssY9RrXLwdIma1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pcssY9RrXLwdIma1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pcssY9RrXLwdIma1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pcssY9RrXLwdIma1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pcssY9RrXLwdIma1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pcssY9RrXLwdIma1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pcssY9RrXLwdIma1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pcssY9RrXLwdIma1 .marker.cross{stroke:#333333;}#mermaid-svg-pcssY9RrXLwdIma1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pcssY9RrXLwdIma1 p{margin:0;}#mermaid-svg-pcssY9RrXLwdIma1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pcssY9RrXLwdIma1 .cluster-label text{fill:#333;}#mermaid-svg-pcssY9RrXLwdIma1 .cluster-label span{color:#333;}#mermaid-svg-pcssY9RrXLwdIma1 .cluster-label span p{background-color:transparent;}#mermaid-svg-pcssY9RrXLwdIma1 .label text,#mermaid-svg-pcssY9RrXLwdIma1 span{fill:#333;color:#333;}#mermaid-svg-pcssY9RrXLwdIma1 .node rect,#mermaid-svg-pcssY9RrXLwdIma1 .node circle,#mermaid-svg-pcssY9RrXLwdIma1 .node ellipse,#mermaid-svg-pcssY9RrXLwdIma1 .node polygon,#mermaid-svg-pcssY9RrXLwdIma1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pcssY9RrXLwdIma1 .rough-node .label text,#mermaid-svg-pcssY9RrXLwdIma1 .node .label text,#mermaid-svg-pcssY9RrXLwdIma1 .image-shape .label,#mermaid-svg-pcssY9RrXLwdIma1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-pcssY9RrXLwdIma1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pcssY9RrXLwdIma1 .rough-node .label,#mermaid-svg-pcssY9RrXLwdIma1 .node .label,#mermaid-svg-pcssY9RrXLwdIma1 .image-shape .label,#mermaid-svg-pcssY9RrXLwdIma1 .icon-shape .label{text-align:center;}#mermaid-svg-pcssY9RrXLwdIma1 .node.clickable{cursor:pointer;}#mermaid-svg-pcssY9RrXLwdIma1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pcssY9RrXLwdIma1 .arrowheadPath{fill:#333333;}#mermaid-svg-pcssY9RrXLwdIma1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pcssY9RrXLwdIma1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pcssY9RrXLwdIma1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pcssY9RrXLwdIma1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pcssY9RrXLwdIma1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pcssY9RrXLwdIma1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pcssY9RrXLwdIma1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pcssY9RrXLwdIma1 .cluster text{fill:#333;}#mermaid-svg-pcssY9RrXLwdIma1 .cluster span{color:#333;}#mermaid-svg-pcssY9RrXLwdIma1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pcssY9RrXLwdIma1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pcssY9RrXLwdIma1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-pcssY9RrXLwdIma1 .icon-shape,#mermaid-svg-pcssY9RrXLwdIma1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pcssY9RrXLwdIma1 .icon-shape p,#mermaid-svg-pcssY9RrXLwdIma1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pcssY9RrXLwdIma1 .icon-shape .label rect,#mermaid-svg-pcssY9RrXLwdIma1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pcssY9RrXLwdIma1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pcssY9RrXLwdIma1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pcssY9RrXLwdIma1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 挤出候选
是
再次访问
否
被拒
新 key
window LRU 1%
频率 > probation 队首?
probation
protected
丢弃
扫描冷 key 频率=1

六、按扫描强度选型

扫描不是均匀发生的,所以选型要先估"污染有多重":

  • 几乎没有扫描、只读热点:LRU。0.284--0.297 微秒最便宜,够用;
  • 有后台任务、偶发批量冷读:2Q 或计数桶 LFU。命中率 52.82--54.31%,成本 0.519--2.328 微秒;
  • 扫描频繁、命中率直接影响下游成本:W-TinyLFU。扫描后仍有 49.88--53.66%,愿意付 11 倍单次开销。

还有一个容易被忽略的点:TTL 不能替代淘汰策略。给扫描 key 设短 TTL 能限制污染时长,但热点 key 也被迫定期失效,命中率反而更差------本次实验没有跑 TTL 组合,这里只作为方向说明,不给数字。

避坑指南

  1. 别用"线性找最小"的 LFU 做基准 。你测到的是 min() 的成本,不是 LFU 的成本;换分桶后差两个数量级。
  2. 命中率一定要带"污染段"看。只报整体命中率,会把"扫描后掉 5--8 个百分点"这件事藏起来。
  3. 扫描负载要按真实比例注入。本文是每 2 万次请求插 3000 个冷 key、其中 1/5 真正请求;比例不同,结论的排序可能变化。
  4. sketch 的计数会饱和。4 位计数器到 15 封顶,长跑要走"总量到阈值就整体减半"的衰减,否则历史热点会永远压着新热点。
  5. 别把 3 微秒当绝对值。Python 实现的常数很大,结构结论(谁更抗污染、谁更便宜)可以迁移,绝对开销不能。

结论

在带扫描的 Zipf 流量下,LRU 的命中率是五种里最低的(45.84--46.07%),扫描后掉到 38.10--41.30%;LFU 用两次访问的信息挽回了约 8 个百分点;W-TinyLFU 用"频率准入"把扫描挡在主区之外,拿到 56.46--56.65%,并守住扫描后的 49.88--53.66%,代价是单次操作贵 11 倍。

如果只记一条:选策略之前先把实现写对------同一份 LFU,两种实现差了两个数量级的单次开销,而命中率完全一样。

附:实验代码

LRU 的基线足够短,也足够说明问题:

python 复制代码
    def get(self, k):
        d = self.d
        if k in d:
            d.move_to_end(k)
            self.hit += 1
            return True
        return False

完整的 Zipf 流量生成、扫描注入、五种策略与逐段命中率统计都在本地留档,本文所有区间来自同一份 transcript。

相关推荐
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 小视频模块实战:访客记录、FastDFS 存储与 SpringCache 缓存
java·缓存·交友
安易算力1 小时前
智算中心网络架构:RDMA/IB/以太网选型深度解析与优化实践
网络·架构·php
汤米粥1 小时前
cmd手动创建的Django项目怎么用社区版Pycharm运行起来
python·pycharm·django
武子康1 小时前
DOA 100%、GSC 改善 12 dB,ESP32-P4 远场拾音就能用了吗?
人工智能·后端·agent
涵玥1 小时前
python题目练习12-小玉家的电费//小玉在游泳//小鱼的航程
python·python每日一练·洛谷题目
计算机毕业编程指导师1 小时前
基于深度学习的花卉图像分类识别系统(PyTorch + ResNet50 + Django)
pytorch·python·深度学习·分类·毕业设计·迁移学习·图像分类
微订校园外卖平台1 小时前
区县外卖跑腿系统搭建:四端架构、订单链路与验收
架构·外卖系统·本地生活·微订
2601_962885722 小时前
vnpy 用什么数据源?A 股历史行情接入与 BarData 映射
python
日日大王学编程2 小时前
deerflow-memory-design-blog
python·langchain·开源