想象你是一家小面馆的主厨。灶台(CPU)就在手边,但真正最顺手的,是灶台旁那个备菜架——上面摆着切成丝的肉、拍好的蒜、调好的酱汁。客人点单(请求进来),你先看备菜架有没有现成的料,有就下锅(命中),没有才喊帮工去后仓搬货(回源到数据库)。这个备菜架,就是「本地缓存」:进程内存里的那一小方天地。它离你最近、拿得最快,但空间有限,得精心安排放什么、扔什么、什么时候清掉、多人同时伸手时怎么不打架。这篇文章就用「主厨的备菜架」这根线,把本地缓存的认知、特性、选型、淘汰策略、过期机制、并发安全、配置调优和工程实践一条条讲透。

岗位关联

本地缓存是 AI 服务后端第一道防线,掌握它你能把热点访问延迟从毫秒降到微秒,用最小的内存换来数倍吞吐。这是每个后端与推理工程团队都会面对的基础功课,学完即可落地到自己的项目。

目录
  1. 可视化演示
  2. 缓存认知
  3. 核心特性
  4. 实现选型
  5. 淘汰策略
  6. 过期机制
  7. 并发安全
  8. 最优配置
  9. 工程实践
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

动态演示本地缓存的核心机制,通过架构/数据/状态/性能四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

本地缓存:本地缓存 本地缓存定位与核心优势 Caffeine 与 Guav 核心机制可视化 本地缓存整体架构与关键组件

缓存认知

小面馆刚开张时,主厨小陈每次客人点一份回锅肉,都得放下锅铲冲到后仓翻肉、拍蒜、调酱,一趟下来客人等得冒火。后来他学乖了:每天开门前把最常点的十道菜配料备好,放在灶台旁的备菜架上。这就是缓存的核心逻辑——把「可能要用的东西」提前放到离手最近的地方。在计算机世界里,CPU 的 L1/L2 缓存、进程内的本地缓存、分布式缓存、数据库,就像从手边到后仓依次排开的货架:越近越快,但越贵越小。

本地缓存是应用进程内存里维护的一份数据副本。它利用了一个朴素的观察:程序对数据的访问并不是均匀的——某些数据被反复读取,另一些很久才碰一次。这叫做访问局部性。既然热点集中,那就为热点数据开辟一块「特区」,让它们驻留在离计算最近的内存里。这就是空间换时间:多花一点内存,换回数量级的访问提速。

命中率是衡量缓存价值的第一指标。它回答一个问题:一百次请求里,有多少次不用跑后仓?

备菜架的价值全靠命中率来度量。80% 的命中率意味着十次点单只有两次需要跑后仓,出菜速度自然飞快。理解了这一点,你就拿到了评估本地缓存好坏的尺子。

命中率=HH+M\displaystyle \text{命中率} = \frac{H}{H + M}
命中率等于命中次数除以请求总次数,比值越高说明「备菜架」越顶用。
  1. 命中次数 H请求直接在缓存中找到数据即命中。每次命中就计数加一。
    H=H+1(当 key 存在且未过期时)\displaystyle H = H + 1 \quad (\text{当 key 存在且未过期时})
  2. 未命中次数 M缓存中没有该 key,或已过期,需要回源后端。每次回源计数加一。
    M=M+1(当回源加载数据时)\displaystyle M = M + 1 \quad (\text{当回源加载数据时})
  3. 总请求数 H + M所有进入缓存层的请求之和,是命中率的归一化分母。
    总请求数=H+M\displaystyle \text{总请求数} = H + M

核心特性

主厨小陈的备菜架有多快?伸手即得,一秒都不用等。相比之下,让帮工跑去街口超市(远程缓存)再回来,怎么也得几分钟;去后仓翻货(数据库),时间更没谱。本地缓存的第一个特性就是极致的速度——进程内存内的查找通常只需几十到几百纳秒,虽然做不到 CPU 寄存器那么夸张,但比访问远程服务动辄几毫秒,快了三四个数量级。此外,内存数据结构天然支持 O(1) 的哈希查找,加一个 key、查一个 value 都是固定时间,与缓存里存了多少东西无关。

