4. 缓存穿透:问题定义、产生原因、Go代码演示及布隆过滤器解决方案

4.1 问题定义

缓存穿透是指客户端请求的数据在缓存中不存在,同时在数据库中也查询不到,导致每次请求都直接穿透缓存层,直达数据库层。由于数据库返回空结果,缓存层无法写入有效数据,后续相同请求会持续穿透缓存,对数据库造成巨大压力,甚至引发数据库宕机。

核心特征:缓存未命中 + 数据库无记录 = 请求绕过缓存直击数据库。

4.2 产生原因

  • 恶意攻击:攻击者构造大量不存在的 Key(如负 ID、超长字符串、随机组合)发起请求,导致缓存形同虚设。
  • 业务逻辑缺陷:用户查询已删除或未生成的数据,且系统未做前置校验。
  • 缓存预热遗漏:新上线功能未将热点数据提前加载到缓存,导致上线初期大量穿透。
  • 代码 Bug:查询数据库后未正确处理空结果,未设置空值缓存或布隆过滤器。

4.3 缓存穿透 vs 缓存击穿 vs 缓存雪崩

现象 缓存状态 数据库状态 影响范围
缓存穿透 Key 不存在 数据不存在 大量不同 Key 持续穿透
缓存击穿 热点 Key 过期 数据存在 单个热点 Key 瞬时高并发
缓存雪崩 大量 Key 同时过期 数据存在 大面积缓存失效

4.4 缓存穿透流程示意图

缓存穿透请求流程 客户端 Redis 缓存 MySQL 数据库 1. 查询 Key 2. 缓存未命中 3. 穿透缓存,直查数据库 4. 数据库无记录,返回空 ⚠ 缓存未写入空值,后续相同 Key 请求重复步骤 1-4,持续穿透 大量不存在 Key 并发时,数据库连接池迅速耗尽 重复穿透循环 Key₁ 穿透 → Key₂ 穿透 → Key₃ 穿透 → … → Keyₙ 穿透 数据库 QPS 飙升,响应延迟增加,最终可能宕机

4.5 Go代码演示:缓存穿透问题

以下代码模拟了未做防护的查询逻辑,每次请求都直接穿透到数据库:

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var (
    rdb *redis.Client
    db  *gorm.DB
    ctx = context.Background()
)

type User struct {
    ID   int    `gorm:"primaryKey"`
    Name string `gorm:"column:name"`
}

// GetUserByID 未做缓存穿透防护的查询
func GetUserByID(userID int) (*User, error) {
    // 1. 查询缓存
    key := fmt.Sprintf("user:%d", userID)
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        // 缓存命中
        fmt.Println("缓存命中:", val)
        return &User{ID: userID, Name: val}, nil
    }

    // 2. 缓存未命中,查询数据库
    fmt.Println("缓存未命中,查询数据库")
    var user User
    result := db.Where("id = ?", userID).First(&user)
    if result.Error != nil {
        // 数据库无记录 —— 缓存穿透!
        fmt.Printf("数据库无记录,userID=%d\n", userID)
        return nil, result.Error
    }

    // 3. 写入缓存
    rdb.Set(ctx, key, user.Name, 0)
    return &user, nil
}

func main() {
    // 模拟大量不存在 ID 的请求
    for i := 1000; i < 1100; i++ {
        user, err := GetUserByID(i)
        if err != nil {
            fmt.Printf("查询失败: userID=%d, err=%v\n", i, err)
        } else {
            fmt.Printf("查询成功: %+v\n", user)
        }
    }
}
问题分析:上述代码中,当数据库查询返回空结果时,缓存层没有写入任何标记。后续对相同不存在 ID 的请求会重复穿透缓存,每次都要查询数据库。在并发量达到每秒数千次时,数据库连接池会迅速耗尽。

4.6 布隆过滤器解决方案

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它由二进制位数组和多个哈希函数组成。

4.6.1 布隆过滤器原理

  • 初始化:创建一个长度为 m 的位数组,所有位初始化为 0。
  • 添加元素:使用 k 个哈希函数对元素计算哈希值,将位数组中对应位置置为 1。
  • 查询元素:同样使用 k 个哈希函数计算哈希值,检查对应位是否全部为 1:
    • 如果任意一位为 0,则元素一定不存在。
    • 如果全部为 1,则元素可能存在(存在误判率)。

4.6.2 布隆过滤器工作流程

