缓存击穿¶
💡 一句话概述
缓存击穿是某个热点 key 过期的瞬间,大量并发请求同时穿透到 DB 导致数据库瞬时尖峰——防护核心是控制回源并发数。
🔑 核心概念¶
- 热点 key 过期:某个被高频访问的 key 在 TTL 到期后,缓存中消失。
- 并发回源:大量请求同时发现缓存 miss,同时去 DB 查询。
- 瞬时尖峰: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 查询失败怎么办?
此时缓存返回的是旧数据,一致性要求不高的场景可以接受;如果一致性要求高,应在刷新失败时记录日志/告警,并设置一个最大容忍时间。
答案
- 返回旧数据,保证可用性(AP 优先)
- 刷新失败时记录日志并告警
- 设置
MaxStale上限,超过后返回错误或降级 - 关键业务不适合单独使用逻辑过期,需配合其他手段
🔗 相关链接¶
- 缓存雪崩 — 大面积 key 同时失效
- 缓存穿透 — 查不存在的数据绕过缓存
- Go singleflight 文档 — 请求合并