但备菜架终究只有那么大。内存是宝贵资源,本地缓存容量受限于进程堆大小。放多了、放久了,垃圾回收压力骤增,反而拖慢整个服务。你不可能把整间仓库搬进厨房。还有一点常被新手忽略:每个进程的缓存是独立的。你的服务部署了十个实例,每个实例都有自己的备菜架,同一个 key 在十个实例里各有副本,互相看不到。这就是进程内隔离。

本地缓存的价值可以用一个平均延迟公式来理解:

把数字代入就能看出:哪怕命中率只有 90%,平均延迟也几乎被缓存主导。备菜架虽然小,但把它用好了,整个出菜节奏就稳了。

Tavg=Phit×Tcache+(1−Phit)×Tbackend\displaystyle T_{\text{avg}} = P_{\text{hit}} \times T_{\text{cache}} + (1 - P_{\text{hit}}) \times T_{\text{backend}}
平均访问延迟 = 命中率 × 缓存耗时 + 未命中率 × 回源耗时,这就是缓存加速的总账。
  1. 命中率 P_hit请求命中缓存的比例,数值在 0 到 1 之间。
    Phit=HH+M\displaystyle P_{\text{hit}} = \frac{H}{H + M}
  2. 缓存耗时 T_cache命中时从进程内存取数据的耗时,通常在微秒级。
    Tcache≈10−6 秒\displaystyle T_{\text{cache}} \approx 10^{-6} \ \text{秒}
  3. 未命中率 1 - P_hit需要回源到数据库或下游服务的请求比例。
    1−Phit=MH+M\displaystyle 1 - P_{\text{hit}} = \frac{M}{H + M}
  4. 回源耗时 T_backend未命中时访问远程后端的一次完整往返,常是毫秒甚至秒级。
    Tbackend≈10−3∼100 秒\displaystyle T_{\text{backend}} \approx 10^{-3} \sim 10^{0} \ \text{秒}

实现选型

备菜架不是凭空变出来的。小陈第一周用木板自己钉了个架子(手写 HashMap 加业务代码),勉强能用,但要么满了不知道扔什么,要么多个帮厨一起翻的时候乱套。后来老师傅推荐他买现成的:Guava Cache 像一个老牌的木头调料柜,结实耐用,功能齐全;Caffeine 则是新一代的智能保鲜柜,自动帮你统计什么食材被用得最多、自动安排淘汰顺序,性能还更好。

在 Java 生态里,自建缓存最常见的起点是 ConcurrentHashMap,它只提供存储和无锁读,但没有任何淘汰、过期、统计能力。Guava Cache 引入了 CacheBuilder,可以配置容量上限、过期时间、监听移除事件,是很多老项目的标配。Caffeine 是 Guava 的升级版,基于 W-TinyLFU 淘汰算法,命中率更高,读写吞吐更强,还被 Spring Boot 默认集成。选型时从三方面权衡:团队熟悉度、需要的特性、性能要求。

选型可以看作一个加权打分的过程:

小陈最终选了智能保鲜柜 Caffeine——省心、快、还有统计报表。选缓存方案就是这样:别什么都自己造,成熟的轮子能把你从「修架子」里解放出来,专心炒菜。

Score=0.4×Sperformance+0.3×Sfeature+0.2×Secosystem+0.1×Sopex\displaystyle \text{Score} = 0.4 \times S_{\text{performance}} + 0.3 \times S_{\text{feature}} + 0.2 \times S_{\text{ecosystem}} + 0.1 \times S_{\text{opex}}
方案评分 = 性能 40% + 特性 30% + 生态 20% + 运维成本 10%,帮你在备菜架方案里做理性决策。
  1. 性能分 S_performance读写吞吐与延迟表现,Caffeine 在此项通常最高。
    Sperformance∈[0,100]\displaystyle S_{\text{performance}} \in [0, 100]
  2. 特性分 S_feature是否支持淘汰策略、过期时间、统计与监听。
    Sfeature=∑iwi×fi\displaystyle S_{\text{feature}} = \sum_{i} w_i \times f_i
  3. 生态分 S_ecosystem社区活跃度、与框架集成便利度。
    Secosystem∈[0,100]\displaystyle S_{\text{ecosystem}} \in [0, 100]
  4. 运维成本分 S_opex越容易维护分数越高,手写缓存此项最低。
    Sopex=100−每月维护工时折算\displaystyle S_{\text{opex}} = 100 - \text{每月维护工时折算}

