Files
docs/docs/architecture/cache/cache-breakdown-avalanche-penetration.md
T
wonder 410e46e2a6
Deploy Docs / deploy (push) Successful in 10s
feat: 三篇文章均加入 Mermaid 图表
2026-08-24 03:06:05 +00:00

15 KiB
Raw Blame History

缓存击穿 & 缓存雪崩 & 缓存穿透

!!! note "💡 一句话概述" 缓存击穿是单 key 过期瞬间的并发涌入,雪崩是大面积 key 同时失效,穿透是根本不存在的数据绕过缓存直击 DB——三者解决思路各不同,但经常一起考察。


🔑 核心概念

  1. 缓存击穿(Breakdown):某个热点 key 过期的瞬间,大量并发请求同时穿透到 DB,压垮数据库。
  2. 缓存雪崩(Avalanche):大量 key 在同一时间集中过期,或缓存节点宕机,导致请求全部涌向 DB。
  3. 缓存穿透(Penetration):请求查询的数据在 DB 中根本不存在,缓存永远无法命中,每次请求都直达 DB。

📊 三者发生机制

graph TB
    subgraph 击穿["🔓 缓存击穿 — 单热点 key 过期"]
        B1["热 key 过期"] --> B2["大量并发同时 miss"]
        B2 --> B3["同时回源 DB"]
        B3 --> B4["DB 瞬时尖峰 💥"]
    end

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

    subgraph 穿透["🕳️ 缓存穿透 — 查不存在的数据"]
        C1["请求查询不存在的 key"] --> C2["缓存永远 miss"]
        C2 --> C3["每次直达 DB"]
        C3 --> C4["DB 持续高压 💥"]
    end

📝 三者对比

缓存击穿 缓存雪崩 缓存穿透
根因 热 key 过期 大面积 key 同时过期 / 缓存宕机 查询不存在的数据
特征 单 key、高并发 多 key、集中失效 缓存永远 miss
触发方式 自然过期 自然过期 / 节点故障 恶意攻击 / 业务异常
危害 DB 瞬时尖峰 DB 持续高压 DB 持续高压

🛡️ 解决方案总览

graph LR
    subgraph 击穿["击穿"]
        A1["A1 永不过期"]
        A2["A2 加锁排队<br/>singleflight"]
    end

    subgraph 雪崩["雪崩"]
        B1["B1 加锁/限流"]
        B2["B2 随机失效"]
        B3["B3 Redis 高可用<br/>多级缓存"]
    end

    subgraph 穿透["穿透"]
        C1["C1 参数校验"]
        C2["C2 缓存空对象"]
        C3["C3 布隆过滤器"]
    end

🛡️ 解决方案

A. 缓存击穿

A1 永不过期

逻辑过期而非 TTL 过期:缓存中存一个过期时间字段,由后台异步刷新,请求始终命中缓存。

sequenceDiagram
    participant C as 客户端
    participant R as Redis
    participant W as 后台 Worker
    participant DB as DB

    C->>R: GET key
    R-->>C: 返回数据 + expireAt
    alt 未过期
        C-->>C: 直接使用 ✅
    else 已过期(逻辑过期)
        C-->>C: 仍返回旧数据 ✅(可用性优先)
        C->>W: 触发异步刷新
        W->>DB: 查询最新数据
        DB-->>W: 返回新数据
        W->>R: SET key + 新 expireAt
    end
type CacheItem struct {
    Data       any
    ExpireAt   time.Time
}

func (s *Service) Get(ctx context.Context, key string) (any, error) {
    raw, err := s.rdb.Get(ctx, key).Bytes()
    if err != nil {
        return s.loadAndSet(ctx, key)
    }

    var item CacheItem
    json.Unmarshal(raw, &item)

    // 逻辑过期:数据仍可返回,触发异步刷新
    if time.Now().After(item.ExpireAt) {
        go s.asyncRefresh(ctx, key)
    }
    return item.Data, nil
}

!!! tip "适用场景" 对一致性要求不高、对可用性要求极高的热点数据(如首页推荐)。

A2 加锁排队

