凌晨两点半,告警电话把我从床上拽起来——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 是 5000,Redis 命中率 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 和限流阈值时,如果不知道当初为什么设这个值,那它就是一颗不定时炸弹。把这些细节记好了,下一次大促和高峰流量来临时,你心里会特别有底。