淘汰策略

备菜架满了,这是小陈每天最头疼的事。新到的鲜虾得放上去,但架子已经塞满,必须扔点东西。扔什么?有几种思路。有的厨师按「谁最后用就扔谁」——这是 LRU(最近最少使用):把最久没碰过的食材清掉。有的厨师按「谁用得最少就扔谁」——这是 LFU(最不经常使用):把被取用频率最低的食材清掉。还有的图省事,按进货顺序先扔最先放的——这是 FIFO(先进先出):完全不管食材受不受欢迎。

三种策略各有脾气。LRU 实现简单、对突发流量响应快,但一次顺序扫描会把它误伤:缓存里明明有常客爱吃的蒜蓉,却被一轮「每小时遍历一次」的冷门任务挤了出去。LFU 对稳定热点很友好,但需要记录每个 key 的使用频次,内存开销大,而且面对突发热点反应迟钝——新来的爆款食材因为累计次数低,总是先被扔。Caffeine 的 W-TinyLFU 则聪明得多:它用一小块「抽样簸箕」近似记录全量频率,既保留 LFU 识别真正热点的能力,又对突发流量有专门的「保护窗口」。

LFU 淘汰的本质是选一个频率最低的 key 移出:

小陈的最终方案是「保鲜柜」的自动模式——W-TinyLFU。他只需要告诉柜子架子的总容量,剩下的事柜子自己办。淘汰策略没有银弹,关键是理解每种策略的脾气,让工具替你管理,而不是自己当人肉 LRU。

k∗=arg⁡min⁡k∈keys freq(k)\displaystyle k^* = \mathop{\arg\min}_{k \in \text{keys}} \ \text{freq}(k)
LFU 每次淘汰都挑「累计被使用次数最少」的那位下架,频率统计是决策依据。
  1. 候选集合 keys当前缓存中所有 key 的集合。
    keys={k1,k2,…,kn}\displaystyle \text{keys} = \{ k_1, k_2, \dots, k_n \}
  2. 频率函数 freq(k)记录 key k 被访问的次数,每命中一次加一。
    freq(k)=freq(k)+1\displaystyle \text{freq}(k) = \text{freq}(k) + 1
  3. argmin 操作从全部 key 中找出使 freq 最小的那个 key,作为淘汰对象。
    k∗=arg⁡min⁡k∈keys freq(k)\displaystyle k^* = \mathop{\arg\min}_{k \in \text{keys}} \ \text{freq}(k)

过期机制

备菜架上的食材不是永生的。肉馅放三天会变味,酱汁隔夜可能馊掉。小陈给每样食材贴了标签:鸡胸肉上午 11 点前用完,调好的酱汁两小时内用完。这就是 TTL(Time To Live,存活时间)——缓存数据从写入到失效之间的最大存活时长。过了保质期还硬拿给客人,轻则口感不对,重则吃坏肚子。对系统来说「吃坏肚子」就是数据过期后还在提供旧值,用户看到的是错乱的信息。

过期检查有几种做法。懒删除最省事:客人点单时,小陈才瞄一眼标签,发现过期了才扔。对系统来说,就是每次 get 的时候检查是否超过 TTL,没有独立线程,完全零开销,但过期数据会一直占着架子,直到被人翻到。定期清除则设一个后台闹钟:每五分钟扫一遍,把过期食材批量清走,架子随时清爽,但代价是多一个扫货线程在跑。工程上常两者结合:读时懒检查,配合低频扫描兜底。

过期判断的数学表达很直观:

小陈每周会调整标签时长:夏天肉馅保质期缩短,冬天可以放宽。TTL 不是越小越好,也不是越大越好。定得太短,菜还没卖出去就过期,回回都得重新备料;定得太长,客人吃到的可能是不新鲜的。过期机制就是要在「新鲜度」和「备料成本」之间找那个甜点。