布隆过滤器拦截缓存穿透流程 客户端 布隆过滤器 Redis 缓存 MySQL 查询 Key Key 一定不存在,直接拒绝 可能存在,放行 缓存未命中,查库 ✅ 布隆过滤器拦截了 99%+ 的不存在 Key 请求 只有极少数误判的请求会穿透到缓存和数据库 数据库压力从 O(n) 降低到 O(误判率 × n) 布隆过滤器位数组示意(m=16, k=3) 位数组: [1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]

4.7 Go代码实现:布隆过滤器防护

使用 github.com/bits-and-blooms/bloom/v3 库实现布隆过滤器:

package main

import (
    "context"
    "fmt"
    "github.com/bits-and-blooms/bloom/v3"
    "github.com/go-redis/redis/v8"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var (
    rdb    *redis.Client
    db     *gorm.DB
    ctx    = context.Background()
    filter *bloom.BloomFilter
)

func init() {
    // 初始化布隆过滤器:预计插入 100000 个元素,误判率 1%
    filter = bloom.NewWithEstimates(100000, 0.01)

    // 预热:将数据库中所有存在的 ID 加入布隆过滤器
    var ids []int
    db.Model(&User{}).Pluck("id", &ids)
    for _, id := range ids {
        filter.Add(uint64ToBytes(uint64(id)))
    }
}

func uint64ToBytes(n uint64) []byte {
    return []byte(fmt.Sprintf("%d", n))
}

// GetUserByIDWithBloom 使用布隆过滤器防护的查询
func GetUserByIDWithBloom(userID int) (*User, error) {
    // 1. 布隆过滤器前置检查
    if !filter.Test(uint64ToBytes(uint64(userID))) {
        fmt.Printf("布隆过滤器拦截: userID=%d 一定不存在\n", userID)
        return nil, fmt.Errorf("user not found")
    }

    // 2. 查询缓存
    key := fmt.Sprintf("user:%d", userID)
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        fmt.Println("缓存命中:", val)
        return &User{ID: userID, Name: val}, nil
    }

    // 3. 缓存未命中,查询数据库
    fmt.Println("缓存未命中,查询数据库")
    var user User
    result := db.Where("id = ?", userID).First(&user)
    if result.Error != nil {
        // 理论上布隆过滤器已拦截,但误判时仍可能走到这里
        fmt.Printf("数据库无记录(误判): userID=%d\n", userID)
        return nil, result.Error
    }

    // 4. 写入缓存
    rdb.Set(ctx, key, user.Name, 0)
    return &user, nil
}

func main() {
    // 模拟大量不存在 ID 的请求
    for i := 1000; i < 1100; i++ {
        user, err := GetUserByIDWithBloom(i)
        if err != nil {
            fmt.Printf("查询失败: userID=%d, err=%v\n", i, err)
        } else {
            fmt.Printf("查询成功: %+v\n", user)
        }
    }
}

4.8 布隆过滤器参数配置

参数 说明 推荐值
n 预计插入元素数量 根据业务数据量估算,如 10 万、100 万
p 期望误判率 0.01(1%)或 0.001(0.1%)
m 位数组长度(自动计算) m = -n × ln(p) / (ln2)²
k 哈希函数个数(自动计算) k = (m/n) × ln2
实践建议:
  • 布隆过滤器适合读多写少的场景,新增数据时需要同步更新过滤器。
  • 误判率 p 建议设置为 0.01~0.001,过低会占用过多内存。
  • 布隆过滤器无法删除元素,如需删除功能可考虑使用计数布隆过滤器或布谷鸟过滤器。
  • 对于已存在的 Key,布隆过滤器不会误判;只有不存在的 Key 可能被误判为存在。

4.9 其他解决方案对比

方案 原理 优点 缺点
布隆过滤器 位数组 + 哈希函数 内存占用极小,查询速度快 存在误判率,无法删除元素
缓存空值 将空结果也缓存(TTL 较短) 实现简单,无误判 占用缓存空间,可能存储大量无效 Key
参数校验 前置校验 ID 格式、范围 零成本,拦截明显非法请求 无法拦截合法格式的不存在 ID
限流降级 对异常 IP 或高频 Key 限流 保护数据库不被打满 无法从根源解决问题
最佳实践组合:参数校验(前置拦截非法格式)+ 布隆过滤器(拦截不存在 Key)+ 缓存空值(兜底处理误判),三层防护可覆盖 99.9% 的缓存穿透场景。