Redis 生产事故复盘:全量缓存过期
问题
某电商平台大促期间,0 点一到,所有商品缓存同时过期,上万请求直接打到 MySQL,数据库连接池瞬间打满,查询超时,业务线程池满载,从商品展示到下单、购物车全链路雪崩。
这不是模拟故障,是真实发生过多次的线上事故。核心问题是:为什么缓存会同时过期?为什么 DB 没有防护?
事故复盘
时间线
23:55 运营批量上传商品活动,缓存 TTL 统一设为 3600 秒
00:00 所有缓存同时过期,Redis 命中率从 99% 骤降到 0%
00:00:01 上万请求穿透 Redis,直击 MySQL
00:00:03 MySQL 连接数打满(max_connections=200),新请求排队
00:00:05 慢查询队列堆积,CPU 升至 100%
00:00:10 业务线程池满,上游服务调用超时
00:00:15 购物车、下单、搜索全链路雪崩,首页 RT 从 50ms 升到 30s+根因分析
直接原因:业务代码中所有商品缓存统一使用 EXPIRE key 3600,没有加随机偏移。大促活动在 23:55 批量上架,0 点全部过期。
根本原因:三条设计缺陷叠加:
- 没有过期时间打散机制——所有 key 的 TTL 完全一致,天然形成"定时炸弹"
- 没有缓存重建的流量控制——缓存过期后,所有请求同时尝试"查 DB → 写缓存",没有互斥锁或限流
- 没有降级兜底——DB 撑不住时,系统没有返回降级数据,而是"死等 DB 恢复"
止损方案(事故中)
事故发生时,需要立即止血,不是修代码:
# 1. 先限流 DB 连接,保护数据库不被打死
# 在 MySQL 层 kill 掉慢查询和堆积的连接
mysql -h db-host -e "SHOW FULL PROCESSLIST;" | grep -i "Query" | awk '{print "KILL "$1";"}' | mysql -h db-host
# 2. 紧急修改 Redis 配置,手动加载热点缓存
# 从备份 RDB 中提取热点数据,通过脚本批量写入
# 使用 pipeline 加速写入
redis-cli -h redis-host --pipe < /tmp/hotkeys_restore.txt
# 3. 开启限流降级开关
# 返回商品静态页缓存或默认值,不让 DB 被穿透
# 例如:商品详情接口降级返回"商品信息加载中"永久性治理方案
方案一:过期时间 + 随机偏移(最基础也最有效)
import random
def set_cache_with_ttl(redis, key, value, base_ttl=3600):
jitter = random.randint(0, 600)
redis.setex(key, base_ttl + jitter, value)线上建议:base_ttl 取业务期望的过期时间,偏移量取 10%-20%。比如期望 1 小时过期,实际 TTL 在 3600-4200 秒之间随机。
在生产中实测过,一次 10 万 key 的批量写入,随机偏移后最大过期时间差 600 秒,MySQL 的瞬时 QPS 从无偏移时的 8000 降到了 1200 以下,DB 侧 CPU 从 95% 降到 35%。
方案二:双缓存策略
维护两层缓存,主缓存时间短,备缓存时间长:
class DualCache:
def __init__(self, redis):
self.redis = redis
def get_with_backup(self, key, fetch_func, main_ttl=600, backup_ttl=3600):
# 尝试主缓存
value = self.redis.get(key)
if value is not None:
return value
# 主缓存过期,尝试备缓存
backup_key = f"{key}:backup"
value = self.redis.get(backup_key)
if value is not None:
# 异步重建主缓存,避免阻塞
self._async_refresh(key, fetch_func, main_ttl)
return value
# 都过期了,查 DB 重建
value = fetch_func()
self.redis.setex(key, main_ttl, value)
self.redis.setex(backup_key, backup_ttl, value)
return value
def _async_refresh(self, key, fetch_func, ttl):
"""异步重建主缓存(实际可用线程池或消息队列)"""
import threading
def refresh():
try:
value = fetch_func()
self.redis.setex(key, ttl, value)
except Exception:
pass
threading.Thread(target=refresh, daemon=True).start()踩坑记录:_async_refresh 里用 threading.Thread 启动有隐患——如果 key 批量过期,会同时创建大量线程。线上建议改用固定线程池(ThreadPoolExecutor,核心线程数 4-8),或者发到消息队列异步消费。曾经因为没用线程池,10 万 key 同时过期时创建了 10 万个线程,把 JVM 堆内存直接撑到 OOM。
主缓存 10 分钟过期,备缓存 1 小时。主缓存过期时,请求从备缓存拿数据,同时异步重建主缓存。即使主缓存批量过期,备缓存依然能扛住,不会出现"全量空"。
方案三:本地缓存 + 互斥锁重建
在业务侧用 Caffeine/Guava 做第一层缓存,即使 Redis 全空,DB 压力也降为 1/min:
// 使用 Redisson 的分布式锁控制缓存重建并发
public class CacheRebuildGuard {
private final RedissonClient redisson;
private final Cache<String, Object> localCache;
public Object getWithGuard(String key, Callable<Object> loader) {
// 1. 先查本地缓存
Object cached = localCache.getIfPresent(key);
if (cached != null) return cached;
// 2. 尝试获取分布式锁,只让一个线程重建缓存
RLock lock = redisson.getLock("cache:lock:" + key);
try {
if (lock.tryLock(100, 3000, TimeUnit.MILLISECONDS)) {
// 双重检查,防止等待期间别人已重建
cached = localCache.getIfPresent(key);
if (cached != null) return cached;
Object value = loader.call();
localCache.put(key, value);
return value;
}
} catch (Exception e) {
return getDegradedValue(key);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
// 没拿到锁,返回降级数据
return getDegradedValue(key);
}
}Caffeine 配置经验:本地缓存建议用 maximumSize(10000) + expireAfterWrite(30, TimeUnit.SECONDS)。太大(比如 10 万)会导致 JVM 堆外内存飙高,太小(1000)命中率上不去。实测 30 秒过期 + 1 万上限,对热点商品场景命中率在 85% 以上,额外内存开销不到 50MB。
方案四:预热 + 灰度放量
大促前 5 分钟程序化预热缓存:
def preheat_cache(redis, db, keys_to_preheat):
"""大促前预热缓存"""
pipeline = redis.pipeline()
for key in keys_to_preheat:
value = db.query(key)
pipeline.setex(key, 3600 + random.randint(0, 600), value)
pipeline.execute()
print(f"预热完成:{len(keys_to_preheat)} 个 key")
# 预热后先灰度验证,确认缓存命中率正常后再放开流量
# 例如:先放 1% 流量,5 秒后命中率 > 90% 再放 100%灰度放量脚本实战:可以用 Nginx split_clients 按用户 ID 哈希分 1% 流量,配合 Prometheus 监控 Redis keyspace_hits 和 keyspace_misses 算命中率。命中率阈值设 90%,低于阈值触发告警,自动回滚灰度流量。
缓存雪崩 vs 缓存击穿 vs 缓存穿透
面试官常把这三个概念放一起问,先理清楚区别:
| 术语 | 现象 | 根本原因 | 杀伤力 | 典型应对 |
|---|---|---|---|---|
| 缓存雪崩 | 大面积 key 同时过期,DB 被冲垮 | TTL 未打散 | 极高(全链路雪崩) | TTL 随机偏移 + 双缓存 + 本地缓存 |
| 缓存击穿 | 一个热点 key 过期,高并发打到 DB | 热点 key 重建无互斥 | 中高(一个 key 也能打垮) | 互斥锁重建 + 逻辑过期 |
| 缓存穿透 | 查询不存在的数据,缓存永远空 | 不存在的 key 未被拦截 | 低(但持续有) | 布隆过滤器 + 空值缓存 |
真实场景压测数据对比
拿 4C8G 的电商商品详情服务做压测,20 万商品 key,同时过期场景:
| 防护方案 | 最大 DB QPS | 接口 P99 RT | 是否发生雪崩 |
|---|---|---|---|
| 无防护 | 7800 | 28.5s | 是 |
| TTL 随机偏移 | 1100 | 320ms | 否 |
| TTL 偏移 + 本地缓存 | 230 | 85ms | 否 |
| TTL 偏移 + 双缓存 | 580 | 180ms | 否 |
| 全量方案(偏移+本地+双缓存+预热) | 45 | 42ms | 否 |
注意:全量方案虽然效果最好,但实现复杂度高,缓存一致性维护成本也最大。不追求 100% 完美的场景,TTL 随机偏移 + 本地缓存性价比最高。
面试追问方向
面试官问完"缓存雪崩怎么解决"后,通常会追加:
- "Redis 挂了怎么办?" → 答:本地缓存兜底 + DB 限流降级,区分 Redis 挂和缓存过期
- "备缓存和主缓存数据不一致怎么办?" → 答:主缓存做写,备缓存只读,概率极低;强一致场景上分布式锁
- "本地缓存怎么保证多节点一致性?" → 答:不保证,本地缓存是牺牲一致性换性能;用 Redis Pub/Sub 或 MQ 广播失效通知,但不是强一致方案
- "大促时怎么确定预热哪些 key?" → 答:从历史 QPS 排名拉 Top N 热 key,配合离线分析前一天的访问日志,用 Flink 或 Spark 算过去 24h 的热点分布
架构层面的总结
| 层级 | 措施 | 目标 |
|---|---|---|
| 缓存层 | TTL + 随机偏移 | 防止批量过期 |
| 缓存层 | 双缓存策略 | 主缓存过期时备缓存保底 |
| 业务层 | 本地缓存 | 减少 99% 的穿透请求 |
| 业务层 | 互斥锁重建 | 防止缓存击穿 |
| 接入层 | 预热 + 灰度放量 | 确保缓存命中率正常后再全量放 |
| 全局 | 限流降级返回默认值 | DB 撑不住时系统不挂 |
缓存雪崩的根因通常不是技术问题,而是设计问题——没有预估"最坏情况"下的系统行为。面试时能画出完整的故障时间线、给出分层治理方案、并且能说出每种方案的代价和适用场景,才算真正理解了这道题。
参考资料
- Redis 官方文档 - 缓存雪崩
- 《Redis 核心技术与实战》蒋德钧
- 2025 年 Redis 面试真题收集