只放一个请求去 DB 加载,其余请求等锁释放后读缓存。

sequenceDiagram
    participant C1 as 请求1
    participant C2 as 请求2
    participant C3 as 请求3
    participant R as Redis
    participant Lock as 分布式锁
    participant DB as DB

    C1->>R: GET key → miss
    C2->>R: GET key → miss
    C3->>R: GET key → miss

    C1->>Lock: 尝试获锁 ✅
    C2->>Lock: 尝试获锁 ❌ 等待
    C3->>Lock: 尝试获锁 ❌ 等待

    C1->>DB: SELECT ...
    DB-->>C1: 返回数据
    C1->>R: SET key + TTL
    C1->>Lock: 释放锁

    C2->>R: GET key → hit ✅
    C3->>R: GET key → hit ✅
var mu sync.Mutex

func (s *Service) GetWithLock(ctx context.Context, key string) (any, error) {
    // 先查缓存
    val, err := s.rdb.Get(ctx, key).Result()
    if err == nil {
        return val, nil
    }

    // 缓存 miss,加锁
    mu.Lock()
    defer mu.Unlock()

    // double-check:可能其他协程已加载
    val, err = s.rdb.Get(ctx, key).Result()
    if err == nil {
        return val, nil
    }

    // 查 DB 并写缓存
    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
}

!!! warning "分布式环境用分布式锁" 单机 sync.Mutex 只在单进程内有效,多实例部署时需要用 Redis 分布式锁(如 redsync)或 singleflight。

更优雅的方式——singleflight:

var g singleflight.Group

func (s *Service) GetWithSingleFlight(ctx context.Context, key string) (any, error) {
    v, err, _ := g.Do(key, func() (any, error) {
        // 只有一个协程执行查询
        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
    })
    return v, err
}

B. 缓存雪崩

B1 加锁排队

与击穿的加锁思路相同,限制并发回源数量,避免 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
}

B2 随机失效

给 TTL 加随机偏移量,避免大量 key 在同一时刻集中过期。

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)
}

B3 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
}

C. 缓存穿透

C1 参数校验

在入口层拦截非法请求,根本不让它进入缓存/DB 查询流程。

func (s *Service) GetUser(ctx context.Context, id int64) (any, error) {
    if id <= 0 {
        return nil, fmt.Errorf("invalid user id: %d", id)
    }
    // 继续正常查询流程...
}

C2 缓存空对象

当 DB 查不到时,缓存一个空值(带短 TTL),避免同一 key 反复穿透。

const emptyTTL = 5 * time.Minute

func (s *Service) GetWithNullCache(ctx context.Context, key string) (any, error) {
    val, err := s.rdb.Get(ctx, key).Result()
    if err == nil {
        if val == "NULL" {
            return nil, nil // 命中空值缓存
        }
        return val, nil
    }

    data, err := s.db.Query(ctx, key)
    if err != nil {
        return nil, err
    }
    if data == nil {
        // DB 也没有,缓存空值
        s.rdb.Set(ctx, key, "NULL", emptyTTL)
        return nil, nil
    }
    s.rdb.Set(ctx, key, data, 30*time.Minute)
    return data, nil
}

!!! warning "空值缓存的风险" - 短 TTL 是必须的,否则真正写入数据后缓存中的空值会遮挡新数据 - 大量不同 key 穿透时,会缓存大量空值,占用内存

C3 布隆过滤器

在缓存之前加一层布隆过滤器,不存在的 key 直接拦截。

graph LR
    Req["请求"] --> BF{"布隆过滤器"}
    BF -->|"一定不存在 ✗"| Reject["直接拒绝<br/>不查缓存/DB"]
    BF -->|"可能存在 ✓"| Cache{"Redis 缓存"}
    Cache -->|hit| Return["返回数据"]
    Cache -->|miss| DB["查询 DB"]
    DB -->|有数据| Cache
    DB -->|无数据| Null["缓存空对象<br/>短 TTL"]
import "github.com/bits-and-blooms/bloom/v3"