isExpired(k)=( now−timestamp(k) )>TTL(k)\displaystyle \text{isExpired}(k) = (\, \text{now} - \text{timestamp}(k) \,) > \text{TTL}(k)
判断 key 是否过期:当前时间减去写入时间,超过该 key 的 TTL 就算过期。
  1. 写入时间戳 timestamp(k)key k 被写入缓存时的系统时间。
    timestamp(k)=当前系统时间\displaystyle \text{timestamp}(k) = \text{当前系统时间}
  2. 当前时间 now执行读取操作那一刻的时钟读数。
    now=Clock.time()\displaystyle \text{now} = \text{Clock.time()}
  3. TTL(k)该 key 允许存活的时长,可全局统一也可按 key 定制。
    TTL(k)=配置的过期秒数\displaystyle \text{TTL}(k) = \text{配置的过期秒数}
  4. 比较运算缓存存活时长大于 TTL,判定为过期;否则仍有效。
    now−timestamp(k)>TTL(k)\displaystyle \text{now} - \text{timestamp}(k) > \text{TTL}(k)

并发安全

面馆午市高峰,三个帮厨同时伸手翻备菜架:一个拿蒜,一个拿肉,一个往架上补新货。如果大家没有规矩,可能两个人同时抓住同一只碗——一个往外拿、一个往里放,食材洒一地。计算机世界里这叫并发竞争。本地缓存是进程内共享资源,所有处理请求的线程都会同时读写它,如果放任不管,轻则数据错乱,重则进程崩溃。

解决竞争的第一招是加锁。给整个备菜架装一扇门,谁进去谁锁门,出来再开——简单粗暴,但高峰时三个帮厨排队等门的滋味不好受。改进一点是分段锁:把架子分成 16 个格子,每格一扇小门,拿蒜的不用等拿肉的,并发能力大幅提升。再现代一点的是无锁 CAS 机制:不锁门,靠「先比较再交换」的原子指令。帮厨先看一眼目标碗里的东西跟自己预期是否一致,一致才动手换,不一致就重来。如果冲突不频繁,CAS 比锁更高效;冲突激烈时反复重试反而更累。

并发竞争的开销可以用一个简化模型表达:

小陈的经验是:选现成保鲜柜而不是自己钉架子。Caffeine、ConcurrentHashMap 这些容器已经内置了分段锁或无锁算法,内部并发细节早已打磨好。真正的高手不是自己写并发代码,而是知道什么时候该信成熟工具。

Tcontended=Tserial×(1+contention)\displaystyle T_{\text{contended}} = T_{\text{serial}} \times (1 + \text{contention})
并发总耗时 = 串行耗时 ×(1 + 竞争系数)。竞争越激烈,锁/重试带来的额外开销越大。
  1. 串行耗时 T_serial没有任何竞争时,某个操作独占资源完成所需时间。
    Tserial=单线程临界区耗时\displaystyle T_{\text{serial}} = \text{单线程临界区耗时}
  2. 竞争系数 contention反映多个线程同时争抢同一资源的激烈程度,值在 0 到 1+。
    contention=等待锁的时间有效工作时间\displaystyle \text{contention} = \frac{\text{等待锁的时间}}{\text{有效工作时间}}
  3. 实际耗时 T_contended考虑竞争后的端到端耗时,越大说明并发设计越差。
    Tcontended≥Tserial\displaystyle T_{\text{contended}} \geq T_{\text{serial}}

最优配置

备菜架多大合适?小陈一开始贪心,买了个超大的架子,结果把厨房过道堵了一半,帮厨们走路都费劲——内存被吃光,服务变慢。后来又矫枉过正,换了个巴掌大的架子,结果一到饭点就满,帮厨平均两分钟跑一趟后仓,腿都跑细了。最优的备菜架容量,不是越大越好,也不是越小越好,而是让「多备一道菜的边际收益」刚好等于「多占一块地方的边际成本」。

