
如果你维护过一条上了规模的读多写少链路一定对这样一种告警不陌生凌晨 0 点刚过数据库 CPU 瞬间拉满慢查询刷屏Redis 的命中率曲线却出现一个诡异的深坑。排查到后来很多人会把结论放在一句“缓存不命中都把请求打到数据库了”上。这句话没错但太笼统了——因为同样的“缓存不命中”表象背后藏着四种成因完全不同的问题缓存穿透、缓存击穿、缓存雪崩以及一个经常被漏掉的缓存一致性。它们的共同点是会让数据库承受本不该承受的流量但从触发条件到解决手段完全是四条不同的路。这篇文章我会按照我在生产环境排查和改造的思路把这四个问题从头到尾拆开来讲并且给出可直接落地的代码、参数和排障清单。无论你是正在准备面试还是线上刚好被这类故障折磨应该都能在这里找到接下来要动的那个开关。1. 一次线上故障的复盘三个“缓存兄弟”是怎么把数据库打垮的1.1 一个同时踩中三种机制的故障现场我先描述一个非常典型的场景。电商公司要上零点活动运营把商品列表、活动页、首页聚合数据的缓存失效时间都设成了当晚 00:00。这里就埋下了第一个雷大量 key 使用相同的绝对过期时间等于给数据库准备了一场“定时冲击”。0 点一到Redis 里成百上千的 key 同时过期流量一下子全部转向数据库这是雪崩。紧接着用户疯狂刷新页面某个超级热销商品的 key 也在同一批过期名单里。几万个并发请求同时发现缓存 miss同时去数据库查这一条数据这就变成了击穿。如果这批请求里还混着一些已经下架、数据库里永远查不到的商品 ID那么这些请求不管缓存有没有都必然 miss每次都会真实打到数据库底层这就成了穿透。就这么一个 0 点的场景穿透、击穿、雪崩全部出现了。你如果只是笼统地跟同事说“缓存不命中”你根本不知道该先保哪个环节是先挡掉无效查询还是先修热 key还是把过期时间打散往往等你想明白数据库已经扛不住了。1.2 用一张表把四件事彻底分开我习惯用一张对比表来给团队讲这四个问题的边界。这张表看起来简单但真的能解决很多“看似都是缓存问题”的争论问题本质触发条件影响范围典型比喻缓存穿透查询一个缓存和数据库都不存在的 key任意时间恶意或非法请求每个请求都真实打库持续放血门是开着的但屋里根本没东西缓存击穿单个热点 key 过期瞬间的并发回源某个 key 恰好过期单点出现流量尖峰一个门突然坏了所有人涌向前门缓存雪崩大量 key 同时过期或 Redis 不可用大批 key 同 TTL、节点故障全局流量倾斜到数据库所有门同时打开缓存一致性数据库已更新缓存还是旧值任何写操作之后读请求拿到脏数据门牌号换了贴纸没撕几个容易混淆的边界我单独强调一下穿透和击穿的区别看 key 是否存在。key 根本不存在导致的 miss 是穿透key 存在但缓存过期导致的 miss 是击穿。击穿和雪崩的区别看失效 key 的数量。一个 key 过期是击穿一大批 key 同时过期是雪崩。一致性问题和前面三个的关系前面三个是读路径上的“流量放大”一致性是写路径上的“数据错乱”。面试虽然经常把四者放在一起问但解决思路是完全分开的。把这张表刻在脑子里后面的治理方案才有落脚点。2. 缓存穿透查询根本不存在的 key为什么让数据库“白干加倍”2.1 穿透的流量模型正常缓存的流量模型是第一次 miss 回源数据库之后所有请求都命中缓存。穿透的特殊性在于每次请求都会 miss因为你查询的对象在数据库里压根不存在。对数据库来说这些请求不仅一条都不会少而且大部分是“白干”——查出一个空结果没有产生任何业务价值却消耗了 CPU、内存和数据库连接。更麻烦的是穿透经常被攻击者利用。举个真实场景商户系统的订单号是自增数字。如果我不做任何校验攻击者把订单 ID 从 1 遍历到 100 万每一笔查询都能绕过 Redis 直接打到数据库。高速遍历加上慢查询数据库几分钟就能被打出问题。这也是为什么“最新网络热词redis 缓存穿透”总在面试和事故复盘里出现——它看起来一点都不高级但杀伤力极强。2.2 第一道防线接口入口的参数校验成本最低的一道防线通常不在缓存层而在接口层。它能拦截的流量非常可观对 ID 做合法性校验。负数、0、超长、格式不合法直接拒绝。对高频非法参数做频控。比如同一个 IP 在一秒内请求超过阈值直接拒绝同一账号反复查询不存在的订单触发风控。对不需要历史数据的查询设置合理的超时和结果集上限。这一层的价值是“拦截明显不怀好意的流量”。它不能根治穿透但能大幅减少无效请求进入下一层。很多团队跳过这一步直接上布隆过滤器其实是把成本放错了位置。2.3 第二道防线缓存空值如果数据库查询结果本来就是 null我们仍然把这个 null 缓存下来。这样后续相同 key 的请求就能命中缓存不再打数据库。伪代码长这样public Product getProduct(String id) { String key product: id; Object value redis.get(key); if (value ! null) { return value NULL_PLACEHOLDER ? null : (Product) value; } Product p productMapper.selectById(id); if (p null) { // 空值也缓存但 TTL 必须短 redis.set(key, NULL_PLACEHOLDER, 60, TimeUnit.SECONDS); return null; } redis.set(key, p, 3600, TimeUnit.SECONDS); return p; }几个容易忽略的细节空值占位符要和正常值区分开不要直接存 null否则缓存框架可能把它当成“无缓存”处理。空值缓存 TTL 要短建议 30 到 60 秒。否则数据库新插入一条数据后用户在 TTL 过期之前仍然查不到业务上就是“数据延迟可见”。如果恶意请求用的是随机 key空值缓存会在 Redis 里塞大量无用 key内存照样会涨。所以空值缓存更适合“非法 key 种类有限”的业务遇到无规律且变化极快的 key就要交给布隆过滤器。2.4 第三道防线布隆过滤器布隆过滤器的核心原理是拿一个超大的位数组和多个哈希函数把每个可能存在的 key 映射到若干位并置 1。查询时检查这几个位是否都是 1只要有一个位为 0就说明 key 一定不存在全部为 1只能说“可能存在”。因为不同 key 可能映射到相同的位所以它存在误判但不会漏判。换句话说布隆过滤器告诉你“不存在”是准确的告诉你“存在”则可能有误报。位数组大小 m、元素数量 n、误判率 p、哈希函数个数 k 之间工程上常用这两个公式估算m - (n * ln p) / (ln 2)^2 k (m / n) * ln 2如果预计合法 key 是 100 万个误判率 p 设为 0.01那么 m 约 958 万 bit约 1.2MBk 约 7。也就是说一个 1MB 左右的过滤器就能覆盖百万级 key 集合内存成本非常低。Java 里用 Guava 实现最方便BloomFilterString bloom BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 1000000, 0.01); if (!bloom.mightContain(id)) { return null; // 一定不存在直接返回 } // 可能存在继续走正常查缓存链路用布隆过滤器有一个必须小心的点它维护的是一个相对固定的集合。如果业务频繁新增合法 ID而过滤器没有同步更新那么合法 ID 也会被判为“不存在”这比误判带来的“放行一两个非法请求”严重得多。所以要么在写入数据时同步往过滤器里加 ID要么设计成“每天一个过滤器实例”每天凌晨用全量 ID 重建一次覆盖当天的新增数据。2.5 穿透治理的个人经验我实际遇到过的穿透事故最终不是靠单个方案解决的而是“参数校验 空值缓存 布隆过滤器”的组合。监控上还要加一个信号缓存命中率如果连续几分钟异常下降同时数据库慢查询全是“查无此记录”的 SQL就要立刻怀疑是穿透先把接口层的限流打开再慢慢核对过滤器数据是否同步。千万不要等到数据库被拖垮了再去复盘那种代价太大了。3. 缓存击穿热 key 过期的那一瞬间如何挡住百万并发3.1 单点过期为何能放大成流量尖峰热 key 的特点大家都很清楚某一秒内可能有几万个请求同时访问同一个 key。正常情况下这些请求全部命中 Redis数据库安然无恙。但这个 key 的 TTL 一到所有请求会同时 miss。第一个请求去数据库回源但在数据写回缓存之前一路上遇到的所有请求都在重复查库。如果数据库查询需要 100ms那这 100ms 内的几万并发就全部压在数据库上瞬间形成一个恐怖的尖峰。击穿之所以比普通 miss 更危险是因为热 key 的并发量天然很高。秒杀商品、明星热点内容、排行榜榜单都属于这种场景。一个 key 失效就可能引发连锁反应。这也是为什么很多系统的第一道防线不是“怎么快速重建缓存”而是“怎么让这个 key 不要出现同时回源”。3.2 互斥锁方案把并发回源变成单点回源最常见的方案是互斥锁缓存 miss 后只有拿到锁的线程去查数据库并回填缓存其他线程等待一小段时间然后重新读缓存。给一段 Java 风格的伪代码public String getHotData(String key) { String value redis.get(key); if (value ! null) { return value; } String lockKey lock: key; String requestId UUID.randomUUID().toString(); boolean locked redis.setIfAbsent(lockKey, requestId, 5_000); if (locked) { try { // 拿到锁后要二次确认防止上一个持锁线程已经回填 value redis.get(key); if (value null) { value loadFromDb(key); redis.set(key, value, 600); } return value; } finally { // 释放锁必须用 Lua 保证原子性防止误删其他线程的锁 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redis.eval(script, Collections.singletonList(lockKey), requestId); } } else { Thread.sleep(50); return getHotData(key); // 自旋重读缓存 } }这里有三个要点必须说清楚锁超时不能拍脑袋。它的下限是“数据库查询回源的 P99 耗时”。如果系统里慢查询很多你却把锁超时设成 1 秒数据库查询超过 1 秒后锁提前释放其他线程又会同时回源方案等于失效。我一般给 5 秒配合数据库慢查询治理一起做。自旋等待不能无限。建议最多重试 3 到 5 次超过次数就返回默认值或快速失败不要让调用线程全部挂在等待上。锁的粒度要按 key而不是全局。全局锁会把不同 key 的流量互相阻塞击穿问题变成锁竞争问题反而更糟。3.3 逻辑过期用旧数据顶住窗口期比互斥锁更优雅的进阶方案是“逻辑过期”。它不是让 Redis 在 TTL 到期时把 key 删掉而是把业务过期时间放在 value 里比如 value 结构是{data: xxx, expireAt: 时间戳}。读请求先判断 expireAt未过期正常返回。已过期不删 key也不阻塞当前线程而是立即返回旧数据同时丢一个异步任务去数据库拉新数据并刷新缓存。这样做的好处很直接热 key 的缓存永远不会在“那一瞬间”消失数据库只会有极少数异步重建请求用户也始终能拿到一个可接受的结果。缺点是不能保证实时一致而且异步任务如果挂了需要有重试机制。逻辑过期适合“后端数据变化不频繁、但可用性要求极高”的场景典型如活动榜单、首页聚合数据。3.4 永不过期与本地缓存怎么选“永不过期 异步更新”本质上和逻辑过期是同一思路只是把过期判断完全去掉靠业务事件或定时任务主动刷新。它适合 key 的更新链路可控的场景比如数据变更后通过 MQ 通知缓存服务去刷新。本地缓存则是另一条完全不同的路把热 key 放到 JVM 内存里请求先在本地缓存命中不再访问 Redis。这样即便 Redis 中的 key 失效每个应用节点最多只产生一次回源。如果服务有 20 个实例数据库承受的是 20 个查询而不是几万个。代价是多级缓存之间的一致性变复杂所以只建议用于已经确认的少量热 key并配上主动失效广播。3.5 击穿治理的一个常见误区不要对所有 key 无脑加互斥锁。锁是给热 key 用的普通 key 的并发量远达不到需要加锁的程度给每个 key 加锁反而增加 Redis 的请求量放大延迟。先通过访问日志、热 key 探测工具或者redis-cli --hotkeys找出真正的热 key再针对它们配置互斥锁或逻辑过期策略这才是正确姿势。4. 缓存雪崩大面积 key 同时失效靠随机过期就能救命吗4.1 雪崩的两个诱因要分开看提到雪崩很多人只想到“大量 key 同时过期”。但真实故障里还有另一半Redis 本身不可用。一个是大批 key 的 TTL 撞在同一时刻另一个是整个缓存节点宕机或网络分区。两者都会让流量集体扑向数据库但处理手段完全不同前者偏重“错峰”后者偏重“冗余和降级”。把它们混为一谈方案就一定会有缺口。4.2 过期时间打散的工程细节给 TTL 加随机值是最常见也最容易做错的第一步。实际工程里要注意随机范围不能太小。如果基础 TTL 是 600 秒随机只有 0 到 10 秒那么同一批写入的 key 在实际效果上还是会在一个窄窗口内集体失效意义不大。建议随机范围至少为基础 TTL 的 5% 到 10%。必须对同一批次的 key 全部生效。如果你的建缓存任务只对部分 key 加了随机剩下那一部分还是会在同一时刻过期那么过期流量依然会形成尖峰。对“定时失效”类场景要特殊处理。比如运营活动要求 0 点统一换数据你不能为了让 key 错开就让一部分数据晚 10 分钟生效。这时候随机化就不适用了要靠预热。4.3 双层缓存与预热给数据加一层“缓存的缓存”双层缓存的设计逻辑是一级缓存 ATTL 短比如 5 分钟。二级缓存 BTTL 长比如 2 小时。读取时先看 Amiss 了再看 BB 存在就回填 A。后台任务或写事件负责重建 B。这样即使 A 在整点集体失效B 还在数据库不会直接暴露在流量下。B 的过期时间同样要打散并且 B 的构建尽量放在低峰期完成。这个模式在首页、活动页这类“数据量不大但访问量极高”的场景里非常实用相当于给热数据加了一层冗余。主动预热则是更直接的办法在流量高峰来临前用脚本把接下来可能被集中访问的 key 全部重建一遍使它们的过期时间落在高峰之后。预热配合随机过期能把“0 点定时炸弹”拆掉。甚至可以把热 key 的 TTL 设成一个大值让刷新任务在高峰前一小时分批触发把过期点重新分散。4.4 Redis 节点不可用时的兜底方案如果 Redis 集群真的挂了前面所有“错峰”手段都失效。我建议按下面的优先级做第一优先级数据库限流。在数据库前加网关层或服务内限流把数据库 QPS 压到安全水位。宁可错杀一部分请求也不能让数据库彻底宕机。第二优先级快速降级。对非核心页面直接返回缓存副本或默认数据对核心链路优先保证下单、支付非核心查询可以先拒绝。第三优先级Redis 高可用。主从 哨兵或 Cluster保证单个节点故障能自动切换。注意主从切换期间连接会有短暂中断应用层要做好重连。第四优先级缓存重建异步化。数据库恢复后不要让所有线程同时去回填缓存用 MQ 攒批重建或者用一个后台任务慢慢把缓存填回去。4.5 对“随机过期救命论”的客观评价随机过期确实是最便宜的第一道防线但它只能防“集中失效”防不了“Redis 宕机”。很多团队把 TTL 打散就以为自己处理好了雪崩这是对雪崩理解的偏差。雪崩应对一定是一个组合错峰 预热 冗余 限流降级缺一个都不稳。尤其是 Redis 作为单点引入后它本身就是系统里的另一个单点故障如果不做高可用后面一定会还债。5. 缓存一致性更新数据库后缓存里的脏数据到底该怎么清5.1 先接受一个现实强一致是伪命题最终一致才是常态在数据库 Redis 的架构下两个存储各自提交事务不存在一个分布式事务能让两者真正同时更新。就算用两阶段提交性能和复杂度也得不偿失。所以工程里讨论的“缓存一致性”实际是“最终一致性”允许在更新数据库和清理缓存之间存在一个短暂窗口但窗口要收敛。收敛速度跟业务强相关。优惠券、用户资料可以容忍几秒库存、余额可能只能容忍几十毫秒。方案选择本质上就是在“更短窗口”和“更复杂代价”之间找平衡。5.2 “先更库再删缓存”为什么优于“先删缓存再更库”Cache Aside 模式里最容易争论的就是这个顺序。我分别写一下两种顺序的竞态大家就能看清差距。先删缓存再更新数据库线程 A 删除缓存 key。线程 B 读到缓存 miss去数据库读到旧值并把旧值回填缓存。线程 A 更新数据库数据库变成新值。最终结果缓存里一直是旧值而且不会自愈。除非下次有人主动删除否则脏数据会一直存在。先更新数据库再删除缓存线程 A 更新数据库。线程 A 删除缓存 key。线程 B 在删除前读到了旧缓存。但旧缓存只是一次性的A 删除成功后下一次请求就会 miss 并读到新值。真正的问题只剩一个第 2 步删除如果失败缓存会持续是旧值。但这属于可靠性问题可以用重试解决而不是顺序逻辑上的硬伤。所以工程上通常选择“先更库再删缓存”这是最基础也最正确的姿势。5.3 延时双删的正确打开方式“先更库再删缓存”最大的风险是删除失败所以很多人喜欢用“延时双删”删除缓存。更新数据库。休眠一个时间窗口。再次删除缓存。这个流程针对的场景是“先删缓存后更库”时并发读把旧值回填回去的问题。只要第二次删除发生在旧值回填之后就能把脏缓存清掉。休眠时间怎么定它至少需要大于“一次完整读回源”的耗时也就是从缓存 miss 到数据库查询完成并写完缓存的时间。实操中我会取数据库查询的 P95 或 P99再加一点余量。数据库查询平均 50ms、P99 是 200ms那我就用 500ms 到 1s。注意不是越久越好时间太长会放大不一致窗口用户在更长的时间里读到旧值。还要注意主从延迟。如果 Redis 是主从结构第一次删除删的是主库但从库可能还没同步如果第二次删除只删主库从库上的旧 key 在很短时间内还是可能被读到的。稳妥做法是删除操作在所有节点执行或者等从库同步后再重试。双删同样卡在“第二次删除失败”上。因此真正可靠的方案是在双删后面接一层异步重试把待删除的 key 放进 MQ消费者拿到后反复尝试删除直到成功。5.4 更省心的最终一致方案监听 Binlog 或使用消息队列如果不想在业务代码里每个写操作都手动删缓存可以考虑两种更系统化的方案MQ 异步删除业务写完数据库后发一个“DeleteCache(key)”事件消费端负责删缓存。好处是业务代码基本不依赖 Redis 的即时状态删除失败还能重试。Binlog 订阅用 Canal 之类的组件监听 MySQL binlog解析出变化的主键自动生成删除缓存消息。业务代码完全不用动缓存治理被收敛成一个独立组件。代价是需要额外部署和运维而且只适用于 MySQL 这类支持 binlog 的存储。我推荐一个成熟组合数据库写入 - binlog/Canal 捕获 - MQ - 缓存删除服务 - Redis 删除失败 - 进入重试表 - 定时任务继续重试这个方案的窗口取决于 binlog 解析和 MQ 延迟通常可以控制在秒级以内而且能彻底避免“删除失败导致缓存永远脏”的问题。5.5 版本号方案给缓存加一道“有效期校验”如果业务对一致性要求很苛刻并且有余力做改造可以在缓存里同时保存数据版本号或更新时间戳。读请求拿到缓存后拿版本号和数据库中的当前版本对比不一致就主动回源刷新。这个方案能防御极端竞态但代价是引入了额外的版本存储链路复杂度上了一个台阶。我没有把它作为默认方案因为大多数业务用不到这个级别。先把“更库 → 删缓存 → 重试”做扎实收益已经很高。等这套体系稳定了再评估是否需要版本号。6. 故障排查与架构选型四类问题混在一起时怎么快速定位和治理6.1 通过监控指标区分四种故障线上排查时第一件事不是改代码而是看指标。我的习惯是打开三个面板缓存命中率、数据库 QPS、Redis key 过期相关的统计然后按下表判断现象大致定位下一步动作命中率整体下降DB 查询多为“查无记录”缓存穿透检查是否存在非法 ID空值缓存是否生效命中率下降集中在个别 keyDB QPS 尖峰与该 key 过期时间吻合缓存击穿定位热 key给这个 key 配互斥锁或逻辑过期大批 key TTL 相同或同一时刻失效DB QPS 周期性尖峰缓存雪崩打散 TTL、预热、检查集群高可用和降级数据库已更新但读接口仍返回旧值缓存一致性检查写后删缓存是否执行删除是否失败重试是否生效这个表只是一个起点。更重要的是一旦发现异常要能快速确认“是哪个环节出了问题”而不是在 Redis 和数据库之间反复试探。6.2 线上的应急动作按优先级往下做不要一接到告警就去改缓存代码。我推荐这个顺序保护数据库。在数据库前面加限流或熔断或者在接入层限制总 QPS。宁可错杀一部分请求也不能让数据库被整挂。临时兜底。把故障 key 的缓存重建改为异步如果能手工确定 key 集合直接写一个预热脚本先塞进去一批兜底值。恢复后复盘。等流量平缓再分析根因决定是加布隆过滤器、加锁还是调整 TTL。这个顺序能让你在 5 分钟内止血不必急着部署代码。我在实际事故里见过太多人一上来就改代码结果代码还没上线数据库先挂了。6.3 一份可以抄作业的缓存治理清单按模块整理方便对照落地穿透治理接口入参校验空值缓存 TTL 30 到 60 秒布隆过滤器误判率 p0.01监控命中率异常。击穿治理热 key 定位互斥锁超时按 DB 慢查询 P99 设置逻辑过期异步刷新。雪崩治理TTL 随机化分层缓存高峰前预热Redis 高可用DB 限流和熔断降级。一致性治理先更库再删缓存删除失败走 MQ 重试重试表兜底数据敏感时用 binlog 订阅。监控项命中率、DB QPS、慢查询、Redis 连接数、大 key、过期 key 分布、热 key 出现频次。6.4 我的选型习惯和踩坑总结最后说点个人体会。穿透的治理很多人一上来就选布隆过滤器但布隆过滤器最大的坑是“集合更新”。一旦新增大量合法 ID你必须同步更新过滤器否则误杀合法数据比穿透更可怕。如果 key 集合变动快我宁愿先用空值缓存和参数校验顶着再慢慢设计过滤器重建方案。击穿方面互斥锁不是银弹。锁超时设得太短会失效设得太长又会让很多线程傻等。逻辑过期加异步刷新在热 key 场景更省心但你要能接受旧数据存在几秒钟。雪崩方面随机过期只是最基础的一道防线真正的底牌是 DB 限流和 Redis 高可用。一个连限流都没有的系统配置做得再花哨Redis 宕机时照样雪崩。一致性方面我吃过最痛的亏是“延时双删”的第二次删除失败。后来我把所有删除缓存操作都接了 MQ 重试再也没有出现“数据库更新了缓存永远不变”的线上事故。如果一定要我浓缩成一句话先保住数据库再研究一致性所有缓存治理方案本质上都是在“少让数据库挨打”和“少让用户看到旧数据”之间找平衡。把上面清单里的基础方案逐项落地那些深夜告警就不会再让你手忙脚乱了。