想象你是一家川菜馆的掌勺大厨。后厨里有一台巨大的冷藏库(数据库),存放着全部食材;你的操作台面上有几个透明的调料盒和一个配菜盘(缓存),里面摆着今天最常用的花椒、干辣椒和提前切好的葱姜蒜。客人点菜时,伸手就能拿到台面上的调料,几秒钟就出锅;只有当台面缺料时,你才不得不转身去冷藏库翻找,白白多花好几分钟。这就是缓存设计的全部智慧:把「最常取用的数据」放在「离手边最近的地方」,用一点空间的成本,换回巨大的时间收益。但台面就那么大,放哪些调料、多久换一次、菜单改了怎么同步、遇到疯狂点单的客人怎么不被拖垮——这些问题正是系统设计面试中缓存设计要考察的全部核心。本文就用这个「大厨与调料台」的模型,带你一口气通关缓存设计的高频考点。
缓存设计是系统设计面试的必考模块,掌握它等于拿到分布式系统「性能优化」的钥匙,是架构师与高级工程师的核心竞争力之一。
可视化演示
演示LRU缓存淘汰策略:访问数据移至头部(MRU端),缓存满时淘汰尾部(LRU端),实时展示命中率变化。
本节配有交互动效(进入视口后自动加载,约 3KB)。
LRU缓存淘汰机制 访问缓存中的数据时,该数据移至头部(MRU端) 新数据插入缓存头部,若已满则淘汰尾部(LRU端)数据 淘汰策略基于最近访问时间,最久未访问的优先淘汰 命中率 = 命中次数 / 总访问次数
缓存基本原理
大厨每次做菜前,都会下意识扫一眼台面的调料盒:花椒还有吗?干辣椒够不够?如果台面上就有,直接抓一把下锅,整个过程不到一秒——这就是「缓存命中」。如果台面上没有,他得转身打开冷藏库翻找,来回少说也要半分钟——这就是「缓存未命中」。所谓缓存(Cache),本质就是给数据建一个「离手边更近的副本」:冷藏库(数据库)里的数据是权威版本,台面(缓存)上放的是高频访问的临时版本。
厨师伸手的频率并不均匀:有的菜一天被点十几次,有的菜一周都无人问津。台面空间有限,只能放最常被点的那些。衡量这个「放得准不准」的指标,就是命中率(Hit Rate)——命中次数占总访问次数的比例。
有了命中率,就能算出平均每次访问的耗时:命中时走缓存(几毫秒),未命中时走数据库(几十毫秒),两者按概率加权平均。
回到厨房:大厨的目标不是把冷藏库搬到台面,而是让台面上的每一寸空间都「放得恰到好处」。缓存设计的第一步,就是找到那盘最常被点的菜——数据访问的局部性,是缓存一切优点的来源。
- h × T_cache(命中贡献)命中时只花缓存访问的时间,乘以命中概率 h。
- (1 - h) × T_db(未命中贡献)未命中时不但查不到缓存,还要额外付出数据库访问的代价,乘以未命中概率 (1-h)。
- 求和两部分相加得到平均耗时。当 h=0.9、T_cache=1ms、T_db=20ms 时,平均耗时仅 2.9ms,比直连数据库快近 7 倍。
架构模式
同样是备料,不同规模的厨房做法截然不同:街边小馆的大厨自己跑腿,星级酒店的厨房有专门的备料助手,连锁餐厅则有一套严格的备料与盘库流程。缓存架构模式,就是这几种「备料分工」的不同设计。
Cache-Aside(旁路缓存)是最常见也最灵活的模式,相当于「大厨亲自跑腿」:应用先查缓存,命中直接返回;未命中则查数据库,然后将结果回填缓存,最后返回给客户端。它的优势是应用对缓存行为有完全的控制权,任何缓存组件都能用。Read-Through(读穿透)则像「雇了一个助手」:应用只需要跟缓存代理要数据,代理自己查缓存、自己查数据库、自己回填,应用完全不需要关心缓存逻辑。Write-Through(写穿透)像「边炒菜边记账」:每次写数据库的同时立刻更新缓存,保证缓存与数据库几乎同步。Write-Back(写回)则像「打烊统一对账」:先写在台面上的便签(缓存),后台异步批量更新冷藏库台账(数据库),写入效率最高。
写入延迟的对比,可以用两个极简的表达式来概括:
选哪种模式,取决于业务要「快」还是要「稳」。秒杀场景要极致的写速度,选 Write-Back;账务系统要每笔都确认落库,选 Write-Through。就像大厨的备料方式取决于餐厅定位——路边摊讲究快,米其林讲究稳,各有各的取舍。
- T_WB = T_cacheWrite-Back 的写入延迟仅等于写缓存的耗时,数据库的更新被异步延后,所以写入极快。
- T_WT = T_cache + T_dbWrite-Through 每一步都同时写缓存和数据库,总延迟是两者之和。
淘汰策略
台面空间只有那么大,新调料放上来之前,必须请走一位「老住户」。请谁走?这就是淘汰策略(Eviction Policy)要回答的问题。
FIFO(先进先出)最简单:谁先上台面,谁先回冷藏库,像排队打饭一样公平,但它不管使用频率——一盒刚开封却没人用的调料,可能先挤掉天天用的老干妈。LRU(最近最少使用)更聪明:最久没被碰过的调料被请走。像大厨心里有本账——昨天用过、上周用过、上月用过,优先淘汰最久没上过手的那盒。LFU(最不经常使用)按使用次数淘汰:用得最少的调料先走。它适合访问频率差异大的场景,但实现更复杂、还需要维护计数。TTL(过期时间)不是一种淘汰策略,而是每盒调料的「保质期」:不管用不用,到时间就要扔掉,防止食材变质。
在请求均匀分布的假设下,缓存容量 C 与数据总量 N 的比值直接决定了命中率上限:
实际工程中,面试官最爱让候选人手写 LRU。实现方案是「双向链表 + 哈希表」:双向链表维护使用顺序,哈希表提供 O(1) 的定位能力。每次访问一个 key,就把它摘下来挂到链表头部;容量满了,就把链表尾结点淘汰掉。这就像大厨给每盒调料贴了一张「最近使用时间」标签,同时又在脑海里维护了一张「调料位置索引」——拿哪盒、放回哪个位置,都是一瞬间的事。
- C(缓存容量)台面最多能放的调料盒数,即缓存能存多少个 key。
- N(数据总量)冷藏库中全部食材种类,即系统里所有不同 key 的总数。
- 除法关系均匀随机访问时,命中概率等于缓存覆盖的 key 比例。这告诉我们,缓存不是越大越好,而是「覆盖高频 key」才最有效——这就是为什么大厨只把每周必用的那几样放台面,而不是把整个冷藏库搬上来。
一致性挑战
台面上的调料是冷藏库食材的「复印件」。客人点了一份回锅肉,大厨照着台面上的豆瓣酱做了——但如果冷藏库里的库存已经更新(换了新品牌),台面上还是旧标签,这盘菜的味道就对不上。这就是缓存与数据库的「一致性」问题。
双写(同时更新缓存和数据库)时,顺序至关重要。先删缓存再更新数据库是大部分人容易踩的坑:请求 A 删掉缓存后,还没来得及更新数据库,请求 B 发现缓存为空,去数据库读到旧值并回填缓存;等 A 写完数据库,缓存里躺着的还是 B 填的旧值——不一致的窗口被无限拉长。所以行业共识是先更新数据库,再删除缓存:即使删除缓存失败,最多也就是多查一次库,数据最终还是对的。
为了进一步压缩主从复制延迟造成的回填窗口,还有一招「延迟双删」:先删一次缓存,更新数据库,等一段时间(必须大于主从复制延迟),再删一次缓存。这个等待时间可以形式化为:
一致性是个光谱:强一致代价极高,最终一致在绝大多数业务里足够。大厨不需要每秒钟都核对台面与冷藏库的标签是否一字不差,只要保证「客人点菜的当下不出错、标签在可接受的时间内换新」,厨房就能有条不紊地运转。
- T_delay(等待时间)第一次删除缓存后、第二次删除前的 sleep 时长。太短则主从还没同步完,第二次删除形同虚设。
- T_replication(主从复制延迟)数据从主库写入到从库可读的时间差,通常在几十毫秒到几百毫秒之间。
- 比较关系严格大于,才能保证旧值已经没有机会从从库被读到并回填。工程上一般取经验值,比如 500ms。
三座大山
面试官最爱的「压轴题」,是缓存系统的三大经典故障,被戏称为「三座大山」。用厨房场景来记,一目了然。
缓存穿透:客人点了一道菜谱上根本不存在的菜(比如「火山岩浆炖乌龟」)。大厨每次都得去冷藏库翻找,翻遍整个库也找不到,白白耗费体力。恶意攻击者经常用大量不存在的 ID 来打接口,每个请求都直击数据库。解法有两个:一是「空值缓存」——连不存在也缓存一个空标记,下次直接挡住;二是「布隆过滤器」——在菜谱入口挂一张「可能的菜名清单」,清单上没有的直接拒绝,省得跑冷藏库。
缓存击穿:某道招牌菜(热点 key)的食材在前一秒刚好用完(缓存过期),下一秒所有客人都点了这道菜,大厨一个人冲去冷藏库取料,其他人只能在灶台前干瞪眼。它与穿透的本质区别是:这道菜是存在的,只是恰好缓存失效。解法是「互斥锁」——只让一个请求去冷藏库取料,其他请求耐心等待;或者给热点 key 设置「永不过期」,用后台线程定期续一下「保质期」。
缓存雪崩:更可怕的是所有调料盒同时空了——大量 key 在同一时间过期。所有客人的菜都要临时去冷藏库取,冷藏库的门被挤爆(数据库连接池耗尽)。解法核心是「错峰」:把过期时间打散,加上随机抖动,让雪崩变成细小的毛毛雨;再加上多级缓存(本地缓存 + 分布式缓存)作为第二道防线。
三座大山的数据库压力可以用一条公式直观对比:
记住这个「体检口诀」:穿透查「不存在」,击穿查「一个热点」,雪崩挂「一片热点」。能用一句话说清三者差异,面试基本上就成功了一半。
- N_expired(同时失效的 key 数量)雪崩的规模决定性变量。所有 key 设置相同 TTL 时,N_expired 就等于全部 key 数。
- q_key(每个 key 的请求速率)每个热点 key 每秒被访问多少次。
- 乘积关系既要降低 N_expired(随机化 TTL),也要降低 q_key(多级缓存兜底),双管齐下才能把数据库压力降回安全水位。
容量与优化
台面到底该做多大?这是缓存设计的收尾问题。台面太小,调料老是不够用,厨师频繁跑冷藏库;台面太大,成本高且占厨房空间。工程上有一个快速估算公式:
容量之外,还有几招优化手段:热点优化——个别招牌菜(如明星账号、爆款商品)单独建「小灶」,用独立缓存或者本地缓存扛住超高并发;监控手段——给厨房装「仪表盘」,紧盯命中率、缓存大小、淘汰数量、慢查询四项指标,命中率突然跳水意味着缓存策略可能出了问题;性能调优——对大 value 做压缩(调料盒换成真空包装)、选用更快的序列化方案、在分布式缓存前面再加一层进程内本地缓存,多级缓存层层兜底。
大厨最终的台面设计,不是一次定死的:他会观察一周的点菜记录,调整调料品种与数量,在不同时段动态微调。缓存设计也是一样——容量规划是起点,持续监控与调优才是让系统保持「顺手状态」的日常功课。
- QPS(每秒请求量)系统吞吐的峰值,决定了窗口期内有多少请求会涌入。
- L(平均响应时间)每个请求从发出到返回的耗时,即数据在缓存中的「存活窗口」内可能被访问的次数。
- (1 + k)(安全系数)给突发流量留出的余量。k 越大,缓存越宽裕,成本也越高。
AI 应用场景
电商秒杀活动:热点商品的高并发保护
阶段:系统架构设计、容量规划
解决的问题:秒杀瞬间千万级请求涌向同一件商品,数据库直接被击穿。用 Cache-Aside 模式把商品详情与库存预载入 Redis,热门商品设置「永不过期 + 后台定时刷新」,配合布隆过滤器拦截不存在的商品 ID 穿透,数据库压力从百万 QPS 降到千级。
具体案例:某头部电商平台大促时,预先将秒杀商品的详情 JSON 与库存余量写入 Redis 集群,通过一致性哈希分片分散热点;后端线程每 10 秒刷新一次热点 key。实测秒杀期间 Redis 命中率稳定在 99.2%,数据库 QPS 峰值仅为此前的 1/50。
推荐系统在线推理:特征缓存加速
阶段:在线推理性能优化
解决的问题:AI 推荐模型每次推理都要拼接用户特征与物品特征,重复计算开销巨大,推高 p99 时延。将高频特征计算后的 embedding 向量缓存到 Redis,命中时直接读取向量参与模型计算,未命中才走特征计算服务。
具体案例:某短视频推荐平台为 1 亿用户的 embedding 特征设计 LRU 缓存 + 热点用户永不过期策略,特征命中率达到 93%,模型推理 p99 时延从 80ms 降至 25ms,同时节省了 60% 的特征计算资源。
社交信息流:读多写少的缓存读写模型
阶段:数据层架构设计
解决的问题:Feed 流每次刷新要聚合关注列表的几百条动态,直接查库成本极高。采用 Cache-Aside + Write-Back 组合:用户 Feed 在 Redis 中以 List 结构缓存最近 100 条动态,发布时先写缓存再异步落库,冷门用户的数据自动过期淘汰。
具体案例:某社交应用采用该方案后,Feed 读取命中率达 97%,发布接口写入时延仅 3ms(得益于 Write-Back 异步刷库),数据库写入压力集中在低峰期批量处理。
Python 演示
"""带 TTL 的 LRU 缓存:演示命中率统计与缓存穿透防护。"""import timefrom collections import OrderedDict class TTLCache: def __init__(self, capacity=3, default_ttl=5): self.capacity = capacity self.default_ttl = default_ttl self._data = OrderedDict() # key -> (value, expire_at) self.hit_count = 0 self.miss_count = 0 def get(self, key): if key not in self._data: self.miss_count += 1 return None value, expire_at = self._data[key] if expire_at is not None and time.time() > expire_at: del self._data[key] self.miss_count += 1 return None self._data.move_to_end(key) # LRU: 标记最近使用 self.hit_count += 1 return value def set(self, key, value, ttl=None): ttl = self.default_ttl if ttl is None else ttl expire_at = time.time() + ttl if ttl > 0 else None if key in self._data: del self._data[key] self._data[key] = (value, expire_at) if len(self._data) > self.capacity: self._data.popitem(last=False) # 淘汰最久未使用的 key @property def hit_rate(self): total = self.hit_count + self.miss_count return self.hit_count / total if total else 0.0 def load_from_db(key): time.sleep(0.05) return "data-" + key def get_data(cache, key, enable_null_cache=False): value = cache.get(key) if value is not None: return value if key.startswith("ghost"): # 模拟数据库中不存在的数据 if enable_null_cache: cache.set(key, "", ttl=2) # 空值缓存: 挡住穿透请求 return None value = load_from_db(key) cache.set(key, value) return value def main(): cache = TTLCache(capacity=3, default_ttl=3) get_data(cache, "user:1") get_data(cache, "user:1") get_data(cache, "user:2") for i in range(8): get_data(cache, "ghost:%d" % i) print("无防护: 命中率=%.2f%% (hit=%d miss=%d)" % (cache.hit_rate * 100, cache.hit_count, cache.miss_count)) cache2 = TTLCache(capacity=3, default_ttl=3) get_data(cache2, "user:1") for i in range(8): get_data(cache2, "ghost:%d" % i, enable_null_cache=True) get_data(cache2, "ghost:0") print("空值缓存: 命中率=%.2f%% (hit=%d miss=%d)" % (cache2.hit_rate * 100, cache2.hit_count, cache2.miss_count)) if __name__ == "__main__": main()
self._data = OrderedDict()用有序字典同时实现「双向链表」与「哈希表」,既能 O(1) 定位 key,又能记录插入顺序供 LRU 淘汰。expire_at = time.time() + ttl if ttl > 0 else None每条数据记录过期时间戳,ttl=0 表示永不过期。self._data.move_to_end(key)LRU 核心操作:每次命中把 key 移到末尾,相当于「放到台面最顺手的位置」。if len(self._data) > self.capacity: self._data.popitem(last=False)容量满时把最开头的键淘汰,对应台面最久没被翻动的那盒调料。if key.startswith("ghost")模拟数据库中不存在的数据,制造缓存穿透场景。cache.set(key, "", ttl=2)空值缓存:把「不存在」也缓存 2 秒,挡住后续穿透请求。
常见误区
误区反正内存很便宜,把数据库全量塞进缓存,命中率不就 100% 了吗?缓存当然越大越好。
正解命中率与容量的关系是一条「收益递减曲线」:当缓存覆盖了高频访问的 20% 数据后,再加大容量,命中率提升非常有限(参考公式 h = C/N 的边际效应)。更大的缓存还带来更长的遍历淘汰耗时、更高的内存占用和冷数据污染——台面太大,大厨找调料反而更慢。真正的优化方向是「精准缓存热点」,而不是「暴力全量缓存」。
为何常见:直觉上默认「存得多 = 命中多」,忽略了数据访问的二八定律(20% 的 key 承担 80% 的访问)。
误区反正缓存只是副本,先把它删掉,让请求去数据库读最新值,再更新数据库,这样数据一定一致。
正解这中间有一个致命的竞态窗口:请求 A 删掉缓存后还没更新数据库,请求 B 发现缓存为空就去数据库读到了旧值并回填缓存;随后 A 才更新数据库,缓存里从此躺着旧值,可能永远不被修正。正确姿势是先更新数据库、再删除缓存——不一致窗口只有「删缓存」这一瞬,且缓存不删的话下次读到的也是旧值,但概率极小。配合延迟双删,再拉长主从复制的时间差,才足够稳妥。
为何常见:单线程线性思维忽视了并发请求在时间上的交错重叠,这也是面试中最常考察的并发陷阱。
误区给所有 key 设一个过期时间,数据不一致时等它自动过期就好了,不用写删除缓存的逻辑。
正解TTL 是兜底,不是主力。在 TTL 到期之前的整个窗口期里,用户读到的都是脏数据;且大量 key 同时到期还会引发雪崩。对于一致性要求高的数据(如库存、余额),必须在写入时就主动失效缓存(删除或更新),TTL 只是防止「万一漏删」的最后防线——就像调料盒贴了保质期,但你不能等它坏了才换,而是知道食材变质的那一刻就该倒掉。
为何常见:只看到「过期能自愈」的表象,忽略了过期窗口的长度可能远超业务可容忍的不一致时长。
误区都是缓存没查到数据导致数据库压力大,名字也像,处理方式应该差不多吧。
正解两者根因完全不同。穿透是「数据本身不存在」,查多少次都查不到,每次都穿透到数据库,用空值缓存或布隆过滤器拦截;击穿是「数据存在但恰好某个热点 key 的缓存失效」,大量请求在同一微妙涌向同一次重建,用互斥锁或热点永不过期解决。混淆它们会导致用了错误的方案,面试官一眼就能看出来你只是背了名词。
为何常见:症状相似(缓存未命中 + 数据库压力大),加上「穿」「击」字形相近,初学者极易混淆。
面试题
Q1. 引入缓存最主要解决的性能问题是?
- A.提升数据存储总量
- B.显著降低硬件成本
- C.缩短数据访问延迟← 正确答案
- D.增强系统安全能力
答案:C。缓存把高频数据放在离应用更近的存储中,核心收益是缩短访问延迟(从数据库的几十毫秒降到缓存的几毫秒)。它不提升存储总量(A 错),通常反而增加硬件成本(B 错),也与系统安全无关(D 错)。就像大厨把常用调料放台面,是为了少跑冷藏库,而不是为了多买调料。
Q2. 缓存命中(Cache Hit)指的是?
- A.所需数据不在缓存之中
- B.缓存容量已经完全用满
- C.缓存服务发生了崩溃
- D.所需数据已在缓存之中← 正确答案
答案:D。命中表示请求的数据在缓存里找到了,可以直接返回,无需访问数据库。A 是未命中;B 容量满只是淘汰策略的触发条件;C 是缓存故障,都与「命中」无关。用厨房的比喻说,命中就是你伸手就能拿到台上的调料。
Q3. 与数据库相比,缓存的读写速度通常?
- A.缓存访问比数据库慢
- B.缓存访问比数据库快← 正确答案
- C.两者速度完全相同
- D.数据库比缓存快得多
答案:B。缓存基于内存存储,访问延迟在微秒到毫秒级;数据库通常涉及磁盘 I/O 和网络传输,延迟在十毫秒级以上。理论上内存读写比磁盘快一到两个数量级,这也是缓存存在的根本原因。
Q4. 手写 LRU 缓存最常用的数据结构组合是?
- A.数组与哈希表组合
- B.二叉树与哈希表组合
- C.双向链表与哈希表← 正确答案
- D.单向链表与哈希表
答案:C。LRU 需要 O(1) 定位 key 和 O(1) 调整使用顺序。哈希表提供 O(1) 定位;双向链表提供 O(1) 的摘除和插入(因为知道前驱后继)。数组的插入删除是 O(n);二叉树的调整是 O(log n);单向链表删除任意节点时无法 O(1) 找到前驱,都不满足。
Q5. 缓存穿透问题的本质是?
- A.查询存在数据且已入缓存
- B.热点数据同时集中失效
- C.缓存服务器意外宕机
- D.查询不存在的数据绕过缓存← 正确答案
答案:D。穿透是指请求的数据在数据库里根本不存在,导致每次查询都绕过缓存直达数据库,形成无效压力。B 是缓存击穿或雪崩的场景;C 是缓存故障;A 与穿透含义相反。就像客人点一道不存在的菜,大厨每次都得去冷藏库翻找,白费力气。
Q6. 防止缓存击穿的常用手段有哪些?
- A.热点数据加互斥锁重建← 正确答案
- B.热点数据设置永不过期← 正确答案
- C.让所有请求排队串行
- D.提前预加载并预热缓存← 正确答案
答案:ABD。击穿是单个热点 key 过期瞬间,大量请求同时冲进数据库重建缓存。互斥锁(A)保证只有一个请求重建并回填;永不过期(B)从根源避免热点 key 失效;预热(D)在流量高峰前主动把缓存填好,也属有效预防。C 对全局所有请求排队会让吞吐坍缩,是错误做法——锁的粒度应限在单个 key,而不是整个系统。
Q7. 保证缓存一致性的推荐写入顺序是?
- A.先更新数据库再删缓存← 正确答案
- B.先删除缓存再更新数据库
- C.同时删除缓存与数据库
- D.每次写操作清空全部缓存
答案:A。先更新数据库再删缓存,不一致窗口最小:即使删除失败,下次读库也是新值;而先删缓存再更新库时,请求可能在「删缓存后、更新库前」读到旧值并回填缓存,导致缓存长期存旧数据。C「同时删」没有原子性保障;D 全量清空缓存的代价过高,完全不可取。
Q8. 关于 Write-Back(写回)模式的特点,下列说法正确的是?
- A.每次写入实时同步数据库
- B.直写数据库不经过缓存
- C.先写缓存再异步批量刷库← 正确答案
- D.同步更新缓存与数据库
答案:C。Write-Back 先写入缓存,数据由后台任务异步批量写回数据库,写入延迟极低,但缓存与数据库之间存在短暂的不一致窗口,适合允许最终一致的场景。A 是 Write-Through 的特征;B 是无缓存直写;D 也是 Write-Through,均不符合。
Q9. 缓解缓存雪崩的有效方法有哪些?
- A.设置随机化的过期时间← 正确答案
- B.热点数据设置永不过期← 正确答案
- C.对所有 key 设置相同过期时间
- D.采用多级缓存架构兜底← 正确答案
答案:ABD。雪崩是大量 key 在同一时刻失效导致数据库瞬时压力爆炸。随机化过期时间(A)把集中失效打散成错峰失效;热点永不过期(B)排除「主力选手」参战;多级缓存(D)在分布式缓存失效时还有本地缓存扛住。C 恰恰是雪崩的诱因,应坚决避免。
Q10. 下列哪些方案可以应对缓存穿透?
- A.将空值也写入缓存兜底← 正确答案
- B.布隆过滤器前置拦截← 正确答案
- C.对所有请求不设任何校验
- D.接口层参数校验与限流← 正确答案
答案:ABD。穿透针对「数据库中不存在的数据」。空值缓存(A)让同一个不存在 key 第二次起就命中缓存;布隆过滤器(B)在入口快速判断 key 是否可能存在,不存在直接返回;参数校验与限流(D)从入口挡住恶意批量构造的非法 ID。C 等于完全敞开大门,会放大穿透攻击,是错误的。注意延长热点过期时间对穿透无效,那是防击穿的手段。