想象你正在准备一顿年夜饭。最常用的盐和酱油,你随手放在灶台边——伸手就能拿到;提前解冻的鸡鸭鱼肉,放在冰箱里——转身就能取;而那扇需要现买的黑猪肉、罕见的干货,才值得你开车去一趟大卖场。聪明的厨师绝不会让每一次取料都跑大卖场:先在灶台找,再去冰箱翻,最后才考虑出门,而且回家后还会记下“今天大卖场有某样东西”,下次不再白跑。 这个烟火气十足的取物智慧,就是多级缓存的全部秘密。在计算机世界里,AI 推理服务每秒要处理成千上万次请求,如果每次都去数据库“大卖场”捞数据,系统很快就会被拖垮。于是我们把这套厨房分工搬进了软件架构:本地缓存当灶台调料架,分布式缓存当楼下社区超市,数据库当远处大卖场。再加上应对“什么东西都买不到”“超市集体下架”“明星食材一抢而空”这些极端情况的防护手段,你就拥有了一套高性能、高可用的数据访问体系。本文就用这条“厨房—超市—大卖场”的线索,带你从头搭建这套体系。

岗位关联

对 AI 从业者而言,多级缓存是 AI 推理服务“跑得快、稳得住”的底盘功夫。掌握它,你才能在高并发系统设计中不再惧怕数据库抖动,让模型服务的吞吐与稳定性真正达到工业级。

目录
  1. 可视化演示
  2. 缓存分级体系
  3. 拓扑读写路径
  4. 一致性协调
  5. 穿透防护
  6. 雪崩防护
  7. 击穿防护
  8. AI推理应用
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

可视化L1本地→L2分布式→DB多级缓存拓扑、缓存穿透/雪崩/击穿三大威胁防护机制及一致性失效策略,滑块调节命中率观察延迟变化。

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

架构拓扑:L1本地→L2分布式→DB三层缓存 L1本地缓存:Caffeine堆内存,微秒级访问 L2分布式缓存:Redis集中存储,毫秒级访问 DB数据库:持久化存储,5ms+访问 Latency = HR×0.01 + (1-HR)×5.0 ms 命中率越高,平均延迟越低

缓存分级体系

做年夜饭的厨师,绝不会把小区超市的货架搬进自家厨房。他会把最常用的盐、糖、酱油放在灶台边的调料架上——伸手就能拿到;把提前处理的半成品放进冰箱——转身就能取;而整扇猪肉、罕见干货这种不常用的,才需要开着车去大卖场采购。这个直觉就是“缓存分级”的生活原型:越常用的东西,放得越近,取用越快,代价也越昂贵。

计算机里的数据访问也有类似的“远近之别”。内存访问是微秒级,网络访问是毫秒级,磁盘数据库查询可能是几十毫秒甚至更久。于是我们把这三级差距搬进了软件架构:L1 本地缓存,跑在进程内,相当于“灶台调料架”,极快但容量小;L2 分布式缓存(如 Redis 集群),相当于“楼下社区超市”,容量大、多机共享,但每次访问都走一次网络;数据库则相当于“大卖场”,拥有全量权威数据,但查询成本最高。

在实际系统中,L1 一般用 Caffeine 或 Guava Cache 这类进程内缓存组件实现,延迟在 0.1~1ms 量级,容量受限于单机内存,通常几十 MB 到几 GB。L2 用 Redis、Memcached 等分布式缓存集群,延迟约 1~5ms,容量可达几十 GB 甚至 TB,所有服务实例共享。加上数据库兜底,三个层级天然形成“快慢有别、容量互补”的配合关系。用一个分段函数概括读取一个数据可能经过的路径:

把最常用的佐料放灶台边、次常用的放冰箱、不常用的去超市——这套“按频次分层”的收纳哲学,正是缓存分级的灵魂。理解了“近的快但小、远的慢但大”,就理解了多级缓存为何一定要分层。

