跳转至

缓存穿透

💡 一句话概述

缓存穿透是请求查询根本不存在的数据,缓存永远无法命中,每次请求都直达 DB——防护核心是在缓存层之前拦截无效请求。


🔑 核心概念

  1. 数据不存在:请求的 key 在 DB 中根本不存在,缓存层也无法存储有效结果。
  2. 每次穿透:每次请求都 miss → 查 DB → 空 → 回来,缓存形同虚设。
  3. 恶意攻击:攻击者用大量不存在的 key 发起请求,每条都穿透到 DB。

📝 详细说明

发生机制

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

典型场景:

场景 说明
恶意攻击 用随机 ID 请求 /user/{id},大量 ID 根本不存在
业务异常 前端传了无效参数(负数 ID、非法格式),后端未校验
数据删除后 DB 中数据已删除,但请求仍用旧 key 访问

与击穿、雪崩的区别

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

穿透的独特性:击穿和雪崩是"有数据但缓存失效",穿透是"根本没数据"——这意味着常规的"缓存 miss → 回源"流程完全失效。


🛡️ 解决方案

方案 1:参数校验(第一道防线)

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

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

校验规则示例:

参数类型 校验规则
用户 ID 正整数,范围在合法区间
订单号 符合格式(如日期+序号)
枚举值 必须在预定义集合内
分页参数 page ≤ maxPage、size ≤ maxSize

方案 2:缓存空对象

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

sequenceDiagram
    participant C as 客户端
    participant R as Redis
    participant DB as DB

    C->>R: GET user:9999 → miss
    R-->>C: nil
    C->>DB: SELECT * FROM users WHERE id=9999
    DB-->>C: 空(不存在)
    C->>R: SET user:9999 "NULL" EX 300
    Note over R: 缓存空值,5 分钟 TTL

    C->>R: GET user:9999 → hit "NULL"
    C-->>C: 直接返回空,不再查 DB ✅
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
}

空值缓存的风险

  • 短 TTL 是必须的,否则真正写入数据后缓存中的空值会遮挡新数据
  • 大量不同 key 穿透时,会缓存大量空值,占用内存
  • 适合穿透 key 种类有限的场景

方案 3:布隆过滤器

在缓存之前加一层布隆过滤器,不存在的 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))
}

布隆过滤器的特点

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

**布隆过滤器 + 缓存空对象的组合**是防御穿透最完整的方案:

情况 布隆过滤器 缓存空对象
key 确实不存在 直接拦截 ✅ 不需要
key 存在、缓存命中 放行 → 缓存返回 ✅ 不需要
key 存在、缓存 miss 放行 → 回源 ✅ 不需要
布隆误判(假阳性) 放行 → 缓存 miss 缓存空值兜底 ✅

📊 方案对比

方案 防护强度 内存开销 复杂度 适用场景
参数校验 弱(仅防非法参数) 无 ★ 入口层拦截,第一道防线
缓存空对象 中 较大(大量空值) ★★ 穿透 key 种类不多时
布隆过滤器 强 小(~1.14MB/百万) ★★★ 数据量大、穿透 key 随机时

⚠️ 常见陷阱

空值缓存 TTL 太长

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

布隆过滤器不支持删除

数据被删后布隆过滤器不会更新,可能误判"存在"。详见 布隆过滤器删除问题。

布隆过滤器需要预加载

服务启动时必须从 DB 加载全量 ID 到过滤器,启动时间与数据量成正比。数据量极大时需考虑异步加载。

攻击者用全随机 key 时,空值缓存会爆内存

每个随机 key 都会缓存一个空值,内存线性增长。此时应依赖布隆过滤器前置拦截,而非空值缓存。


🏋️ 练习题

练习 1:布隆过滤器说「存在 → 可能存在」,那假阳性时会发生什么?

误判时请求会正常走缓存 → DB 流程,缓存 miss 后查 DB 也没数据,此时可降级到缓存空对象方案兜底。

答案

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

练习 2:攻击者用完全随机的 key 攻击,纯靠缓存空对象能防住吗?

不能。随机 key 意味着每个 key 都是全新的,每次都会穿透到 DB 并缓存一个空值。攻击量大时,Redis 内存会被空值占满。

答案

纯缓存空对象在随机 key 攻击下会**内存溢出**。正确做法是布隆过滤器前置拦截——随机 key 大概率不在过滤器中,直接被拒绝。布隆过滤器 + 缓存空对象的组合才能同时覆盖常规穿透和恶意攻击。

练习 3:业务数据频繁增删(如用户注册/注销),布隆过滤器还适用吗?

标准布隆过滤器不支持删除,频繁删除后误判率会上升。此时应考虑 Counting Bloom Filter 或 Cuckoo Filter。

答案

频繁增删场景下标准布隆过滤器不适用——删除的用户 ID 仍会在过滤器中标记为"存在",假阳性持续累积。解决方案: 1. 使用 Counting Bloom Filter(支持删除,但 4x 空间开销) 2. 使用 Cuckoo Filter(支持删除,空间更优) 3. 定期全量重建(适合删除频率低的场景) 详见 布隆过滤器删除问题。


🔗 相关链接