这个原理在经济学和缓存领域是通用的。开始时,容量每增加一点,能装下更多热点数据,命中率提升很快;但随着容量接近全量热点,再增加容量,命中的新数据越来越少,收益曲线变平。当增加单位容量带来的命中率收益,恰好等于增加的 GC 压力与内存成本时,就是最优容量。除了容量,TTL 也要权衡:太短命中率低,太长数据陈旧。还有两件配套操作帮大忙:缓存预热——服务启动时把最热的 key 提前放进备菜架,避免刚开门就疯狂跑后仓;命中率监控——像装个仪表盘,实时盯着每分钟的命中率,低于阈值就报警。

最优容量点可以形式化表达为:

小陈后来用数据说话:先按经验估一个容量,再通过监控调整,直到命中率曲线趋于平稳。配置缓存不是一锤子买卖,而是一个持续调优的过程。把容量、TTL、预热、监控四件事做好,备菜架才能真正成为后厨的得力干将。

∂ 收益hit∂C=∂ 成本mem∂C\displaystyle \frac{\partial \ \text{收益}_{\text{hit}}}{\partial C} = \frac{\partial \ \text{成本}_{\text{mem}}}{\partial C}
最优容量 = 边际命中收益等于边际内存成本的位置。多一分浪费,少一分亏。
  1. 容量 C缓存允许存的最大 key 数量或总字节数。
    C=缓存大小上限\displaystyle C = \text{缓存大小上限}
  2. 命中收益 收益_hit命中率提升带来的延迟节省,是容量 C 的函数。
    收益hit(C)=延迟节省×总请求量\displaystyle \text{收益}_{\text{hit}}(C) = \text{延迟节省} \times \text{总请求量}
  3. 内存成本 成本_mem占用的堆内存、GC 开销。
    成本mem(C)=内存占用量×单位成本\displaystyle \text{成本}_{\text{mem}}(C) = \text{内存占用量} \times \text{单位成本}
  4. 偏导相等两者随容量的边际变化相等时,总收益最大化。
    ∂收益hit∂C−∂成本mem∂C=0\displaystyle \frac{\partial \text{收益}_{\text{hit}}}{\partial C} - \frac{\partial \text{成本}_{\text{mem}}}{\partial C} = 0

工程实践

生意做大了,小陈遇到了三种麻烦。第一种,有客人故意点「菜单上没有的菜」——请求一个根本不存在的 key,每次都不在缓存里,每次都穿透到数据库,数据库被空查打死。这叫缓存穿透。第二种,某道招牌菜到了保质期被清理,偏偏此时大拨客人同时点它,所有人一起冲向后仓——一个热点 key 失效瞬间的高并发回源,这叫缓存击穿。第三种,小陈前一天晚上把一批食材的保质期标签都贴成同一个钟点,午市一到,几十道菜同时过期,帮工们集体瘫痪——大量 key 同一时刻过期引发的雪崩。

应付穿透,小陈在菜单上加了「这道菜没有」的标记:缓存空值,短时间内对不存在的 key 也存一个空结果,拦截后续同样的请求;再用布隆过滤器,像一块快速筛板,先筛一遍,查无此菜直接回绝。应付击穿,给热点 key 用锁:第一个请求回源重建缓存,后面的人等着,拿到新值就走,不再重复打后端。应付雪崩,给每样食材的保质期标签加一点随机抖动:有的贴 55 分钟,有的贴 62 分钟,大家错开过期时间,就不会同时崩。

有效回源量是评价防护效果的汇总指标:

小陈把这三道防线都搭好后,后厨终于清静了。健壮的缓存系统不是把命中率提到 100%,而是想清楚每一种失效场景,让故障只影响几个人,而不是拖垮整家店。记住:缓存是防线,不是保险箱——它防的是慢,防不了错,更防不了设计上的漏洞。

