跳转至

缓存击穿

💡 一句话概述

缓存击穿是某个热点 key 过期的瞬间,大量并发请求同时穿透到 DB 导致数据库瞬时尖峰——防护核心是控制回源并发数。


🔑 核心概念

  1. 热点 key 过期:某个被高频访问的 key 在 TTL 到期后,缓存中消失。
  2. 并发回源:大量请求同时发现缓存 miss,同时去 DB 查询。
  3. 瞬时尖峰:DB 承受短时间大量相同查询,压力可能压垮数据库。

📝 详细说明

发生机制

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

典型场景:电商秒杀商品详情、热门文章、排行榜——这类 key 访问量极高,一旦过期瞬间就有成百上千请求涌入 DB。

与雪崩、穿透的区别

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

🛡️ 解决方案

方案 1:逻辑过期(永不过期)

逻辑过期而非 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
}

适用场景

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

方案 2:加锁排队

只放一个请求去 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
}

分布式环境用分布式锁

单机 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
}

📊 方案对比

方案 一致性 可用性 复杂度 适用场景
逻辑过期 弱 高 ★★ 可接受短暂不一致的热点数据
加锁排队 强 中 ★★★ 数据一致性要求高
singleflight 强 中 ★★ Go 生态最佳实践

⚠️ 常见陷阱

加锁 ≠ 互斥锁就够了

单机 sync.Mutex 在多实例部署下无效,要么用分布式锁,要么用 singleflight。

逻辑过期的"脏窗口"

异步刷新完成前,所有请求读到的都是旧数据。如果业务对一致性敏感,需要设置最大容忍时间,超时后降级。


🏋️ 练习题

练习 1:为什么 singleflight 能缓解击穿但不能解决雪崩?

singleflight 合并的是**同一个 key** 的并发请求。雪崩是**多个不同 key**同时失效,每个 key 形成独立的回源请求,singleflight 无法跨 key 合并。

答案

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

练习 2:逻辑过期方案中,如果后台异步刷新 DB 查询失败怎么办?

此时缓存返回的是旧数据,一致性要求不高的场景可以接受;如果一致性要求高,应在刷新失败时记录日志/告警,并设置一个最大容忍时间。

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

🔗 相关链接