Tread={TL1,命中 L1 缓存TL1+TL2,命中 L2 缓存TL1+TL2+TDB,回源数据库T_{read} = \begin{cases}T_{L1}, & \text{命中 L1 缓存} \\T_{L1} + T_{L2}, & \text{命中 L2 缓存} \\T_{L1} + T_{L2} + T_{DB}, & \text{回源数据库}\end{cases}
读取耗时取决于数据最后在哪一层被找到:越深的层级,延迟叠加越多,这就是为什么要尽量让请求在浅层命中。
  1. 命中 L1请求在本地缓存直接返回,耗时就是 L1 的访问延迟,不产生任何网络开销。
    TL1≈0.1∼1msT_{L1} \approx 0.1 \sim 1 \text{ms}
  2. 命中 L2L1 未命中后去分布式缓存里找,累计耗时是两级的延迟之和。
    TL1+TL2≈1.1∼6msT_{L1} + T_{L2} \approx 1.1 \sim 6 \text{ms}
  3. 回源数据库两级缓存都没有,只能查数据库,耗时达到几十毫秒量级。
    TL1+TL2+TDB≥10msT_{L1} + T_{L2} + T_{DB} \geq 10 \text{ms}

拓扑读写路径

熟手的厨师取料有自己的固定路线:先看灶台,再看冰箱,最后才决定是否出门。而且从大卖场回来后,他会顺手把“今天大卖场有这种菜”记在便签上,下次就不用再跑。这就是多级缓存读写路径的全部秘密。

读请求的路径是“三级探测”:先查 L1,命中直接返回;未命中再查 L2,命中则返回并把数据回填到 L1;L2 也未命中才回源数据库,同时在 L1 和 L2 都做回填。这个“回填”动作,相当于厨师从大卖场回来后写下的便签。写请求则反过来:先更新数据库,再删除 L1 和 L2 中的旧缓存,保证下一次读取能拿到最新数据。容量管理方面,各级缓存都有淘汰策略——LRU(最近最少使用) 是最常见的做法,就像冰箱空间有限,总要先扔掉最不常吃的剩菜。

这个路径设计的目标只有一个:让尽可能多的请求在尽量靠前的层级命中。于是平均读取耗时的期望,就是各级命中概率与对应延迟的加权组合:

厨师用固定路线加回填便签,省掉大量重复跑路;多级缓存把“常用数据前置、回填上级缓存”固化成了自动流程,系统性能就在这一次次“少跑路”中翻倍。

Tavg=h1TL1+(1−h1)h2(TL1+TL2)+(1−h1)(1−h2)(TL1+TL2+TDB)T_{avg} = h_1 T_{L1} + (1 - h_1)h_2 (T_{L1} + T_{L2}) + (1 - h_1)(1 - h_2)(T_{L1} + T_{L2} + T_{DB})
平均耗时由 L1 命中率 h1 与 L2 命中率 h2 共同决定——h1、h2 越高,请求越少沉底,平均延迟越低。
  1. L1 命中部分权重是 L1 命中率 h1,这部分请求只花 T_L1。
    h1⋅TL1h_1 \cdot T_{L1}
  2. L2 命中部分L1 未命中(概率 1-h1)且 L2 命中(概率 h2)的请求,耗时两层之和。
    (1−h1)h2⋅(TL1+TL2)(1 - h_1)h_2 \cdot (T_{L1} + T_{L2})
  3. 回源数据库部分两级都没命中(概率 (1-h1)(1-h2)),付出全链路代价。
    (1−h1)(1−h2)⋅(TL1+TL2+TDB)(1 - h_1)(1 - h_2) \cdot (T_{L1} + T_{L2} + T_{DB})
  4. 四项加总把三种情况乘以各自概率再求和,就是系统平均读取耗时。

一致性协调

楼下超市的鸡蛋降价了,但你冰箱门上还贴着“鸡蛋 15 元/斤”的旧便签。直到有人撕掉它、贴上新价,你才不会被误导。缓存与数据库之间,每天都在上演这种“标签战”。

多级缓存的核心问题之一,是数据库里的数据变了,缓存里那份旧副本何时失效、如何让所有层级一起失效。最经典的模式是 Cache Aside:写操作先更新数据库,然后删除缓存——下次读请求发现缓存缺失,自然会把新数据重新加载进来。但“删除”与“读取”之间存在竞态窗口:读请求可能在缓存删除前读到旧值并回填,于是就有了延迟双删——第一次立即删,隔一小段延迟再删一次,把窗口期内可能被回填的旧值再清掉。

在 L1、L2 多级场景下,协调成本翻倍:写服务要同时失效 L1 和 L2。L1 是各实例私有的,通常通过“消息广播”通知所有实例删除本地缓存;L2 是共享的,直接删即可。如果追求最终一致性,还可以给缓存设置一个较短的 TTL 作为兜底——即使通知丢了,数据也会在 TTL 内自动过期。若完全没有主动失效,缓存中数据的平均陈旧程度正好是:

管理便签的人越勤快,顾客看到的标价越准;同理,主动失效越及时,数据越新鲜。工程上常采用“主动失效为主 + 短 TTL 兜底”的混合策略,在新鲜度和开销之间取平衡。

E[age]=TTL2E[\text{age}] = \frac{TTL}{2}
每一条缓存数据从写入到过期平均存活 TTL 秒,任意时刻被读到的那份副本,平均已经“出生”了 TTL/2 秒——这就是不主动管理的一致性代价。
  1. TTL 窗口每条缓存从写入到自动过期,存活 TTL 秒。
    TTL=缓存过期时间(秒)\text{TTL} = \text{缓存过期时间(秒)}
  2. 平均年龄数据在任意时刻被读取时,其已存活时间的期望恰为 TTL 的一半。
    E[age]=TTL2E[\text{age}] = \frac{\text{TTL}}{2}
  3. 主动失效的意义每次删除缓存,都把这份数据的“平均年龄”清零,从而缩小读到陈旧版本的时间窗口。

穿透防护

做一道家传川菜,需要一味罕见的香料。你翻遍灶台、冰箱,又跑遍社区超市,结果大卖场也断货——可每次做这道菜,你都重复这趟三处跑。缓存穿透,就是这种“查一个根本不存在的数据”的请求:缓存和数据库里都没有,每次都必须穿过缓存直达数据库。

大量这样的请求会瞬间压垮数据库。防护手段有三类。第一是参数校验:请求的 key 明显不合法(空 ID、负数 ID)就在入口直接拦截,相当于“一看菜名就知道大卖场不可能有”。第二是空值缓存:数据库查不到时,把“空”也缓存起来,并给一个很短 TTL(比如 30 秒),避免反复打库。第三是布隆过滤器:用一个极紧凑的位数组为所有可能存在的 key 建立“指纹”,查询前先问过滤器——如果它说“不存在”,那就一定不存在,连缓存都不用查。

布隆过滤器的核心参数有三个:位数组长度 m、已插入元素个数 n、哈希函数个数 k。它的误判率(把不存在误报为存在)有一个经典近似公式:

有了“食材黑名单”,不必真跑一趟大卖场就能确认“准没有”。布隆过滤器偶尔误报“可能有”,但从不漏报“一定没有”——用极小的空间挡住绝大多数无效请求,正是穿透防护的性价比所在。

Pfp≈(1−e−kn/m)kP_{fp} \approx \left(1 - e^{-kn/m}\right)^k
误判率取决于“每个元素平均占多少位”和“用几个哈希函数”:空间越宽裕、参数越合理,误报概率越低,但它永远不会漏报“不存在”。
  1. m/n 的含义位数组中每个元素平均占用 m/n 个比特位,空间越宽裕,误判越低。
    mn=每个元素可用的位数\frac{m}{n} = \text{每个元素可用的位数}
  2. k 个哈希每个 key 由 k 个哈希函数映射到 k 个位置并置 1,k 越多,位数组越拥挤。
    k=哈希函数个数k = \text{哈希函数个数}
  3. 误判率整体当参数取优时(经验值 k ≈ 0.7 × m/n),误判率可以压到 1% 以下,而每个 key 只花十几个比特。
    (1−e−kn/m)k<0.01(参数取优时)\left(1 - e^{-kn/m}\right)^k < 0.01 \quad \text{(参数取优时)}

雪崩防护

假设社区超市突然停电,所有冰柜同步解冻,货架上的酸奶集体到期下架。消息传开,全小区的人同时冲向唯一的大卖场——大卖场门口瞬间排起长队,系统直接瘫痪。缓存雪崩,就是大量缓存 key 在同一时刻过期(或整个缓存节点宕机),导致海量请求同时穿透到数据库。

最常见成因有两个:一是所有 key 设置了完全相同的 TTL,数据同时写入、同时过期;二是缓存集群整体故障。对应防护手段有三:第一,TTL 随机化,给每个 key 的过期时间加上随机偏移,让失效时刻错开;第二,多级熔断降级,当 L2 故障时系统自动降级为“L1 兜底 + 直连数据库”,并限制回源速率,保护数据库不被打崩;第三,热点数据永不过期——不是真的不设 TTL,而是后台维护逻辑过期时间,由后台线程提前异步刷新。

TTL 随机化的计算方式非常朴素:

就像把全小区的酸奶分七天分批下架,而不是周日午夜集体报废;TTL 随机化能把一次“雪崩”拆解成无数场互不影响的“小降温”。再加上熔断降级这道保险闸,即使缓存节点真宕了,系统也能以“半速”继续服务,而不是彻底趴窝。

TTLi=Tbase+Uniform(0,Δ)TTL_i = T_{base} + \text{Uniform}(0, \Delta)
每个 key 的过期时间 = 统一基础值 + 一个 0~Δ 之间的随机数。海量 key 的失效点被“打散”到一个时间区间里,而不是集中在一瞬间。
  1. T_base 基础值所有 key 共享的基础过期秒数,比如 300 秒。
    Tbase=300sT_{base} = 300 \text{s}
  2. 随机偏移量为每个 key 独立抽取一个 0 到 Δ 之间的随机数,例如 Δ = 60 秒。
    Uniform(0,Δ),Δ=60s\text{Uniform}(0, \Delta), \quad \Delta = 60 \text{s}
  3. 失效区间各 key 的实际过期时间分布在 [T_base, T_base+Δ] 内,不再同时失效。
    TTLi∈[Tbase,  Tbase+Δ]TTL_i \in [T_{base}, \; T_{base} + \Delta]

击穿防护

社区超市里那款网红和牛切片,平时总有人买。这天它恰好售罄,货架空空如也。消息一传十十传百,所有人同时涌向大卖场抢购。缓存击穿,指的不是大量 key,而是一个超级热门的 key 在过期的那一瞬间,海量并发请求同时越过缓存、打到数据库。

击穿与雪崩的区别在于“规模粒度”:雪崩是大量 key 同时失效,击穿是单个热点 key 失效。防护思路因而不一样。第一招,互斥锁(Mutex):热点 key 过期后,只允许一个请求去数据库重建缓存,其他请求阻塞等待锁释放,然后直接从缓存拿到新值。第二招,逻辑过期:缓存里的 value 带有一个“逻辑过期时间”字段,线程发现逻辑过期后不删除缓存,而是触发异步线程去后台刷新,读请求仍然能拿到旧值——这类“旧但可用”的数据,在秒杀、热点资讯场景下完全可接受。第三招,热点识别:通过统计访问频率提前识别热点 key,为它们单独设置更长的 TTL 或启用“永不过期”策略,从源头减少击穿窗口。

如果不用任何防护,热点 key 失效瞬间并发 N 个请求都去回源,至少有一个请求穿透到数据库的概率是:

厨师的做法是:发现和牛没了,先派一个人去大卖场补货,其他人在厨房等着——而不是一窝蜂全跑出去。互斥锁和逻辑过期,就是把“派一个人 vs 全冲出去”的决策,固化成了系统自动执行的规则。

Pth=1−(1−p)NP_{th} = 1 - (1 - p)^{N}
当并发数 N 很大时,这个概率趋近于 1——也就是说,热点 key 一旦失效,数据库几乎必然被“打穿”。互斥锁的作用就是把 N 个同时打,变成 1 个打、N-1 个等。
  1. p 的含义单个请求未命中缓存、需要回源的概率。
    p=P(单个请求未命中缓存)p = P(\text{单个请求未命中缓存})
  2. N 的含义该 key 在失效瞬间的并发请求数。
    N=热点 key 的并发请求数N = \text{热点 key 的并发请求数}
  3. 都不打库的概率每个请求都不穿透数据库的概率是 1-p,N 个请求同时不穿透则是 (1-p)^N。
    (1−p)N=∏i=1NP(请求 i 未穿透)(1 - p)^{N} = \prod_{i=1}^{N} P(\text{请求 i 未穿透})
  4. 至少一个打库的概率用 1 减去“全都不打库”,即得至少一个请求穿透到数据库的概率,N 越大越接近 1。
    Pth=1−(1−p)N→N→∞1P_{th} = 1 - (1 - p)^{N} \xrightarrow{N \to \infty} 1

AI推理应用

把 AI 推理服务想象成一座中央厨房:每来一份订单(一次推理请求),后厨都要以最短时间配齐食材(用户特征、模型参数、中间结果)。如果每次订单都现去大卖场采购,出菜速度必然垫底。多级缓存,就是中央厨房的“冰箱—超市”配置方案。

在真实的 AI 工程中,多级缓存有几个高频落点。第一,特征数据缓存:推荐系统给每个用户算好的特征向量,L1 放 Caffeine 缓存热门用户特征,L2 用 Redis 存全量特征,特征数据库只承担低频回源。第二,模型与元数据缓存:模型版本号、动态 batch 策略、路由表这类“低频变、高频读”的配置,放本地缓存即可,配置中心更新后通过广播失效。第三,推理结果复用:对于前缀相同的 Prompt(如系统提示词 + 用户输入),可以用多级缓存直接复用已算好的 KV Cache 部分结果,大幅降低 LLM 推理延迟。

这些手段最终都汇聚到一个公式——系统吞吐量(Throughput)与平均响应时间成反比:

中央厨房里“高频备菜前置、低频备菜后置”的布局,映射到 AI 系统就是那套“冰箱—超市—大卖场”的三级栈。当用户特征、模型元数据和推理结果都放到了正确的层级,AI 服务才真正做到了既快又稳——这正是多级缓存对 AI 从业者的核心价值。

Throughput=ConcurrencyTresp‾\text{Throughput} = \frac{\text{Concurrency}}{\overline{T_{resp}}}
多级缓存压低了公式分母里的平均响应时间,于是在并发数不变的情况下,系统的整体吞吐量直接获得提升——快,就是产能。
  1. Concurrency 并发数系统能够同时处理的在途请求数量。
    Concurrency=系统并发容量\text{Concurrency} = \text{系统并发容量}
  2. 平均响应时间从请求发出到返回的平均耗时,正是前文的多级缓存平均读取耗时。
    Tresp‾=Tavg=h1TL1+(1−h1)h2(TL1+TL2)+⋯\overline{T_{resp}} = T_{avg} = h_1 T_{L1} + (1-h_1)h_2 (T_{L1}+T_{L2}) + \cdots
  3. 反比关系分母越小,整体吞吐越高;缓存命中率每提升一点,都能直接转化为推理服务的吞吐收益。
    Throughput∝1Tresp‾\text{Throughput} \propto \frac{1}{\overline{T_{resp}}}

AI 应用场景

实时推荐系统的特征读取

阶段:特征工程 → 在线推理

解决的问题:推荐服务每次请求都要读取用户画像、物品特征、历史行为序列等上百个特征。传统做法是直接从特征数据库查询,QPS 稍高数据库就告警,跨机房网络延迟甚至高达几十毫秒。引入多级缓存后,高频热点用户特征常驻 L1(Caffeine),全量用户特征放 L2(Redis),只有缓存未命中的冷用户才回源特征库,数据库 QPS 直降 90%,P99 延迟从 45ms 压到 8ms。

具体案例:某短视频推荐系统把用户 embedding 与近期行为特征做 L1+L2 缓存,并配合布隆过滤器拦截不存在的用户 ID。大促峰值 20 万 QPS 下,特征库负载平稳,推理服务吞吐提升 3 倍。

LLM 推理网关的元数据与结果缓存

阶段:模型推理 → 响应复用

解决的问题:大模型推理延迟高、GPU 成本贵。对于 Prompt 前缀相同的请求(比如“你是一位助手,请……” + 用户输入),重复进行全量预填充是巨大的浪费。网关层用多级缓存保存 KV Cache 与推理结果:L1 存最近热门的 Prompt 前缀,L2 存跨节点的共享 KV Cache,命中后跳过 prefill 直接进入 decode,显著降低首 token 延迟与算力消耗。

具体案例:某企业知识库问答系统为系统提示词与常用问题前缀建立多级缓存,命中率约 35%,整体推理成本下降约 20%,平均首 token 延迟从 1.2s 降到 0.4s。

Python 演示

python109 行
import randomimport threadingimport time  class LRUCache:    """L1 本地缓存:最近最少使用淘汰"""     def __init__(self, capacity=100):        self.capacity = capacity        self.data = {}        self.order = []     def get(self, key):        if key in self.data:            self.order.remove(key)            self.order.append(key)            return self.data[key]        return None     def set(self, key, value):        if key in self.data:            self.order.remove(key)        elif len(self.data) >= self.capacity:            old = self.order.pop(0)            del self.data[old]        self.data[key] = value        self.order.append(key)     def delete(self, key):        if key in self.data:            self.order.remove(key)            del self.data[key]  class MultiLevelCache:    """多级缓存:L1(LRU) + L2(分布式TTL) + DB"""     def __init__(self, l1_capacity=100, l2_capacity=1000, ttl_base=300):        self.l1 = LRUCache(l1_capacity)        self.l2 = {}        self.l2_order = []        self.l2_capacity = l2_capacity        self.ttl_base = ttl_base        self.store = {}        self.hits = [0, 0, 0]        self.lock = threading.Lock()     def _l2_set(self, key, value):        if key in self.l2:            self.l2_order.remove(key)        ttl = self.ttl_base + random.randint(0, 60)        self.l2[key] = (value, time.time() + ttl)        self.l2_order.append(key)        if len(self.l2_order) > self.l2_capacity:            old = self.l2_order.pop(0)            self.l2.pop(old, None)     def get(self, key):        value = self.l1.get(key)        if value is not None:            self.hits[0] += 1            return value        item = self.l2.get(key)        if item is not None:            value, expire = item            if time.time() < expire:                if value is not None:                    self.l1.set(key, value)                self.hits[1] += 1                return value            self.l2.pop(key, None)        value = self.store.get(key)        if value is not None:            self.l1.set(key, value)            self._l2_set(key, value)        else:            self._l2_set(key, None)        self.hits[2] += 1        return value     def set(self, key, value):        self.store[key] = value        self.l1.delete(key)        self.l2.pop(key, None)     def stats(self):        total = sum(self.hits)        if total == 0:            return "暂无请求"        return (            f"L1命中 {self.hits[0] / total:.1%} | "            f"L2命中 {self.hits[1] / total:.1%} | "            f"DB回源 {self.hits[2] / total:.1%}"        )  def main():    cache = MultiLevelCache(l1_capacity=50, l2_capacity=500, ttl_base=300)    for i in range(200):        cache.set(f"user:{i}", {"name": f"user{i}", "age": 20 + i % 30})    for _ in range(2000):        key = f"user:{random.randint(0, 300)}"        cache.get(key)    print(cache.stats())  if __name__ == "__main__":    main()
目的:用两个容器类模拟“L1 本地缓存(LRU 淘汰)+ L2 分布式缓存(TTL 过期)+ 数据库”的三级多级缓存,统计各级命中率,并演示空值缓存防穿透。
  1. class LRUCache模拟进程内本地缓存,用字典存数据、列表记录访问顺序,超出容量时淘汰最久未用的 key。
  2. def get(self, key)命中后把 key 移到列表末尾,表示“最近用过”,这是 LRU 的核心操作。
  3. class MultiLevelCache组装 L1、L2 和模拟数据库,对外只暴露 get/set 两个方法,内部自动走三级探测。
  4. def _l2_set(self, key, value)写入 L2 时给 TTL 加上 random.randint(0, 60) 的随机偏移,防止大量 key 同时过期造成雪崩。
  5. if value is not None: self.l1.set(key, value)只有真实数据才回填 L1;空值只进 L2 且带短 TTL,防止缓存穿透打垮数据库。
  6. self.l1.delete(key); self.l2.pop(key, None)写数据库后同步失效两级缓存,保证下次读取能拿到新值,即 Cache Aside 模式。
  7. cache.get(f"user:{random.randint(0, 300)}")用 0~300 的随机 key 制造一部分“数据库中不存在”的请求,观察空值缓存如何拦截穿透。

常见误区

误区我多加几层缓存,L1、L2、L3……性能肯定翻倍,反正缓存不嫌多。

正解每多一层缓存,就多一层数据拷贝、多一次一致性同步、多一层故障概率。层级之间是“用空间换速度”,如果 L1 命中率已经很高,再增加层级的边际收益极小,反而让写路径的失效广播变得复杂。设计多级缓存应根据热度分布决定层数,而不是无脑堆叠。就像厨房里灶台、冰箱、储物间三层就够用了,你总不会在灶台和冰箱之间再塞五个中转筐。

为何常见:看到“级”就联想到“越多越快”的直觉,忽略了缓存层级之间的协调开销。

误区缓存嘛,过期了自然会更新,写数据库的时候不用管缓存。

正解如果写数据库后不失效缓存,读请求会在 TTL 窗口内持续读到旧值。对于金额、库存这类强一致要求的数据,这种“没关系”会酿成资损事故。正确做法是 Cache Aside 主动删缓存,或用消息队列异步失效;只有弱一致场景才允许短 TTL 兜底。就像超市已经换了新价签,你冰箱门上还贴着旧标签,顾客按旧价买单,超市迟早赔穿。

为何常见:对“最终一致性”的误解,以为反正最终会一致,过程就无所谓。

误区穿透和击穿都是缓存失效导致数据库被打爆,防护方法应该一样。

正解穿透是“查不存在的数据”穿过了所有层级直达数据库;击穿是“单个热点 key 失效瞬间并发穿透”。防护也不同:穿透用布隆过滤器/空值缓存/参数校验,击穿用互斥锁/逻辑过期。混用会出大问题:击穿场景用布隆过滤器无效(key 确实存在),穿透场景用互斥锁无效(数据根本不存在,锁也白锁)。

为何常见:两者名字相近,且表现都是“数据库压力激增”,初学者极易混淆。

误区命中率是 KPI,我干脆把所有数据都塞进缓存,命中率 100%。

正解缓存的价值在于“用有限空间装高频数据”。全量缓存意味着容量要逼近数据库,成本极高且内存装不下;即使装下,写一致性也几乎无法维护。更合理的指标是“热点命中率”和“性价比”:只缓存访问频次高的数据,让少量缓存服务大多数请求。就像你不会把整个大卖场的货都搬进自家冰箱——冰箱放不下,而且大多数货你一年也碰不了一次。

为何常见:把“命中率”当作绝对目标,忽略了缓存的容量与一致性成本。

误区TTL 随机化做好,缓存就不会有问题了。

正解雪崩只是三类缓存故障之一,穿透和击穿依然可能存在;而且 TTL 随机化只防“自然过期雪崩”,防不了“缓存节点宕机”。完整的防护应该是:TTL 随机化(防自然雪崩)+ 熔断降级(防节点宕机)+ 空值/布隆(防穿透)+ 互斥锁/逻辑过期(防击穿),四管齐下。

为何常见:学习时按“点”记忆,没有建立完整的故障防护体系。

面试题

Q1. L1 本地缓存通常用什么实现?

单选题 · easy

  • A.进程内内存组件(Caffeine)← 正确答案
  • B.远端 Redis 缓存集群
  • C.MySQL 数据库数据表
  • D.消息队列消息副本

答案:A。L1 是离应用最近的进程内缓存,Caffeine/Guava 是典型实现,延迟微秒级。Redis 属于 L2 分布式缓存,MySQL 是数据源,消息队列与缓存无关。

Q2. 多级缓存中 L2 通常指什么?

单选题 · easy

  • A.CPU 内的高速缓存
  • B.本地进程内存缓存
  • C.分布式缓存集群← 正确答案
  • D.浏览器端的缓存

答案:C。L2 是多实例共享的分布式缓存,如 Redis 集群。CPU 高速缓存与浏览器缓存都不属于应用层缓存体系;本地进程内存是 L1。

Q3. 缓存穿透一般由什么请求引起?

单选题 · easy

  • A.热点数据请求过于集中
  • B.大量请求不存在的 key← 正确答案
  • C.缓存容量不足导致淘汰
  • D.数据库写入操作过慢

答案:B。穿透指缓存与数据库都不存在该 key,每次请求都穿过缓存直达数据库。热点集中是击穿诱因,容量不足是淘汰问题,写慢与读穿透无关。

Q4. 布隆过滤器主要用于解决?

单选题 · mid

  • A.缓存雪崩
  • B.缓存击穿
  • C.缓存穿透← 正确答案
  • D.缓存一致性

答案:C。布隆过滤器用位数组判断 key 是否可能存在,对“不存在”的结论是确定的,因此能在缓存前拦截大量无效 key,是防穿透利器。它不解决雪崩、击穿与一致性问题。

Q5. 以下哪些是防缓存雪崩的常用手段?

多选题 · mid

  • A.TTL 加入随机偏移← 正确答案
  • B.热点数据永不过期← 正确答案
  • C.多级缓存熔断降级← 正确答案
  • D.关闭缓存直接查库

答案:ABC。随机 TTL 让失效点错开,永不过期 + 异步刷新让热点稳定,熔断降级在节点故障时保护数据库,三者都是防雪崩。关闭缓存等于自杀,不是防护手段。

Q6. Cache Aside 模式中更新数据的正确顺序?

单选题 · mid

  • A.先删除缓存再写数据库
  • B.先写数据库再删缓存← 正确答案
  • C.只更新缓存不写数据库
  • D.数据库缓存同步双写

答案:B。先写库再删缓存,下次读时缓存缺失会回填新值,数据最终一致。若先删缓存再写库,写库窗口期会有其他请求把旧数据读回缓存,造成脏读。

Q7. 防缓存击穿的手段有哪些?

多选题 · mid

  • A.互斥锁重建热点缓存← 正确答案
  • B.热点数据逻辑过期← 正确答案
  • C.空值短 TTL 缓存
  • D.布隆过滤器拦截请求

答案:AB。互斥锁让单个请求重建缓存,逻辑过期用旧值兜底 + 异步刷新,都是防击穿。空值缓存与布隆过滤器防的是穿透(数据不存在),不适用于击穿(数据存在只是过期)。

Q8. 多级缓存一致性协调的常用方法?

多选题 · hard

  • A.写库后删除各级缓存← 正确答案
  • B.消息队列异步失效通知← 正确答案
  • C.短 TTL 作为兜底← 正确答案
  • D.缓存永不更新保一致

答案:ABC。写库后主动删缓存(Cache Aside)、消息队列广播失效通知 L1/L2、短 TTL 最终兜底都是标准做法。缓存永不更新会让缓存变成脏数据,无一致性可言。

Q9. L1 命中率 70%,L2 命中率 50%(L1 未命中时),L1 耗时 1ms,L2 耗时 5ms,DB 耗时 50ms,平均读取耗时约为?

单选题 · hard

  • A.约 6 毫秒
  • B.约 8 毫秒
  • C.约 10 毫秒← 正确答案
  • D.约 56 毫秒

答案:C。0.7 × 1 + 0.3 × 0.5 × (1+5) + 0.3 × 0.5 × (1+5+50) = 0.7 + 0.9 + 8.4 = 10ms。若不做多级缓存全部回源,平均 56ms,多级缓存带来 5 倍以上延迟收益。

Q10. 延迟双删中,第二次删除前要等待一段时间,原因是什么?

单选题 · extreme

  • A.等待数据库事务完全提交
  • B.等并发读回填旧值后再清← 正确答案
  • C.降低缓存删除的瞬时压力
  • D.等待网络传输完全稳定

答案:B。第一次删缓存后,若写库尚未完成,并发读可能查到旧数据并回填缓存。延迟一段时间再删第二次,目的就是把窗口期内被回填的旧值再清掉,消除竞态。等待数据库提交是写库顺序问题,与“第二次删除为什么延迟”无关。