4. 缓存穿透:问题定义、产生原因、Go代码演示及布隆过滤器解决方案
4.1 问题定义
缓存穿透是指客户端请求的数据在缓存中不存在,同时在数据库中也查询不到,导致每次请求都直接穿透缓存层,直达数据库层。由于数据库返回空结果,缓存层无法写入有效数据,后续相同请求会持续穿透缓存,对数据库造成巨大压力,甚至引发数据库宕机。
核心特征:缓存未命中 + 数据库无记录 = 请求绕过缓存直击数据库。
4.2 产生原因
- 恶意攻击:攻击者构造大量不存在的 Key(如负 ID、超长字符串、随机组合)发起请求,导致缓存形同虚设。
- 业务逻辑缺陷:用户查询已删除或未生成的数据,且系统未做前置校验。
- 缓存预热遗漏:新上线功能未将热点数据提前加载到缓存,导致上线初期大量穿透。
- 代码 Bug:查询数据库后未正确处理空结果,未设置空值缓存或布隆过滤器。
4.3 缓存穿透 vs 缓存击穿 vs 缓存雪崩
| 现象 | 缓存状态 | 数据库状态 | 影响范围 |
|---|---|---|---|
| 缓存穿透 | Key 不存在 | 数据不存在 | 大量不同 Key 持续穿透 |
| 缓存击穿 | 热点 Key 过期 | 数据存在 | 单个热点 Key 瞬时高并发 |
| 缓存雪崩 | 大量 Key 同时过期 | 数据存在 | 大面积缓存失效 |
4.4 缓存穿透流程示意图
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 布隆过滤器工作流程
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% 的缓存穿透场景。