Reffective=Rtotal×(1−Phit)×(1−Pempty)\displaystyle R_{\text{effective}} = R_{\text{total}} \times (1 - P_{\text{hit}}) \times (1 - P_{\text{empty}})
最终打到后端的有效请求 = 总请求 × 未命中率 ×(1 − 空结果占比)。穿透越少,分母越小。
  1. 总请求量 R_total一段时间内进入缓存层的全部请求数。
    Rtotal=统计周期内请求总数\displaystyle R_{\text{total}} = \text{统计周期内请求总数}
  2. 未命中率 1 − P_hit缓存没接住、需要继续往下走的比例。
    1−Phit=MH+M\displaystyle 1 - P_{\text{hit}} = \frac{M}{H + M}
  3. 空结果占比 P_empty请求的数据在底层根本不存在(如非法 key)的比例。
    Pempty=查无数据请求数未命中请求数\displaystyle P_{\text{empty}} = \frac{\text{查无数据请求数}}{\text{未命中请求数}}
  4. 有效回源量 R_effective真正需要后端处理的数据请求量,这个值越小系统越安全。
    Reffective=Rtotal×(1−Phit)×(1−Pempty)\displaystyle R_{\text{effective}} = R_{\text{total}} \times (1 - P_{\text{hit}}) \times (1 - P_{\text{empty}})

AI 应用场景

AI 推理服务的 Embedding 本地缓存

阶段:模型推理与 Serving 阶段

解决的问题:在线推荐与向量检索时,用户和物品的 Embedding 向量要从特征库拉取。特征库远在 Redis 或数据库中,一次远程读取 3-10ms,而一次推理可能涉及几十个特征,累加起来延迟爆炸。本地缓存把高频实体的 Embedding 放在进程内存里,读取降到微秒级。

具体案例:某电商推荐系统每天 2 亿次推理请求,热门商品 Top 1000 的 Embedding 被反复查询。在推理服务内嵌入 Caffeine,容量设为 20 万条、TTL 10 分钟,命中率稳定在 85% 以上,单次推理 p99 延迟从 180ms 降到 65ms。

AI 问答服务的常见问题片段缓存

阶段:大模型应用的后端编排

解决的问题:客服机器人遇到的提问大量重复:「怎么退款」「如何改密码」。每次都让大模型重新生成,费钱又费时。本地缓存可以把高频问答的回复片段缓存起来,命中时直接拼接返回。

具体案例:某银行智能客服把高频问题的回复模板及检索结果缓存到本地,TTL 设为 30 分钟,缓存命中率 62%,大模型调用成本下降 40%,用户等待时间从 4 秒缩短到 0.8 秒。

在线特征平台的特征值本地缓存

阶段:特征工程与在线预测

解决的问题:实时风控模型每次请求要读几十个特征,特征存储服务压力大、延迟高。用户画像类特征变化慢、读多写少,非常适合用本地缓存承载。配合订阅通知,特征变更时主动刷新对应 key。

具体案例:某支付风控团队对用户风险等级、设备指纹特征做本地缓存,容量 50 万 key、TTL 60 秒,命中率约 90%,特征读取从 5ms 降到 50 微秒,风控决策总耗时稳定在 100ms 以内。

Python 演示

python82 行
import timeimport random  class CacheItem:    def __init__(self, value, expire_ts):        self.value = value        self.expire_ts = expire_ts  class LocalCache:    """带 TTL 与 LRU 淘汰的极简本地缓存模拟器"""     def __init__(self, capacity=100, ttl=30):        self.capacity = capacity        self.ttl = ttl        self._store = {}        self._order = []        self._hit_count = 0        self._total_count = 0     def get(self, key):        self._total_count += 1        item = self._store.get(key)        now = time.time()        if item is None:            return None        if now > item.expire_ts:            self._store.pop(key, None)            self._remove_from_order(key)            return None        self._hit_count += 1        self._remove_from_order(key)        self._order.append(key)        return item.value     def set(self, key, value):        now = time.time()        if key in self._store:            self._store[key] = CacheItem(value, now + self.ttl)            self._remove_from_order(key)            self._order.append(key)            return        if len(self._store) >= self.capacity:            self._evict()        self._store[key] = CacheItem(value, now + self.ttl)        self._order.append(key)     def _evict(self):        if not self._order:            return        oldest = self._order.pop(0)        self._store.pop(oldest, None)     def _remove_from_order(self, key):        try:            self._order.remove(key)        except ValueError:            pass     def hit_rate(self):        if self._total_count == 0:            return 0.0        return self._hit_count / self._total_count  if __name__ == "__main__":    cache = LocalCache(capacity=80, ttl=10)    hot_keys = ["item_" + str(i) for i in range(20)]    cold_keys = ["item_" + str(i) for i in range(20, 100)]    requests = []    for _ in range(2000):        if random.random() < 0.8:            requests.append(random.choice(hot_keys))        else:            requests.append(random.choice(cold_keys))    for key in requests:        val = cache.get(key)        if val is None:            cache.set(key, "origin-data")    print("总请求数:", cache._total_count)    print("命中率: %.2f%%" % (cache.hit_rate() * 100))
目的:模拟 80/20 热点的请求流,用带 LRU 淘汰和 TTL 过期机制的本地缓存扛流量,输出最终命中率。
  1. class LocalCache:定义本地缓存类,三个关键职责:读、写、淘汰。
  2. self._store[key] = CacheItem(...)用字典存数据,value 裹上过期时间戳。
  3. if len(self._store) >= self.capacity:容量满了触发淘汰,这是本地缓存最核心的边界控制。
  4. self._order.pop(0)队头是最久未用的 key,LRU 淘汰直接扔掉它。
  5. self._remove_from_order(key)每次命中把 key 挪到队尾,实现「最近使用」语义。
  6. self._hit_count += 1每次命中计数,用于最后计算命中率。
  7. now > item.expire_ts懒删除的过期判断:读的时候顺手检查保质期。
  8. random.random() < 0.880% 请求落在 20 个热 key 上,模拟真实热点分布。

