ARTICLE DETAIL

资讯详情

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

缓存雪崩的成因与防护:从Redis到回源限流的完整指南

缓存雪崩的成因与防护:从Redis到回源限流的完整指南 提到缓存雪崩很多人的第一反应就是“Redis 集群挂了”。但实际上我带过的线上事故里十次雪崩有七八次 Redis 都活得好好的而是某一批 key 同时过期了或者一个大 key 把 Redis 拖慢了几百毫秒整个读链路就崩了。对后端来说雪崩的杀伤力不在于缓存掉线那一刻而在于回源流量瞬间砸垮数据库之后就算缓存恢复系统也会在窗口期里持续恶化。这篇文章会把缓存雪崩的成因、拦截手段、最后防线和验证方法完整拆一遍适合正在维护高并发读链路、又不想在大促前夜被报警电话叫醒的同学参考。我先讲一个自己踩过的场景。某次大促压测前我们做了一轮缓存预热把商品详情、库存、活动配置全部写进 Redis过期时间统一设成了 30 分钟。结果第 30 分钟前后数据库 QPS 从 800 直接飙到 13000连接池被打满服务告警瞬间刷屏。检查 Redis 的时候一切正常慢查询也没有就是那一批 key 正好在同一时刻集体失效。这种问题不看破绽根本想不到因为“缓存失效”四个字在监控面板上就是命中率跳水没有专门的告警项。从那以后我对缓存雪崩的理解就变了它不是单点故障而是“缓存层集中失守”的系统性风险必须用一套组合动作来应对。1. 雪崩前的“平静日子”三种被低估的触发路径1.1 先分清矛盾穿透、击穿、雪崩不是一回事很多人把缓存穿透、击穿、雪崩混着聊其实处理手段完全不同。穿透请求的数据根本不存在缓存和数据库都查不到每次请求都直接打到 DB。常见于恶意攻击或者查询非法 ID比如有人拿id-1或者一个超大随机数来刷接口。击穿某一个热点 key 在某个瞬间过期大量并发请求同时回源。比如一个明星直播间的商品详情key 刚好过期几十万人在同一秒刷新DB 直接被击穿。雪崩大量 key 同时失效或者缓存层整体不可用导致几乎所有请求都回源 DBDB 被压垮后服务进入雪崩状态。雪崩的关键特征是“覆盖范围大、恢复窗口长”它不只是某个 key 的问题而是整个缓存层在短时间内失去拦截能力。所以应对雪崩不能只靠给单个 key 加锁必须有全局的兜底设计。1.2 第一条路同一拨 key 集体过期这是最常见的触发方式。很多团队写缓存时有一个习惯所有业务 key 统一设置固定的过期时间比如 1 小时、30 分钟、第二天零点。表面上看每个 key 生命周期都正常但只要 key 的生成时间是连续的它们就会在同一个时间点集体失效。举一个具体例子。你有一条定时任务每天凌晨把一批活动配置写进缓存expire全部设为 3600 秒。那么一个小时之后这批 key 会在同一秒过期。假设有 5 万个 key过期后第一波用户请求刚好命中这个区间DB 就会瞬间接到 5 万个回源查询。如果平时 DB 的 QPS 只有 2000这一波直接就是 25 倍流量连接池、SQL 慢查询、CPU 全部报警。我见过更隐蔽的版本key 不是同一批写入的但它们都设置了“当天 23:59:59 过期”。每天夜里临近 24 点所有当天写入的缓存都集中失效系统每到深夜就抖动一次。1.3 第二条路缓存节点故障后的冷启动瞬间有人会觉得Redis 做了哨兵或者 Cluster主节点挂了能自动切换高可用就够了。但实际上主从切换期间客户端会有一段时间完全访问不了缓存流量全部回源。切换完成后新的主节点是冷启动状态缓存里什么都没有后续流量依然全部回源。如果切主后没有做预热Redis 是空的等于缓存层从“不可用”变成了“可用但没数据”DB 依然承担全部流量。这种情况和 key 同时过期的差别在于expire 导致的雪崩是一波流量打上来节点故障导致的雪崩是一个持续窗口期。持续窗口期的杀伤力更大因为你不知道 Redis 要多久才能把热点数据重新积累起来。1.4 第三条路Redis 只是“变慢”了线上也会崩这一条最容易忽略。缓存雪崩不只是“缓存失效”还包括“缓存变慢”。Redis 是单线程模型一个慢命令的执行会阻塞整个实例。当你执行DEL删除一个几百万成员的集合或者执行KEYS *Redis 会被卡住几百毫秒甚至几秒。这期间所有缓存读写都超时客户端纷纷走回源逻辑DB 瞬间被打满。我遇到过一起事故清点粉丝列表时用了DEL bigKeyRedis 阻塞了 1.2 秒期间接口超时率达到 70%所有查询回源 DB连接池被打穿。事后看监控Redis 本身没挂CPU 也没有异常就是那一个删除命令让整个缓存层“失灵”了几秒钟。如果你只盯着“Redis 有没有宕机”来判断雪崩那这一类事故你永远发现不了。2. 从源头拆掉“同时过期”这根引线2.1 过期时间随机化基础时长怎么定解决集体过期的第一招是过期时间随机化但很多人做得不够彻底。简单加一个随机值有效但随机区间太小key 数量大了之后依然会形成新的“波峰”。比如统一 3600 秒随机加 0 到 60 秒1 万个 key 仍然可能集中在 3600 到 3660 秒内失效DB 还是会接到一波尖峰。我的做法是基础过期时间覆盖业务需要的缓存时长随机区间要能覆盖一个完整的业务周期。比如商品详情缓存希望保留 1 小时那基础时间设 3600 秒随机区间设为 600 到 3600 秒实际过期时间在 4200 到 7200 秒之间均匀分布。这样 key 的失效时间就摊开了不会形成明显波峰。// 基础有效期 1 小时 long baseExpire 3600L; // 随机附加 10 到 60 分钟让 key 的过期时间在 70~120 分钟内均匀分布 long randomExpire ThreadLocalRandom.current().nextLong(600L, 3600L); long finalExpire baseExpire randomExpire; redisTemplate.opsForValue().set(key, value, finalExpire, TimeUnit.SECONDS);这一段代码本身很简单关键是你要在缓存写入的公共入口统一做不能散落在各个业务代码里否则后面维护的人很容易悄悄写死一个固定时间。2.2 热点 key 单独续期把重建窗口错开随机化解决了“集体过期”但对单个热点 key 还不够。一个超级热点 key就算过期时间随机到了凌晨三点过期那一刻依然可能有百万请求同时回源。这种场景需要给热点 key 单独做续期不要让它的过期时间完全靠自然流逝。实现方式不复杂在客户端读缓存时如果发现 key 的剩余 TTL 小于某个阈值比如 60 秒就触发一个异步任务去后台刷新这个 key 的过期时间。这样热点 key 永远不会真正过期只在后台被“续命”。// 读缓存时顺带检查 TTL触发续期 Long ttl redisTemplate.getExpire(key, TimeUnit.SECONDS); if (ttl ! null ttl 0 ttl 60) { // 异步续期避免阻塞当前请求 asyncExecutor.submit(() - refreshCache(key)); }续期操作要注意一个问题不能让所有客户端都同时触发续期。否则热点 key 过期前的一分钟所有请求都会提交一次 refresh等于把 DB 压力平移到了异步任务里。我一般会用一个SETNX锁或者本地去重同一个 key 只允许一个刷新任务在运行。2.3 低峰期预热给 180 秒后就要过期的 key 续命随机化 热点续期能解决大部分问题但总有一些 key 是定时任务写入的、过期时间高度一致。除了改代码还有一个很实用的兜底策略低峰期预热。具体思路是维护一个 ZSetscore 存 key 的绝对过期时间member 存 key 名。每次写缓存时往 ZSet 里塞一条记录定时任务每分钟扫描一次把 score 在“当前时间 180 秒”范围内的 key 找出来提前重新加载数据并刷新过期时间。// 写入缓存时顺便登记过期时间索引 redisTemplate.opsForZSet().add(CACHE_EXPIRE_INDEX, key, expireAt); // 定时任务每分钟扫一次 SetString needRefreshKeys redisTemplate.opsForZSet() .rangeByScore(CACHE_EXPIRE_INDEX, 0, System.currentTimeMillis() 180_000); for (String key : needRefreshKeys) { refreshCache(key); }这个方法能保证绝大部分 key 在过期前被动刷新掉真正“自然过期”的 key 数量大幅减少。代价是需要多维护一个 ZSet并且所有缓存写入都要走公共入口适合对稳定性要求比较高的核心业务链路。2.4 随机化常见失效场景随机化不是做了就一劳永逸有几个场景会让它失效。运维批量清理有人手动redis-cli --scan --pattern xxx | xargs redis-cli del清完之后业务自动重建重建时如果写入逻辑用了一个固定过期时间所有 key 又变成同一时刻过期。时间边界重置缓存代码里写“当天 23:59:59 过期”这种逻辑随机化完全无效因为业务要求所有 key 在一天结束时统一失效。逻辑过期时间业务在 value 里存了一个业务过期时间自己做校验。虽然 Redis 的 TTL 随机了但业务逻辑里时间一到就不认缓存了效果等于同时过期。所以做随机化的时候最好在所有写缓存的入口统一封装并且把“过期时间”的生成逻辑收敛到一个方法里避免各业务自己写一套。3. 回源请求收敛本地缓存、单飞、互斥锁怎么配合3.1 先算一笔账数据库连接池能扛多少回源不管怎么随机化过期总会有节点故障也避免不了。所以必须做好最坏打算缓存失效后大量请求同时回源 DB。这时候要先把 DB 的真实承载能力算清楚。以 MySQL 为例连接池一般配置 50 个连接单条查询平均耗时 50ms那么数据库的理论 QPS 上限是50 * (1000 / 50) 1000。如果你的业务流量是 5000 QPS缓存一失效DB 就会被 5000 QPS 打过来远超 1000 的上限连接池排队的请求越来越多最终整个服务不可用。看到这个数字你就明白回源请求必须被“收敛”也就是把大量并发回源压缩成少量请求让 DB 承受的压力保持在能力范围内。3.2 本地缓存兜底Caffeine 怎么配本地缓存是回源收敛的第一层。每个服务节点在本地维护一份热点数据的副本Redis 失效时先查本地缓存查不到再走 RedisRedis 也失败了再回源 DB。我用 Caffeine 比较多配置如下CacheString, Object localCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats() .build();读链路就变成了Object value localCache.getIfPresent(key); if (value ! null) { return value; } value redisTemplate.opsForValue().get(key); if (value ! null) { localCache.put(key, value); return value; } // 最后才回源 DB value loadFromDB(key);本地缓存能扛住 Redis 故障后的第一波冲击因为每个节点的本地缓存还在请求不需要全部回源。但本地缓存有几个问题一是容量有限只能放最热的数据二是有一致性问题数据更新后本地缓存不会立刻失效只能靠短期过期来容忍三是多节点之间缓存不同步。所以我的定位是本地缓存只放“一致性要求不高、读多写少”的数据比如商品基础信息、配置项、分类树。价格、库存这类强一致数据不能放否则会出现库存已经扣减但本地缓存还在卖的情况。3.3 单飞机制合并相同 key 的回源单飞singleflight解决的是“同一个 key 在同一时刻被大量并发请求回源”的问题。核心思想是对同一个 key只让一个请求真正去查 DB其他请求等待这个请求的结果。Go 里直接用golang.org/x/sync/singleflightvar group singleflight.Group func GetData(ctx context.Context, key string) (interface{}, error) { v, err, _ : group.Do(key, func() (interface{}, error) { // 这里才是真正的回源 return loadFromDB(ctx, key) }) return v, err }Java 里没有官方实现但思路很简单用一个ConcurrentHashMap维护每个 key 当前正在执行的 future相同 key 的请求都挂到这个 future 上等待结果。单飞对“热点 key 击穿”非常有效但在真正的雪崩场景下它的作用是有限的——因为雪崩是大量不同 key 同时失效单飞只能合并相同 key 的请求不同 key 的回源请求依然会全部打到 DB。所以单飞更适合和本地缓存、熔断配合而不是单独作为雪崩的解决方案。3.4 互斥锁重建细节和坑互斥锁是另一种回源收敛手段通常和单飞配合使用。它的思路是回源前先抢一把锁抢到的线程负责查 DB 并写缓存抢不到的线程短暂等待后重读缓存。String lockKey lock:cache: key; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { Object value loadFromDB(key); redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS); return value; } finally { redisTemplate.delete(lockKey); } } else { // 等等再读缓存 Thread.sleep(ThreadLocalRandom.current().nextLong(50, 200)); return redisTemplate.opsForValue().get(key); }这个方案的坑不少我列几个实际踩过的锁超时时间不能太短DB 查询慢的话锁可能提前过期第二个线程又拿到了锁导致多个线程同时回源。锁超时建议设置为“预估回源耗时 2 秒”。抢锁失败后的行为要限流不能无脑 sleep否则 Redis 锁住了但线程还在疯狂重试把 CPU 打满。锁粒度锁 key 要带业务 key否则所有数据共用一把锁会导致不同 key 的回源互相阻塞。finally 里删锁要判断是不是自己加的锁防止把别人的锁误删。推荐用 Lua 脚本实现“比对值再删除”。单飞和互斥锁可以同时用单飞负责合并相同 key 的并发回源互斥锁负责防止不同实例之间的并发回源。不过说实话如果雪崩已经发生回源请求非常多光靠锁是不够的必须上流量治理手段。4. 熔断、降级、限流最后防线的阈值怎么定4.1 熔断条件不能拍脑袋熔断的作用是当 DB 开始扛不住时主动切断回源流量宁可返回旧数据或错误也不能把 DB 打垮。但熔断条件设置得过松保护不了 DB设置得过紧正常流量也会被误伤。我习惯用压测数据反推。还是上面那个例子MySQL 压测出的最大 QPS 是 1500那么熔断阈值可以设为 1200留 20% 的余量。触发条件不要只看 QPS还要看错误率和慢调用比例。以 Resilience4j 的配置为例resilience4j.circuitbreaker: instances: dbQuery: slidingWindowSize: 100 failureRateThreshold: 40 slowCallRateThreshold: 40 slowCallDurationThreshold: 1s waitDurationInOpenState: 30s意思是最近 100 次调用中如果失败率或慢调用率超过 40%熔断器打开 30 秒期间直接走降级逻辑。这里的关键是slowCallDurationThreshold如果你平时查询 50ms缓存失效后回源变成 800ms说明 DB 已经接近极限了慢调用率是一个很好的早期信号。4.2 降级策略要分业务等级熔断之后要做降级降级不是简单返回一个 null而是给用户一个“次优结果”。我把降级策略按业务等级分三类强一致数据不能降级比如订单支付状态、库存扣减。这类数据缓存失效了该回源就得回源最多通过限流保护 DB不能给错误结果。弱一致数据可以降级为本地缓存比如商品基本信息、推荐列表回源失败时返回本地缓存或上次成功的结果。非关键数据可以降级为默认值比如用户个性化推荐、广告位直接返回兜底列表或空列表不影响核心流程。具体实现时我建议把降级逻辑做成一整套配置开关不要散落在业务代码里。例如在接口入口处定义一个 fallback handler根据业务等级决定返回什么。大促前把这些开关全部检查一遍确保能一键开启。4.3 两级限流入口限流和 DB 限流熔断是事后保护限流是提前约束。我推荐做两级限流。第一级是入口限流保护整个系统不被打垮。比如接口整体限流 5000 QPS超过的直接返回 503让调用方做重试或降级。可以用 Guava RateLimiter 或者 Sentinel 做RateLimiter limiter RateLimiter.create(5000.0); // 每秒 5000 个请求 if (!limiter.tryAcquire()) { return fallbackResult(); }第二级是 DB 限流保护数据库不超过压测极限。在回源 DB 的公共方法上加信号量限制同时进入 DB 的并发数。Semaphore 设成 50也就是最多 50 个线程同时查询 DB其余请求直接走降级。Semaphore dbSemaphore new Semaphore(50); if (dbSemaphore.tryAcquire()) { try { return loadFromDB(key); } finally { dbSemaphore.release(); } } return fallbackResult();这套组合下来就算缓存全挂了DB 承受的并发也不会超过 50服务对外可能表现成“部分请求降级”但不会全站不可用。5. 真正被低估的隐形杀手大 key、fork 阻塞与连接池耗尽5.1 大 key 删除阻塞一次 DEL 引发的“伪雪崩”这一节说的是 Redis 本身的问题但它导致的线上表现和缓存雪崩完全一样。Redis 是单线程处理命令DEL一个包含几百万元素的 key删除动作会一次性执行完期间整个实例阻塞。我遇到过一个 Group 场景一个群聊成员列表是一个ZSET成员数达到 20 万。清理数据时执行了DEL group:12345:membersRedis 直接卡了 1.2 秒。这 1.2 秒里所有读写缓存的请求全部超时紧接着大量请求回源 MySQL把连接池打爆。应对大 key 的方法不要用DEL用UNLINKUNLINK是异步删除主线程不会被阻塞。拆分大 key把成员列表按组内成员 ID 取模拆成多个小 key比如members:12345:0、members:12345:1。定期扫描用redis-cli --bigkeys找出大 key提前处理。排查大 key 的命令redis-cli --bigkeys输出里会列出每个类型里最大的几个 key。我建议每周跑一次尤其是大促前把 top 10 大 key 全部过一遍。5.2 fork 阻塞RDB 持久化的隐藏停顿Redis 开启 RDB 持久化后bgsave会 fork 一个子进程。fork 瞬间父进程要复制内存页表如果 Redis 占用了大量内存fork 操作本身就会阻塞主线程几百毫秒。这里有个常见误解以为 Redis 是多线程的子进程持久化不会影响主进程。实际上 fork 是同步操作期间 Redis 无法处理任何命令。内存越大、写入越频繁fork 阻塞时间越长。排查手段redis-cli info stats | grep latest_fork_usec如果latest_fork_usec超过 100_000也就是 100ms就要警惕了。优化手段是降低单实例内存占用开启auto-aof-rewrite-percentage或者调整maxmemory不要让 Redis 实例膨胀到几个 G 甚至十几个 G。5.3 连接池耗尽和重连风暴缓存雪崩的时候很多人只盯着 Redis 和 MySQL却忘了中间还有一层连接池。先看数据库连接池。HikariCP 默认maximumPoolSize10如果你没有调过数据库最大并发只有 10 个查询。平时看起来够用雪崩一来10 个连接被慢查询占满后面的请求全部排队接口 RT 直线上升。再看 Redis 客户端连接池。Redis 故障恢复后如果客户端配置了无限重连所有服务实例会在同一瞬间发起重连导致 Redis 还没来得及提供服务就收到了大量连接请求连接数瞬间打满。我建议的配置参数如下配置项建议值说明DB 连接池 maximumPoolSize50 ~ 100根据压测结果调整DB 连接池 connectionTimeout2000ms不能太长否则线程会堆积Redis 连接池 maxTotal50 ~ 100和 QPS 匹配Redis 客户端 connectTimeout200ms快速失败走降级Redis 客户端 maxAttempts2避免无限重试6. 上线前怎么验证故障演练与监控联动6.1 第一步在预发环境人为制造一次雪崩缓存雪崩这种事最怕的是“没有演练过第一次遇到就是真实事故”。我建议每个大促前都在预发环境做一次主动故障演练模拟两类场景场景一随机淘汰大量 key模拟集体过期。可以用redis-cli批量设置过期时间把预发环境一半的缓存 key 在 5 秒后全部过期。场景二直接重启一个缓存集群节点让缓存层短暂不可用。# 模拟 3000 个 key 在 10 秒后同时过期 redis-cli --scan --pattern product:* | head -3000 | \ xargs -I {} redis-cli expire {} 10演练过程中观察三个数据接口成功率、DB QPS、回源线程数。合格的标准是DB QPS 不超过压测极限的 70%接口成功率保持在 99% 以上降级开关能正常兜底。6.2 第二步监控指标和告警阈值怎么设没有监控的雪崩应对就是盲打。我这里给一套比较实用的监控组合。指标告警阈值触发动作Redis 命中率低于 90% 持续 1 分钟告警进入人工排查DB QPS超过压测极限的 70%告警自动开启降级模式DB 慢查询数超过 50 个/分钟告警回源链路异常Redis 平均 RT超过 20ms告警排查大 key 和慢命令线程池活跃线程数超过 80%告警可能发生线程堆积熔断器状态由关闭变为打开告警通知值班人命中率是我最看重的一个指标。正常情况下核心链路的缓存命中率应该在 98% 以上。如果发现命中率从 99% 掉到 90% 再掉到 80%这就是雪崩的早期信号。建议给命中率单独做一个看板而不是只依赖 CPU 和内存监控。6.3 第三步把应对动作从“人肉”变成“自动”演练之后要把应对动作固化成配置而不是出事了再拉群开会。我目前的做法是在配置中心里维护一组开关。cache.proactive-refresh是否开启自动预热和热点续期。cache.force-throughput强制回源模式用于 Redis 故障但数据必须最新的场景。cache.degrade-mode降级模式开启后弱一致数据全部走本地缓存非关键数据直接返回默认值。db.semaphore-limitDB 回源并发上限故障时动态调低。监控触发告警时值班人员只需要在配置中心调整开关不需要紧急发版。正常情况下雪崩发生时系统内的熔断和限流会自动拦截一部分流量配置开关用来做兜底和恢复操作。这次事故复盘之后我把“缓存雪崩”列进了每个月必做的风险检查清单每次上线前都会先问三个问题这批 key 的过期时间是怎么分布的缓存挂掉五分钟系统还能不能撑住降级开关能不能在十秒内打开这三个问题想清楚了缓存雪崩就基本不会变成事故。如果还没有做任何防护建议先从上线的第一个小问题入手——给所有 key 的过期时间加一个随机值成本最低见效也最快。
返回列表