拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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

缓存雪崩这词儿,我在生产环境里被它咬过不止一次,但真正让我把整套防御逻辑刻进脑子里的,是当时那个代号叫 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, Callable<String> 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。我用的配置是最大条目 10000,5 分钟内无更新或未被读取就过期。这里要强调一个细节:本地缓存里放的数据最好和 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 那次修复里的实际操作顺序,你可以当成参考清单用:

  1. 先把数据库连接池的上限从 200 降到 50,防止雪崩时连接被彻底占满。
  2. 接好限流规则:单用户 QPS 上限设为正常值的 1.5 倍,网关层先挡一波。
  3. 在代码里把缓存过期时间改成随机抖动,顺手处理掉续期逻辑。
  4. 热点数据上互斥锁,锁超时设为 5 秒,避免重建久拖不决。
  5. 接入 Caffeine 本地缓存,部署后先压测验证是否还有穿透流量。
  6. 最后才动手配置哨兵和熔断规则。

这个顺序的核心逻辑是先把“能立刻止血”的事情做了,再慢慢补架构。你要是反着来,先花半天配置哨兵,可雪崩还在继续打数据库,业务可能早就挂了。像连接池上限调整这种改动,一分钟就能上线,但很多人嫌它影响性能,死活不肯做。后来我明白一个道理:在雪崩面前,稳定的“慢”比不稳定的“快”更有价值。

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-ttl=3600、cache.jitter-range=300、cache.lock-wait=5000,这些参数在出问题时要能通过配置中心秒级修改,不然改一次 TTL 要发一次版,雪崩早就把线上打穿了。我还习惯把数据库读写的连接池分开配置,避免慢查询占满连接池后,连写入也受影响。

5.3 避坑心得和一个小技巧

最后分享一个压在箱底的习惯:所有缓存 key 的过期时间不要用魔法数字,全部封装成 TTL 枚举,里面带上“持久”“短时”“中时”“长时”四类档位。这样排查时只要对着枚举看一眼,就能知道哪些 key 容易共振。另一个技巧是给缓存读接口加一个异步旁路续期任务,定期扫描热点 key 列表,如果剩余 TTL 小于整体 TTL 的某个阈值,就主动续期一次,能显著降低热点 key 失效概率。这个思路是从分布式锁的看门狗机制里借鉴来的,实践里非常好用。

说回 Qwen3.5-Plus 那次现场,真正让我长记性的不是那套高深的 Redis 集群方案,而是“把过期时间打散”这件小事。技术方案再花哨,不如先想清楚你的流量在什么场景下会共振,又能在哪一层被削掉。缓存雪崩的本质是多个薄弱点叠加,解法也不是靠一个技巧救全场,而是把过期随机化、连接池收紧、多级缓存、限流熔断这些基本功都做到位,数据库才能真正稳稳站在流量洪峰后面。

返回列表