@@ -0,0 +1,350 @@
|
||||
# 缓存击穿 & 缓存雪崩 & 缓存穿透
|
||||
|
||||
!!! note "💡 一句话概述"
|
||||
缓存击穿是单 key 过期瞬间的并发涌入,雪崩是大面积 key 同时失效,穿透是根本不存在的数据绕过缓存直击 DB——三者解决思路各不同,但经常一起考察。
|
||||
|
||||
---
|
||||
|
||||
## 🔑 核心概念
|
||||
|
||||
1. **缓存击穿(Breakdown)**:某个热点 key 过期的瞬间,大量并发请求同时穿透到 DB,压垮数据库。
|
||||
2. **缓存雪崩(Avalanche)**:大量 key 在同一时间集中过期,或缓存节点宕机,导致请求全部涌向 DB。
|
||||
3. **缓存穿透(Penetration)**:请求查询的数据在 DB 中根本不存在,缓存永远无法命中,每次请求都直达 DB。
|
||||
|
||||
---
|
||||
|
||||
## 📝 三者对比
|
||||
|
||||
| | 缓存击穿 | 缓存雪崩 | 缓存穿透 |
|
||||
|---|---------|---------|---------|
|
||||
| **根因** | 热 key 过期 | 大面积 key 同时过期 / 缓存宕机 | 查询不存在的数据 |
|
||||
| **特征** | 单 key、高并发 | 多 key、集中失效 | 缓存永远 miss |
|
||||
| **触发方式** | 自然过期 | 自然过期 / 节点故障 | 恶意攻击 / 业务异常 |
|
||||
| **危害** | DB 瞬时尖峰 | DB 持续高压 | DB 持续高压 |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 解决方案
|
||||
|
||||
### A. 缓存击穿
|
||||
|
||||
#### A1 永不过期
|
||||
|
||||
逻辑过期而非 TTL 过期:缓存中存一个过期时间字段,由后台异步刷新,请求始终命中缓存。
|
||||
|
||||
```go
|
||||
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 加载,其余请求等锁释放后读缓存。
|
||||
|
||||
```go
|
||||
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`**:
|
||||
|
||||
```go
|
||||
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 瞬时过载。
|
||||
|
||||
```go
|
||||
// 使用带容量限制的令牌桶
|
||||
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 在同一时刻集中过期。
|
||||
|
||||
```go
|
||||
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 同时"失效",必须依赖高可用架构:
|
||||
|
||||
| 方案 | 说明 |
|
||||
|------|------|
|
||||
| **Redis Sentinel** | 自动故障转移,主节点挂掉时从节点升主 |
|
||||
| **Redis Cluster** | 数据分片 + 自动故障转移,生产环境推荐 |
|
||||
| **多级缓存** | 本地缓存(如 `bigcache`/`ristretto`)+ Redis,Redis 不可用时降级到本地 |
|
||||
| **熔断降级** | DB 压力过大时直接返回兜底数据或错误页 |
|
||||
|
||||
```go
|
||||
// 多级缓存示例:本地 + 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 查询流程。
|
||||
|
||||
```go
|
||||
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 反复穿透。
|
||||
|
||||
```go
|
||||
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 直接拦截。
|
||||
|
||||
```go
|
||||
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. 关键业务不适合单独使用永不过期方案,需配合其他手段
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [Redis 官方文档 — Caching](https://redis.io/docs/manual/patterns/caching/) — Redis 缓存模式
|
||||
- [Go singleflight 文档](https://pkg.go.dev/golang.org/x/sync/singleflight) — 请求合并
|
||||
- [bloom 库](https://github.com/bits-and-blooms/bloom) — Go 布隆过滤器实现
|
||||
@@ -85,5 +85,6 @@ nav:
|
||||
- 架构:
|
||||
- architecture/index.md
|
||||
- 缓存: architecture/cache.md
|
||||
- 缓存击穿雪崩穿透: architecture/cache-breakdown-avalanche-penetration.md
|
||||
- 算法:
|
||||
- algorithm/index.md
|
||||
|
||||
Reference in New Issue
Block a user