
双十一零点刚过页面上的优惠券接口突然大面积超时。监控看板里Redis 的 QPS 断崖式下跌MySQL 的 CPU 直接顶着 100% 红线慢查询数量三分钟内涨了 50 倍。这套 Qwen3.5-Plus 促销系统是我们家底最厚的服务集群年初压测还能稳稳扛住 12 万 TPS结果一个缓存雪崩打回原形。我说的缓存雪崩不是某个 key 被穿透也不是热点 key 被打爆而是同一时刻成千上万个 key 集体过期或者 Redis 整个实例宕机所有请求瞬间落到底层数据库上。做过电商大促、金融结算、社交 feed 流的兄弟应该都有共鸣缓存设计得再漂亮只要雪崩来一次直接能把线上数据库打进“不可用”状态。这套 Qwen3.5-Plus 系统不是简单的高并发读场景它是一个多租户权益中心缓存里既有用户维度 key又有策略维度的批量 key。数量大、维度杂、过期时间又集中在几个整点等于把所有雷埋在了同一条时间线上。这次故障让我把缓存雪崩的治理方案从头到尾完整踩了一遍包括过期时间打散、多级缓存兜底、请求合并、Redis 故障降级和恢复后的流量控制。下文就把这套从故障到治理的完整经验写下来。这篇文章适合正在维护高并发线上系统的后端开发同学尤其是你们已经上了 Redis但还没有把雪崩治理纳入日常巡检体系的团队。我会把每个方案的原理、代码落脚点、压测验证方法都过一遍。1. 先搞清楚雪崩是怎么发生的不是玄学是计算出来的1.1 雪崩的本质是请求路径上的“护城河”消失正常请求走到 Redis命中就直接返回只有 miss 才穿透到 MySQL。缓存的容量和速度就是数据库前面的一道护城河。雪崩发生那一刻Redis 本来能挡住的请求突然全都掉进了 MySQL。有一个点很多资料讲得含糊就是雪崩不等于某一两个 key 失效。真正的雪崩是“缓存整体失效窗口”出现的瞬间并行涌来的大量请求在没有缓存兜底的情况下直接砸向数据库层。数据库的连接数、CPU、IO 任何一个先被打满后面排队的请求就开始指数级堆积最后表现为接口大面积超时、连接池耗尽、应用线程阻塞。Qwen3.5-Plus 的场景更典型。权益策略是按批次发布的一批用户策略 key 的 TTL 都设置成了固定 24 小时每天晚上 0 点统一过期。0 点一到这批 key 的 Get 操作集体 miss所有涉及该批次用户的请求全部下钻到 MySQL 去重新加载策略。我统计了一下当时 Redis QPS 从 30 万掉到 5 万MySQL QPS 从 2 千冲到 18 万数据库连接池瞬间打满。1.2 雪崩的两种典型触发路径触发雪崩的第一种路径是集中过期。所有 key 都被设置了相同 TTL比如 3600 秒那么就意味着正好在同一秒全部失效。这种方式下Redis 本身没有问题但缓存命中率就像被一刀切掉下钻流量瞬间翻倍。第二种路径是 Redis 实例不可用。注意“不可用”不只是宕机还包括长时间阻塞。比如 Redis 在做 RDB 持久化时 fork 进程卡住或者某条慢命令比如 KEYS *拖垮了单线程事件循环客户端侧所有请求都会超时。很多客户端的默认配置是连接超时后继续重试反而把 Redis 的压力进一步放大。这种场景比集中过期更危险因为集中过期只是部分 key 失效而实例宕机是 100% miss。1.3 为什么数据库扛不住这种冲击一个普通 MySQL 实例的读 QPS 能稳定扛到 2 万到 5 万已经不错了而一套 Redis 集群承载的 QPS 可以轻松过百万。当缓存的百万级 QPS 全部灌给数据库哪怕只有十分之一真正穿透也足够瞬间打崩连接池。我用一句话给团队解释这个逻辑缓存每秒能挡 10 万发子弹数据库每秒只能挡 1 万发。缓存失效的瞬间原本被挡住的 9 万发子弹全部落在数据库上数据库连一秒都撑不过去。所以雪崩治理的核心目标不是让数据库扛住流量而是让流量根本没有机会下钻到数据库。2. 过期时间打散把集中失效的时间线扯开2.1 基础做法固定 TTL 加随机偏移最直接的方案是在生成缓存 key 时给 TTL 加一个随机偏移量。原来 3600 秒失效的现在加入一个 0 到 300 秒的随机值让同一批 key 的过期时间均匀分散在一个时间窗口里而不是精确落在同一秒。以 Java 环境为例核心逻辑只有一行int ttl 3600 new Random().nextInt(300); stringRedisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);实践中我建议把随机范围控制在固定 TTL 的 5% 到 15% 之间。偏移太小比如只有 10 秒对大流量系统来说还是太集中偏移太大比如偏移 1800 秒会导致缓存提前失效的 key 过多数据库压力也会上升。还有一个细节同一个业务 key 在每次重建时的随机值不能固定否则第二次重建后过期时间又会重新对齐。所以随机值必须每次生成不能存在本地变量里复用。2.2 更稳的双级 TTL 设计单纯加随机偏移只能解决“大概率”问题对于超高并发场景还不够。我后来给 Qwen3.5-Plus 上了双级 TTL 设计Redis 里的 key 保留一个物理过期时间比如 600 秒同时在 value 里塞一个逻辑过期时间戳比如 300 秒。读取时如果逻辑时间戳已经过期不会直接从数据库重建而是先返回旧缓存值同时触发一个异步任务去更新缓存。这套设计的关键在于有效避免了“缓存空窗期”。传统方案里key 一旦过期在缓存重建完成之前所有请求都会穿透到数据库。双级 TTL 里物理 key 还没过期逻辑时间戳过期的那一瞬间返回的是旧数据虽然存在短期不一致但数据库永远不需要接受瞬时穿透流量。很多团队会问旧数据返回如果被业务方拒绝怎么办。这需要做业务层妥协比如 A 类业务要求强一致就不能用双级 TTL但大多数权益展示、商品详情、feed 流业务允许秒级到分钟级的不一致这个方案收益远大于风险。2.3 打散场景下的 key 重建策略key 分散过期后还有一个隐藏坑重建缓存时的并发控制。即使是分散过期的 key当某个 key 失效时还是可能同时有很多请求 miss 它。如果每个请求都去查数据库数据库压力仍然很大。正确做法是给缓存重建加互斥锁。同一个 key 只允许一个请求去查数据库并回填其他请求要么等待锁释放后直接读取新缓存要么返回降级数据。String key order:user: userId; String value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } String lockKey lock: key; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (locked) { try { value loadFromDb(userId); int ttl 3600 new Random().nextInt(300); redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS); } finally { redisTemplate.delete(lockKey); } } else { // 短暂自旋等待拿锁线程回填后直接查缓存 Thread.sleep(50); value redisTemplate.opsForValue().get(key); if (value null) { // 兜底直接走降级数据不再打数据库 return fallbackData(); } }这个锁不是分布式锁的全部用法但用于缓存重建足够了。要注意锁的过期时间不能小于数据库查询时间否则锁先释放了后面请求又会重复查库。我这里设置 5 秒是根据压测环境和线上 P99 响应时间倒推出来的。3. 多级缓存与请求合并让流量根本到不了数据库3.1 本地缓存是抵御雪崩的第二道闸门Redis 是分布式缓存但理论上 Redis 本身也可能抖动或宕机。要真正防住雪崩还要在应用层加一层本地缓存。Qwen3.5-Plus 用的 Caffeine基于本地内存访问速度比 Redis 还快一个数量级最关键的是它不受 Redis 可用性影响。本地缓存的容量设置很重要我们每台应用服务器给 Caffeine 分配了最大 500MB 堆外空间配置了基于 key 数量和过期时间的驱逐策略。热点用户策略、权限配置、商品基础信息这类读多写少的数据直接从本地缓存返回绝大多数请求根本不会触达 Redis。这里要注意本地缓存不适合存储更新频率极高的数据。比如用户实时余额放本地缓存会出现脏读情况更糟。我的原则是能容忍秒级延迟一致性的冷热数据才放本地缓存强一致数据宁可不放。3.2 单飞模式同一时刻同一个 key 只让一个请求查库本地缓存挡住了部分流量但仍然存在 miss 场景尤其第一次请求或者本地缓存过期的场景。这时候需要引入请求合并也叫单飞模式。单飞的核心思想是当大量并发请求发现同一个 key 没有缓存时只放一个请求去数据库加载其他请求挂起等待这个结果。实现方式可以用 Java 里的 Future 线程池加 ConcurrentHashMap 做 key 维度锁。我用一个简化版伪代码描述核心逻辑private ConcurrentHashMapString, FutureString pending new ConcurrentHashMap(); public String get(String key) throws Exception { String value cache.get(key); if (value ! null) { return value; } while (true) { FutureString future pending.get(key); if (future null) { FutureTaskString task new FutureTask(() - loadFromDb(key)); future pending.putIfAbsent(key, task); if (future null) { future task; task.run(); } } try { value future.get(1, TimeUnit.SECONDS); cache.set(key, value, 300 random.nextInt(60)); return value; } catch (TimeoutException e) { // 等待超时再等一轮避免直接打库 } finally { if (future.isDone()) { pending.remove(key, future); } } } }这里有个细节就是等待超时之后不能立刻放弃。很多请求超时是因为数据库负载瞬间升高响应变慢如果客户端等 200ms 就放弃然后自己直接查库等于又给数据库加了一把压力。我这边设置的是等待 1 秒在等待窗口内能拿到结果就用结果拿不到就重试等待最多等 3 轮。3.3 用信号量做线程隔离防止数据库被拖死前面的方案能防住 key 维度的并发但还有一种极端情况数据库出问题了比如慢查询或死锁每个 key 的查询都要耗掉几十秒。即使只有一个请求查库应用服务器的 Tomcat 线程也会被耗尽。应对办法是引入 Hystrix 或者 Resilience4j 这类容错框架给数据库访问做一个独立的信号量隔离。我后来用 Resilience4j 给 Qwen3.5-Plus 的 DB 访问单独建了一个线程池核心线程 20最大线程 50队列 100。超过负载的请求直接走 fallback不再往数据库里挤。信号量配置不能拍脑袋。我压测得出的经验是数据库连接池给到 100 连接应用线程池队列最多堆积 500 个任务超过 500 之后直接失败返回降级数据。这样的代价是部分请求可能返回旧数据或默认值但保障了系统整体不崩。4. Redis 高可用与故障兜底预防实例级雪崩4.1 主从加哨兵不是万能解药集中过期可以用 TTL 打散解决Redis 实例级故障要用高可用架构兜底。业内标准做法是主从复制加哨兵或者直接上 Redis Cluster。Qwen3.5-Plus 早期是单主多从架构哨兵负责自动故障切换。但有次主节点发生内存 swap进程卡了近 10 秒哨兵超过阈值触发切换这 10 秒内所有读请求全部 miss直接打穿数据库。所以说哨兵解决了主节点“真正宕机”的情况但无法解决主节点“半死不活”的情况。我在线上实践时额外加了客户端侧容错读取 Redis 失败时不直接抛异常而是走本地缓存降级再走数据库兜底。Jedis 和 Lettuce 都有连接池超时参数我建议把连接超时设置在 200ms 以内读取超时 500ms 以内宁可快速失败也不让应用线程长时间挂在 Redis 上。4.2 缓存重建时的流量控制Redis 恢复后会进入一个特殊状态缓存里所有 key 都是空的所有流量重新开始往 Redis 里写数据。如果这时候业务流量还是全量并发瞬间的大规模回填会把数据库再次压垮。所以在故障恢复阶段我给系统加了“重建限流”缓存重建请求按 key 维度做令牌桶限流每秒钟最多只允许 500 个重建请求进入数据库其他请求直接返回本地缓存旧值或空值。这个限流阈值是结合数据库当前负载动态调整的如果 DB 的活跃线程数低于 30%就放松限流到 2000如果高于 80%则收紧到 200。4.3 从故障中学会的配置清单我还总结了一份 Redis 故障兜底的配置清单可以对照检查Redis 连接池最大连接数要和业务 TPS 匹配不要盲目调大连接数大了反而会放大故障时的堆积效应。所有缓存 key 必须设置统一的前缀里面带上业务版本号方便出问题时通过版本号全局失效或灰度恢复。缓存值里的逻辑过期时间戳必须存在即使物理 TTL 很长也可以通过逻辑时间戳做预更新。启动预热脚本系统启动时把热点 key 提前加载进缓存避免服务初始化时缓存空的尴尬期。5. 故障复盘与压测验证用数据说服所有人5.1 模拟集中过期的压测方法治理做完了就要验证。我用的压测方式是先用脚本批量写入一批带较短 TTL 的测试 key然后让所有 key 同时过期模拟集中失效场景。具体做法是准备 10 万个 keyTTL 全部设为 60 秒到第 55 秒时启动压测流量确保流量到达时正好经历一波集中 miss。压测要观察的核心指标不是 Redis QPS而是数据库 QPS、数据库连接池活跃连接数、应用平均响应时间、错误率。我这边做了对照组一组是未加随机 TTL 的版本另一组是加了双级 TTL 和单飞模式的版本。结果很直观第一组数据库中 80% 时间内连接池打满错误率超过 15%第二组数据库 QPS 峰值只有 6000错误率 0.2%。5.2 模拟 Redis 宕机的演练方法Redis 宕机演练不能直接在线上做主节点 kill风险太大。我在预发环境做的手动停掉主节点观察哨兵切换时间同时观察客户端的行为。重点看两类指标Redis 恢复期间有多少请求走到了降级链路恢复后流量重建是否平稳。有团队会在这个阶段发现客户端重试机制造成的问题比如 Lettuce 默认在连接断开后无限重试导致应用线程大量阻塞。我建议把重试次数设置为 1 次重试间隔不超过 300ms。重试超过 1 次就不再有意义因为这个时候 Redis 侧大概率还没恢复继续重试只是给网络层加大负载。5.3 缓存命中率与 P99 延迟的监控指标日常监控能提前发现雪崩征兆。我建议至少盯这几个指标Redis 的缓存命中率正常情况应该在 95% 以上一旦低于 85% 就要引起注意。Redis 的 expires 监控清理的 key 数量出现瞬时峰值说明某批 key 集中过期了。数据库 QPS 基线没有活动时数据库 QPS 很低出现规律性尖峰就要排查是否有缓存同步失效。应用侧 P99 延迟正常情况下 P99 应该远低于 1 秒如果连续五分钟超过阈值大概率是有缓存问题。指标要设多个级别告警。比如缓存命中率连续 3 分钟低于 95%触发黄色告警低于 80% 触发红色告警直接拉群并呼叫值班同学。6. 常见问题与排查技巧实录6.1 为什么我加了随机 TTL数据库还是被打满这是被问得最多的一个问题。加了随机 TTL 之后单个 key 的过期时间被打散了但如果你在生成缓存时用了同一套随机种子或者随机范围太小比如 3600 到 3601那等于没打散。还有可能是业务侧主动执行了全局刷新操作比如配置发布后强制所有缓存失效这就是人为制造的集中过期。这种情况不能用随机 TTL 解决要加全局刷新开关的错峰延迟。6.2 缓存重建时拿锁失败后续请求怎么办拿锁失败的线程不能无限自旋。自旋次数太多线程本身也会成为性能瓶颈。我建议自旋次数控制在 3 次以内每次间隔 50ms。如果 3 次后还没拿到缓存结果就返回本地缓存旧值或者走默认值。最怕的是拿锁失败后直接查数据库这会绕开互斥锁的保护又变成并发穿透。6.3 Redis 实例恢复了缓存却一直 miss这是个很经典的问题。Redis 进程恢复后内存里的数据可能是旧的也可能因为持久化配置原因完全为空。很多系统在 Redis 故障期间写入数据库的数据没有反向回填 Redis导致恢复后缓存长期 miss。解决思路是在 Redis 恢复后开启一段“缓存预热窗口”把最近 10 分钟活跃的用户策略、商品数据从数据库批量加载回 Redis。预热速率要平滑控制我习惯用循环批量加载每批 200 条批次间隔 50ms确保不会对数据库造成二次冲击。6.4 缓存雪崩和缓存击穿、穿透怎么区分这三个概念经常混着出问题。穿透是请求一个根本不存在的数据缓存和数据库都没有击穿是某一个热点 key 在过期瞬间被高并发请求直接打到数据库雪崩是大规模 key 同时失效或者 Redis 整体不可用。穿透的解法是布隆过滤器加空值缓存击穿的解法是热点 key 永不过期加互斥重建而雪崩的解法就是我前面写的这一整套组合拳。很多人只加了布隆过滤器就自以为防住了雪崩定位完全不对。6.5 我的经验雪崩治理没有银弹必须组合使用这轮排查下来我最大的体会是不要迷信任何一个单点方案。随机 TTL 能防集中过期但防不了 Redis 宕机双级 TTL 能防空窗期但业务上要接受一致性妥协本地缓存能扛 Redis 抖动但容量有限单飞模式能减少 DB 穿透但如果 DB 本身挂了也没有意义。Qwen3.5-Plus 最终的方案是随机 TTL 加双级 TTL加本地缓存加互斥重建加信号量隔离再加 Redis 故障降级和恢复预热六层一起才真正扛住了大促流量。还有一个建议这套治理方案上线后要做一次故障注入演练而不是只在代码里写了就当完事。缓存雪崩和所有高并发问题一样只有真实压测和故障演练才会暴露问题。我们在预发环境模拟了三次 Redis 宕机前两次都出现客户端重试堆积、恢复后缓存空窗期的问题第三次才基本平稳。演练反馈出来的问题比代码 review 有价值得多。