ARTICLE DETAIL

资讯详情

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

缓存雪崩排查与防御:从固定TTL到Redis高可用的完整实战

缓存雪崩排查与防御:从固定TTL到Redis高可用的完整实战 凌晨两点半钉钉连着震了十几下群里贴的监控截图让我瞬间醒透Redis命中率从95%砸到18%数据库CPU直逼100%慢查询日志一屏都装不下。当时我们Qwen3.5-Plus商品聚合服务的详情页接口正在经历一次教科书级别的缓存雪崩Cache Avalanche——大量缓存key在同一时刻集体过期平时被Redis挡掉的请求一瞬间全部砸到了MySQL头上。事后复盘时我发现很多团队对缓存雪崩的认知还停留在“听说过、好像很危险”的层面真到线上炸了又不知道排查顺序是什么。这篇文章我准备从两个触发条件开始拆把“大量key同时过期”和“Redis宕机”这两条线分开讲再给出一套可以直接抄走的防御组合拳最后用我们Qwen3.5-Plus第一次雪崩的完整复盘收尾。适合正在做高并发接口的后端开发、中间件运维还有被缓存问题反复折腾的SRE同学参考。1. 雪崩不是玄学两个触发条件背后是缓存层承担了它不该承担的流量先聊聊最基础的事缓存到底在替数据库挡什么。拿我们Qwen3.5-Plus的详情页接口举例一次请求的生命周期很简单请求进来先查Redis命中就直接返回序列化好的JSON没命中才查MySQL查到结果后再回填Redis并设置过期时间。正常运行时一万个请求进来Redis能挡住九千五百个真正打到数据库的只有五百个。MySQL稳定状态下的QPS大概两三千到一万连接池默认也就一两百扛住这点流量绰绰有余。问题就出在“同时”这两个字上。如果一万个key是在同一秒过期那这一秒的请求全部走miss分支数据库收到的是整整一万个查询。Redis单机QPS能做到十万级别而单机MySQL在几千QPS时CPU就开始拉警报连接池开始排队请求变慢调用方开始重试重试又放大了流量最后整个集群跟着完蛋。这就像银行网点平时有取号机分流取号机一坏所有人都涌向柜台柜员再多也处理不过来。1.1 一个请求的“正常路径”和“雪崩路径”正常路径的代码大概是这样的// 先查缓存 String cached stringRedisTemplate.opsForValue().get(qwen:product:1001); if (cached ! null) { return cached; } // 未命中查数据库 Product product productMapper.selectById(1001); // 回填缓存设置过期时间 stringRedisTemplate.opsForValue().set( qwen:product:1001, JSON.toJSONString(product), 3600, TimeUnit.SECONDS ); return product;这段代码在小流量下没有任何问题但它埋了一个雷所有key都固定设置了3600秒过期。如果这批key是同时写入的那它们也会在同一秒集体失效。雪崩路径本质上没有改任何代码逻辑只是把“每秒钟五百个miss”变味了“某一秒一万个miss”数据库那一瞬间根本接不住。1.2 穿透、击穿、雪崩为什么经常被人搞混很多面试八股文和线上排查里穿透、击穿、雪崩这三个词经常被混用但它们的成因和解法完全不同。概念核心场景典型原因影响范围缓存穿透查询一个缓存和数据库都不存在的数据恶意请求、非法参数每次请求都打DB不限于某个key缓存击穿单个热点key在失效瞬间被大量并发访问热点key刚好过期单个key造成的瞬时高并发缓存雪崩大量key同时失效或Redis整体不可用固定TTL、宕机、重启全量请求涌向DB最危险的场景为什么必须区分因为解法不能互相套用。缓存击穿可以用分布式锁让同一个key只有一个请求去回源但雪崩发生时如果也加锁一万个请求会全部堵在锁上排队等锁释放后又是一波瞬时流量等于给数据库来了个二次冲击。所以在雪崩面前分布式锁不是救命药反而可能添乱。2. 从批量key同时失效到Redis宕机两种诱因两种完全不同的应对路径标题里提到的两个触发条件——大量缓存key同时过期、Redis服务宕机——必须拆成两条线来看。它们的表象都是“请求涌向数据库”但持续时长、处理方式、防御侧重完全不一样。2.1 固定TTL是雪崩的头号种子我在排查过不少团队的缓存代码后发现固定TTL几乎是无处不在的。最常见的几种产生方式批量初始化脚本统一写入。比如上线时写了个for循环给一千个商品都设置了86400秒过期时间这些key会在24小时后的同一秒集体失效。定时任务集中刷新。每天凌晨3点跑一次全量预热把一批同前缀key统一刷新成2小时过期那这批key就会在凌晨5点整齐划一过期。业务上“整点生效”的设计。秒杀开始、每日榜单更新故意让缓存挂在整点失效结果整点同时也是请求高峰。框架层统一设置TTL。网关或微服务框架统一拦截缓存写入所有key共用同一个过期时间。为什么说固定TTL是“定时炸弹”因为它把失效时间精确地钉在了某一秒而业务流量本身是有周期的。假设预热任务是凌晨两点跑的key统一设成6小时过期那么上午八点正是早高峰缓存却偏偏全部失效。更隐蔽的是Redis的过期机制本身还会放大问题它采用惰性过期和定期过期两种策略大量key同时到期时定期删除循环会突然占用大量CPURedis的读性能也会跟着下降服务端和数据库两头一起遭殃。2.2 Redis宕机比key过期更均匀但更猛的一刀另一条线是Redis宕机。很多团队以为宕机就是进程挂了实际上主从切换期间不可写、网络分区导致读写超时、内存碎片率过高触发内存换页、AOF重写阻塞主线程都会让上层体验到“缓存不可用”。宕机的可怕之处在于它不是“瞬间一击”而是“持续高压”。key同时过期至少还有个恢复窗口过完那几秒缓存就自动回来了但Redis进程挂掉后如果主从切换失败缓存会持续空白几分钟甚至更久数据库这段时间里扛的是全量流量而且扛不住就彻底僵死。还有一个细节很多人栽过客户端连接超时后如果不加控制地重试流量会被放大好几倍。有些团队用的Lettuce客户端默认没开启拓扑刷新主从切换后客户端依然往旧的master地址上发请求出现“redis command timed out”报错后服务端又拼命重试数据库压力二次放大。这个坑我会在后面“主从哨兵”那节专门讲怎么避免。3. 让过期时间“交错开”随机化、逻辑过期与多级缓存的组合打法针对“大量key同时过期”这条线防御手段的核心思路只有一个让缓存失效时间从“同一时刻”变成“一段区间”。下面这几种方法我按部署成本从低到高排了一下建议组合使用。3.1 给过期时间加随机偏移成本最低、见效最快最简单能落地的改造是把固定TTL改成“基础TTL 随机偏移”。原本一秒内全部过期现在分散到十分钟内陆续过期数据库每秒多承受的压力就变得非常有限。public void setWithJitter(String key, String value, long baseTtlSeconds, int jitterPercent) { long range baseTtlSeconds * jitterPercent / 100; long ttl baseTtlSeconds ThreadLocalRandom.current().nextLong(0, range 1); stringRedisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS); }偏移幅度怎么取我建议基础TTL的10%~20%。太小了没意义比如一小时加1%就是36秒对错峰毫无帮助太大了会显著降低缓存命中率等于提前让一批key失效。如果缓存周期本身是1小时偏移五六分钟就足够平滑了。这里还有个隐性问题团队里每个人写的缓存代码风格不一样有人用setex有人先set再expire。建议把随机TTL封装到公司统一的Redis工具类里直接在SDK层处理后续任何人接入都不会再写出固定时间的代码。3.2 逻辑过期与异步刷新热点数据不走物理过期随机偏移适合大多数普通key但对于秒杀商品这种热点数据我们希望它“永远不过期内容却可以随时变”。这时候可以用逻辑过期方案。所谓逻辑过期就是Redis里不设置物理TTL而是在value里塞一个过期时间戳。读取时先判断时间戳未过期直接返回已过期则先返回旧值同时异步触发一个回源刷新任务更新缓存。// 写入时包装逻辑过期时间 CacheObj obj new CacheObj(...json..., System.currentTimeMillis() 60_000); redis.set(qwen:hot:1001, JSON.toJSONString(obj)); // 读取时判断逻辑过期 CacheObj obj JSON.parseObject(redis.get(qwen:hot:1001), CacheObj.class); if (obj.getExpireAt() System.currentTimeMillis()) { return obj.getValue(); // 未过期直接返回 } asyncRefresh(qwen:hot:1001); // 已过期先返回旧值异步刷新 return obj.getValue();异步刷新这里一定要做并发控制否则同一个key被一百个请求同时触发会有一百个线程去查库。用分布式锁把刷新动作锁住但要注意锁的粒度只锁“正在刷新中的那个key”不要全局一把锁。这个方案最大的代价是短暂读到旧数据所以只适合对一致性要求不高的读多写少场景——比如商品详情、推荐列表库存余额这类数据千万别这么干。3.3 本地缓存加Redis双层防护下的容错空间第三招是引入本地进程内缓存。以Caffeine为例每个JVM实例的本地缓存过期时间也可以加随机数这意味着即使Redis整体失效各实例的本地缓存还在它们天然分散在不同时间点过期能挡住不小比例的流量。Caffeine.newBuilder() .expireAfterWrite(Duration.ofSeconds(600 ThreadLocalRandom.current().nextLong(120))) .maximumSize(5000) .build();注意本地缓存的适用场景数据量不能太大最多存几千到几万个热点key对一致性不能太敏感因为不同实例之间的数据天然会有几十秒甚至几分钟的偏差。我们通常只把最热的商品详情和推荐位内容放进本地缓存冷门key直接略过不然内存会爆炸。3.4 请求合并回源避免同一key的并发miss重复打库请求合并解决的是另一个问题同一瞬间大量请求访问同一个未命中的key时能不能只让一个请求去数据库回源其他请求等着拿结果。ConcurrentHashMapString, CompletableFutureObject inflight new ConcurrentHashMap(); public Object getOrLoad(String key) { return inflight.computeIfAbsent(key, k - CompletableFuture.supplyAsync(() - loadFromDb(k))) .get(200, TimeUnit.MILLISECONDS); }原理很简单进程内维护一个“正在进行回源”的Future后续相同key的请求直接挂在这个Future上等待。但它有一个很关键的边界请求合并只对“同一个key”生效。雪崩发生时往往有几千个不同key同时失效合并就帮不上忙了。所以它更适合应对缓存击穿场景而不是全量雪崩。真到了数据库已经被打满的时候合并反而会让大量请求阻塞在Future上拖着线程池一起死那时候应该直接走降级快速返回。4. Redis挂了之后怎么办业务层的限流、熔断与降级才是真正的兜底如果说第三章讲的是“让缓存更抗造”这一章要面对的现实是光靠缓存层自身的加固永远治不了本。Redis宕机的场景只有靠业务层的保护机制扛过去。就我个人经验而言很多团队在Redis高可用上花了大把时间却在限流降级上一点准备都没有这是最大的误区。4.1 先想清楚缓存不可用时系统是“快失败”还是“降级”Redis挂了以后你的接口第一反应是什么这一条必须在设计阶段就定好而不是等故障发生了再临时开会决定。我习惯用一个简单分类业务类型用户容忍度推荐策略交易、支付、库存扣减不能接受错误结果快速失败提示稍后再试商品详情、资讯、榜单可以接受轻微延迟或旧数据降级兜底后台报表、管理端可以接受排队异步任务慢慢拉取快失败不是消极应对它本身就是保护数据库的手段。直接返回“系统繁忙”比让用户请求一路打到数据库、再把数据库拖垮要好得多。真正危险的是接口既没有降级方案又没有快速失败策略任由所有请求穿透到DB层。4.2 基于数据库吞吐设置限流阈值限流的阈值不能拍脑袋定要先知道数据库的“安全水位”是多少。压测后如果MySQL在3000 QPS时CPU已经70%那就用它的60%~70%作为限流阈值即1800~2100 QPS超出部分直接返回降级结果。一个简单的信号量实现就能起到保护作用Semaphore dbSemaphore new Semaphore(200); // 同时访问DB的最大并发数 public Object getProduct(String key) { if (!dbSemaphore.tryAcquire(50, TimeUnit.MILLISECONDS)) { return degradedResult(key); // 拿不到信号量直接降级 } try { return loadFromDb(key); } finally { dbSemaphore.release(); } }这里故意用了tryAcquire而不是acquire就是要让拿不到信号量的请求立即走降级而不是阻塞排队。如果200个并发都阻塞在信号量上等数据库线程池很快就会被占满后面连降级请求都进不来了。4.3 熔断点要放在“数据库访问”和“下游RPC”上很多人以为熔断是Redis的事这不对。缓存雪崩时真正的受害者是数据库和下游服务熔断应该布置在数据库访问层和RPC调用层。基础参数可以这样设滑动窗口10秒内最小请求数达到20个失败率超过50%就打开熔断熔断打开后30秒进入半开状态放行5个试探请求如果成功就关闭熔断失败就继续打开。要特别注意熔断打开之后被熔断的请求必须配套快速失败或降级逻辑。不然请求虽然没打到数据库却全压在了熔断器自己的判断逻辑上照样把服务线程池打满。4.4 降级流程设计先从“次要数据”降再从“核心数据”降降级方案最好做成动态开关由配置中心一键下发。以Qwen3.5-Plus的详情页为例我们的降级顺序是推荐位列表降级先返回空列表再切换为本地静态推荐数据。商品价格降级读取最近一次成功回源时序列化到本地磁盘的价格快照。库存展示降级直接用页面静态文案替换实时库存。为什么先降次要数据因为推荐位是锦上添花价格和库存才是用户真正关心的核心信息。如果把核心数据也降级了整个页面就废了。但即使只降次要数据也能把回源DB的QPS砍掉一大半。5. Qwen3.5-Plus第一次雪崩的完整复盘从告警到根因修复的两小时理论讲再多不如一场真实事故来得刻骨铭心。我以当时的时间线把Qwen3.5-Plus第一次缓存雪崩从发生到修复的全过程完整还原一下重点展示排查链路和决策逻辑。5.1 故障表象15分钟内从P99翻倍到全站不可用那天晚上的表象很有迷惑性。一开始只是Redis命中率缓慢下降从95%掉到90%看起来像流量波动十分钟后命中率直接掉到30%以下数据库慢查询告警开始刷屏再过五分钟整个服务集群的P99从60ms飙升到5秒大量请求超时。最让人抓狂的是当时从上到下没人知道发生了什么。Redis服务本身是活的PING命令能通但几乎所有的查询都在走数据库。5.2 排查链路先证明Redis活着再证明key“扎堆死了”我们当时的排查顺序现在回头看是对的先排除Redis宕机再检查key的过期分布最后回溯代码变更。第一步确认Redis进程状态。使用redis-cli --stat观察每秒命令数dbsize看key总量再用INFO memory看内存占用。所有指标正常说明不是宕机线的问题。这里要特别提醒一点监控图上Redis的QPS和延迟都很正常因为它只承受了原本流量的一小部分真正的压力全在数据库那边。第二步抽样看key的TTL分布。这一步最关键也是很多团队会忽略的。我们写了个脚本从qwen:product:*前缀下随机抽2000个key逐一执行TTL命令然后按秒统计分布。redis-cli --scan --pattern qwen:product:* | head -2000 | while read k; do echo $(redis-cli ttl $k) done | sort | uniq -c | sort -rn | head -20结果触目惊心2000个key里TTL在300~299秒之间的占了一大半。这意味着再过五分钟左右这批key就会像闹钟一样同时响起来。我们再看时间这批key是晚上7点30分左右集中写入的每900秒过期一次下一次集体失效正好落在流量高峰上。第三步回溯变更记录。找到当晚上线的一个批量脚本里面用for循环给所有商品统一执行了setex命令过期时间硬编码成900秒。到这里根因已经很清楚了。5.3 临时止血与长效修复根因定位后我们没有急着改代码而是先做了三件事止血把核心商品的缓存从“物理过期”改成“不设过期”由于这些数据变化频率低短期风险可以接受。将数据库的读流量切换到专门的只读副本主库只承担写入避免慢查询拖垮主库。在配置中心紧急下发降级开关把推荐位和详情页的非核心数据全部降级。等系统稳定后才启动长效修复封装带随机偏移的TTL工具类、在监控中增加命中率和过期key增速告警、把“批量key集中过期”加入上线前压测用例。这次事故给我最大的教训是修复一个过期时间设置并不难难的是让团队从流程上避免再犯同样的错误。如果只是改个随机数而不沉淀机制第二次雪崩只是时间问题。6. 别让缓存层成为单点主从哨兵、集群分片与持久化兜底本章回到Redis自身的高可用话题。很多人以为把Redis升级成主从或集群就万事大吉但部署架构只是第一步客户端配置、持久化策略、发布规范同样决定你的缓存层在极端情况下能不能撑住。6.1 主从加哨兵让Redis进程挂掉不再是事故主从哨兵是目前最常见的Redis高可用方案。Master负责读写Slave负责备份Sentinel集群监控Master的健康状态Master挂了以后自动发起选举把某个Slave提升为新的Master。但这里有个非常隐蔽的坑即使Sentinel完成了主从切换客户端也未必能马上感知到新Master。以Spring Boot里常用的Lettuce为例如果不开启拓扑刷新客户端会持续往旧Master地址发请求出现“redis command timed out”而这恰恰是很多人主从切换后遇到的第一个线上事故。所以一定要在配置里开启节点自动发现spring: data: redis: sentinel: master: mymaster nodes: sentinel1:26379,sentinel2:26379,sentinel3:26379 lettuce: pool: max-active: 200即使有了哨兵架构主从切换窗口期的几十秒到几分钟内Redis依然可能短暂不可用。所以上一章的业务层限流降级不能因为高可用架构而省略。6.2 Cluster分片把“全量失效”变成“局部失效”Redis Cluster把数据分到16384个槽位通过CRC16(key)计算落点每个节点负责一部分槽位。这样做的好处是单节点故障时只影响它负责的槽位对应的key其他节点还能正常服务不会出现全局缓存空白。什么时候该上Cluster单节点内存逼近10GB、QPS长期超过5万、或者对故障隔离粒度有更高的要求。但别神话Cluster如果热点请求高度集中在一个节点那个节点挂掉时局部流量依然可能打穿数据库。所以分片只是缩小了爆炸半径不是消灭了爆炸。6.3 持久化与重启被忽视的“人工雪崩”Redis重启后缓存全空所有请求一次性涌向数据库这本质上就是一次主动触发的雪崩只是时间轴从整点变成了部署时间。很多团队没有意识到这一点发布时直接重启Master结果把数据库打崩了。持久化配置建议至少做到appendonly yesappendfsync everysec。这样极端情况下最多丢失一秒的数据重启后能在较短时间恢复大部分缓存。发布规范上尽量先重启从节点再手动切换主节点避免Master清空后直接暴露在流量前面。7. 预防比抢救更值钱监控指标、代码规约与故障演练文章最后这部分讲的是日常治理。缓存雪崩这种事抢救做得再好也是事后补救真正的高手会把精力花在“让它不发生”上。下面这几条是我们Qwen3.5-Plus团队现在坚持做的贵在长期执行。7.1 四个指标盯紧雪崩前夜就能看见雪崩发生前一定会有征兆关键是你的监控能不能发现。缓存命中率从95%掉到80%并且持续超过一分钟就该告警了。expired_keys增量速率用INFO stats里的expired_keys字段计算每秒增量一旦出现突刺说明有大批key正在集中过期。数据库慢查询数和连接池使用率这两项上升往往比业务层告警更早。Redis服务端指标blocked_clients、内存碎片率、主线程阻塞耗时这些能帮你提前发现内存问题引发的“假宕机”。给一个Prometheus告警规则示例groups: - name: cache-availability rules: - alert: RedisHitRateDrop expr: 100 - (rate(redis_keyspace_hits_total[1m]) / (rate(redis_keyspace_hits_total[1m]) rate(redis_keyspace_misses_total[1m])) * 100) 20 for: 1m labels: severity: critical annotations: summary: Redis命中率下跌超过20%没有完整监控体系的团队先别急着上复杂组件。写个定时脚本每分钟抓一次INFO stats把hit_rate、expired_keys、evicted_keys落到本地日志或Prometheus里先把趋势看起来。7.2 把随机TTL固化到代码规范里代码规约是成本最低的防线。我们的做法是Redis操作统一走公司封装的工具类工具类里提供的setWithJitter方法强制要求传入基础TTL和随机偏移范围业务方不允许绕过。Code Review时看到任何固定过期时间的代码直接打回并附上标准评论“这个固定TTL会在预计时间点引发一次缓存雪崩。”听起来很武断但确实有效。7.3 上线前的“灾难演习”应当包含缓存失效故障演练不是走形式要真的模拟三种场景用脚本把一批key的TTL改成1秒观察数据库压力曲线。直接kill -9Redis进程模拟单点不可用验证降级开关能不能快速兜底。同时重启多个服务实例观察缓存重建时的流量冲击。每次演练要有报告回答三个问题数据库是否还在安全水位告警是否及时触发降级开关能不能一键开启如果其中任何一项没通过就说明预案还停留在PPT阶段。7.4 别忘了一并治理缓存穿透经常有人把雪崩和穿透割裂来看但数据库同时承受“雪崩压力”和“穿透扫描”时会死得更快。穿透的常规治理就两板斧布隆过滤器拦截不存在的key或在缓存里给空值也设置一个较短的过期时间。和雪崩治理一起做才能形成完整的缓存治理体系。Qwen3.5-Plus的这套方法用了大半年后再没发生过一次真正的雪崩倒是有几次眼看要到告警阈值由于随机TTL和本地缓存的双重缓冲数据库的负载警报都只是闪了一下就消失了。我自己的体会是监控能帮你早看见但真正让系统“活下来”的永远是业务层的降级和限流预案。以后再有人问我高并发缓存怎么设计我就反问一句如果Redis整体失效你的接口能撑几分钟这个问题问多了很多隐患在评审阶段就会现形。
返回列表