缓存穿透¶
💡 一句话概述
缓存穿透是请求查询根本不存在的数据,缓存永远无法命中,每次请求都直达 DB——防护核心是在缓存层之前拦截无效请求。
🔑 核心概念¶
- 数据不存在:请求的 key 在 DB 中根本不存在,缓存层也无法存储有效结果。
- 每次穿透:每次请求都 miss → 查 DB → 空 → 回来,缓存形同虚设。
- 恶意攻击:攻击者用大量不存在的 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. 定期全量重建(适合删除频率低的场景) 详见 布隆过滤器删除问题。