Redis 大量 Key 集中过期问题

缓存雪崩的隐形杀手 —— 当批量过期遇上瞬时高并发

🏷️ Cache Stampede / Cache Avalanche

一、问题是什么?

问题定义

当大量 Redis Key 被设置了相同或非常接近的过期时间时,在过期时刻到达的瞬间,这些 Key 会同时失效,导致原本由缓存承载的请求全部穿透到后端数据库,引发缓存雪崩(Cache Avalanche)

🚨
这不同于缓存穿透(查不存在的数据)和缓存击穿(单个热点 Key 过期),缓存雪崩是大面积缓存同时失效,冲击力远超前两者。

二、常见触发场景

📌 典型场景

  • 批量预热缓存:系统启动或定时任务刷新时,批量写入缓存并设置相同的 TTL(如 24h),导致下次刷新时全部同时过期
  • 定时数据同步:凌晨定时从 DB 同步一批数据到 Redis,设置统一过期时间,导致次日同一时刻集中失效
  • 业务设计缺陷:所有验证码/Token 设置固定有效期(如 5 分钟),整点批量创建的 Token 在同一秒失效
  • 缓存重建策略单一:所有热点数据的过期时间写死为同一值,无随机化处理
📊 集中过期时间轴示意
时间 T₀ 写入 T₀+TTL 过期 相同 TTL 💥 全部失效! 海量请求打到 DB 数据库压力激增 → 可能宕机

三、危害链路

🕐
Key 集中过期
大量缓存同一秒失效
🔄
请求全穿透
缓存未命中,全部转向 DB
📈
DB 压力暴增
瞬时 QPS 可能翻数十倍
💀
系统崩溃
DB 宕机 → 服务雪崩

⚠️ 具体危害

  • 数据库瞬时过载:原本被缓存拦截的 99% 请求瞬间涌入 DB,QPS 可能从几十飙升到几万
  • Redis 主线程阻塞:Redis 删除大量过期 Key 需要消耗 CPU,且过期删除是主线程操作,会导致 Redis 响应变慢
  • 级联故障:DB 慢查询 → 连接池耗尽 → 上游服务超时 → 更多重试 → 恶性循环
  • 服务降级/熔断:依赖该缓存的所有接口同时不可用,用户体验断崖式下降
  • 恢复困难:即使 DB 恢复,缓存仍为空,请求持续穿透,形成"恢复-压垮"的死循环
📈 过期时刻的 QPS 波动示意
0 500 1000 1500 QPS 正常 雪崩 Key 集中过期 时间 →

四、Redis 过期删除机制

🔧 Redis 如何删除过期 Key?

Redis 采用惰性删除 + 定期删除双策略:

  • 惰性删除:访问 Key 时才检查是否过期,过期则删除。不访问就不删,节省 CPU,但浪费内存
  • 定期删除:每 100ms 执行一次,随机抽取部分设置了过期时间的 Key 检查,过期则删除。若本次删除比例超过 25%,立即再执行一轮
💡
关键点:当大量 Key 集中在同一时刻过期时,定期删除任务会密集触发,且每轮都可能超过 25% 阈值而循环执行,这会持续占用 Redis 主线程 CPU,导致正常请求响应延迟。

五、解决方案

1

TTL 加随机偏移量(最常用)

推荐

在设置过期时间时,给 TTL 加上一个随机值,使 Key 的过期时间分散开,避免同一时刻大量 Key 失效。

Python # ❌ 错误做法:所有 Key 相同 TTL import redis r = redis.Redis() for key in keys: r.setex(key, 86400, value) # 全部 24h 后过期 # ✅ 正确做法:TTL 加随机偏移 import random for key in keys: ttl = 86400 + random.randint(-3600, 3600) # ±1小时随机 r.setex(key, ttl, value)
Java // ✅ Java 实现 public static int randomTtl(int baseSeconds, int jitterSeconds) { return baseSeconds + ThreadLocalRandom.current() .nextInt(-jitterSeconds, jitterSeconds + 1); } // 使用:基础 1 小时,±5 分钟随机 int ttl = randomTtl(3600, 300); jedis.setex(key, ttl, value);
Go // ✅ Go 实现 func randomTTL(base int, jitter int) int { offset := rand.Intn(2*jitter+1) - jitter return base + offset } // 使用 ttl := randomTTL(86400, 3600) client.Set(ctx, key, value, time.Duration(ttl)*time.Second)
2

缓存永不过期 + 异步更新

进阶

不设置过期时间,由后台线程定期刷新缓存。热点数据适合此方案。

