跳转至

缓存雪崩

💡 一句话概述

缓存雪崩是大面积 key 同时失效或缓存节点宕机,导致请求全部涌向 DB 持续高压——防护核心是分散失效时间和保障高可用。


🔑 核心概念

  1. 集中失效:大量 key 在同一时间点过期,或缓存节点故障导致缓存集体不可用。
  2. 持续高压:与击穿(瞬时尖峰)不同,雪崩是持续的 DB 过载。
  3. 级联故障:DB 压垮后,依赖 DB 的其他服务也开始超时,故障蔓延。

📝 详细说明

发生机制

graph TB
    subgraph 雪崩["❄️ 缓存雪崩 — 大面积 key 集中失效"]
        A1["大量 key 同时刻过期<br/>或缓存节点宕机"] --> A2["请求大面积 miss"]
        A2 --> A3["全部涌向 DB"]
        A3 --> A4["DB 持续高压 💥"]
        A4 --> A5["级联故障<br/>依赖服务超时"]
    end

两种触发方式:

触发原因 场景
大量 key 同时过期 批量导入数据时设了相同 TTL,凌晨 0 点大批 key 同时到期
缓存节点宕机 Redis 主节点故障,未及时切换,所有请求穿透到 DB

与击穿、穿透的区别

缓存击穿 缓存雪崩 缓存穿透
根因 热 key 过期 大面积 key 同时过期 / 缓存宕机 查询不存在的数据
特征 单 key、高并发 多 key、集中失效 缓存永远 miss
危害 DB 瞬时尖峰 DB 持续高压 + 级联故障 DB 持续高压

🛡️ 解决方案

方案 1:随机失效(TTL Jitter)

给 TTL 加随机偏移量,避免大量 key 在同一时刻集中过期——最简单有效,批量设置 TTL 时必备。

graph LR
    subgraph 无 Jitter["❌ 无随机偏移"]
        T1["key_a TTL=30min"] --> E1["同时过期"]
        T2["key_b TTL=30min"] --> E1
        T3["key_c TTL=30min"] --> E1
        E1 --> CRASH["DB 雪崩 💥"]
    end

    subgraph 有 Jitter["✅ 有随机偏移"]
        J1["key_a TTL=30m+2m"] --> D1["32min 过期"]
        J2["key_b TTL=30m+7m"] --> D2["37min 过期"]
        J3["key_c TTL=30m+4m"] --> D3["34min 过期"]
        D1 --> SAFE["DB 压力分散 ✅"]
        D2 --> SAFE
        D3 --> SAFE
    end

    无 Jitter -.->|加 jitter| 有 Jitter
func (s *Service) SetWithJitter(ctx context.Context, key string, data any, baseTTL time.Duration) {
    jitter := time.Duration(rand.Intn(300)) * time.Second // 0~300s 随机偏移
    s.rdb.Set(ctx, key, data, baseTTL+jitter)
}

// 批量设置时
for _, item := range items {
    base := 30 * time.Minute
    jitter := time.Duration(rand.Intn(600)) * time.Second
    s.rdb.Set(ctx, item.Key, item.Data, base+jitter)
}

方案 2:加锁/限流

限制并发回源数量,避免 DB 过载。

// 使用带容量限制的令牌桶
var limiter = rate.NewLimiter(rate.Limit(100), 50)

func (s *Service) GetWithRateLimit(ctx context.Context, key string) (any, error) {
    val, err := s.rdb.Get(ctx, key).Result()
    if err == nil {
        return val, nil
    }

    // 限流:超出速率直接拒绝或排队
    if err := limiter.Wait(ctx); err != nil {
        return nil, fmt.Errorf("service busy, try later")
    }

    data, err := s.db.Query(ctx, key)
    if err != nil {
        return nil, err
    }
    s.rdb.Set(ctx, key, data, 10*time.Minute)
    return data, nil
}

方案 3:Redis 高可用 + 多级缓存

缓存节点宕机 = 所有 key 同时"失效",必须依赖高可用架构:

graph TB
    subgraph 多级缓存架构
        Client["客户端请求"]
        L1["L1 本地缓存<br/>bigcache / ristretto"]
        L2["L2 Redis<br/>Sentinel / Cluster"]
        L3["L3 DB"]

        Client --> L1
        L1 -->|miss| L2
        L2 -->|miss| L3
        L3 --> L2
        L2 --> L1

        L1 -.->|Redis 不可用时<br/>降级到本地| Client
    end
方案 说明
Redis Sentinel 自动故障转移,主节点挂掉时从节点升主
Redis Cluster 数据分片 + 自动故障转移,生产环境推荐
多级缓存 本地缓存(如 bigcache/ristretto)+ Redis,Redis 不可用时降级到本地
熔断降级 DB 压力过大时直接返回兜底数据或错误页
// 多级缓存示例:本地 + Redis
func (s *Service) GetMultiLevel(ctx context.Context, key string) (any, error) {
    // L1: 本地缓存
    if v, ok := s.localCache.Get(key); ok {
        return v, nil
    }

    // L2: Redis
    val, err := s.rdb.Get(ctx, key).Result()
    if err == nil {
        s.localCache.Set(key, val, 5*time.Minute)
        return val, nil
    }

    // L3: DB
    data, err := s.db.Query(ctx, key)
    if err != nil {
        return nil, err
    }
    s.rdb.Set(ctx, key, data, 30*time.Minute)
    s.localCache.Set(key, data, 5*time.Minute)
    return data, nil
}

📊 方案对比

方案 防护方向 复杂度 适用场景
随机失效 集中过期 ★ 最简单有效,批量设 TTL 必备
加锁/限流 DB 过载 ★★★ 应急限流,配合其他方案使用
Redis 高可用 缓存宕机 ★★★★ 生产环境必备
多级缓存 缓存宕机 + DB 降级 ★★★★ 对可用性要求极高的核心链路

⚠️ 常见陷阱

批量导入数据时忘记加 jitter

最常见的雪崩根因。批量设缓存时统一用 SET key value EX 3600,3600 秒后所有 key 同时过期。

本地缓存与 Redis 缓存的一致性

多级缓存中,本地缓存 TTL 应远小于 Redis TTL,否则 Redis 更新后本地仍返回旧值。

Redis Cluster 全量迁移导致集中过期

扩容/缩容时 slot 迁移可能触发 key 重新设置 TTL,需要关注迁移过程中的 TTL 分布。


🏋️ 练习题

练习 1:为什么随机失效能防雪崩但不能防击穿?

随机失效分散的是**不同 key 的过期时间**。击穿针对的是**单个热点 key**,它只管一个 key 的过期,加不加 jitter 对单个 key 无意义。

答案

随机失效解决的是"多个 key 同时到期"的问题——让过期时间错开,避免集中回源。但击穿是单个 key 过期引发的并发回源,jitter 无法让同一个 key 的过期时间"分散"。防击穿需要加锁或逻辑过期。

练习 2:多级缓存中,如果本地缓存和 Redis 都 miss,大量请求同时打到 DB,怎么办?

本地缓存 + Redis 都 miss 说明是冷启动或 Redis 宕机场景。需要加限流或 singleflight 来控制回源并发。

答案

多级缓存只解决了"缓存层可用性"问题,没有解决"回源并发控制"。应该在 L3 回源前加一层限流(令牌桶)或 singleflight,确保 DB 不被瞬时请求压垮。生产环境通常是 多级缓存 + 限流 + 熔断 的组合。


🔗 相关链接