
凌晨两点十七分监控群里弹出一条告警商品详情接口 P99 从 60ms 冲到 2.4s数据库活跃连接数瞬间打满紧接着整个下单链路开始超时。那是我第一次在大促前夜经历真正意义上的缓存事故。事后复盘问题既不是 Redis 挂了也不是代码写得多烂而是我们对缓存穿透、缓存击穿、缓存雪崩这三种事故的边界认识不清——当天晚上值班的同学把击穿当成了穿透在处理加了空值缓存结果一点用没有白白浪费了四十分钟黄金时间。这三个词在面试题里被翻来覆去地问在真实线上环境里也真的会一个接一个地出现但它们的成因、现象、治理手段完全不同。搞混了轻则方案无效重则把小事故放大成大故障——比如给雪崩场景加互斥锁锁竞争本身就会变成新的瓶颈。这篇内容我会把三者的本质区别、每一种的完整解决方案、可以直接抄的参数与代码以及一次真实排障的完整链路讲清楚。适合已经用过 Redis、但对缓存治理还停留在“加个过期时间”阶段的同学也适合正准备面试、想把这几个问题答出深度的人。里面涉及的数值和配置都来自我实际跑过的环境你可以按自己的量级等比调整。1. 三种缓存事故的本质区别先把敌人认清楚1.1 用小区快递柜类比一次看懂三者差异把 Redis 想成小区楼下的快递柜MySQL 是想成几公里外的总仓库。用户下单查商品相当于住户下楼取包裹。缓存穿透是住户报了一个根本不存在的取件码。快递柜里没有总仓库里也没有但按照流程每一次请求都必须跑一趟总仓库确认“确实没有”。如果有一万个住户同时报一万个随机取件码仓库的门就会被踏破。关键在于数据在数据库里根本不存在缓存这一层天然挡不住因为查询结果本身就是空的没有东西可以被缓存下来。缓存击穿是某一个爆款包裹的柜格租期到了被系统清空。恰好这个时候有一千个人同时来取这个包裹快递柜全都没有一千个人同时涌向总仓库。关键在于数据是存在的只是热点 Key 在某一瞬间失效了与此同时并发量恰好集中在这个 Key 上。它不需要很多 Key 出问题一个就够前提是这个 Key 足够热。缓存雪崩则是整个小区的快递柜集体断电或者大批柜格的租期被设在了同一个时间点导致同一秒内大面积清空。住户全部涌向仓库。关键在于失效是面状的可能是大量 Key 同时过期也可能是 Redis 集群本身出了故障两种形态的处理方式还不一样。这三句话建议你先记住穿透是“查不存在的东西”击穿是“一个热点东西突然没了”雪崩是“一大片东西同时没了”。1.2 一张对照表把差异钉死把三者的关键特征放在一起区别会非常直观维度缓存穿透缓存击穿缓存雪崩数据是否存在数据库中不存在数据库中存在数据库中存在涉及 Key 数量大量不存在的 Key单个热点 Key大量 Key 或整个实例触发时机任意时刻攻击或异常参数Key 过期的那一瞬间批量过期时刻、实例故障Redis 层表现缓存永远不命中命中率瞬间掉一个坑命中率整体断崖式下跌数据库压力特征无效查询持续冲刷单条 SQL 被并发执行上千次几乎全量 SQL 同时涌入典型来源恶意枚举、参数错误、爬虫秒杀商品、首页配置、热搜榜定时任务批量刷缓存、节点宕机危害量级中到高取决于攻击强度高往往直接打崩主库极高可能引发全站不可用首选方案布隆过滤器 参数校验 空值缓存互斥锁重建 逻辑过期 热点预热过期时间打散 多级缓存 高可用 限流降级这张表我建议你存下来。线上告警响起来的时候先对着这张表判断是哪一类比直接翻代码快得多。1.3 为什么这三件事经常被混着说因为从外部看它们最终的症状高度相似数据库 CPU 飙升、慢查询变多、接口 RT 上涨、连接池打满。如果你只看数据库的监控面板三种事故长得一模一样。差别藏在 Redis 侧的指标里——缓存命中率曲线的形状、QPS 的分布、以及是不是集中在某几个 Key 上。更麻烦的是实际故障经常是混合的。我遇到过一种情况有人在凌晨三点用脚本批量刷新了一批配置缓存把所有 Key 的过期时间都设成了 30 分钟结果半小时后大批 Key 同时过期雪崩的成因而其中一个首页配置恰好是流量最大的热点击穿的成因同时脚本刷的过程中还带了几个不存在的配置 ID穿透的成因。三种问题在同一条时间线上叠加排查的时候如果心里没有清晰的分类很容易东一榔头西一棒子。所以第一步永远不是写代码而是判断类型。判断错了后面的所有动作都是负功。2. 缓存穿透查不到的数据才是最危险的2.1 穿透往往不是攻击而是业务自己写出来的很多人一提穿透就想到恶意攻击但我在实际项目里遇到的大部分穿透来源都特别朴素前端下拉框传了id0或者id-1后端没做校验直接拿去查库商品下架、用户注销后数据被物理删除但历史订单页面还在渲染旧链接搜索页面的分页参数被用户手动改成了一个巨大的页码对应的数据区间根本不存在爬虫按自增 ID 顺序遍历其中夹杂着大量已被删除的 ID手机号、订单号生成的规则被猜到有人从有效号段边缘开始扫这些场景的共同点是ID 空间是有限的、可枚举的。这一点非常关键因为它决定了方案的选择。如果攻击者每次用的是完全随机的 UUID那空值缓存基本失效——每个请求都是一个新 Key缓存反而成了攻击者的内存写入口。我在一个招聘类项目里就被这条坑过。当时订单查询接口的空值缓存设了 10 分钟过期结果有人用脚本每分钟发几万个不存在的订单号每个都往 Redis 里写一个空值 Key两个小时内 Redis 内存涨了 4 个 G触发了内存告警。后来改成只对自增 ID 类型的查询做空值缓存并且加了参数长度和格式校验问题才消失。2.2 空值缓存最简单也最容易翻车的一招空值缓存的逻辑很直白查数据库没查到就往 Redis 里写一个特殊标记的空值下次同样的请求直接被挡住。在代码层面有四件事必须做对public Product getProduct(Long id) { String key product: id; String cached redis.get(key); if (cached ! null) { // 必须显式区分“空值标记”和“真实数据”不能都靠 null 判断 return EMPTY_MARK.equals(cached) ? null : JSON.parseObject(cached, Product.class); } Product product productMapper.selectById(id); if (product null) { // 空值过期时间要短30~120 秒足够因为数据可能刚被插入 redis.set(key, EMPTY_MARK, 60, TimeUnit.SECONDS); return null; } redis.set(key, JSON.toJSONString(product), 1800 random.nextInt(300), TimeUnit.SECONDS); return product; }第一空值必须有独立的标记比如__EMPTY__这种业务上不可能出现的字符串。如果你存的是空字符串或者字面量null序列化框架和业务代码很容易分不清“缓存没命中”和“缓存命中了一个空结果”然后掉进无限回源的循环。第二空值的过期时间一定要短。真实数据的过期时间可以是半小时空值的过期时间我一般设 60 秒最多不超过 120 秒。理由很简单如果某个商品刚刚被创建出来而之前有一次查询已经写了空值缓存那这个商品在过期时间内就是“隐身”的。对电商、社交这类写入频繁的业务这个窗口越短越好。第三空值缓存不能替代参数校验。前面说过随机 ID 会让空值缓存变成内存炸弹。所以在进入缓存逻辑之前先做一轮廉价校验ID 必须大于 0、长度不能超过某个阈值、格式必须匹配。第四注意序列化框架的行为。用 Jackson 或者 Fastjson 存空对象时反序列化回来的可能是一个没有任何字段默认值的实例前端拿到会显示成一堆空白。这种情况用标记字符串比用对象更安全。2.3 布隆过滤器把不存在的请求挡在 Redis 之前空值缓存是“事后补救”布隆过滤器是“事前拦截”。它的位置在 Redis 之前甚至在业务逻辑之前——请求进来先问一句“这个 ID 有可能存在吗”答案是“肯定不存在”直接返回Redis 和数据库都不用碰。原理不复杂一个很长的 bit 数组加上 k 个哈希函数。写入一个 ID 时用 k 个哈希函数算出 k 个位置把这 k 位都置 1。查询一个 ID 时同样是这 k 个位置只要有任意一位是 0就说明这个 ID 一定没写过如果全是 1说明“可能写过”。这个“可能”就是布隆过滤器的代价——它说存在未必真的存在它说不存在一定不存在。这个特性刚好适配穿透场景我们宁可放过一小部分不存在的请求误判率也绝对不能把真实存在的数据挡在外面。容量和哈希个数的计算公式是固定的m -n * ln(p) / (ln2)^2 // 需要的 bit 数 k (m / n) * ln2 // 需要的哈希函数个数假设你的商品表有 1 亿条数据允许的误判率 p 0.01m -1亿 × ln(0.01) / (ln2)^2 ≈ 1亿 × 4.605 / 0.4805 ≈9.59 亿 bit ≈ 114 MBk (9.59亿 / 1亿) × 0.693 ≈6.6向上取整为 7也就是说114 MB 的内存就能为 1 亿条数据提供 1% 误判率的过滤能力对比动辄几十 G 的 Redis 数据量这个成本几乎可以忽略。这个数字我第一次算出来的时候也挺惊讶的这也是布隆过滤器在大厂内部被普遍使用的原因。落地方式有两种用 Redis 的 Bloom 模块BF.RESERVE、BF.ADD、BF.EXISTS好处是多个应用实例共享一份数据或者用 Guava 的BloomFilter放在每个应用实例的本地内存里好处是零网络开销坏处是每个实例都要单独预热且扩容实例时需要重新加载。# 提前建好误判率 0.01初始容量 1 亿 BF.RESERVE product:filter 0.01 100000000 # 写入与查询 BF.ADD product:filter 10086 BF.EXISTS product:filter 100862.4 布隆过滤器必须配合的三个细节预热过滤器是空的第一次请求必然误判。所以应用启动时必须从数据库加载全量 ID 灌进去。1 亿条数据全量 dump 一遍不现实实际做法是按时间倒序加载最近 N 天的活跃数据或者直接用BF.LOADCHUNK从离线任务生成的快照恢复。重建布隆过滤器删不掉元素商品下架、订单作废之后这些 ID 依然会被判定为“可能存在”。这不是致命问题只是误判率升高但长期运行后误判率会漂移。解决办法是定时重建——比如每天凌晨用一个后台任务生成新的过滤器写入一个新的 Key等新版本就绪后把业务代码里的 Key 名切过去。切换瞬间会有极短的窗口期所以建议用双 Buffer 的方式两个过滤器同时保留一段时间。兜底布隆过滤器永远存在误判所以后端必须有空值缓存作为第二道防线。两道防线各司其职——布隆挡住 99% 的无效请求空值缓存兜住漏网的那 1%。只上其中一种都不是完整方案。2.5 参数校验这一层成本最低收益最高我见过太多项目把参数校验当成“前端的事”结果后端接口在裸奔。其实在 Controller 或者 Service 的第一行加几个判断就能干掉大部分脚本流量if (id null || id 0 || id 99999999999L) { return null; }这一行的成本是纳秒级的挡掉的可能是每秒几万次的无效查询。我做过一个小实验在某个被爬的接口上只加了一个范围校验Redis QPS 从 8 万降到 1.2 万数据库 QPS 从 6000 降到 200。没有任何架构改动就是一行 if。3. 缓存击穿单个热点 Key 失效引发的连锁反应3.1 击穿的核心是“并发”两个字穿透是“不存在的 Key”击穿是“存在的 Key 在过期的那一瞬间被大量并发访问”。这里有两个必要条件同时成立才会出事Key 是热点、失效与高并发撞在同一个时刻。如果 Key 是冷的过期就过期了最多几个请求回源一下数据库毫无压力。如果 Key 一直很热但从不过期也不会有问题。偏偏热点数据通常访问量最大、我们又最希望它常驻于是“给热点设过期时间”这个习惯性动作就成了击穿的温床。我处理过的典型场景是首页的推荐榜单、秒杀商品的库存信息、大 V 的主页数据。这些 Key 的 QPS 可能是几万一旦失效几万个请求在几十毫秒内同时压向数据库一条简单的select就能把主库 CPU 拉满。3.2 逻辑过期让热点 Key 永不物理失效思路是把“过期”这件事从 Redis 手里拿回来自己控制。存进去的 value 不是一个裸对象而是一个包装结构{ data: { productId: 10086, stock: 3 }, expireTime: 1735689600000 }写入 Redis 的时候不设置物理 TTL这个 Key 永远不会因为过期而消失。读取的时候先拿到这个结构然后判断expireTime是否早于当前时间没过期直接返回data已过期立刻返回data旧值同时开一个异步线程去重建返回旧值这一步是关键。用户拿到的可能是几十秒前的数据但接口是快的、数据库是安全的。这适合那些能容忍短暂不一致的场景比如榜单、推荐、配置类数据。实现上有两个细节要注意。第一异步重建必须加锁或者加去重标记否则第一个请求触发了重建紧接着一万个请求也会各自触发一次重建等于把击穿推迟了 50 毫秒。常见做法是在判断到逻辑过期后先抢一个SET NX的短锁抢到的才去重建。第二重建线程的异常一定要吞掉并打日志不能让它影响主流程——毕竟旧值已经返回了重建失败最多是数据继续旧一会儿。3.3 互斥锁重建稳但要把三个参数调对互斥锁的思路更直接发现缓存没了就去抢锁抢到的那个线程查库、回写缓存没抢到的线程等一下重试直接读缓存。同一时刻只有一个线程能碰到数据库。锁本身用 Redis 的SET key value NX PX就能实现不需要额外引入组件String lockKey lock:product: id; String lockValue UUID.randomUUID().toString(); boolean locked redis.set(lockKey, lockValue, NX, PX, 3000); if (locked) { try { // 双重检查防止前面的线程已经回写完成 String again redis.get(cacheKey); if (again ! null) { return JSON.parseObject(again, Product.class); } Product product productMapper.selectById(id); redis.set(cacheKey, JSON.toJSONString(product), 1800, TimeUnit.SECONDS); return product; } finally { // 必须用 Lua 保证“判断值 删除”是原子操作 String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redis.eval(lua, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); } } else { // 没抢到锁的线程短暂休眠后重试或者直接返回兜底数据 Thread.sleep(50); return getProduct(id); }三个参数必须调好锁的过期时间固定 3 秒太粗暴。更合理的做法是预估查库耗时取下限的 2 到 3 倍。如果查库 P99 是 200ms锁设 1 秒就够了。设太长一旦持锁线程崩溃其他线程要白等很久设太短业务还没执行完锁就释放锁形同虚设。lockValue 必须是唯一标识释放锁时要校验这个值否则可能把别人的锁删掉。用 UUID 是最省事的做法。重试策略要设上限。我一般最多重试 2 次每次间隔 50ms超过就直接返回兜底的默认值比如空列表或者提示“稍后再试”。无上限重试会把线程池占满把小问题变成大问题。3.4 互斥锁和逻辑过期该怎么选这两个方案经常被拿来对比其实它们解决的是不同诉求对比项互斥锁重建逻辑过期一致性强返回的一定是最新数据弱可能返回过期数据响应时间有锁等待P99 会抖恒定快永远不阻塞实现复杂度中要处理锁超时和重试中要写异步重建和去重数据库压力单次查询压力最小单次查询压力最小适用场景库存、价格、账户余额等敏感数据榜单、推荐、配置、详情页主要风险持锁线程崩溃导致长时间阻塞数据陈旧用户可能看到旧值我的习惯是涉及钱和库存的用互斥锁其他一律用逻辑过期。因为前者容忍不了脏数据后者容忍不了慢响应。3.5 热点 Key 怎么提前发现不预热就上战场等于赌运气。热点 Key 的发现手段有这么几层业务侧埋点在组装缓存 Key 的地方统计计数每 10 秒上报一次超过阈值的自动加入热点名单。这是最可控的方式。redis-cli --hotkeys前提是maxmemory-policy设为allkeys-lfu或volatile-lfu它会扫描出访问频率最高的 Key。缺点是会阻塞生产环境要谨慎使用。代理层抽样如果你们用的是带代理的 Redis 集群代理层通常天然有全量命令统计按 Key 聚合一下就是热点榜。人工梳理大促前把首页、秒杀、榜单这几类接口的 Key 列出来提前预热并做特殊标注。这是最土但最有效的方法。预热的做法很简单写一个定时任务在大促开始前 30 分钟把这些 Key 主动查一遍灌进缓存并且给它们设置明显更长的过期时间或者干脆用逻辑过期。3.6 一个容易被忽略的点本地锁其实也能用如果热点 Key 的访问分布相对均匀每个应用实例承担的并发量并不夸张那么用 JVM 级别的锁比如ConcurrentHashMap加ReentrantLock反而更划算——没有网络往返也避免了 Redis 故障时锁一起失效的悖论。Redis 挂了分布式锁也没了这时候本地锁至少还能保证单机内的并发只有一个线程回源。我现在的做法通常是“本地锁 分布式锁”双层本地锁确保单实例内不重复回源分布式锁确保全局只有一次查询。4. 缓存雪崩大面积同时失效与实例故障4.1 雪崩的两种形态必须分开处理第一种形态是大量 Key 在同一时间段内集体失效。常见的成因是定时任务在凌晨统一刷缓存、或者所有缓存都用了同一个固定 TTL。这种情况的特征是Redis 本身是健康的命中率断崖下跌数据库压力陡增。第二种形态是Redis 集群本身不可用比如主节点宕机、网络分区、内存被打满触发大量淘汰。这时候命中率直接归零所有请求全部涌向数据库破坏力比第一种大得多。这两种形态的处理手段不一样第一种靠 TTL 打散和多级缓存第二种靠高可用架构和限流降级。很多文章把它们混在一起讲导致方案看起来很全但落地时抓不住重点。4.2 过期时间打散不是加个随机数就完事最基础的做法是给 TT L 加随机扰动expire base random(0, base * 0.1)比如基础时间 30 分钟随机范围 0 到 180 秒。这样即使一批数据是同一时间写入的失效时间也会被摊开在 3 分钟区间里峰值压力被削平。但只做这一步是不够的。真正的关键在于避免批量操作。我见过最典型的反模式是每天凌晨 2 点跑一个任务把全部 20 万个商品缓存一次性刷新。这个任务本身就会让 Redis 在短时间内写入量大增而且刷新后所有 Key 的失效时间又趋同——哪怕加了随机数也只是把雪崩从 2 点推迟到了 2 点半到 2 点 33 分之间。改进的做法是改成滚动预热把刷新任务拆成小批次比如每分钟刷 5000 个配合上游的流量分布曲线在低峰期慢速推进。同时对特别重要的数据不设物理过期改用逻辑过期从根本上杜绝“同一时刻集体失效”。还有一个容易忽略的细节不同业务的数据用不同的基准 TTL。配置类数据可以设 1 小时商品详情设 30 分钟用户会话类设 15 分钟。基准时间拉开差距天然就形成了错峰。4.3 多级缓存把 Redis 的压力分给本地内存当 Redis 出问题的时候你才会意识到本地内存的价值。Caffeine 是目前 Java 生态里最成熟的本地缓存方案配置起来也就几行CacheString, Object localCache Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(60, TimeUnit.SECONDS) .refreshAfterWrite(30, TimeUnit.SECONDS) .recordStats() .build();读逻辑就是“先查本地本地没有再查 RedisRedis 没有再查库”。这样在 Redis 不可用时本地缓存还能挡住绝大多数读请求。60 秒的过期时间意味着数据最多旧一分钟对大部分读多写少的场景完全可以接受。代价是一致性问题。用户在 A 实例上更新了数据B 实例的本地缓存可能还是旧值。解决办法有三种按复杂度从低到高把本地 TTL 设短比如 30 秒用时间换一致性。简单但一致性窗口是固定的。用 Redis 的 Pub/Sub 广播失效消息所有实例订阅同一个频道收到消息就清掉对应的本地 Key。延迟通常在一毫秒以内适合对一致性要求稍高的场景。接入配置中心或者消息队列做变更通知可靠性更高但引入了额外依赖。我在实际项目里用的是方案二注意广播消息本身可能丢失所以本地 TTL 依然保留作为兜底。4.4 高可用主从、哨兵、集群怎么选针对 Redis 实例故障这一类雪崩架构层面的选择直接决定了恢复时间方案故障恢复运维复杂度适用规模主要风险单机无自动恢复最低开发测试、极小流量无任何容错主从复制手动切换分钟级低读多写少的中小业务切换期间写请求全失败哨兵模式自动切换秒级到十几秒中一般生产环境切换期间有短暂写失败客户端需支持重连集群模式分片内自动切换高大流量、大数据量多 Key 命令受限运维成本高选型的判断标准其实很简单数据量决定要不要分片业务对写失败时长的容忍度决定要不要自动切换。数据量在几十 G 以内、能接受十几秒写失败哨兵模式就够了。数据量超过单机内存上限或者对可用性要求极高才上集群。不管选哪种客户端侧都要做两件事一是配置合理重试切换期间失败的命令在几百毫秒后自动重试一次二是准备好降级逻辑重试仍然失败时不能抛异常给用户要返回兜底数据。4.5 限流、熔断、降级最后一道闸门缓存全崩了数据库就是最后的防线而数据库的承载能力是有限的。这时候必须有一层机制确保进入数据库的流量不超过它的极限。我用过的手段有三个层次限流放在最外层。对单接口做 QPS 限制超过阈值的请求直接返回“系统繁忙”。阈值怎么定取数据库能承受的最大 QPS 的 60% 到 70%留出余量。Sentinel 和 Guava 的RateLimiter都能做我倾向于放在网关层因为那里能做统一的统计和拦截。熔断针对的是下游依赖。当数据库的慢查询比例或者错误率超过阈值熔断器打开后续请求在短时间内直接走降级逻辑不再尝试访问数据库给数据库留出恢复时间。降级要提前准备好返回什么。空数据是最差的降级方式用户看到空白页会以为系统挂了。更好的做法是返回一份缓存快照比如最近一次成功查询的结果、推荐位的默认内容、或者一个状态码 200 但带“数据加载中”标识的响应。降级内容的质量直接决定了用户感知到的故障严重程度。4.6 我线上实际使用的连接池与超时参数缓存雪崩时有一类问题特别隐蔽Redis 本身恢复了但应用侧因为连接池耗尽或者超时配置不合理依然不可用。下面是我在 Lettuce 上的实际配置思路参数取值理由connectTimeout2000ms建连超过 2 秒基本说明网络有问题早失败早降级soTimeout2000ms读超时不能过长否则线程被挂住maxTotal业务线程数 × 1.5缓存操作是短平快的 IO不需要按 1:1 配maxWaitMillis500ms拿不到连接就快速失败不要堆积testOnBorrowfalse每次借连接都 ping 一次会显著增加延迟用心跳保活代替Lettuce 和 Jedis 的取舍Lettuce 基于 Netty天然支持异步和连接共享适合高并发Jedis 更简单直观老项目多用它。我现在的项目基本都用 Lettuce因为它的自动重连和命令队列在故障恢复时表现更好——Redis 重连成功后队列里积压的命令会继续发送而不是直接抛异常。5. 一次线上缓冲击穿的完整排查链路5.1 告警响起后的前五分钟该做什么先说错误示范。那天晚上值班同学的第一反应是打开 Redis 的MONITOR命令想看看在发生什么结果MONITOR本身在几万 QPS 下会显著加重 Redis 负担还刷出了满屏日志五分钟后才关掉。这五分钟是白扔的。正确的顺序是先看全局指标再看局部细节。具体是这几个数字接口 QPS 有没有突增判断是不是流量本身涨了缓存命中率变化曲线判断缓存层是否正常数据库 QPS 与活跃连接数判断压力传导到哪一层Redis 的INFO stats里的命中与未命中计数、INFO clients的连接数这四个数字拿到手方向基本就有了。5.2 从命中率曲线的形状判断类型那天的情况是接口 QPS 平稳没什么异常但缓存命中率从 99.2% 掉到 71%而且是单个接口的命中率掉下来其他接口正常数据库里出现大量完全相同的 SQL来自同一个商品 ID。这三个证据拼起来指向非常明确缓存击穿。因为如果是穿透QPS 通常会明显上升大量无效请求而且命中率不会掉那么多不存在的 Key 本来就不在统计范围里如果是雪崩会看到所有接口的命中率一起掉。判断出类型之后方向就清楚了不需要加空值缓存不需要布隆过滤器要解决的是“热点 Key 失效瞬间的并发回源”。5.3 用命令把证据坐实定性之后还要定量。三个命令帮我确认了根因# 1. 看慢查询确认是哪些命令慢 SLOWLOG GET 20 # 2. 看命令级别的统计确认哪些命令的调用量异常 INFO commandstats # 3. 找到实际的 Key 前缀生产环境用抽样别全量扫 redis-cli --scan --pattern product:detail:* --count 100 | head -20INFO commandstats的输出里cmdstat_get的调用次数在故障期间涨了三倍多但cmdstat_set基本没变——这说明大量请求在查同一个不存在的缓存项符合击穿的特征缓存被清空后一直查不到直到重建完成。如果是穿透你会看到get和set都在涨因为有空值回写。日志层面还有一个关键证据数据库的慢查询日志里同一条select * from product where id 10086在一秒内出现了上千次。这条 SQL 本身执行只要 3 毫秒但它被并发执行上千次就是灾难。5.4 临时止血的优先级排序确认是击穿之后据我实测有效的止血顺序是这样限流先把这个接口的 QPS 限制在数据库能承受的范围内。这是最快见效的动作一分钟内就能让数据库喘口气。手动预热直接手动执行一次查询把热点数据写回缓存并把 TTL 设长一点。这一步能立刻恢复大部分流量。本地缓存兜底如果有本地缓存层把热点数据塞进去让本机能挡掉一部分请求。扩容数据库这是最后手段成本高、生效慢而且如果前面的问题没解决扩容只是把崩溃时间往后推。那天我们做完前三步接口 RT 在四分钟内回到了正常水平。第四步没做因为没必要。5.5 复盘哪些动作是多余的事后复盘时我们列了一份“做了但没用”的清单比成功动作更有价值MONITOR命令在高 QPS 下有害无益应该从应急手册里删掉换成SLOWLOG和INFO。加空值缓存判断成穿透之后的动作实际上这次是击穿加了也没用。重启应用实例有人提议重启但重启只会让本地缓存全部清空反而加重 Redis 和数据库的压力。紧急扩容 RedisRedis 本身不是瓶颈扩容解决不了问题。真正有效的长期方案是两条把首页那几个高频 Key 改成逻辑过期永不做物理失效在这几个接口上加本地缓存作为单机第一层防线。改完之后我们做了两次压测同样的失效场景下数据库 QPS 峰值从 6000 降到了 300 以内。6. 面试里怎么把这三个问题答出深度6.1 三者区别的回答框架大多数人的回答是背定义这只能拿到及格分。我建议用“数据是否存在 并发特征 治理手段”三段式来答先说穿透重点是数据在数据库里也不存在所以缓存天然无效方案是前置拦截参数校验、布隆过滤器加后置兜底空值缓存。再说击穿重点是单个热点 Key 在失效瞬间遇到高并发方案是互斥锁重建和逻辑过期。最后说雪崩重点是大面积失效或者实例故障方案是 TTL 打散、多级缓存、高可用架构加限流降级。三段说完再补一句“实际线上经常是混合的所以第一步是判断类型”这一句会让面试官觉得你有真实经验。6.2 被追问布隆过滤器时的加分点面试官几乎一定会追问“布隆过滤器能不能删除元素”标准答案是“不能因为一个 bit 位可能被多个元素共享”。但这个答案只是及格。加分的地方在于说出后续如果业务真的需要删除可以考虑计数布隆过滤器每个 bit 位用一个计数器代替代价是内存涨几倍或者用布谷鸟过滤器它支持删除且空间效率更高。再进一步可以提一句“实际工程里更常见的做法是定时重建用双 Buffer 切换来规避删除需求”这一句基本上就能把这道题聊透了。6.3 主动提到的两个细节如果面试节奏允许我通常还会主动补两个点。一是本地锁的必要性。很多人一讲缓存击穿就说分布式锁但实际上 Redis 故障时分布式锁也会失效本地锁反而更可靠。双层锁的组合是一个能体现思考深度的回答。二是降级内容的设计。大部分方案讲到“限流降级”就停了但降级到底返回什么才是真正影响用户体验的地方。返回空列表和返回上一次的快照用户的感受差别巨大。这也是我在真实故障里学到的一课。7. 我个人在缓存治理上的几点体会做缓存治理这些年我最大的感受是大多数缓存事故不是技术选型的问题而是对“失效”这件事缺乏敬畏。我们习惯性地给所有 Key 加上 TTL觉得这是最佳实践但从来没算过这些 TTL 会落在什么时刻、失效时会有多少请求同时回源。有几个习惯我现在保持得很好你可以参考新接口上线前必须回答三个问题这个 Key 的 QPS 峰值是多少失效瞬间会有多少并发回源回源的 SQL 在数据库上执行一次要多久这三个数字乘起来就是潜在的最大压力。如果乘积超过了数据库的能力就必须做特殊处理。监控里一定有一条“缓存命中率”的告警线而且是按接口维度拆分的。整体命中率跌 1% 可能毫无感觉但单个接口跌 20% 就是明确的信号。这条线帮我提前发现过好几次问题。大促前一定做失效演练手动把最热的几个 Key 删掉观察系统的表现。这个动作成本极低但能暴露出很多平时看不出来的问题。我第一次做这个演练的时候删掉一个 Key 就让数据库 CPU 涨到了 80%那次之后就老老实实把逻辑过期改上了。别迷信任何一种方案。布隆过滤器挡不住随机 UUID空值缓存挡不住海量新 Key逻辑过期容忍不了脏数据互斥锁扛不住锁竞争。真正稳的系统是这几种方案按场景组合出来的——热点数据用逻辑过期敏感数据用互斥锁ID 类查询用布隆过滤器加参数校验再加一层本地缓存扛住 Redis 抖动最后用限流降级守住数据库。最后再分享一个小技巧如果你不确定某个接口该用哪种方案就去看它的数据量级和访问分布。数据总量小、QPS 高、单 Key 特别热——是击穿场景访问 ID 空间大、命中率低——是穿透场景数据量大、TTL 集中、依赖 Redis 实例——是雪崩场景。分清楚这三件事剩下的都是参数调优的活了。