常见误区

误区反正服务器内存挺大,干脆把数据库里的数据全塞进本地缓存,这样命中率 100%,再也不怕慢了。

正解本地缓存占的是应用进程堆内存。塞满全量数据,GC 停顿变长、内存碎片化,甚至直接 OOM。合适的容量是「能装下热点数据」而不是「能装下全部数据」。就像备菜架只摆当日热卖菜,没人会把整个仓库搬进厨房。

为何常见:直觉上命中率越高越好,忽略了内存是共享稀缺资源,缓存过大反而拖慢整个进程。

误区怕用户看到旧数据,把所有 key 的 TTL 设成 3 秒,过一会儿就自动失效,这样总能拿到最新值。

正解TTL 过短等于没缓存。3 秒一过期,回源频率极高,后端压力反而更大。TTL 应根据数据新鲜度需求分层配置:配置类数据 5-10 分钟,用户敏感数据 30 秒。缓存本来就是「用一点延迟换速度」的妥协。

为何常见:把一致性看得过重,忘了缓存的意义是容忍短暂过期来换取高吞吐。

误区每台机器缓存各自一份,反正不共享,用个 HashMap 多线程访问应该没问题。

正解进程内缓存必然被服务所有线程并发访问。裸 HashMap 在多线程 put 时可能丢数据,极端情况还会死循环。必须用 ConcurrentHashMap、Caffeine 这类并发容器,或自己加锁。帮厨们抢同一个碗,没有一个安全机制肯定出乱子。

为何常见:写 demo 时单线程跑没问题,上线高并发下才暴露,修复成本极高。

误区把用户订单、支付状态也放本地缓存里,反正内存快,重启了数据应该还在吧。

正解本地缓存是易失存储,进程崩溃或发布重启后数据全部丢失。缓存只适合存放「可以从源头重建」的数据,订单等关键业务数据必须落数据库。备菜架上的食材坏了可以再买,但账本丢了就完了。

为何常见:「缓存」和「存储」都带「存」,边界没分清,把临时货架当成了保险柜。

误区LRU 最经典也最常用,所有缓存场景用 LRU 准没错。

正解LRU 被顺序扫描一次就会误伤热点,把常客爱用的数据挤出去。稳定热点场景 LFU 更优;Caffeine 的 W-TinyLFU 用采样统计加保护窗口,兼顾两者。选淘汰策略要看访问分布,不是一招走天下。

为何常见:只听说过 LRU 这一个名词,缺少对淘汰策略横向对比的认知。

面试题

Q1. 缓存请求总数为 100 次,命中 72 次,命中率是多少?

单选题 · easy

  • A.28%
  • B.72%← 正确答案
  • C.100%
  • D.50%

答案:B。命中率 = 命中次数 ÷ 请求总数 = 72 ÷ 100 = 72%。就像备菜架 100 次点单接住了 72 次,只有 28 次要跑后仓。

