Skip to content

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 点全部过期。

根本原因:三条设计缺陷叠加:

  1. 没有过期时间打散机制——所有 key 的 TTL 完全一致,天然形成"定时炸弹"
  2. 没有缓存重建的流量控制——缓存过期后,所有请求同时尝试"查 DB → 写缓存",没有互斥锁或限流
  3. 没有降级兜底——DB 撑不住时,系统没有返回降级数据,而是"死等 DB 恢复"

止损方案(事故中)

事故发生时,需要立即止血,不是修代码:

bash
# 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 被穿透
# 例如:商品详情接口降级返回"商品信息加载中"

永久性治理方案

方案一:过期时间 + 随机偏移(最基础也最有效)

python
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%。

方案二:双缓存策略

维护两层缓存,主缓存时间短,备缓存时间长:

python
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:

java
// 使用 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 分钟程序化预热缓存:

python
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_hitskeyspace_misses 算命中率。命中率阈值设 90%,低于阈值触发告警,自动回滚灰度流量。

缓存雪崩 vs 缓存击穿 vs 缓存穿透

面试官常把这三个概念放一起问,先理清楚区别:

术语现象根本原因杀伤力典型应对
缓存雪崩大面积 key 同时过期,DB 被冲垮TTL 未打散极高(全链路雪崩)TTL 随机偏移 + 双缓存 + 本地缓存
缓存击穿一个热点 key 过期,高并发打到 DB热点 key 重建无互斥中高(一个 key 也能打垮)互斥锁重建 + 逻辑过期
缓存穿透查询不存在的数据,缓存永远空不存在的 key 未被拦截低(但持续有)布隆过滤器 + 空值缓存

真实场景压测数据对比

拿 4C8G 的电商商品详情服务做压测,20 万商品 key,同时过期场景:

防护方案最大 DB QPS接口 P99 RT是否发生雪崩
无防护780028.5s
TTL 随机偏移1100320ms
TTL 偏移 + 本地缓存23085ms
TTL 偏移 + 双缓存580180ms
全量方案(偏移+本地+双缓存+预热)4542ms

注意:全量方案虽然效果最好,但实现复杂度高,缓存一致性维护成本也最大。不追求 100% 完美的场景,TTL 随机偏移 + 本地缓存性价比最高。

面试追问方向

面试官问完"缓存雪崩怎么解决"后,通常会追加:

  1. "Redis 挂了怎么办?" → 答:本地缓存兜底 + DB 限流降级,区分 Redis 挂和缓存过期
  2. "备缓存和主缓存数据不一致怎么办?" → 答:主缓存做写,备缓存只读,概率极低;强一致场景上分布式锁
  3. "本地缓存怎么保证多节点一致性?" → 答:不保证,本地缓存是牺牲一致性换性能;用 Redis Pub/Sub 或 MQ 广播失效通知,但不是强一致方案
  4. "大促时怎么确定预热哪些 key?" → 答:从历史 QPS 排名拉 Top N 热 key,配合离线分析前一天的访问日志,用 Flink 或 Spark 算过去 24h 的热点分布

架构层面的总结

层级措施目标
缓存层TTL + 随机偏移防止批量过期
缓存层双缓存策略主缓存过期时备缓存保底
业务层本地缓存减少 99% 的穿透请求
业务层互斥锁重建防止缓存击穿
接入层预热 + 灰度放量确保缓存命中率正常后再全量放
全局限流降级返回默认值DB 撑不住时系统不挂

缓存雪崩的根因通常不是技术问题,而是设计问题——没有预估"最坏情况"下的系统行为。面试时能画出完整的故障时间线、给出分层治理方案、并且能说出每种方案的代价和适用场景,才算真正理解了这道题。

参考资料

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。