Python # 不设 TTL,由后台定时刷新 r.set(key, value) # 不设过期时间 # 后台定时任务刷新缓存 def refresh_cache(): for key in hot_keys: new_value = db.query(key) r.set(key, new_value) # 原子替换,用户无感知
  • 优点:用户永远不会遇到缓存 miss
  • 缺点:需要维护刷新逻辑,数据可能有短暂不一致
3

多级缓存架构

架构级

引入多级缓存(L1 本地缓存 + L2 Redis 缓存),即使 Redis 缓存失效,本地缓存仍可挡住部分流量。

请求 L1 本地缓存 (Guava/Caffeine) L2 Redis 缓存 (分布式缓存) 数据库 (最后兜底) 命中即返回 命中即返回 命中即返回 回源查询
  • L1 本地缓存的 TTL 与 L2 Redis 的 TTL 错开设置
  • 即使 Redis 集中过期,L1 仍可挡住大部分流量
4

互斥锁重建缓存

防击穿

当缓存失效时,只允许一个线程去 DB 加载数据并重建缓存,其他线程等待或返回旧数据。

Python def get_with_mutex(key): value = r.get(key) if value is not None: return value # 尝试获取互斥锁(SETNX) lock_key = f"lock:{key}" if r.setnx(lock_key, "1"): r.expire(lock_key, 10) # 锁超时防死锁 try: value = db.query(key) r.setex(key, ttl, value) finally: r.delete(lock_key) else: # 其他线程短暂等待后重试 time.sleep(0.05) return get_with_mutex(key) return value
5

限流降级兜底

保底

在缓存失效、DB 压力飙升时,通过限流降级保护后端系统。

  • 限流:对 DB 请求进行限流(如令牌桶/漏桶算法),超过阈值直接拒绝或排队
  • 降级:返回兜底数据(默认值、历史缓存快照)或友好提示
  • 熔断:当 DB 错误率超过阈值时,自动熔断,直接走降级逻辑

六、方案对比

方案 复杂度 适用场景 优点 缺点
TTL 随机偏移 ⭐ 低 几乎所有场景 实现简单,效果显著 无法完全消除集中过期
永不过期 + 异步刷新 ⭐⭐ 中 热点数据 用户无感知,零 miss 需维护刷新逻辑,数据有延迟
多级缓存 ⭐⭐⭐ 高 高可用系统 多层防护,极高可用 架构复杂,一致性难保证
互斥锁重建 ⭐⭐ 中 单个热点 Key 防缓存击穿 存在锁等待,吞吐受限
限流降级 ⭐⭐ 中 兜底方案 保护后端,防止雪崩 用户体验降级

七、最佳实践组合

推荐组合策略

  • 基础防御:所有缓存 Key 的 TTL 一律加随机偏移(这是最低成本、最高收益的做法)
  • 热点加固:核心热点数据采用"永不过期 + 异步刷新"策略
  • 架构兜底:关键业务部署多级缓存 + 限流熔断
  • 监控预警:监控缓存命中率,命中率骤降时自动告警
Python - 实战工具函数 import random import redis r = redis.Redis() def set_with_jitter(key, value, base_ttl, jitter_ratio=0.1): """ 设置缓存并加入随机 TTL 偏移 Args: key: 缓存 Key value: 缓存值 base_ttl: 基础过期时间(秒) jitter_ratio: 随机偏移比例(默认 10%) Example: set_with_jitter('user:1001', data, 3600) # 实际 TTL 在 3240~3960 之间随机 """ jitter = int(base_ttl * jitter_ratio) actual_ttl = base_ttl + random.randint(-jitter, jitter) r.setex(key, actual_ttl, value) return actual_ttl # 批量设置缓存 def batch_set_with_jitter(items, base_ttl, jitter_ratio=0.1): pipe = r.pipeline() jitter = int(base_ttl * jitter_ratio) for key, value in items: actual_ttl = base_ttl + random.randint(-jitter, jitter) pipe.setex(key, actual_ttl, value) pipe.execute()
🎯
经验法则:TTL 随机偏移量建议设为基础 TTL 的 5%~20%。偏移太小起不到分散效果,偏移太大可能影响业务时效性。对于 1 小时 TTL,偏移 ±5 分钟是合理选择。

八、三种缓存问题对比

问题 原因 特征 核心方案
缓存雪崩 大量 Key 同时过期 / Redis 宕机 大面积缓存同时失效 TTL 加随机 + 多级缓存
缓存击穿 单个热点 Key 过期 一个 Key 失效,大量请求穿透 互斥锁 + 永不过期
缓存穿透 查询不存在的数据 缓存和 DB 都没有 布隆过滤器 + 空值缓存

🛡️ 一句话总结

设置缓存 TTL 时,永远加一个随机偏移量 ——
这就像给每把锁配了不同的开门时间,
避免所有人同时挤进同一扇门。