想象你正在准备一顿年夜饭。最常用的盐和酱油,你随手放在灶台边——伸手就能拿到;提前解冻的鸡鸭鱼肉,放在冰箱里——转身就能取;而那扇需要现买的黑猪肉、罕见的干货,才值得你开车去一趟大卖场。聪明的厨师绝不会让每一次取料都跑大卖场:先在灶台找,再去冰箱翻,最后才考虑出门,而且回家后还会记下“今天大卖场有某样东西”,下次不再白跑。 这个烟火气十足的取物智慧,就是多级缓存的全部秘密。在计算机世界里,AI 推理服务每秒要处理成千上万次请求,如果每次都去数据库“大卖场”捞数据,系统很快就会被拖垮。于是我们把这套厨房分工搬进了软件架构:本地缓存当灶台调料架,分布式缓存当楼下社区超市,数据库当远处大卖场。再加上应对“什么东西都买不到”“超市集体下架”“明星食材一抢而空”这些极端情况的防护手段,你就拥有了一套高性能、高可用的数据访问体系。本文就用这条“厨房—超市—大卖场”的线索,带你从头搭建这套体系。
对 AI 从业者而言,多级缓存是 AI 推理服务“跑得快、稳得住”的底盘功夫。掌握它,你才能在高并发系统设计中不再惧怕数据库抖动,让模型服务的吞吐与稳定性真正达到工业级。
可视化演示
可视化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,所有服务实例共享。加上数据库兜底,三个层级天然形成“快慢有别、容量互补”的配合关系。用一个分段函数概括读取一个数据可能经过的路径:
把最常用的佐料放灶台边、次常用的放冰箱、不常用的去超市——这套“按频次分层”的收纳哲学,正是缓存分级的灵魂。理解了“近的快但小、远的慢但大”,就理解了多级缓存为何一定要分层。
- 命中 L1请求在本地缓存直接返回,耗时就是 L1 的访问延迟,不产生任何网络开销。
- 命中 L2L1 未命中后去分布式缓存里找,累计耗时是两级的延迟之和。
- 回源数据库两级缓存都没有,只能查数据库,耗时达到几十毫秒量级。
拓扑读写路径
熟手的厨师取料有自己的固定路线:先看灶台,再看冰箱,最后才决定是否出门。而且从大卖场回来后,他会顺手把“今天大卖场有这种菜”记在便签上,下次就不用再跑。这就是多级缓存读写路径的全部秘密。
读请求的路径是“三级探测”:先查 L1,命中直接返回;未命中再查 L2,命中则返回并把数据回填到 L1;L2 也未命中才回源数据库,同时在 L1 和 L2 都做回填。这个“回填”动作,相当于厨师从大卖场回来后写下的便签。写请求则反过来:先更新数据库,再删除 L1 和 L2 中的旧缓存,保证下一次读取能拿到最新数据。容量管理方面,各级缓存都有淘汰策略——LRU(最近最少使用) 是最常见的做法,就像冰箱空间有限,总要先扔掉最不常吃的剩菜。
这个路径设计的目标只有一个:让尽可能多的请求在尽量靠前的层级命中。于是平均读取耗时的期望,就是各级命中概率与对应延迟的加权组合:
厨师用固定路线加回填便签,省掉大量重复跑路;多级缓存把“常用数据前置、回填上级缓存”固化成了自动流程,系统性能就在这一次次“少跑路”中翻倍。
- L1 命中部分权重是 L1 命中率 h1,这部分请求只花 T_L1。
- L2 命中部分L1 未命中(概率 1-h1)且 L2 命中(概率 h2)的请求,耗时两层之和。
- 回源数据库部分两级都没命中(概率 (1-h1)(1-h2)),付出全链路代价。
- 四项加总把三种情况乘以各自概率再求和,就是系统平均读取耗时。
一致性协调
楼下超市的鸡蛋降价了,但你冰箱门上还贴着“鸡蛋 15 元/斤”的旧便签。直到有人撕掉它、贴上新价,你才不会被误导。缓存与数据库之间,每天都在上演这种“标签战”。
多级缓存的核心问题之一,是数据库里的数据变了,缓存里那份旧副本何时失效、如何让所有层级一起失效。最经典的模式是 Cache Aside:写操作先更新数据库,然后删除缓存——下次读请求发现缓存缺失,自然会把新数据重新加载进来。但“删除”与“读取”之间存在竞态窗口:读请求可能在缓存删除前读到旧值并回填,于是就有了延迟双删——第一次立即删,隔一小段延迟再删一次,把窗口期内可能被回填的旧值再清掉。
在 L1、L2 多级场景下,协调成本翻倍:写服务要同时失效 L1 和 L2。L1 是各实例私有的,通常通过“消息广播”通知所有实例删除本地缓存;L2 是共享的,直接删即可。如果追求最终一致性,还可以给缓存设置一个较短的 TTL 作为兜底——即使通知丢了,数据也会在 TTL 内自动过期。若完全没有主动失效,缓存中数据的平均陈旧程度正好是:
管理便签的人越勤快,顾客看到的标价越准;同理,主动失效越及时,数据越新鲜。工程上常采用“主动失效为主 + 短 TTL 兜底”的混合策略,在新鲜度和开销之间取平衡。
- TTL 窗口每条缓存从写入到自动过期,存活 TTL 秒。
- 平均年龄数据在任意时刻被读取时,其已存活时间的期望恰为 TTL 的一半。
- 主动失效的意义每次删除缓存,都把这份数据的“平均年龄”清零,从而缩小读到陈旧版本的时间窗口。
穿透防护
做一道家传川菜,需要一味罕见的香料。你翻遍灶台、冰箱,又跑遍社区超市,结果大卖场也断货——可每次做这道菜,你都重复这趟三处跑。缓存穿透,就是这种“查一个根本不存在的数据”的请求:缓存和数据库里都没有,每次都必须穿过缓存直达数据库。
大量这样的请求会瞬间压垮数据库。防护手段有三类。第一是参数校验:请求的 key 明显不合法(空 ID、负数 ID)就在入口直接拦截,相当于“一看菜名就知道大卖场不可能有”。第二是空值缓存:数据库查不到时,把“空”也缓存起来,并给一个很短 TTL(比如 30 秒),避免反复打库。第三是布隆过滤器:用一个极紧凑的位数组为所有可能存在的 key 建立“指纹”,查询前先问过滤器——如果它说“不存在”,那就一定不存在,连缓存都不用查。
布隆过滤器的核心参数有三个:位数组长度 m、已插入元素个数 n、哈希函数个数 k。它的误判率(把不存在误报为存在)有一个经典近似公式:
有了“食材黑名单”,不必真跑一趟大卖场就能确认“准没有”。布隆过滤器偶尔误报“可能有”,但从不漏报“一定没有”——用极小的空间挡住绝大多数无效请求,正是穿透防护的性价比所在。
- m/n 的含义位数组中每个元素平均占用 m/n 个比特位,空间越宽裕,误判越低。
- k 个哈希每个 key 由 k 个哈希函数映射到 k 个位置并置 1,k 越多,位数组越拥挤。
- 误判率整体当参数取优时(经验值 k ≈ 0.7 × m/n),误判率可以压到 1% 以下,而每个 key 只花十几个比特。
雪崩防护
假设社区超市突然停电,所有冰柜同步解冻,货架上的酸奶集体到期下架。消息传开,全小区的人同时冲向唯一的大卖场——大卖场门口瞬间排起长队,系统直接瘫痪。缓存雪崩,就是大量缓存 key 在同一时刻过期(或整个缓存节点宕机),导致海量请求同时穿透到数据库。
最常见成因有两个:一是所有 key 设置了完全相同的 TTL,数据同时写入、同时过期;二是缓存集群整体故障。对应防护手段有三:第一,TTL 随机化,给每个 key 的过期时间加上随机偏移,让失效时刻错开;第二,多级熔断降级,当 L2 故障时系统自动降级为“L1 兜底 + 直连数据库”,并限制回源速率,保护数据库不被打崩;第三,热点数据永不过期——不是真的不设 TTL,而是后台维护逻辑过期时间,由后台线程提前异步刷新。
TTL 随机化的计算方式非常朴素:
就像把全小区的酸奶分七天分批下架,而不是周日午夜集体报废;TTL 随机化能把一次“雪崩”拆解成无数场互不影响的“小降温”。再加上熔断降级这道保险闸,即使缓存节点真宕了,系统也能以“半速”继续服务,而不是彻底趴窝。
- T_base 基础值所有 key 共享的基础过期秒数,比如 300 秒。
- 随机偏移量为每个 key 独立抽取一个 0 到 Δ 之间的随机数,例如 Δ = 60 秒。
- 失效区间各 key 的实际过期时间分布在 [T_base, T_base+Δ] 内,不再同时失效。
击穿防护
社区超市里那款网红和牛切片,平时总有人买。这天它恰好售罄,货架空空如也。消息一传十十传百,所有人同时涌向大卖场抢购。缓存击穿,指的不是大量 key,而是一个超级热门的 key 在过期的那一瞬间,海量并发请求同时越过缓存、打到数据库。
击穿与雪崩的区别在于“规模粒度”:雪崩是大量 key 同时失效,击穿是单个热点 key 失效。防护思路因而不一样。第一招,互斥锁(Mutex):热点 key 过期后,只允许一个请求去数据库重建缓存,其他请求阻塞等待锁释放,然后直接从缓存拿到新值。第二招,逻辑过期:缓存里的 value 带有一个“逻辑过期时间”字段,线程发现逻辑过期后不删除缓存,而是触发异步线程去后台刷新,读请求仍然能拿到旧值——这类“旧但可用”的数据,在秒杀、热点资讯场景下完全可接受。第三招,热点识别:通过统计访问频率提前识别热点 key,为它们单独设置更长的 TTL 或启用“永不过期”策略,从源头减少击穿窗口。
如果不用任何防护,热点 key 失效瞬间并发 N 个请求都去回源,至少有一个请求穿透到数据库的概率是:
厨师的做法是:发现和牛没了,先派一个人去大卖场补货,其他人在厨房等着——而不是一窝蜂全跑出去。互斥锁和逻辑过期,就是把“派一个人 vs 全冲出去”的决策,固化成了系统自动执行的规则。
- p 的含义单个请求未命中缓存、需要回源的概率。
- N 的含义该 key 在失效瞬间的并发请求数。
- 都不打库的概率每个请求都不穿透数据库的概率是 1-p,N 个请求同时不穿透则是 (1-p)^N。
- 至少一个打库的概率用 1 减去“全都不打库”,即得至少一个请求穿透到数据库的概率,N 越大越接近 1。
AI推理应用
把 AI 推理服务想象成一座中央厨房:每来一份订单(一次推理请求),后厨都要以最短时间配齐食材(用户特征、模型参数、中间结果)。如果每次订单都现去大卖场采购,出菜速度必然垫底。多级缓存,就是中央厨房的“冰箱—超市”配置方案。
在真实的 AI 工程中,多级缓存有几个高频落点。第一,特征数据缓存:推荐系统给每个用户算好的特征向量,L1 放 Caffeine 缓存热门用户特征,L2 用 Redis 存全量特征,特征数据库只承担低频回源。第二,模型与元数据缓存:模型版本号、动态 batch 策略、路由表这类“低频变、高频读”的配置,放本地缓存即可,配置中心更新后通过广播失效。第三,推理结果复用:对于前缀相同的 Prompt(如系统提示词 + 用户输入),可以用多级缓存直接复用已算好的 KV Cache 部分结果,大幅降低 LLM 推理延迟。
这些手段最终都汇聚到一个公式——系统吞吐量(Throughput)与平均响应时间成反比:
中央厨房里“高频备菜前置、低频备菜后置”的布局,映射到 AI 系统就是那套“冰箱—超市—大卖场”的三级栈。当用户特征、模型元数据和推理结果都放到了正确的层级,AI 服务才真正做到了既快又稳——这正是多级缓存对 AI 从业者的核心价值。
- Concurrency 并发数系统能够同时处理的在途请求数量。
- 平均响应时间从请求发出到返回的平均耗时,正是前文的多级缓存平均读取耗时。
- 反比关系分母越小,整体吞吐越高;缓存命中率每提升一点,都能直接转化为推理服务的吞吐收益。
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 演示
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()
class LRUCache模拟进程内本地缓存,用字典存数据、列表记录访问顺序,超出容量时淘汰最久未用的 key。def get(self, key)命中后把 key 移到列表末尾,表示“最近用过”,这是 LRU 的核心操作。class MultiLevelCache组装 L1、L2 和模拟数据库,对外只暴露 get/set 两个方法,内部自动走三级探测。def _l2_set(self, key, value)写入 L2 时给 TTL 加上random.randint(0, 60)的随机偏移,防止大量 key 同时过期造成雪崩。if value is not None: self.l1.set(key, value)只有真实数据才回填 L1;空值只进 L2 且带短 TTL,防止缓存穿透打垮数据库。self.l1.delete(key); self.l2.pop(key, None)写数据库后同步失效两级缓存,保证下次读取能拿到新值,即 Cache Aside 模式。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 本地缓存通常用什么实现?
- A.进程内内存组件(Caffeine)← 正确答案
- B.远端 Redis 缓存集群
- C.MySQL 数据库数据表
- D.消息队列消息副本
答案:A。L1 是离应用最近的进程内缓存,Caffeine/Guava 是典型实现,延迟微秒级。Redis 属于 L2 分布式缓存,MySQL 是数据源,消息队列与缓存无关。
Q2. 多级缓存中 L2 通常指什么?
- A.CPU 内的高速缓存
- B.本地进程内存缓存
- C.分布式缓存集群← 正确答案
- D.浏览器端的缓存
答案:C。L2 是多实例共享的分布式缓存,如 Redis 集群。CPU 高速缓存与浏览器缓存都不属于应用层缓存体系;本地进程内存是 L1。
Q3. 缓存穿透一般由什么请求引起?
- A.热点数据请求过于集中
- B.大量请求不存在的 key← 正确答案
- C.缓存容量不足导致淘汰
- D.数据库写入操作过慢
答案:B。穿透指缓存与数据库都不存在该 key,每次请求都穿过缓存直达数据库。热点集中是击穿诱因,容量不足是淘汰问题,写慢与读穿透无关。
Q4. 布隆过滤器主要用于解决?
- A.缓存雪崩
- B.缓存击穿
- C.缓存穿透← 正确答案
- D.缓存一致性
答案:C。布隆过滤器用位数组判断 key 是否可能存在,对“不存在”的结论是确定的,因此能在缓存前拦截大量无效 key,是防穿透利器。它不解决雪崩、击穿与一致性问题。
Q5. 以下哪些是防缓存雪崩的常用手段?
- A.TTL 加入随机偏移← 正确答案
- B.热点数据永不过期← 正确答案
- C.多级缓存熔断降级← 正确答案
- D.关闭缓存直接查库
答案:ABC。随机 TTL 让失效点错开,永不过期 + 异步刷新让热点稳定,熔断降级在节点故障时保护数据库,三者都是防雪崩。关闭缓存等于自杀,不是防护手段。
Q6. Cache Aside 模式中更新数据的正确顺序?
- A.先删除缓存再写数据库
- B.先写数据库再删缓存← 正确答案
- C.只更新缓存不写数据库
- D.数据库缓存同步双写
答案:B。先写库再删缓存,下次读时缓存缺失会回填新值,数据最终一致。若先删缓存再写库,写库窗口期会有其他请求把旧数据读回缓存,造成脏读。
Q7. 防缓存击穿的手段有哪些?
- A.互斥锁重建热点缓存← 正确答案
- B.热点数据逻辑过期← 正确答案
- C.空值短 TTL 缓存
- D.布隆过滤器拦截请求
答案:AB。互斥锁让单个请求重建缓存,逻辑过期用旧值兜底 + 异步刷新,都是防击穿。空值缓存与布隆过滤器防的是穿透(数据不存在),不适用于击穿(数据存在只是过期)。
Q8. 多级缓存一致性协调的常用方法?
- A.写库后删除各级缓存← 正确答案
- B.消息队列异步失效通知← 正确答案
- C.短 TTL 作为兜底← 正确答案
- D.缓存永不更新保一致
答案:ABC。写库后主动删缓存(Cache Aside)、消息队列广播失效通知 L1/L2、短 TTL 最终兜底都是标准做法。缓存永不更新会让缓存变成脏数据,无一致性可言。
Q9. L1 命中率 70%,L2 命中率 50%(L1 未命中时),L1 耗时 1ms,L2 耗时 5ms,DB 耗时 50ms,平均读取耗时约为?
- 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. 延迟双删中,第二次删除前要等待一段时间,原因是什么?
- A.等待数据库事务完全提交
- B.等并发读回填旧值后再清← 正确答案
- C.降低缓存删除的瞬时压力
- D.等待网络传输完全稳定
答案:B。第一次删缓存后,若写库尚未完成,并发读可能查到旧数据并回填缓存。延迟一段时间再删第二次,目的就是把窗口期内被回填的旧值再清掉,消除竞态。等待数据库提交是写库顺序问题,与“第二次删除为什么延迟”无关。