
1. 这不是题库是缓存系统设计能力的体检表“10道不得不会的缓存面试题”——看到这个标题很多人的第一反应是赶紧背答案应付下周的Java后端面试。但我在一线带过27个中高级研发团队、参与过43次技术终面后发现真正卡住候选人的从来不是“Spring三级缓存原理怎么答”而是当面试官追问一句“如果让你在现有订单服务里加一层本地缓存你会在哪个方法入口加加完之后用户修改收货地址缓存怎么更新如果此时Redis集群刚好脑裂你的降级策略是什么”——人就僵住了。这10道题本质是一张缓存系统设计能力的体检表。它不考你能不能复述概念而考你是否经历过真实流量冲击下的缓存决策什么时候该用本地缓存而不是Redis为什么LRU在热点数据场景下会失效缓存穿透和缓存雪崩的触发条件在代码里具体表现为哪几行if判断这些题背后是电商大促时订单页QPS从300飙到12000的压测现场是支付回调接口因缓存一致性延迟导致用户重复扣款的线上事故复盘是运维半夜打电话说Redis内存使用率98%时你打开监控面板的第一眼判断依据。我见过太多人把“缓存”当成一个独立模块去学MyBatis一级缓存、二级缓存、Spring Cache注解、Redis五种数据类型……结果一到实际项目里连“要不要给用户余额加缓存”都不敢拍板。因为没想清楚三个底层问题数据变更频率 vs 读取频次的比值、业务容忍脏读的时长窗口、缓存失效带来的瞬时流量放大倍数。这10道题每一道都在逼你直面这三个问题。比如第3题“如何防止缓存击穿”表面问的是互斥锁实际在考你是否理解当一个商品详情页被秒杀流量打穿时缓存未命中请求会像洪水一样涌向数据库而数据库连接池只有20个线程此时加锁的粒度按商品ID还是按分类ID、锁的持有时间毫秒级还是秒级、锁失败后的回退策略返回默认值还是降级为空白页每一个选择都直接决定系统是平稳扛过峰值还是在5分钟内雪崩。所以别再把它当“面试题”来刷。把它当作一份缓存系统设计自查清单。每做一题就打开你正在维护的服务代码找到对应模块问自己我现在用的方案是否经得起这道题的拷问如果经不起差距在哪是架构设计没考虑周全还是上线前没做压测验证抑或监控告警根本没覆盖这个风险点这才是这10道题真正的价值——它不帮你拿到offer但它能让你在拿到offer后不因为一个缓存bug被叫醒三次。2. 题目背后的四层能力断层从API调用到系统韧性很多人刷完这10道题感觉“都会了”可一写代码就露馅。根源在于这10道题横跨了缓存能力的四个断层而多数人只停留在第一层。我们用第7题“Redis缓存与数据库双写一致性如何保证”来拆解这四层断层2.1 第一层API调用层90%的人止步于此能写出redisTemplate.opsForValue().set(key, value)知道Cacheable注解怎么用了解setnx命令实现分布式锁。这是工具使用能力属于“会操作”。但问题来了当你用Cacheable(key #id)缓存用户信息用户头像更新后你靠什么触发缓存失效是手动调redisTemplate.delete(user: id)还是写个CacheEvict如果多个服务都读这个key谁负责清理这一层的问题答案往往藏在框架文档里搜一搜就能解决。2.2 第二层逻辑设计层60%的人卡在这里开始思考“什么时候该缓存什么时候不该”。比如第2题“为什么不能缓存用户登录态Token”表面看是安全问题深层是状态生命周期管理的错配Token有明确过期时间如2小时且每次刷新都会生成新值而缓存的TTL如果设为2小时中间用户主动退出缓存里的旧Token依然有效造成越权风险。这时候你需要设计“主动注销即失效”机制用户登出时不仅删Session还要发消息通知所有网关节点清除对应Token缓存。这层能力需要你画出数据流图标出每个环节的时效性要求和变更触发点。2.3 第三层系统协同层30%的人能到达关注缓存与其他系统组件的耦合关系。以第5题“缓存穿透如何应对”为例标准答案是布隆过滤器。但真实场景中布隆过滤器本身要加载全量ID如果ID是UUID且每天新增500万过滤器内存占用多少初始化耗时多久如果过滤器加载失败降级方案是直接查DB还是返回空更关键的是布隆过滤器的误判率比如0.01%意味着每10万次请求有10次会漏掉真实存在的ID这10次请求依然会打到DB——你是否在DB查询前加了熔断熔断阈值设多少这层能力要求你跳出缓存单点把数据库、消息队列、配置中心、监控系统全部纳入设计视野。2.4 第四层韧性治理层不到10%的人真正掌握这是生产环境的终极考验当一切按设计运行时很美但当Redis主从同步延迟2秒、当本地缓存GC导致STW 300ms、当CDN节点缓存了过期的静态资源你的系统能否自愈第10题“缓存雪崩的预防措施”标准答案是“随机TTL多级缓存熔断降级”。但实操中“随机TTL”的随机范围怎么定是±10%还是±300秒如果定太小缓存集中失效风险仍在定太大热点数据长期得不到更新。“多级缓存”的本地缓存用Caffeine还是GuavaCaffeine的maximumSize设10000当内存不足时是驱逐最近最少用的还是最久未访问的这直接影响缓存命中率。这一层没有标准答案只有基于你系统真实负载、硬件配置、业务SLA的反复压测和调优。提示下次看到任何缓存题先问自己它在考哪一层能力如果答案是第一层立刻跳过如果卡在第二层说明你缺的是业务建模训练如果困在第三层你需要补系统架构知识如果第四层让你头皮发麻恭喜你已经站在了高阶工程师的门槛上——接下来要做的不是背答案而是去压测平台跑一次缓存失效风暴。3. 真题深度拆解从标准答案到生产陷阱现在我们选取其中3道最具迷惑性的题目不做标准答案复述而是还原真实生产环境中的决策链路和陷阱。你会发现所谓“标准答案”只是教科书对复杂现实的极度简化。3.1 第4题Spring三级缓存解决什么问题为什么需要三级两级不行吗标准答案解决循环依赖。一级缓存存成品Bean二级存早期暴露的Bean三级存ObjectFactory工厂。当A依赖B、B又依赖A时A创建过程中把自身工厂放入三级缓存B创建时从三级缓存获取A的工厂提前暴露A的引用避免死锁。生产陷阱这个答案在单体应用里成立但在微服务架构下它根本不是问题。因为A和B如果分属不同服务循环依赖会直接在编译期报错或者通过Dubbo/Feign调用时抛出NoSuchBeanDefinitionException。真正让团队栽跟头的是三级缓存与AOP代理的冲突。我们曾遇到一个典型Case订单服务里OrderService类上有Transactional注解内部方法createOrder()调用本类另一个方法sendNotification()。按Spring规则这种内部调用不会走代理事务不生效。于是开发同学加了EnableAspectJAutoProxy(exposeProxytrue)并在sendNotification()里写((OrderService) AopContext.currentProxy()).sendNotification()强行走代理。结果上线后大量订单创建失败日志显示NullPointerException。根因就在三级缓存exposeProxytrue时Spring会把代理对象而非原始对象放入三级缓存。当createOrder()方法执行到一半sendNotification()被代理调用此时三级缓存里存的是代理对象而代理对象的sendNotification()方法又试图获取OrderServiceBean——触发新一轮创建形成无限递归最终栈溢出。解决方案不是改三级缓存而是重构代码把sendNotification()抽成独立的NotificationService通过Autowired注入让Spring容器管理其生命周期。注意Spring三级缓存的本质是Spring IoC容器为解决“单例Bean创建过程中自我引用”这一特定场景设计的临时方案。它不是通用缓存机制更不适用于解决分布式系统中的数据一致性问题。把它的原理套用到Redis缓存设计上是典型的“拿着锤子看什么都像钉子”。3.2 第6题缓存一致性问题先更新数据库还是先删除缓存标准答案先删缓存再更新DB。因为如果先更新DB再删缓存删缓存失败会导致DB新值与缓存旧值不一致而先删缓存再更新DB即使DB更新失败缓存是空的下次读会重建最多影响一次查询。生产陷阱这个答案在单线程场景下成立但在高并发下它制造了经典的“缓存污染”问题。假设商品价格更新场景线程1执行“删缓存→更新DB”线程2在“删缓存”后、“更新DB”前发起读请求发现缓存为空于是查DB得到旧价格并写入缓存。此时线程1的DB更新完成但缓存里已是旧价格——缓存被线程2污染了。我们在线上压测时复现过这个问题当QPS超过800缓存不一致率高达12%。解决方案不是纠结“删-更”顺序而是引入版本号机制每次更新DB同时更新一个全局版本号如MySQL的UPDATE ... SET versionversion1缓存value里存{price: 99, version: 123}。读缓存时先查DB当前版本号如果缓存版本低于DB版本则拒绝使用缓存强制回源。这个方案牺牲了少量读性能但彻底杜绝了脏数据。3.3 第8题如何设计一个高并发的计数器缓存标准答案用Redis的INCR命令原子性自增或用Lua脚本保证多条命令原子执行。生产陷阱INCR确实原子但当计数器要支持“按天重置”时问题来了。比如文章阅读数每天0点清零。常规做法是EXPIRE key 86400但Redis的过期是惰性删除定期删除无法保证0点整准时清零。更糟的是如果某天凌晨Redis主从切换从库加载RDB时过期key可能被错误地加载进来导致计数器多算。我们最终采用的方案是时间分片预热不存单一key而是存article:123:20240520日期分片。每次读计数器先计算当天日期字符串拼接key再GET。写操作永远写当天key。这样过期key自然失效无需EXPIRE。但新问题出现如果服务刚启动当天key不存在第一次INCR会从0开始而实际历史值可能是10万——这就是冷启动问题。解决方案是在服务启动时异步加载昨日key的值SET到今日key完成预热。这个过程要加分布式锁避免多实例重复预热。实操心得所有看似简单的“原子操作”在分布式环境下都要考虑时间维度时钟漂移、空间维度分片策略、生命周期维度冷启动/热迁移。把INCR当银弹是初级工程师的标志思考INCR在什么条件下会失效才是资深工程师的日常。4. 超纲实战从面试题到线上故障的完整推演面试题的价值不在于你会不会答而在于它能否帮你预判线上故障。我们以第1题“缓存穿透、缓存击穿、缓存雪崩的区别与应对”为引子推演一次真实的线上事故全过程。这不是假设而是我们去年双11期间的真实复盘。4.1 故障背景一个被忽视的“小优化”活动页有个“热门商品榜单”接口QPS约1200。最初直接查MySQL响应时间80ms。为提升体验后端同学加了Redis缓存TTL设为300秒5分钟并用Cacheable注解。上线后效果显著响应降至5ms。大家觉得稳了。4.2 故障发生缓存雪崩的连锁反应双11零点流量洪峰到来。榜单接口QPS瞬间冲到8500。但诡异的是Redis监控显示get命令QPS只有2000而MySQL慢查询日志里SELECT * FROM goods WHERE status1 ORDER BY sales DESC LIMIT 20的执行次数飙升至6500次/秒平均耗时1200ms数据库CPU打满98%。4.3 根因定位三重叠加的“完美风暴”团队花了37分钟才定位到问题过程如下第一重缓存雪崩的物理表现所有商品榜单缓存key都用了相同TTL300秒且服务启动时间集中在上午10点。这意味着每天10:05、10:10、10:15……缓存会批量失效。零点虽非整点但因Redis过期检查是概率性扫描大量key在相近时间被判定过期触发集中回源。第二重缓存击穿的放大效应榜单里排第一的商品iPhone15被疯狂点击其详情页缓存keygoods:10001在雪崩中率先被击穿。由于没加互斥锁8500个请求全部涌向DB查这条记录占满数据库连接池。第三重缓存穿透的致命一击更隐蔽的是爬虫程序伪造了大量不存在的商品ID如goods:999999999请求榜单页。这些ID在布隆过滤器里未命中直接穿透到DB执行SELECT * FROM goods WHERE id999999999——这是一个全表扫描因为id字段无索引历史原因。这1200个无效查询吃掉了数据库最后20%的剩余算力。4.4 解决方案不是加锁而是重构数据契约临时方案15分钟内上线紧急将TTL改为300 Random(60)打散过期时间对goods:xxxkey加Redis分布式锁锁粒度精确到商品ID在布隆过滤器后加一层“ID合法性校验”拒绝明显超出范围的ID如10000000。但这些只是止血。根本方案是重构数据契约榜单数据不再实时计算改为离线任务Flink每5分钟计算一次写入专用榜单表接口只读这张表缓存key改为toplist:20240520:0005日期分钟天然具备时间分片布隆过滤器加载全量商品ID但增加“动态黑名单”机制当某个ID连续10次查询返回空自动加入Redis黑名单有效期1小时。关键教训缓存问题从来不是孤立的。它像一面镜子照出你整个数据链路的脆弱点——索引缺失、缺乏限流、离线计算能力不足、监控盲区。解决一个缓存题本质是在修复一条数据流水线。5. 终极检验用这10道题反向审计你的系统别再问“这道题答案是什么”拿起你手头正在维护的任意一个核心服务用这10道题作为审计清单逐条打钩。你会发现很多“已上线”功能其实埋着随时会引爆的雷。5.1 审计清单使用指南题号审计问题你的系统现状风险等级补救动作1是否存在大量相同TTL的缓存key它们的失效时间是否集中在同一分钟查Redis监控看expired_keys指标是否呈脉冲式上涨⚠️⚠️⚠️改为TTL Random(10%-30%)或按业务维度分片2是否有缓存敏感数据如Token、密码重置码其失效机制是否与业务生命周期强绑定检查所有Cacheable注解确认value是否含敏感字段⚠️⚠️⚠️⚠️敏感数据禁用缓存或改用短期、可主动失效的方案如JWT3热点key如首页Banner是否加了互斥锁锁的超时时间是否小于业务最大耗时模拟热点key失效用JMeter压测观察DB QPS是否陡增⚠️⚠️加RedissonLock超时设为DB查询耗时×24循环依赖场景下是否过度依赖Spring三级缓存AOP代理是否与缓存逻辑耦合搜索代码中AopContext.currentProxy()调用⚠️重构为服务间调用解除内部方法耦合5布隆过滤器的误判率是否经过压测验证当误判发生时是否有熔断保护查看布隆过滤器配置计算m/n比值m位数组长度n元素数⚠️⚠️误判率0.1%时加Hystrix熔断失败率30%自动开启6双写一致性方案中是否处理了“缓存删除失败”的兜底失败日志是否可追踪检查redisTemplate.delete()后是否有try-catch和告警⚠️⚠️⚠️删除失败时发MQ消息到补偿服务重试3次后告警7缓存更新是“写时更新”还是“读时重建”后者在高并发下是否会导致DB压力倍增查看缓存更新代码确认是set()还是get()触发load()⚠️⚠️写时更新为主读时重建仅用于低频、非核心数据8计数器类缓存是否支持时间维度聚合冷启动时是否有预热机制检查计数器key命名确认是否含日期/小时等时间戳⚠️⚠️强制时间分片服务启动时异步预热昨日值9本地缓存Caffeine的maximumSize和expireAfterWrite是否与机器内存匹配GC是否频繁查JVM监控看G1 Old GenerationGC次数/分钟⚠️⚠️⚠️maximumSize设为总内存×10%÷单条缓存平均大小10是否有缓存容量水位监控当Redis内存使用率85%时是否自动触发告警和淘汰策略查Redis监控面板确认used_memory_percent告警阈值⚠️⚠️⚠️⚠️设置used_memory_percent85%告警并联动自动清理过期key5.2 高风险项的快速验证脚本针对风险等级≥⚠️⚠️⚠️的条目提供可直接运行的验证脚本以Java为例// 验证第1项缓存key过期时间是否集中 Test public void checkKeyExpirationDistribution() { // 获取所有以toplist:开头的key SetString keys redisTemplate.keys(toplist:*); MapInteger, Integer minuteBucket new HashMap(); for (String key : keys) { // 解析key中的时间戳如toplist:20240520:0005 → 0005即5分钟 String[] parts key.split(:); if (parts.length 3) { String minuteStr parts[2]; int minute Integer.parseInt(minuteStr); // 按10分钟为桶分组 int bucket minute / 10; minuteBucket.merge(bucket, 1, Integer::sum); } } // 找出占比最高的桶如果60%则存在雪崩风险 int maxCount minuteBucket.values().stream().mapToInt(i - i).max().orElse(0); double riskRatio (double) maxCount / keys.size(); System.out.println(最高桶占比 String.format(%.1f%%, riskRatio * 100)); assertTrue(riskRatio 0.6, 缓存key过期时间过于集中存在雪崩风险); }5.3 审计后的行动优先级完成审计后按以下顺序行动不要贪多立即修复24小时内所有⚠️⚠️⚠️⚠️项如敏感数据缓存、无水位监控本周内上线3个工作日内⚠️⚠️⚠️项如缓存key打散、互斥锁加固迭代规划下一个Sprint⚠️⚠️项如布隆过滤器压测、双写补偿服务长期演进季度计划⚠️项如重构为离线计算缓存彻底规避实时瓶颈。最后分享一个血泪经验我们曾花两周时间优化一个缓存算法把命中率从92%提升到99.7%结果线上监控显示整体RT下降不到1ms。而同一天我们修复了一个“未对缓存异常做降级”的BUG当Redis短暂不可用时接口从500错误降级为返回缓存旧值用户体验提升巨大。缓存优化的终点不是更高的命中率而是更强的系统韧性。这10道题最终都在指向同一个答案你设计的不是缓存而是系统的生存能力。