Q2. 本地缓存的访问延迟通常处于哪个量级?

单选题 · easy

  • A.纳秒级
  • B.微秒级← 正确答案
  • C.毫秒级
  • D.秒级

答案:B。进程内存的哈希查找是微秒级,比远程数据库的毫秒级快三个数量级。备菜架「伸手即得」,但还到不了 CPU 寄存器那种纳秒级速度。

Q3. 以下哪些属于本地缓存的典型特性?

多选题 · easy

  • A.进程内存储← 正确答案
  • B.微秒级访问← 正确答案
  • C.容量可无限
  • D.支持淘汰策略← 正确答案

答案:ABD。本地缓存存在进程堆内存中,访问微秒级,配容量上限和淘汰策略。容量受物理内存限制,绝不可能无限,C 错在把内存当成无底洞。

Q4. 采用懒删除过期策略时,过期检查发生在什么时刻?

单选题 · mid

  • A.写入缓存时
  • B.读取缓存时← 正确答案
  • C.定时扫描时
  • D.缓存满时

答案:B。懒删除就是「读的时候才看保质期」,命中 get 时顺手判断是否过期。对应主厨只有客人点单才会瞄一眼标签,没有后台闹钟。

Q5. 相比手写 ConcurrentHashMap,选 Caffeine 做本地缓存的优势包括?

多选题 · mid

  • A.自带淘汰策略← 正确答案
  • B.支持过期时间← 正确答案
  • C.内置限流功能
  • D.统计命中率← 正确答案

答案:ABD。Caffeine 开箱即用淘汰、TTL、命中统计。限流不是缓存库的职责,需要独立的限流组件完成,C 是干扰项。

Q6. LRU 淘汰策略的核心思想是什么?

单选题 · mid

  • A.淘汰使用频率最低的
  • B.淘汰最久未被使用的← 正确答案
  • C.淘汰最先加入的
  • D.淘汰随机一个

答案:B。LRU 全称 Least Recently Used,按「上一次使用时间」淘汰最久远的。备菜架满了先扔最长时间没被碰过的食材。A 是 LFU,C 是 FIFO。

Q7. 以下哪些措施能有效缓解缓存雪崩?

多选题 · mid

  • A.TTL 加随机抖动← 正确答案
  • B.调大并发线程数
  • C.引入多级缓存← 正确答案
  • D.给所有 key 设相同 TTL

答案:AC。TTL 随机抖动让过期时间错峰,多级缓存提供给冲。调大并发线程数只会放大同一时刻回源的压力,相同 TTL 恰恰是雪崩的帮凶。

Q8. W-TinyLFU 相比传统 LFU 的主要改进是什么?

单选题 · hard

  • A.用采样统计降低内存开销← 正确答案
  • B.每次淘汰全部旧数据
  • C.完全随机选择淘汰对象
  • D.忽略突发热点流量

答案:A。W-TinyLFU 用 Count-Min Sketch 近似统计频率,内存开销远小于精确计数,还设有保护窗口应对突发流量。B、C、D 都违背了 LFU 保留热点的初衷。

Q9. 应对缓存穿透的常用手段有哪些?

多选题 · hard

  • A.缓存空值← 正确答案
  • B.布隆过滤器← 正确答案
  • C.请求参数校验← 正确答案
  • D.缩短 TTL

答案:ABC。缓存空值让非法 key 短时间内也被缓存拦截;布隆过滤器快速筛掉不存在的 key;参数校验在入口拒绝非法请求。缩短 TTL 只会让合法热点更易失效,与防穿透无关。

Q10. 某个热点 key 失效的瞬间,高并发请求同时穿透到后端导致压力激增,这个现象叫什么?

单选题 · extreme

  • A.缓存穿透
  • B.缓存击穿← 正确答案
  • C.缓存雪崩
  • D.缓存漂移

答案:B。击穿特指「单个热点 key 失效瞬间」被高并发打穿;穿透针对「根本不存在的 key」;雪崩指「大量 key 同时过期」。主厨的招牌菜刚下架就被百人同时点,正是击穿,对应加锁重建或永不过期兜底。