本节摘要:全册的收束现场:面试官拿着你第 1 章写下的 LRU 缓存,从单机一路追问到并发、过期、分布式与监控。这道题没有标准答案,考的是把白板代码接到生产世界的衔接能力——以及"先澄清需求再谈方案"的终面生存习惯。
"这段代码,上线会出什么事?"——终面的转折往往从这一句开始。此前所有现场都在回答"怎么写对",只有这一场回答"写对之后呢"。面试官翻出现场二那段 LRU 缓存,不是因为它写得不好,恰恰是因为它写得太顺:顺到没有处理过时间、并发和故障。
"就用你现场二写的 LRU。第一个问题:多个线程同时读写它,哪里会坏?第二个问题:缓存要有过期功能,加在哪里?第三个问题:这台机器放不下了,怎么办?第四个问题:线上怎么知道这个缓存工作得好不好?"
四问层层放大:第一问还是代码,第二问是设计,第三问是架构,第四问是运维。每一问都没有唯一解,每一问都有答法高下。
候选人的做法值得整个章节记住:他没有立刻回答任何一问,而是先复述需求——"我先确认场景:读写比例大概什么量级?值有多大?能接受偶尔读到稍旧的数据吗?"面试官给出"读多写少、值小、允许短暂不一致"之后,他才开口。这个澄清动作花了不到一分钟,却是终面与普通轮次的分水岭:方案的对错取决于场景,抢答等于赌。
第一问的答案藏在代码自己的数据结构里。 OrderedDict 的 move_to_end 与 popitem 内部都要改链表指针,两个线程同时改,链表就可能成环或者断链——表面看是 O(1) 的操作,底层不是原子的。候选人的修复方案分两档:"单机进程内,一把互斥锁包住 get 和 put,代价是缓存操作串行化,但缓存操作本身纳秒级,锁开销可以接受;热点场景再谈分片——把键哈希到多个槽,每个槽一把锁,写冲突按槽隔离。"
第二问过期,他把现场二的代码扩展了一版,重点是"懒删除":
from collections import OrderedDict class TTLLRU: def __init__(self, cap, ttl): self.cap, self.ttl = cap, ttl self.od = OrderedDict() # 键 -> (值, 过期时刻) self.now = 0.0 # 手动时钟,保证输出可复现 def _expire(self, key): v = self.od.get(key) if v and v[1] < self.now: del self.od[key] # 懒删除:被访问时才清理过期键 def get(self, key): self._expire(key) if key not in self.od: return -1 self.od.move_to_end(key) return self.od[key][0] def put(self, key, val): self.now += 1.0 self._expire(key) if key in self.od: self.od.move_to_end(key) elif len(self.od) >= self.cap: self.od.popitem(last=False) # 容量满:淘汰最久未访问 self.od[key] = (val, self.now + self.ttl) c = TTLLRU(2, ttl=3) c.put('a', 1) c.put('b', 2) c.put('c', 3) # 容量 2,a 被挤出 print('get a(已被淘汰):', c.get('a')) print('get b:', c.get('b')) c.now = 10 # 时钟跳过过期时刻 print('get b(已过期):', c.get('b')) print('懒删除后仍在表里的键:', list(c.od.keys()))
get a(已被淘汰): -1 get b: 2 get b(已过期): -1 懒删除后仍在表里的键: ['c']
最后一行打印是候选人故意留给追问的钩子:c 早在时钟跳到十之前就过了期,却还躺在表里——懒删除只保证"读不到过期的值",不保证"过期键及时消失"。他顺势给出工程补全:"过期键长期不被访问会白占内存,生产实现要配一条定期抽样的清扫线,比如每秒随机抽一批键检查,摊平清理成本。Redis 用的正是懒删除加定期抽删的组合。"
追问一:放不下了怎么办? 候选人先画拓扑再开口:"单机容量到顶,按一致性与运维成本选型:想要简单,客户端哈希或代理分片,把键空间切成多机,每台内部还是一个 LRU;要弹性就用带一致性哈希的分布式缓存中间件,扩容时只迁移相邻片段。取舍点在扩容搬迁量与故障半径——分片数越多,单机故障影响越小,运维越复杂。"
追问二:缓存和数据库的双写不一致怎么处理? "先更新库、再失效缓存,而不是先更新缓存——缓存的职责是加速,宁可回源也不能固化旧值。失效失败用重试与过期时间兜底:就算失效丢了,存活期一到自然一致。要更紧的窗口再谈延迟双删,但那已经是权衡过复杂度的进阶选项,不是默认答案。"
追问三:什么叫缓存穿透、击穿、雪崩,分别怎么防? "穿透查的是根本不存在的键,请求绕过缓存直捣数据库,防法是把'空结果'也缓存一小段时间,或在入口加布隆过滤器;击穿是一个热点键过期瞬间,并发请求同时回源,防法是热点键逻辑过期或加互斥回源,只放一个请求去打数据库;雪崩是大批键同时过期,防法是过期时间加随机抖动,把过期峰值摊开。三个词的共同点:都是'缓存没接住'的故障模式,区别只在没接住的原因——键不存在、键刚过期、键集体过期。"
追问四:线上怎么知道缓存工作得好不好? "命中率是第一指标:命中数除以请求总数,分口径统计——全量、按键前缀、按业务线。配合逐出率看过期与容量策略是否失衡,配合回源延迟看穿透有没有漏网。指标要连着告警:命中率突降通常意味着新上线的代码绕过了缓存,或者大促流量把热点洗掉了。"
单机缓存的进阶变式沿两条线走:并发线,从粗粒度锁到分片锁,再到无锁的近似 LRU——精确的访问序维护本身就有竞争代价,采样近似是工程上更便宜的选择;分布式线,从客户端分片到中间件代理,再到读写分离与多级缓存。这场还有一个反向变式值得自测:把题倒过来,给你一个"命中率忽高忽低"的线上缓存,让你排查——排查题逼你把前面所有正问的答案串成因果链,比正问更接近真实的工程日常。
高频翻车点:不澄清需求直接背方案,被一句"谁告诉你写多"问塌;三个故障模式背得滚瓜烂熟,却连不到自己那段代码——终面要的是"你的 LRU 缺 TTL,所以会被击穿"这种带主语的答案;把一致性说成非黑即白,答不出"允许短暂不一致"这句话背后的业务假设;指标只会报名词,说不出口径——说不出口径的指标等于没有。还有一个隐性的表达失误:画拓扑图时先画中间件再画业务,被追问"哪来的这个中间件"才发现自己默认了没影的前提。
主线候选人这一场拿到的评语是终面里最难的一档:"拿着他的旧代码聊了半小时,每一步他都知道自己站在哪里——这样的人放进系统里,出事他找得到位置。"全册的复盘到此也该合上了:从现场一的词频统计到这一场的缓存架构,白板上的每一段代码,都在等一次"上线会出什么事"的追问。
关键直觉:终面的工程追问没有新知识点,只有旧代码的新角色——把每段白板代码当成生产系统的候选零件去审视,追问链就再也追不倒你。