
凌晨两点半告警电话把我从床上拽起来——Redis 主从同时抖动缓存命中率从 97% 断崖式跌到 11%数据库 CPU 瞬间打满线上接口平均耗时从 30ms 涨到了 4 秒。打开监控一看一大片 key 的过期时间整整齐齐地收盘在同一个时间点我心里一凉这不是普通的抖动是典型的缓存雪崩。如果你还没遇到过这个场景那恭喜你但建议你认真读完这篇清单如果你已经在经历类似的痛苦这篇文章就是为你准备的实战复盘手册。缓存雪崩不是某个边缘问题它是高并发系统里最危险的几类缓存故障之一直接决定你在流量高峰面前是稳稳扛住还是当场躺平。这篇文章会从原理拆解到预防手段再到雪崩发生后的完整恢复流程把每一环的关键动作和参数都给你讲透。无论你是后端开发、架构师还是运维同学只要你的系统里用了 Redis 作为缓存层这些内容都值得花十几分钟过一遍。1. 缓存雪崩到底是什么先把敌人看清楚1.1 从一次真实故障说起雪崩的典型场景我在文章开头提到的那个案例就是教科书级的雪崩现场。那段时间我们正在做一个大促预热活动运营同学把一批商品的详情页数据写进了缓存为了统一管理所有 key 的过期时间都设置成了凌晨 2 点。当时想着凌晨 2 点流量低过期就过期吧结果大促预热阶段用户活跃度远超预期凌晨依然有大量夜猫子在下单。2 点整一到几万个 key 同时失效Redis 的 CPU 瞬间升高因为大量 miss 导致回源请求像洪水一样冲向数据库。数据库连接池被打满接着应用线程阻塞最终整个读链路雪崩。这段经历的教训是什么不是缓存不该设置过期时间而是绝对不能让大家在同一时刻集体过期。你永远不知道业务流量什么时候会跟你的过期时间撞在一起与其赌运气不如在设计上就把这个风险拆掉。1.2 缓存穿透、缓存击穿、缓存雪崩三个名词一次说清很多同学刚接触缓存治理时会把这三个概念混在一起实际上它们攻击的是不同的薄弱点对应的防治手段也完全不同。这里我先用一个表格把它们的关键差异列出来后面再逐个展开。故障类型攻击对象典型特征核心后果缓存穿透不存在的 key查询一个缓存和数据库里都没有的数据每次请求都打到数据库缓存击穿单个热点 key某个极高访问量的 key 在过期瞬间被并发请求穿透单个热点打爆数据库缓存雪崩一大批 key大量 key 在同一时间段集体过期或 Redis 实例整体不可用数据库瞬间被海量请求淹没穿透是查了个不存在的东西击穿是一个热点 key 的命门时刻雪崩是一群 key 集体蒸发或缓存层整体失联。三者可以同时发生也可以互相诱发——击穿如果发生在多个热点 key 上本质就演变成了局部雪崩。所以你在设计治理方案时不要把这三个问题割裂看待它们共享一套防护底座过期时间分散、回源限流、多级缓存兜底。1.3 为什么雪崩破坏力最大请求链路放大效应你可能会问数据库本身有连接池Redis 挂了为什么能把数据库打爆关键在请求链路放大了。假设你有一个接口正常情况下 QPS 是 5000Redis 命中率 95%那么多打向数据库的只有 250 QPS数据库毫无压力。一旦这批 key 集体过期命中率跌到 10%涌入数据库的请求瞬间变成 4500 QPS。如果是微服务架构一个接口被打穿会连累下游服务链路超时、线程池耗尽很快整个应用就瘫了。我在实际排查中见过最夸张的一次线上 Redis 集群某个分片发生网络分区客户端连接超时重试策略又没配好结果所有服务都在等待 Redis 响应Tomcat 线程池活活被拖死最后靠重启大法才恢复。记住一个公式雪崩的破坏力 缓存失效比例 × 回源请求量 × 数据库单请求耗时。这三个乘数里任何一个失控都足以让系统的容量规划化为泡影。2. 预防第一关让过期策略不再整齐划一2.1 过期时间随机化最简单有效的土办法把 TTL 加上随机扰动是成本最低、见效最快、也是我第一个要推荐的预防手段。不要追求精巧的算法直接给每个 key 的过期时间加一个随机偏移量比如原计划 1 小时过期的 key实际 TTL 3600 random(600)让过期时间散落在 50 分钟到 70 分钟之间。代码层面怎么做如果你用的是 Spring Data Redis可以在设置缓存时手动生成 TTL// 给基础TTL加上10%的随机扰动避免同一批key集中过期 long baseTtl 3600L; // 基础过期时间1小时 long randomOffset ThreadLocalRandom.current().nextLong(600L); // 随机偏移0-10分钟 redisTemplate.opsForValue().set(key, value, baseTtl randomOffset, TimeUnit.SECONDS);如果你用的是 Spring Cache 注解建议把 TTL 写在 RedisCacheConfiguration 里并且通过自定义 CacheManager 给不同的 cacheName 配置不同的 TTL 区间。实际操作中你会发现随机化只需要几行代码却能直接打散同批次 key 共生死的问题。这也是我反复强调的治理缓存别上来就上高深架构先把这种基础动作做到位。2.2 热点 key 永不过期后台异步刷新模式随机化 TTL 适合大批普通 key但对真正的热点 key比如大促商品详情、热门视频信息我更倾向于做逻辑过期。所谓逻辑过期就是缓存从物理上不设置 expire 时间而是在 value 里塞一个过期标记比如存一个逻辑过期时间戳。每次读缓存时解析这个时间戳如果发现马上要过期就触发一个后台线程去数据库刷新缓存同时把旧值直接返回给当前请求。这里有个细节绝对不能所有请求都去触发刷新否则又是另一种形式的击穿。必须加互斥锁只让一个线程去刷新其他线程继续用旧值也就是异步重建缓存模式。我用 Redisson 实现过一个简化版本public String getWithAsyncRefresh(String key) { CacheValue cacheValue redisTemplate.opsForValue().get(key); if (cacheValue null) { return loadFromDbWithLock(key); } // 逻辑过期时间快到了触发异步刷新 if (cacheValue.isExpiredSoon()) { RLock lock redissonClient.getLock(refresh: key); if (lock.tryLock(0, 3, TimeUnit.SECONDS)) { // 拿到锁的线程负责刷新 executorService.submit(() - refreshCacheFromDb(key)); } } return cacheValue.getData(); }这种做的核心收益是热点 key 永远不会在物理上消失请求永远能命中缓存数据库只承受极低频的异步刷新。代价是代码复杂度会高一些你需要处理逻辑过期标记、锁的获取失败、刷新线程的监控。但从线上稳定性来看这笔账非常划算。2.3 过期时间的业务分级不同数据不同策略我在团队里一直推行一个规则缓存过期策略必须按业务分级不允许一把尺子量到底。你想想商品基础信息、用户登录态、验证码、排行榜数据它们的实时性要求和访问频率完全不同怎么可能共用同一套 TTL 设计我的建议是把缓存数据分成三类强一致类比如库存、支付状态、弱实时类比如商品详情、店铺信息、热榜统计类比如销量排行、推荐列表。强一致类尽量不缓存或者短 TTL 加主动更新弱实时类可以用较长 TTL 改库后主动驱逐的组合统计类数据能扛一点延迟TTL 可以放长一些。分级的好处不仅是防雪崩还能帮你控制缓存和数据库的一致性窗口。数据类别推荐TTL策略失效后的处理方式强一致类不缓存或TTL≤30秒主动写库后同步更新缓存弱实时类基础TTL 30-60分钟随机偏移异步刷新或加锁重建统计榜单类TTL 1-12小时定期全量预热后台任务主动更新2.4 多级缓存把风险挡在 Redis 之外除了在 Redis 层面做文章另一个防雪崩的有效手段是引入本地缓存也就是 JVM 进程内的缓存层。常见做法是 Caffeine 或 Guava Cache 做一级缓存Redis 做二级缓存数据库做最终兜底。当 Redis 发生雪崩时大量请求仍然可以通过本地缓存命中直接挡住一部分流量。我见过一个比较夸张的例子某系统主要接口只有 30 个热点数据却扛了整个站点 80% 的流量。后来在应用层直接加了 Caffeine 本地缓存TTL 设 1 分钟Redis 挂了之后数据库压力直接下降了 70%。当然多级缓存也有代价比如数据一致性更复杂、本地缓存占用内存、上线发布时缓存会随应用重启而失效。我的建议是本地缓存只放真正的热点数据不要无脑全量缓存同时设置合理的最大容量和过期策略防止 JVM 内存被撑爆。3. 预防第二关在数据库前面加保险丝3.1 限流降级先把请求拦住不管你怎么优化过期策略缓存层总有失手的时候。这时候最关键的就是在数据库前面加一道限流闸门。限流的标准动作有几种单机限流、集群限流、接入层限流。单机限流最简单用 Guava RateLimiter 或者 Resilience4j 直接怼在应用层集群限流可以用 Redis Lua 实现滑动窗口但要注意限流中间件本身不能用 Redis否则 Redis 挂了限流也失效这就尴尬了。我个人推荐在网关层先按接口维度做限流比如 Sentinel 或者自研的网关插件给核心接口配置 QPS 阈值超出阈值的请求直接返回降级数据或快速失败而不是让它们冲进数据库。限流阈值怎么定不是拍脑袋根据数据库连接池大小、单查询耗时和期望的最大 RT 反推。假设你的连接池是 100单查询 50ms那么全链路允许打到数据库的请求上限也就 2000 QPS 左右超过这个数数据库必炸。反正记住一个原则宁可让少部分请求超时也不能让数据库先倒下。3.2 熔断与隔离别让一个故障拖垮全站限流是控制流量熔断是控制错误传播。如果你的数据库已经出现响应变慢、连接超时的迹象继续发请求只会让情况更差。这时候熔断器就该上场了。Hystrix 虽然不维护了但它的线程池隔离 信号量隔离 熔断阈值思路依然值得参考。现在主流选择是 Sentinel、Resilience4j 或者直接在框架层做线程池隔离。配置熔断阈值时我踩过一个坑把熔断最小请求数设得太高导致故障发生时熔断无法及时触发。比如你设置 1 分钟内请求数超过 1000 才开始统计错误率结果故障时间段内请求量本来就低熔断永远不触发。正确的做法是压低触发门槛把最小请求数调到 20错误比例阈值调到 50%熔断窗口 10 秒。这样既不会误伤正常流量也能在故障初期快速兜住。3.3 分布式锁重建缓存只让一个请求回源雪崩的另一个本质问题是回源请求并发量不可控。同一个 key 失效时1000 个请求同时发现缓存为空同时去查数据库。怎么控制最简单的是加分布式锁——只让一个请求拿到锁并查库回源其他请求等待或者拿旧值。Redis 的 SETNX 是实现分布式锁的经典方式但在生产环境建议直接用 Redisson 的 RLock它内置了看门狗续期逻辑避免锁过期导致的重入问题。分布式锁不是银弹锁粒度太粗会拖慢正常请求太细又防不住并发。我的经验是按 key 维度加锁锁超时时间控制在 3 秒以内拿到锁的业务线程快速查库、快速写缓存、快速释放。如果锁等待时间太长可以给等待线程一个兜底比如返回降级值或者默认数据不要让用户干等着。3.4 预热与定时重建把缓存冰面提前凿开缓存雪崩往往集中在两种时刻一是 key 集体过期二是系统重启后的缓存空洞。后者特别容易发生在发布上线后因此在大促或者版本发布前务必要做缓存预热。所谓预热就是提前把热点数据从数据库加载到 Redis让缓存到达即命中状态。我们团队是把预热做成了一个定时任务每秒从配置中心拉取热点 key 列表批量写入缓存。具体操作上预热数据可以先查询数据库然后走和业务写入相同序列化方式写入 Redis这样才能避免序列化不一致导致的兼容问题。值得一提的是预热任务本身也要控制并发否则你把数据库压垮了缓存还没建好这算另一种形式的反向雪崩。4. 雪崩发生后的恢复流程从告警到止血4.1 第一件事确认故障范围别急着重启真到了雪崩发生的那一刻很多人容易慌不择路上来就重启 Redis、重启应用。我劝你先冷静 30 秒确认故障范围。先看监控大盘区分两种场景是 Redis 本身不可用还是大量 key 过期导致的缓存 miss 激增如果是前者优先处理 Redis 进程和集群状态如果是后者才需要按照缓存重建的思路来。快速确认范围可以用几个 Redis 命令INFO 查内存和命中率、CLIENT LIST 看连接数、DBSIZE 看 key 总量。同时看应用侧监控是接口错误率上升还是 RT 上升是全部接口挂掉还是部分接口这些信息决定了你下一步动作。我见过有人把缓存过期当成 Redis 故障重启服务后本地缓存被清空反而扩大了故障面这个教训得记住。4.2 兜底方案空值缓存与临时标记如果确认是由于大量 key 同时过期导致数据库压力过大第一时间的止血手段是给查询降噪。最直接的做法是对于查不到的数据也做一个空值缓存TTL 设置短一些比如 10 秒。这样即使大部分 key 还没有重建同一个 key 的并发查询也只会有一个穿透到数据库其余都命中空值缓存极大减轻数据库压力。另一个技巧是临时标记法在发现缓存穿透的瞬间先在 Redis 里写一个带短 TTL 的特殊占位 key比如 set rebuild:product:1001 1 EX 5 NX只有成功写入占位 key 的那个请求才允许查数据库并重建缓存。本质上和分布式锁重建的方式一样但占位 key 的 TTL 更短、语义更明确非常适合雪崩恢复期的临时使用。4.3 分层恢复先恢复读再恢复写恢复动作不能一把梭。我的建议是分步走第一步优先保证读链路可恢复。做法是启动预热任务用脚本批量重建最核心的热点 key可按数据热度排序先恢复 top 100再恢复 top 1000。第二步逐步恢复写链路。如果写操作也依赖缓存此时要注意防止缓存更新和回源重建之间的相互覆盖。具体执行时我习惯用一段简单的 shell 脚本配 Redis 管道命令批量重建# 从预先导出的热点 key 清单逐批重建每批 500 个 cat hot_keys.txt | xargs -n 500 | while read batch; do for k in $batch; do redis-cli -h redis_host -p 6379 SET $k DB_VALUE_PLACEHOLDER EX 3600 NX done done这只是一个示意生产环境要用应用代码查库重新构建 value不能真的写占位符。恢复过程中持续观察数据库的 QPS 和连接池活跃数如果压力还在飙升就调低恢复任务的并发度。先让数据库喘口气再逐步加码。恢复的目标是回到正常水位而不是一次性把缓存全部填满。4.4 复盘模板把事故变成改进资产雪崩恢复之后最容易被跳过但又最重要的一步是复盘。不要写那种加强巡检、提升意识的空话要有可执行的动作。我建议复盘报告至少包含四个部分故障时间线、根因分析、数据影响、改进清单。我们曾经在一次复盘里发现根因不只是 TTL 集中还包括监控缺失——我们只监控了 Redis 平均命中率没有监控热点 key 的命中率分布导致故障前 20 分钟完全没有被告警捕捉到。那次的改进动作有三条缓存过期时间全量加随机偏移、新增热点 key 命中率监控告警、压测脚本里增加雪崩演练场景。这三条每一条都能量化验证。事故不可怕可怕的是每次都犯同样的错。5. 日常治理与监控配套别等雪崩了才想起来5.1 监控指标与告警阈值到底该盯什么日常防雪崩与其靠人肉盯大盘不如把告警指标配置好。我重点盯的 Redis 指标有几个命中率hit rate、过期 key 数量、内存使用率、慢查询数、客户端连接数。命中率是一个滞后指标等到它掉下去再处理已经晚了所以最好再加一个主动指标同一秒内过期的 key 数量。如果某秒过期 key 数量超过平时基线的 3 倍立即告警。过期 key 的实时数量可以通过 INFO stats 里的 expired_keys 字段观察也可以用 SCAN 命令配合抽样排查。更聪明的做法是用 Redis 版本自带的 keyspace events 能力配合监听 EXPIRED 事件做实时统计但要注意这会给 Redis 增加额外开销。我的建议是先做好关键指标的监控大盘再根据告警记录反推阈值不要一上来就追求花哨的实时统计。5.2 压测模拟雪崩场景验证防线是否有效你会不会跟我以前一样觉得自己已经把限流、随机过期、熔断都做了就一定没问题直到自己做了一次完整的雪崩压测我才发现配置的漏洞比想象中多。压测的目的很简单人为制造一批 key 集中过期、或者模拟 Redis 宕机观察数据库压力和应用响应情况是否符合预期。做法上可以利用 JMeter 或 Gatling 构造高并发读请求配合脚本把缓存 key 批量删除模拟缓存为空的场景。压测过程中重点观察应用有没有触发限流、熔断是否按预期打开、数据库连接池是否仍然可控。我强烈建议把雪崩演练加入季度例行运维计划特别是大促之前压测一次能提前暴露十来个平时看不到的配置盲区。5.3 常用命令与排障工具速查平时排查缓存雪崩相关问题时有几条命令是我最常用的记在下面当速查表用redis-cli INFO stats看 hitted_keys、expired_keys、evicted_keys判断缓存是否大量失效或被逐出。redis-cli DBSIZE看当前 key 总量变化趋势如果整体量级突然下降说明大量 key 丢失或过期。redis-cli --bigkeys定位大 key防止大 key 删除导致的阻塞和连锁影响。redis-cli MONITOR现场抓请求但要小心线上压力大时 MONITOR 自身会成为性能瓶颈。SCAN 0 MATCH cache:product:* COUNT 1000批量遍历 key摸清某类 key 的分布情况。另外Redis Desktop Manager 或者 Another Redis Desktop Manager 适合本地查看 key 分布和内存情况但线上问题排查我建议还是直接用 redis-cli 加脚本速度更快、没有额外开销。5.4 团队规范把缓存治理沉淀成制度最后想说的是缓存治理不应该是某一次故障后的应激反应而应该变成团队的技术规范。我们在实践中沉淀了一套简单的检查清单每个新服务上线前都要过一遍是否排查了集中过期风险是否配置了限流降级是否有回源并发的锁控制是否对热点 key 做过预热的可行性评估是否配置了命中率告警有了这套规范新同学也能迅速上手不会因为经验不足而踩同样的坑。如果公司有条件还可以在发布平台集成缓存风险评估插件自动检查代码里的 TTL 配置是否过于集中、是否有明显的无缓存兜底逻辑。技术债是一点点还的每次事故改进一点系统就会越来越稳。从最初的半夜告警到现在团队能沉着应对各类缓存故障我最大的体会就是缓存雪崩没有一劳永逸的解法它是一套组合拳的准备度和日常治理的严谨度。只要把过期随机化、热点 key 保护、限流熔断、监控告警这四件事做到位你已经比大多数系统领先一大截了。最后再给你一个我一直挂在嘴边的小技巧缓存治理的所有参数都要标注来源和调整日期。你半年后回来看 TTL 和限流阈值时如果不知道当初为什么设这个值那它就是一颗不定时炸弹。把这些细节记好了下一次大促和高峰流量来临时你心里会特别有底。