diff --git a/docs/architecture/cache-breakdown-avalanche-penetration.md b/docs/architecture/cache-breakdown-avalanche-penetration.md new file mode 100644 index 0000000..0eb9d63 --- /dev/null +++ b/docs/architecture/cache-breakdown-avalanche-penetration.md @@ -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 布隆过滤器实现 diff --git a/mkdocs.yml b/mkdocs.yml index 4f08d5a..aeae2a3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,5 +85,6 @@ nav: - 架构: - architecture/index.md - 缓存: architecture/cache.md + - 缓存击穿雪崩穿透: architecture/cache-breakdown-avalanche-penetration.md - 算法: - algorithm/index.md