
凌晨两点半被电话叫醒这种事做过后端的人大概都经历过。运维在群里甩了一张监控图订单查询接口的 TP99 从 80ms 直接窜到 3.2s失败率破了两位数而 Redis 的 QPS 曲线却在往下掉。当时脑子里第一个念头不是哪里报错了而是又是哪个缓存环节出问题了。Redis 缓存穿透、缓存击穿、缓存雪崩这三个词几乎每一个做高并发系统的团队都会在某个深夜跟它们正面碰上。它们看起来都是缓存没扛住、请求全打到数据库但成因、表现和处置手法完全不同混着治只会越治越乱。这篇内容我会把这三类问题的触发条件、底层原因、能落地的解决方案以及我在生产环境里踩过的坑全部摊开讲清楚。适合已经用过 Redis 但还没系统梳理过缓存治理的同学也适合正在准备面试、想把这块知识讲明白的朋友。文中所有代码和参数都以常见的 Java Spring Boot 或原生命令行为例思路换到 Go 或 Python 一样通用。1. 先把三种缓存病分清楚症状、成因、误判很多团队在排查线上问题时第一反应是缓存挂了然后一股脑去加机器、调大内存结果治标不治本。要精准下药第一步就是把这三兄弟拆开看清它们各自在什么条件下发作。1.1 缓存穿透查的是一个数据库里压根不存在的数据缓存穿透的本质是请求的数据既不在缓存里也不在数据库里。正常情况下一个 key 第一次访问没命中缓存会回源查询数据库查到之后写回缓存下次就命中缓存了。但如果这个 key 在数据库里根本不存在那么每次请求都会穿透缓存直奔数据库而且因为查不到结果也没法写回缓存——这就形成了一个永远绕不过去的洞。举一个非常典型的场景某电商平台的商品详情页接口是GET /product/{id}缓存 key 设计成product:detail:{id}。正常情况下用户只会请求真实存在的商品 id但如果有脚本或者爬虫拿着id-1、id999999999这种明显不存在的值疯狂刷接口每刷一次就打一次数据库。假设数据库单表几千万行每次查询都要走索引扫描几千 QPS 就能把数据库连接池占满。穿透最坑的地方在于它在缓存监控上看起来很正常——缓存命中率只是略微下降因为攻击者用的是海量不同的 key缓存命中率的分母被撑大了跌幅不明显。真正暴露问题的是数据库的 QPS 曲线会突然出现一条明显被抬起来的阶梯。判断穿透有一个简单的经验法则如果数据库的 QPS 上涨幅度和缓存命中率下降幅度不成比例且慢查询日志里出现大量返回空结果的全表/索引扫描大概率就是穿透。1.2 缓存击穿一个热点 key 在失效瞬间被并发打到数据库击穿的场景更集中某个被高频访问的 key比如首页 banner、秒杀商品详情刚好到了过期时间被删除而此时恰好有大量并发请求同时涌进来。这些请求发现缓存为空全部同时回源查数据库一瞬间把数据库连接打爆。它和穿透的核心区别是击穿查的是真实存在的数据只是缓存恰好失效了。所以击穿往往是瞬间雪崩式的——一个热点 key 过期几万个并发请求在同一毫秒内扑向数据库。我见过最惨的一次是某秒杀活动的商品库存 key 设置了固定 30 分钟过期上线时没做任何保护。活动开始后第 30 分钟整那个 key 集体失效Redis 里瞬间出现几千个针对同一个 key 的 miss数据库的连接池在半秒内被打满接口全线 500。事后复盘问题不在于过期时间设得短而在于没有任何机制保证同一时刻只有一个请求去重建这个 key。击穿的识别特征很好记数据库 QPS 出现一个极窄但极高的尖峰持续几百毫秒到几秒紧接着缓存里同一个 key 的 miss 计数暴涨。如果你的监控能按 key 维度统计 miss这个尖峰一眼就能看出来。1.3 缓存雪崩大批 key 在同一时间集中失效或者 Redis 整体不可用雪崩是范围最大的一种。它有两种典型触发方式。第一种是时间维度的集中失效。如果大量缓存 key 是在同一时间点批量写入的并且都设了相同的过期时间比如都是 1 小时那么 1 小时之后这批 key 会在极短的时间内集体失效。请求全部回源数据库直接扛不住。这种问题特别容易出现在定时任务批量预热缓存的场景——早上 8 点跑批把 10 万个 key 写进 Redis过期时间统一 2 小时那么 10 点整这批 key 全消失。第二种是节点维度的整体不可用。Redis 单节点挂掉、主从切换失败、集群某个分片宕机都会导致大面积缓存失效甚至完全不可读。这时候所有请求直奔数据库是真正意义上的灾难。它和前面两种最大的差别是它不是一个 key 或一批 key 的问题而是整个缓存层的能力瞬间归零。下面这张表把三者的核心差异拉平对比排查时对着看能省不少时间维度缓存穿透缓存击穿缓存雪崩数据是否存在数据库里不存在存在存在影响范围单个/大量不存在的 key单个热点 key大批 key 或整个缓存节点触发条件恶意或异常请求刷不存在的数据热点 key 过期瞬间高并发大量 key 同时过期或缓存整体宕机数据库表现QPS 持续台阶式上涨极窄的尖峰骤起骤落大范围 QPS 暴涨并持续缓存表现命中率缓降miss 分散单 key miss 集中爆发大范围 miss 或连接报错核心解法布隆过滤器 空值缓存互斥锁 逻辑过期过期时间打散 高可用 多级缓存1.4 实操中容易混淆的几种误判分清理论不难难的是线上真出问题时能快速定位。分享几个我自己踩过的误判。有一次监控报警说数据库 QPS 翻了三倍第一反应是雪崩了赶紧去看 Redis 监控发现 Redis 本身一切正常、内存充足、连接数也没爆——最后查出来是某个新上线的定时任务在批量扫全表跟缓存一点关系没有。教训是数据库 QPS 上涨不一定是缓存问题先把 Redis 那一侧的指标看干净再下结论。还有一次是击穿被误判成穿透。现象是数据库 QPS 尖峰我一开始去查是否存在恶意刷不存在 id 的请求查了半天没有后来才发现是某个热销商品的缓存 key 到点了。区别的关键在于去看那一瞬间 miss 的 key 分布是集中还是分散。集中就是击穿分散且有大量空结果就是穿透。第三种误判是把缓存序列化失败当成雪崩。曾经有段时间线上缓存命中率一直上不去看起来像大面积穿透排查发现是某次发版改了实体类字段老数据的序列化格式跟新代码对不上反序列化直接抛异常被吞掉了导致请求虽然查到了缓存却用不了又去回源。这类问题的特征是命中率统计正常但反序列化异常日志激增排查时别只盯着 QPS 看日志这一侧很重要。2. 缓存穿透的两把刀布隆过滤器与空值缓存穿透的解法思路很明确在请求真正打到数据库之前先想办法判断这个数据到底存不存在。工程上最主流的两条路一条是布隆过滤器做前置拦截另一条是空值缓存做兜底。2.1 布隆过滤器为什么能挡住穿透布隆过滤器的原理说白了就是用一组哈希函数 一个位数组用极小的空间代价回答这个元素可能存在或一定不存在。当一个 key 写入布隆过滤器时会经过 k 个哈希函数得到 k 个位置把这些位置都置为 1。查询时同样算出这 k 个位置只要有一个位置是 0就说明这个元素一定没写过如果全是 1则说明可能存在。它有一个天然特性叫假阳性可能存在实际上不存在的元素被误判为存在因为位数组被其他元素的置位污染了。但它绝对不会出现假阴性也就是说只要布隆过滤器说不存在那这个数据就一定不在库里。这个特性对穿透场景堪称完美把所有真实存在的商品 id 预先灌进布隆过滤器当请求带着一个不存在的 id 过来过滤器直接判定不存在请求就地返回根本不进缓存也不进数据库。代价是布隆过滤器对删除不友好。因为多个元素共享位数组的位置删一个元素不能简单把位置清零否则会影响其他元素。所以它更适合数据一旦写入就基本不变或者允许有轻微延迟的场景比如商品 id 全集、用户 id 全集。如果你的业务数据频繁删除要么定期全量重建要么考虑带计数器的变体。2.2 布隆过滤器的参数该怎么算这一步很多人直接跳过用默认参数上线结果假阳性率高得离谱或者内存炸了。参数计算其实不难核心是三个变量预估元素数量 n、可接受的假阳性率 p、位数组长度 m 和哈希函数个数 k。以 Redis 为例用 RedisBloom 模块或者自己用位图实现。假设我们预估要放 1000 万个商品 id可接受假阳性率 0.1%0.001那 m 和 k 的估算公式是m ≈ -n × ln(p) / (ln2)²k ≈ (m / n) × ln2代入 n10^7p0.001ln(0.001) ≈ -6.9078(ln2)² ≈ 0.4805所以 m ≈ 10^7 × 6.9078 / 0.4805 ≈ 1.437 × 10^8 位也就是约 17.1 MB。k ≈ (1.437×10^8 / 10^7) × 0.693 ≈ 14.37 × 0.693 ≈ 9.96取整为 10 个哈希函数。也就是说1000 万个元素用大约 17MB 内存和 10 个哈希函数就能把假阳性控制在千分之一。这个性价比对大多数业务都很划算。用 RedisBloom 的话命令很简单# 创建一个容量 1000 万、错误率 0.001 的布隆过滤器 BF.RESERVE product:bloom 0.001 10000000 # 添加元素 BF.ADD product:bloom 100001 # 判断是否存在0 一定不存在1 可能存在 BF.EXISTS product:bloom 100001注意BF.RESERVE的容量一旦定下后续元素超过容量会导致过滤器自动扩容或误判率上升具体行为取决于版本。建议预估容量时留出 30% 以上余量。2.3 空值缓存最土但最有效的兜底布隆过滤器解决的是数据不存在这一侧但现实中还有一类情况它管不了布隆过滤器里没预热的、或者数据本身是动态新增的。这时候空值缓存就是最实在的兜底手段。做法很直接查询数据库返回空结果时不要什么都不写而是往缓存里塞一个特殊标记比如空字符串或者一个约定好的空对象同时给一个较短但随机的过期时间。这样下一次同样的请求过来缓存会命中这个空值标记直接返回不再回源。我用 Spring Boot 演示一下常见的写法。假设用 StringRedisTemplate 手动序列化避免 redis 序列化机制带来的对象反序列化问题public Product getProduct(Long id) { String key product:detail: id; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { // 约定 __NULL__ 表示空值 if (__NULL__.equals(cached)) { return null; } return JSON.parseObject(cached, Product.class); } // 缓存未命中回源 Product product productMapper.selectById(id); if (product null) { // 写空值过期时间 60~120 秒随机避免同一批空 key 同时过期 long ttl 60 ThreadLocalRandom.current().nextInt(60); redisTemplate.opsForValue().set(key, __NULL__, ttl, TimeUnit.SECONDS); return null; } long ttl 1800 ThreadLocalRandom.current().nextInt(600); redisTemplate.opsForValue().set(key, JSON.toJSONString(product), ttl, TimeUnit.SECONDS); return product; }这段代码有两点值得强调。第一空值的过期时间一定要短一般几十秒到几分钟就够了。设太长会导致数据被创建后长时间查不到设太短又起不到拦截效果60~120 秒是我常用的区间。第二过期时间必须加随机扰动这是顺手就把雪崩也防了一手。如果所有空值 key 都在同一时刻过期反而变成了一次小规模雪崩。2.4 分层拦截的部署顺序与心得生产上布隆过滤器和空值缓存通常不是二选一而是分层部署请求进入服务后先过布隆过滤器不存在直接拒绝通过了再查缓存缓存 miss 就回源回源为空则写空值缓存。顺序不能反反了会让布隆过滤器的性能优势白白浪费。有一个细节很多人忽略布隆过滤器的预热时机。如果你的商品数据是分库分表的全量预热布隆过滤器可能需要几千万次操作耗时不短。我的做法是在服务启动后的异步线程里做同时对外接口暂时用空值缓存顶着等预热完成再开关切换过去。不要试图在启动流程里同步等布隆过滤器预热完成那会让服务启动时间长得离谱滚动发布时风险很大。提示布隆过滤器数据如果存在 Redis 里要考虑它自己也会占内存并且一旦 Redis 重启数据可能丢失除非开了持久化。丢失后重建期间穿透防护会退化到只剩空值缓存这个降级窗口要在预案里考虑到。3. 缓存击穿的破法互斥锁与逻辑过期怎么选击穿的核心矛盾是同一个热点 key 被多个请求并发重建。解决办法是把重建这件事变成串行的或者干脆让重建过程对读请求透明。工程上两条主流路线互斥锁方案和逻辑过期方案各有适用场景。3.1 互斥锁方案用 SETNX 保证只有一个请求去重建思路很朴素谁能抢到锁谁去重建缓存没抢到的等一会儿再来读缓存。用 Redis 的SETNX或者SET key value NX EX实现分布式锁是常见做法。用原生命令演示正确的加锁方式# 加锁同时设置过期时间避免死锁 SET lock:product:100001 uuid-xxx NX EX 10 # 解锁要用 Lua 保证原子性先判断是不是自己加的锁解锁的 Lua 脚本if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end为什么必须用 Lua因为判断锁是否属于自己和删除锁这两个动作如果分开执行在两步之间锁可能刚好过期并被别的请求拿到这时你删掉的就是别人的锁会导致后续请求全部失控。这属于用分布式锁时的经典陷阱用 redis 分布式锁时一定不能省。Java 里如果用 Redisson可以直接用它的RLock它内部已经处理了续期看门狗机制和可重入但要记得仅在重建缓存这段关键逻辑里持锁别把整个业务流程包在锁里。锁的粒度越细越好一个 key 一把锁别用一把全局大锁。互斥锁方案的缺点是当热点 key 失效瞬间有大量请求进来除了一两个抢到锁的其余请求都在等待重试这段时间接口延迟会明显变高。如果等待逻辑写得不好比如死循环重试还可能拖垮线程池。所以等待那一侧一定要加超时和降级等不到就返回一个兜底数据或者上次的旧值。3.2 逻辑过期方案物理上永不过期逻辑上自己判断逻辑过期是我在秒杀这类高并发场景更偏爱的方案。它的核心是缓存 key 的物理过期时间设得很长甚至不设真正的过期时间作为数据的一部分存进去。每次读缓存时都拿到数据然后判断这个逻辑时间是否过期。如果没过期直接返回如果过期了同样去抢锁重建但没抢到锁的请求不会等待而是直接返回旧数据。数据结构大概长这样public class RedisDataT { private LocalDateTime expireTime; // 逻辑过期时间 private T data; // 真实业务数据 }读取时的伪代码public Product queryWithLogicalExpire(Long id) { String key product:cache: id; RedisDataProduct redisData getFromRedis(key); if (redisData null) { return null; // 或者走其他兜底 } if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { return redisData.getData(); // 未逻辑过期直接返回 } // 逻辑过期尝试获取互斥锁 String lockKey lock: key; boolean locked tryLock(lockKey); if (locked) { // 开新线程去重建当前请求先返回旧数据 CACHE_REBUILD_EXECUTOR.submit(() - rebuildCache(id)); } return redisData.getData(); // 无论是否拿到锁都返回旧数据 }这个方案最大的好处是请求永远不会因为等锁而阻塞响应时间稳定。代价是数据有一小段时间是旧的一致性是最终一致。对商品详情、活动页这类旧一点没关系的场景完全可以接受但对库存这类强一致场景就不合适。3.3 两种方案对比与选型建议对比项互斥锁方案逻辑过期方案数据一致性较强重建后即最新最终一致有旧数据窗口响应延迟未抢到锁的请求需等待稳定无阻塞实现复杂度较低较高需封装过期时间字段内存占用正常略高key 几乎不过期适用场景一致性要求较高的数据极热点、可容忍短暂旧值的数据我的选型经验是默认用互斥锁因为是通用解当接口 QPS 特别高、对延迟极度敏感时用逻辑过期。在同一个系统里两者并不冲突热点 key 用逻辑过期普通 key 用互斥锁完全可以混着用。3.4 热点 key 的探测与本地缓存兜底再往上一步真正扛住击穿的不只是过期策略还有对热点 key 的识别。如果连哪些 key 是热点都不知道就没法针对性地做保护。探测热点 key 的思路有这么几种。一是靠监控统计把 Redis 的 key 访问频率导出来可以用MONITOR命令短时采样但别在生产长时间开性能开销大找出排名前列的 key。二是业务侧埋点对明确的核心接口首页、秒杀直接标记哪些 key 是热点。三是在客户端做轻量统计用本地滑动窗口记录每个 key 的访问次数超过阈值就上报。识别出热点之后本地缓存是成本最低的兜底手段。在应用进程内用 Caffeine 或 Guava Cache 缓存一份热点数据请求先查本地缓存miss 了再查 Redis。这样 Redis 那一层的击穿压力会被本地缓存挡住一大半即便 Redis 里那个 key 失效了本地缓存还在扛。代价是数据更新要通知所有节点清本地缓存一般用 Redis 的发布订阅或者消息队列来做广播。这部分展开就是另一个话题了核心思路记住热点识别 多级缓存即可。4. 缓存雪崩的防线从过期时间打散到高可用架构雪崩是三种里破坏力最大的因为它可能导致整个缓存层失去意义。防它的思路也有两个层次降低它发生的概率以及发生后能撑住。4.1 过期时间为什么必须加随机扰动这是防雪崩性价比最高的一招一行代码能避免一大类事故。核心原理是如果所有 key 的过期时间都是同一个固定值那它们就会在同一秒集体消失只要给每个 key 加上一个随机偏移量它们的过期时间就会被打散到一段时间区间内不再集中。错误示范redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);正确做法// 基础 30 分钟随机浮动 0~5 分钟 long baseSeconds 30 * 60; long randomOffset ThreadLocalRandom.current().nextLong(0, 5 * 60); redisTemplate.opsForValue().set(key, value, baseSeconds randomOffset, TimeUnit.SECONDS);就这么一个简单的随机偏移就能把原本集中在某一秒的失效潮摊平到几分钟的窗口里。虽然在这几分钟内整体缓存命中率还是会有波动但因为不是同时失效数据库的瞬时压力能降低一个数量级。有个进阶技巧对于确实需要同时失效的批量数据可以用双层缓存加缓存预热来替代统一过期。所谓缓存预热就是在缓存过期前用定时任务主动去刷新让新值在旧值失效之前就已经写进去这样请求永远打到缓存里根本不给它失效的机会。这种方案对定时任务和本地缓存的维护成本略高但对核心数据非常值得。4.2 Redis 高可用别让单点成为雪崩的起点如果说随机过期解决的是渐进式雪崩那么Redis 整体不可用导致的雪崩必须靠高可用架构来兜底。这部分其实是 Redis 部署的基础功展开说几个关键点。主从模式主从复制能做读写分离和数据备份主挂了可以手动切从但切换需要人工介入恢复时间偏长不适合对可用性要求高的生产环境。哨兵模式在主从基础上增加了自动故障转移哨兵集群会监控主节点主挂了自动选举一个从节点升级为主业务方通过哨兵拿到的地址是无感知的。集群模式则是把数据分片到多个主节点上既解决了单机内存上限问题也提供了分片级的高可用。# docker-compose 片段一个最小可用的哨兵示例结构 services: redis-master: image: redis:6.2 command: redis-server --appendonly yes redis-slave: image: redis:6.2 command: redis-server --slaveof redis-master 6379 redis-sentinel: image: redis:6.2 command: redis-sentinel /etc/sentinel.conf注意哨兵做了主从切换之后客户端必须能感知到新主节点的地址。如果用 Spring Boot 的 Redis 客户端要正确配置哨兵节点列表而不是写死单个主节点 ip。这里也是热词里哨兵模式启动未生成 know常被讨论的地方哨兵之间靠互相发现建立连接网络和配置文件要保证它们能互相通信。高可用之外还要关注一点Redis 的持久化和内存策略。如果内存被打满触发 maxmemory-policy 淘汰你可能眼睁睁看着热点 key 被淘汰掉这本身就是一种小规模雪崩。生产环境务必设好淘汰策略一般 allkeys-lru 或 volatile-lru并且根据业务重要性加内存扩容预案。4.3 多级缓存与限流降级即便 Redis 高可用做到了位也不能假设它永远不挂。真正稳健的系统会在缓存层之后再加几道闸。第一道是本地缓存。在应用进程内挂一份热点数据Redis 挂了本地还能顶一会儿。用 Caffeine 的写法很简洁CacheString, Product localCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build();第二道是限流。当缓存大面积失效、请求都压向数据库时限流是保护数据库不被压垮的关键。可以在网关层对数据库依赖严重的接口做 QPS 限制或者用信号量控制同时回源的请求数。哪怕只能放行一部分请求也能争取到时间让缓存恢复。第三道是降级。对非核心功能缓存不可用时直接返回兜底数据或默认页别再往下走数据库。比如推荐位挂了就返回一批静态配置的推荐内容商品评价挂了就先展示加载中而不是把主流程拖垮。这三道闸配合起来的顺序是本地缓存能顶则顶顶不住靠限流控制回源速率限流之外尚有富余能力时对非核心功能降级。关键是要提前设计好而不是等 Redis 挂了才临时改代码。绝大多数的雪崩事故最后都不是因为 Redis 容量不够而是因为没做降级预案数据库一被压垮整个链路就全凉了。4.4 缓存预热与持久化配置的实践经验缓存预热在防雪崩里被严重低估了。所谓预热就是系统上线或重启前提前把热点数据加载进缓存避免启动瞬间因为缓存全空而大量回源。我一般会在服务启动后的异步线程里做一个热点数据 warmup按访问频次排序取前 N 个 key 提前查库写缓存。预热期间可以配合限流让冷启动的冲击平滑一些。持久化方面RDB 和 AOF 要按业务取舍。RDB 恢复快、体积小但可能丢最近一段数据AOF 几乎不丢数据但文件大、恢复慢。我的常见配置是两者都开AOF 用 everysec 策略兼顾性能和安全。需要特别提醒的是如果 Redis 重启时只是丢了缓存业务能容忍那持久化的优先级可以放低但如果是把 Redis 当队列或分布式锁存储来用持久化就必须严格对待否则重启后锁状态丢失会引发更麻烦的问题。5. 生产排查手册从告警到定位的完整链路方案讲完了但真出问题时决定性因素往往是排查速度和定位准度。这一节把我在实际事故里总结的排查路径、监控指标和几条硬性规矩整理出来。5.1 常见问题速查表现象大概率原因快速验证方式应急处理数据库 QPS 台阶式上涨缓存命中率缓降缓存穿透看数据库慢查询是否大量空结果Redis miss key 是否分散开启/检查布隆过滤器对可疑 ip 限流数据库 QPS 极窄尖峰几秒后恢复缓存击穿看某单个 key miss 计数是否暴涨检查是否热点 key加互斥锁或本地缓存大批 key 同时 miss数据库持续高压缓存雪崩对比 key 过期时间是否集中立即给存量 key 重设带随机的过期时间缓存写入后读不到命中率异常低序列化/反序列化失败查看反序列化异常日志统一序列化方式排查实体类改动Redis 连接超时、报错激增缓存节点不可用或连接池耗尽看 Redis 节点状态、连接数切从库、扩容连接池、限流降级5.2 需要盯住的几个关键指标排查缓存问题光看 QPS 不够下面这几个指标我会重点盯按 key 维度的 miss 计数这是分辨穿透和击穿的关键。如果 miss 集中在少数几个 key是击穿如果 miss 分散且数量巨大是穿透。Redis 的命中率整体命中率下降可能是慢性的也可能是一个瞬间的尖峰要按时间粒度去看别只看平均值。数据库的慢查询数量缓存出问题最终会体现在数据库上慢查询暴涨是比较可靠的信号。Redis 连接数与拒绝连接数排除是不是连接池满了而不是缓存逻辑的问题。热点 key 的 TTL 分布直接看哪些 key 即将过期能提前发现潜在的集中失效。我个人的习惯是给核心接口配一条缓存 miss 突增的告警比单纯看数据库 QPS 更灵敏因为缓存层是问题的第一现场。5.3 我给团队的几条硬性规矩最后分享几条我要求团队在缓存设计上必须遵守的规矩都是踩过坑换来的。规矩一所有缓存 key 的过期时间必须带随机扰动。这条是底线不接受我这边数据量小应该没事这种侥幸。规矩二所有回源数据库的查询必须做兜底控制。要么加锁要么限制并发绝不允许同一时刻无限量请求同时穿透到数据库。规矩三空值缓存的过期时间要短且随机。别图省事给空值也设个 30 分钟那样数据更新后用户要等半小时才能看到。规矩四压测缓存失效场景。上线前专门用工具模拟大批 key 同时过期的场景看数据库能不能撑住。很多雪崩问题在做这个测试时就暴露了。规矩五Redis 永远不是唯一防线。不管缓存做得多好都要假设它随时可能挂做好本地缓存、限流、降级三层兜底。这个思路转变之后你会发现系统整体的稳定性提高的不止一个档次。这三类缓存问题说到底都是缓存和数据库之间的流量平衡被打破的不同表现形式。穿透是无效流量绕过了缓存击穿是瞬时流量集中冲破了缓存雪崩是缓存整体失去了拦截能力。针对性地把这三条路堵住再配合前面提到的高可用和降级预案高并发下 Redis 才会真正成为你的护城河而不是半夜把你叫醒的那个报警源。这些方案没有哪个是银弹都是根据实际压测和线上数据一点点调出来的你完全可以从随机过期和空值缓存这两件最省事的事情开始先把最容易出问题的地方堵上。