var userFilter = bloom.NewWithEstimates(1_000_000, 0.01) // 100万条,1%误判率

// 服务启动时加载数据
func InitFilter(ctx context.Context) {
    ids, _ := db.GetAllUserIDs(ctx)
    for _, id := range ids {
        userFilter.AddString(strconv.FormatInt(id, 10))
    }
}

func (s *Service) GetWithBloom(ctx context.Context, id int64) (any, error) {
    key := strconv.FormatInt(id, 10)

    // 布隆过滤器判断:如果一定不存在,直接返回
    if !userFilter.TestString(key) {
        return nil, fmt.Errorf("user %d does not exist", id)
    }

    // 可能存在,走正常缓存 → DB 流程
    return s.GetWithNullCache(ctx, fmt.Sprintf("user:%d", id))
}

!!! info "布隆过滤器的特点" - 不存在 → 一定不存在(无假阴性) - 存在 → 可能存在(有假阳性,约 1% 误判率可接受) - 不支持删除,数据变更时需重建 - 适合数据集相对稳定、查询频繁的场景


🔀 方案速查表

问题 方案 复杂度 适用场景
击穿 A1 永不过期 ★★ 可接受短暂不一致的热点数据
击穿 A2 加锁排队 ★★★ 数据一致性要求高
雪崩 B1 加锁排队 ★★★ 应急限流,配合其他方案使用
雪崩 B2 随机失效 ★ 最简单有效,批量设置 TTL 时必备
雪崩 B3 Redis 高可用 ★★★★ 生产环境必备
穿透 C1 参数校验 ★ 入口层拦截,第一道防线
穿透 C2 缓存空对象 ★★ 穿透 key 种类不多时
穿透 C3 布隆过滤器 ★★★ 数据量大、穿透 key 随机时

⚠️ 常见陷阱

!!! warning "加锁 ≠ 互斥锁就够了" 单机 sync.Mutex 在多实例部署下无效,要么用分布式锁,要么用 singleflight。

!!! warning "空值缓存 TTL 太长" TTL 太长会遮挡后续写入的真实数据;太短则穿透频繁。一般 1~5 分钟,视业务容忍度调整。

!!! warning "布隆过滤器不支持删除" 数据被删后布隆过滤不会更新,可能误判"存在"。解决方案:使用 Counting Bloom Filter 或定期全量重建。


🏋️ 练习题

??? question "练习 1:为什么 singleflight 能缓解击穿但不能解决雪崩?" singleflight 合并的是 同一个 key 的并发请求。雪崩是 多个不同 key 同时失效,每个 key 形成独立的回源请求,singleflight 无法跨 key 合并。

??? success "答案"
    singleflight 按 key 做请求合并,只能保证同一个 key 只有一个回源请求。雪崩时大量不同 key 同时失效,各自独立回源,singleflight 无法跨 key 去重。解决雪崩需从 TTL 分散和高可用入手。

??? question "练习 2:布隆过滤器说「存在 → 可能存在」,那假阳性时会发生什么?" 当布隆过滤器误判某个不存在的 key 为"存在"时,请求会正常走缓存 → DB 流程,缓存 miss 后查 DB 也没数据,此时可降级到缓存空对象方案兜底。

??? success "答案"
    假阳性不会导致错误结果——只是让本可拦截的请求穿透到了缓存/DB 层。配合缓存空对象(C2),在 DB 查空后缓存一个短 TTL 的空值,后续同样的 key 就不会重复穿透了。实际中 1% 的误判率完全可控。

??? question "练习 3:永不过期方案中,如果后台异步刷新 DB 查询失败怎么办?" 此时缓存返回的是旧数据,一致性要求不高的场景可以接受;如果一致性要求高,应在刷新失败时记录日志/告警,并设置一个最大容忍时间,超过后主动置为失效状态。

??? success "答案"
    1. 返回旧数据,保证可用性(AP 优先)
    2. 刷新失败时记录日志并告警
    3. 设置 `MaxStale` 上限,超过后返回错误或降级
    4. 关键业务不适合单独使用永不过期方案,需配合其他手段

🔗 相关链接