首页 / 资讯中心 / 文章详情

缓存雪崩防御实战:从Redis TTL随机化到多级缓存与熔断限流

缓存雪崩防御实战:从Redis TTL随机化到多级缓存与熔断限流 ★ FEATURED ARTICLE
缓存雪崩这词儿我在生产环境里被它咬过不止一次但真正让我把整套防御逻辑刻进脑子里的是当时那个代号叫 Qwen3.5-Plus 的 AI 网关项目。那套服务用 Redis 缓存模型响应和中间计算结果缓存 key 有几十万个。某个凌晨流量刚起数据库 CPU 从 40% 直接干到 100%接口超时率从不足 0.2% 飙到 20% 以上。我第一反应是 Redis 挂了结果 Redis 活得好好的慢查询日志里全是同一个 SQL。翻监控才发现那批 key 都设了同一个过期时间零点一到集体失效几万个请求在同一秒穿透到数据库。这就是典型的缓存雪崩现场。所谓缓存雪崩Cache Avalanche指的不是单个 key 失效而是在同一时刻有大量缓存 key 同时过期或者 Redis 服务整体不可用导致原本由缓存扛住的读流量全部直接涌向数据库。数据库压力骤增、连接池被占满、接口变慢然后上游超时重试又来一波更大的流量整个链路就这么被压垮。这篇文章不打算堆概念我把成因、排查思路、防御方案以及实际踩过的坑全摊开讲一遍适合正在做高并发后端、或者第一次被缓存故障折腾得睡不着的朋友。1. 先分清同类问题缓存雪崩、穿透与击穿的差别1.1 一次缓存雪崩的完整时间线复盘先把 Qwen3.5-Plus 那天的故障时间线还原一下。线上架构不算复杂接入网关在最前面Redis 做一级缓存MySQL 做最终数据源另外还挂了一个向量索引库。活动开始时间是零点整所有参与用户会同时拉取首页推荐配置程序在配置加载时统一设置了缓存过期时间全部是 3600 秒。零点之前一切正常过了零点第一批 key 同时过期第二批、第三批也紧跟着在同一秒过期。用户端恰好也在这个时段集中请求缓存里查不到每个请求都去 MySQL 做配置组装主库的 CPU 在五分钟内从 30% 冲到 100%。那张配置表其实很简单单行查询也不贵CPU 冲上去的原因不是单个查询慢而是数据库同时打开的连接数和执行线程数爆了。MySQL 的 max_connections 线上调到了 500活动请求峰值有将近 6000 QPS连接排队直接把数据库拖死。数据库挂了以后网关侧开始报超时客户端自动重试重试流量又回到网关层层叠加整个链路就凉了。这类故障的共性就是第一波流量不算真的“巨量”但所有流量都绕过了缓存压力全落在最脆弱的那一层。1.2 雪崩、穿透、击穿到底差在哪很多人把这三个词混着用排查的时候容易跑偏。缓存穿透指的是请求的 key 在缓存里不存在在数据库里也不存在比如恶意请求故意用id-1来打接口每次都要查一次数据库。缓存击穿指的是某一个热点 key 在失效瞬间大量请求同时打到数据库说白了是“一个 key 背后站了几十万人”。而缓存雪崩就是标题里说的那种同一时刻大量 key 一起失效或者 Redis 整体不可用打击面最大修复思路也最依赖事前治理。简单区分见下表。概念失效对象主要后果典型误判缓存穿透不存在的 key空查询打到数据库以为是缓存失效缓存击穿单个热点 key单点压力突增认为是数据库慢查询缓存雪崩大量 key / 整个缓存层数据库被打垮认为是突发大流量这个区分很重要因为不同问题的解法完全不同。穿透的核心解法是布隆过滤器加空值缓存击穿的核心解法是互斥锁和逻辑过期雪崩则要在过期策略、缓存高可用和限流熔断三方面同时下功夫。你要是没分清问题一上来就把过期时间全改成随机值遇到击穿该加锁没加锁照样是个坑。2. 为什么好好的 Redis 会引火烧身雪崩的触发机制2.1 同一时刻批量过期的真实成因批量 key 同时过期最常见的原因就是“设置过期时间时偷懒”。业务写缓存时用cache.set(key, value, 3600)所有同类 key 的过期时间都落在同一秒。定时任务、日报表、推荐配置特别喜欢这么干。另一个坑是续期逻辑写错本该在每次访问时把过期时间顺延结果开发图省事只在写入时设置一次热点数据就在凌晨统一到期。第三个原因是发布或迁移时有刷新缓存的任务代码里循环删掉旧 key删完之后所有新 key 又设置同一个 TTL服务一上线就是新的雪崩点。要说机制其实可以用水池来打比方。缓存就像蓄水池水位是命中率数据库像是下游的农田。大量 key 同时过期等于蓄水池同时开了十几个闸门下游一下子被冲垮。正常情况下Redis 的命中率应该在 95% 以上雪崩瞬间的命中率可能掉到 20% 以下数据库迎来的全是裸奔请求。这个细节在监控里非常明显。你去看 Redis 的expired_keys指标正常情况下是平稳的一条线偶有小峰如果它突然拉出一根陡峭的大柱子基本可以坐实是大量 key 同一批过期。再加上接口平均耗时同步上涨互相印证判断就很清楚了。我还总结过一个经验别只盯 QPS去看数据库的thread_running只要它连续几秒超过核心线程数的 80%雪崩就已经在路上了。2.2 Redis 宕机不过期的雪崩往往更吓人如果大量 key 同时过期是“慢性子”Redis 整体不可用就是“急性子”。Redis 宕机不一定是主机挂掉常见的几种内存被打满触发保护策略主从切换时哨兵误判网络分区导致客户端连接中断还有集群槽位迁移时大量 key 同时失效读流量在迁移瞬间把主库打穿。不管是哪种只要 Redis 不可用所有读请求都会绕过缓存层去找数据库。数据库在没有任何降载准备的情况下挨这么一下连接池会被瞬间抽干。这里有一个经常被忽略的细节暴增的请求其实不全是业务流量变多了而是客户端重试机制在推波助澜。很多网关框架默认在超时后重试两三次第一次超时已经有一批请求在数据库排队重试又给数据库叠了一层压。我那次排查 Qwen3.5-Plus 故障时就看过网关日志同一笔请求在 500 毫秒内连续打进了三次数据库。这就是重试风暴。所以治理雪崩不只是治理 Redis也得治理调用方的超时和重试策略。2.3 连接池与拒绝策略数据库的最后一道防线数据库之所以会在雪崩里被打垮还有一个放大因素——连接池回收速度跟不上新连接创建速度。MySQL 每接收一个新连接都要做线程创建和权限校验瞬时高并发下连接创建本身就变成了最大开销。像 HikariCP 这种连接池有人上线前把它从默认值调到了 200原意是提升吞吐结果雪崩时 200 个连接全被慢查询占住新请求只能排队等连接。等连接数被占满数据库开始拒绝新连接业务层报connection refused可业务层理解不透可能导致更多重试。从架构上看我比较建议在数据库前面保留一层稳妥的降级策略。比如查缓存不中时先去本地缓存副本捞旧值而不是直接放行到数据库。像首页配置这种弱一致数据给几秒钟的旧版本返回远远好过整个系统崩塌。这类逻辑写起来不难难的是想清楚哪些数据可以被降级、可以接受多旧的版本。这个决定得和业务方一起拍板不要自己闷头搞。3. 防御方案全景图从“随手设置 TTL”到整套治理框架3.1 最廉价的一招过期时间加随机值网上看到的第一个方案基本都是给 TTL 加随机值。比如原本统一 3600 秒改成3600 随机数让过期时间散落在 3600 到 3900 秒之间。这个办法的核心不是把过期时间打得多科学而是要打破“同一时刻集体失效”的共振点。以 Qwen3.5-Plus 网关为例我当时把所有 key 的过期时间统一封装了一层叫setWithRandomTtl内部实现大概长这样public void setWithRandomTtl(String key, String value, int baseTtl) { int jitter ThreadLocalRandom.current().nextInt(0, 300); redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(baseTtl jitter)); }就这么一个改动expired_keys的大柱子直接被打平。但我得提醒你随机值只解决“同时过期”的问题解决不了 Redis 宕机引起的雪崩。你还要往下看多级缓存和熔断那部分。另外随机范围也不是越大越好太大会导致部分 key 在业务真正需要的时候提前失效引发缓存击穿。我一般习惯用基础 TTL 的 5% 到 10% 作为抖动区间。3.2 热点数据互斥重建让数据库喘口气加随机值之后如果还有个别热点 key 在失效瞬间挤爆数据库就得考虑互斥重建。核心思路是一个 key 失效之后只有第一个请求能去数据库查询并回填缓存其他请求先等一会儿拿到第一个请求写好的结果。实现方式有 Redis 分布式锁和本地锁两种分布式锁适合集群场景本地锁适合单机应用。我一般用 Redisson 的tryLock来做代码大致是public String getWithMutex(String key, String lockKey, CallableString loader) { String value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(0, 5, TimeUnit.SECONDS); if (!locked) { // 拿不到锁就先等 50ms 再读一次缓存别死等锁 Thread.sleep(50); return redisTemplate.opsForValue().get(key); } // 拿到锁后双重检查一次 value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } value loader.call(); redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(3600)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (locked) { lock.unlock(); } } return value; }这个方案的确定也很明显一旦数据库慢查询持续几秒后面拿不到锁的请求全都在短暂等待中排队延迟会拉长。所以我更推荐和“逻辑过期”一起用缓存里存一个过期时间戳超过逻辑过期时间就触发一个后台线程异步重建缓存请求立刻返回旧值。这样数据库压力被彻底削平用户无感知代价是短时间内可能拿到旧版本数据。电商首页、推荐列表这类业务都很适合这个策略。3.3 多级缓存与兜底值拼的是架构冗余只在一层缓存上做文章再精致的代码也扛不住 Redis 整个挂掉。更稳的做法是铺多级缓存。以 Qwen3.5-Plus 这套为例我当时加了本地缓存层 Caffeine热点数据在 JVM 内存里也留一份。Redis 崩了之后本地的 Caffeine 还能撑住相当比例的读流量只有本地缓存也没命中的请求才会穿透到数据库。两级缓存都被穿透的概率比单级缓存低出去好几倍。Caffeine 配置不复杂核心参数是 maximumSize 和 expireAfterWrite。我用的配置是最大条目 100005 分钟内无更新或未被读取就过期。这里要强调一个细节本地缓存里放的数据最好和 Redis 的 key 做一次区分比如加前缀否则你没法给不同层设置不同的 TTL。Redis 层 1 小时本地缓存层 10 分钟这个错峰本身就在降低雪崩碰撞概率也能让内存里的脏数据更快淘汰。另外就是兜底值。这里说的兜底值是指把上一次成功查询的旧数据序列化后存到本地持久化文件或者配置中心里当缓存和数据库都不可用时直接返回这个值。最典型的是访问量统计模块宁愿返回昨天的数字也不愿意让数据库因为流量波动挂掉。我在生产里实践过这个策略的坑在于数据新鲜度认知不一致上线之前要先和业务方确认“可以接受的旧数据过期时间窗口”不然运维又要背锅。3.4 高可用与限流熔断真到了 Redis 挂的时候怎么保命好多人以为 Redis 集群配上主从复制和哨兵就是高可用实际未必。主从切换本身也有几十毫秒到几秒的不可用窗口如果客户端没有处理连接重连逻辑照样会雪崩。我建议在 Redis 主从之外再考虑以下几步第一哨兵至少 3 个节点且部署在独立主机上避免哨兵和 Redis 同机挂掉。第二给主节点配置合理的maxmemory-policy防止内存写满后直接 OOM。第三客户端要开启连接自动恢复超时重试次数限制在 1 次超时时间设置成数据库单次查询耗时的 2 到 3 倍。限流熔断则是最后一道保险。网关入口层按用户维度做令牌桶限流单用户 QPS 上限通常设置为正常峰值的 1.5 倍超出直接返回友好提示。缓存层也要做一层熔断当 Redis 错误率连续 10 秒超过 30% 时直接切到本地缓存和兜底值模式不再等 Redis 自我恢复。这套逻辑不难实现难的是设计好“自动熔断”和“手动恢复”的开关。我踩过的坑是熔断恢复条件太刁钻Redis 已经恢复正常熔断器还开在那儿请求一直被拒最后查了半天才发现是熔断器没关。后来我规定熔断器半开状态下每 5 秒放一小撮请求进去探测连续几次成功就完全关闭熔断。4. 从一次真实修复看雪崩治理的完整落地过程4.1 排查关键指标拿到数据再动手接手一个雪崩现场先别急着敲代码。我一般按这几个维度去查监控Redis 的expired_keys、evicted_keys、connected_clients数据库的thread_running、innodb_row_lock_current_waits网关的请求总量、P99 延迟、5xx 状态码还有客户端 SDK 的重试次数和超时错误数。如果expired_keys有尖峰那大概率是过期型雪崩如果expired_keys平稳但connected_clients飙升那大概率是 Redis 不可用型雪崩。两条路径的分叉点就在这里。之前有一次我盯着 expired_keys 看了三分钟尖峰就出现在每分钟的整点那一下顺藤摸瓜定位到定时任务统一写 key 导致批量过期。这里有个经验监控面板最好做成 1 分钟粒度5 分钟的聚合太粗根本看不出峰值形状。要能捞原始秒级数据故障定位速度能快很多。4.2 修复清单与上线顺序别一次全改完排查到根因后改动顺序非常关键。以下是我在 Qwen3.5-Plus 那次修复里的实际操作顺序你可以当成参考清单用先把数据库连接池的上限从 200 降到 50防止雪崩时连接被彻底占满。接好限流规则单用户 QPS 上限设为正常值的 1.5 倍网关层先挡一波。在代码里把缓存过期时间改成随机抖动顺手处理掉续期逻辑。热点数据上互斥锁锁超时设为 5 秒避免重建久拖不决。接入 Caffeine 本地缓存部署后先压测验证是否还有穿透流量。最后才动手配置哨兵和熔断规则。这个顺序的核心逻辑是先把“能立刻止血”的事情做了再慢慢补架构。你要是反着来先花半天配置哨兵可雪崩还在继续打数据库业务可能早就挂了。像连接池上限调整这种改动一分钟就能上线但很多人嫌它影响性能死活不肯做。后来我明白一个道理在雪崩面前稳定的“慢”比不稳定的“快”更有价值。4.3 压测数据改造到底有没有效果修复上线后我用压测数据验证过效果。改造前缓存命中率 96%接口 P99 耗时 38ms数据库 CPU 峰值 70%压测模拟 10000 QPS缓存同时过期那一刻命中率掉到 22%P99 疯涨到 8 秒数据库 CPU 冲到 100%。改造后同样用 10000 QPS 压测缓存过期那 300 秒内命中率只在 88% 到 94% 之间波动P99 是 120ms数据库 CPU 峰值 45%。这个 45% 里还有一大部分是本地缓存穿透的贡献证明多级缓存确实把数据库挡在了安全线以内。有一组数据要重点看改造后expired_keys的秒级尖峰从每分钟 6 万个降到了 2 万个但总体过期 key 数量并没有大幅度下降。这说明随机化 TTL 只是把并发过期的波峰削平了并没有减少实际过期数量。这个理解很重要不然你会误以为随机值把 key 的总量变少了。4.4 上线后该盯哪些指标上线不是终点。我自己的习惯是发布后 72 小时内每天盯三组指标数据库thread_running的峰值、Redisexpired_keys的尖峰形状、网关层超时重试次数。任何一组指标出现异常波动都要立刻回查代码合入记录。这里有个很常见的翻车点只测了正常流量没测极端并发改造后第一次大促才发现连接池配置写错把 maxPoolSize 写成了 5压测时看着没事真实流量一冲就废。我另外的习惯是把这些指标做成一张专门的故障看板把背景基线也画进去。一旦实时曲线超过基线两倍就自动告警。平时不觉得这个看板有什么用但真出问题的时候它就是唯一能让你快速定位的救命稻草。5. 常见问题速查表这些坑我挨个踩过5.1 问题排查实录下面这张表是实际运维中遇到概率最高的问题每条都对应一个明确的判断点和处理动作。现象可能原因第一步排查推荐处理expired_keys 有陡峭尖峰大量 key 统一 TTL查看业务代码是否有统一 TTL 写入TTL 加随机值降低共振Redis 没宕机但数据库 CPU 100%缓存命中率骤降抓取一分钟内各 key 的 get 次数按热点 key 加互斥锁数据库连接数跑到上限连接池太小或慢查询堆积查 innodb_trx 长事务收紧连接池设置租约超时接口 5xx 大面积出现但缓存正常熔断/限流规则误触发看熔断器状态和限流计数检查半开条件开放手动恢复开关线程池排队但数据库空闲业务线程卡在锁等待打线程 dump 找锁位置优化锁粒度缩短临界区这些问题我都亲手处理过。第一行那次是定时任务把整份报表的缓存都设成 3600 秒第二行那次是本地缓存没分级热点 key 一失效全往 Redis 打第五行那次更好笑业务代码在 Redis 锁里面调了一个远程接口远程接口又调了一次这个 Redis 锁直接死锁线程卡了整整二十分钟。这些问题都不是什么高深故障但如果没有基本排查顺序很容易在错误方向上空耗。5.2 可以直接抄的一套基线配置如果你用的是 Spring Boot Redis下面这套配置可以作为起步值。注意它不是生产万能药只是我自己验证过的基础线具体数值要按业务流量再调。spring: redis: timeout: 3s lettuce: pool: max-active: 50 max-idle: 10 min-idle: 5 max-wait: 500ms配套的缓存管理建议把默认 TTL 设置放到配置中心不要写死在代码里。比如cache.default-ttl3600、cache.jitter-range300、cache.lock-wait5000这些参数在出问题时要能通过配置中心秒级修改不然改一次 TTL 要发一次版雪崩早就把线上打穿了。我还习惯把数据库读写的连接池分开配置避免慢查询占满连接池后连写入也受影响。5.3 避坑心得和一个小技巧最后分享一个压在箱底的习惯所有缓存 key 的过期时间不要用魔法数字全部封装成 TTL 枚举里面带上“持久”“短时”“中时”“长时”四类档位。这样排查时只要对着枚举看一眼就能知道哪些 key 容易共振。另一个技巧是给缓存读接口加一个异步旁路续期任务定期扫描热点 key 列表如果剩余 TTL 小于整体 TTL 的某个阈值就主动续期一次能显著降低热点 key 失效概率。这个思路是从分布式锁的看门狗机制里借鉴来的实践里非常好用。说回 Qwen3.5-Plus 那次现场真正让我长记性的不是那套高深的 Redis 集群方案而是“把过期时间打散”这件小事。技术方案再花哨不如先想清楚你的流量在什么场景下会共振又能在哪一层被削掉。缓存雪崩的本质是多个薄弱点叠加解法也不是靠一个技巧救全场而是把过期随机化、连接池收紧、多级缓存、限流熔断这些基本功都做到位数据库才能真正稳稳站在流量洪峰后面。
阅读完成 · 觉得有帮助?
咨询建站