4. 缓存穿透:定义、产生原因、Go代码模拟、解决方案
4.1 定义与产生原因
缓存穿透是指查询一个根本不存在的数据,导致请求绕过缓存,直接打到数据库层。由于数据库中也不存在该记录,每次查询都会穿透缓存层,对数据库造成压力。
核心特征:缓存和数据库中均无目标数据,每次请求都直接访问数据库。
产生原因通常包括:
- 恶意攻击:攻击者构造大量不存在的 Key(如负ID、超长字符串)发起请求。
- 业务逻辑缺陷:前端未做参数校验,允许传入非法或空值。
- 数据冷启动:系统上线初期,缓存尚未预热,大量查询不存在的历史数据。
- 缓存淘汰:热点数据被淘汰后,短时间内大量并发查询同一不存在 Key。
4.2 缓存穿透流程示意图
下图展示了缓存穿透的请求路径:
4.3 Go代码模拟缓存穿透
以下代码模拟了缓存穿透场景:
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"log"
"sync"
"time"
)
var (
rdb *redis.Client
db *gorm.DB
ctx = context.Background()
)
type User struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"column:name"`
}
// 模拟缓存穿透:查询不存在的用户
func getUserWithPenetrate(userID int) (*User, error) {
// 1. 先查缓存
cacheKey := fmt.Sprintf("user:%d", userID)
val, err := rdb.Get(ctx, cacheKey).Result()
if err == nil {
// 缓存命中
fmt.Printf("[缓存命中] key=%s, value=%s\n", cacheKey, val)
return &User{ID: userID, Name: val}, nil
}
// 2. 缓存未命中,查数据库
fmt.Printf("[缓存未命中] key=%s, 查询数据库\n", cacheKey)
var user User
result := db.Where("id = ?", userID).First(&user)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
// 3. 数据库也不存在 —— 缓存穿透!
fmt.Printf("[缓存穿透] 用户 %d 不存在,直接返回空\n", userID)
return nil, nil
}
return nil, result.Error
}
// 4. 数据库存在,写入缓存
rdb.Set(ctx, cacheKey, user.Name, 10*time.Second)
return &user, nil
}
func main() {
// 初始化 Redis
rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// 初始化 MySQL
dsn := "root:password@tcp(127.0.0.1:3306)/test?charset=utf8mb4"
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("数据库连接失败:", err)
}
// 模拟并发穿透:查询不存在的用户ID
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
user, _ := getUserWithPenetrate(id)
if user == nil {
fmt.Printf("请求 %d 结果: 用户不存在\n", id)
}
}(99999) // 不存在的用户ID
}
wg.Wait()
}
运行结果分析:上述代码中,10个并发请求查询ID为99999的用户,每次都会绕过缓存直接查询数据库,因为缓存和数据库中均无此记录。这就是典型的缓存穿透现象。
4.4 解决方案一:缓存空对象
缓存空对象(Cache Null Object)是指当数据库查询结果为空时,仍然将该空结果缓存起来,并设置一个较短的过期时间(如30~60秒)。后续相同Key的请求直接从缓存返回空,不再穿透到数据库。
| 维度 | 说明 |
|---|---|
| 实现方式 | 数据库返回空时,缓存一个特殊标记(如空字符串或"NULL") |
| 过期时间 | 建议30~60秒,避免长时间缓存无效数据 |
| 优点 | 实现简单,能有效减少数据库压力 |
| 缺点 | 占用额外缓存空间;短时间内的空值缓存可能被恶意利用 |
Go代码实现缓存空对象:
// 缓存空对象方案
func getUserWithNullCache(userID int) (*User, error) {
cacheKey := fmt.Sprintf("user:%d", userID)
// 1. 查缓存
val, err := rdb.Get(ctx, cacheKey).Result()
if err == nil {
if val == "" { // 空对象标记
fmt.Printf("[空对象缓存] key=%s, 返回空\n", cacheKey)
return nil, nil
}
fmt.Printf("[缓存命中] key=%s, value=%s\n", cacheKey, val)
return &User{ID: userID, Name: val}, nil
}
// 2. 缓存未命中,查数据库
var user User
result := db.Where("id = ?", userID).First(&user)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
// 3. 缓存空对象,过期时间30秒
rdb.Set(ctx, cacheKey, "", 30*time.Second)
fmt.Printf("[缓存空对象] key=%s, 设置空值缓存\n", cacheKey)
return nil, nil
}
return nil, result.Error
}
// 4. 数据库存在,写入缓存
rdb.Set(ctx, cacheKey, user.Name, 10*time.Second)
return &user, nil
}
4.5 解决方案二:布隆过滤器
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它由二进制位数组和多个哈希函数组成。
核心特性:布隆过滤器判断“不存在”是100%准确的;判断“存在”有一定误判率(False Positive)。
| 特性 | 布隆过滤器 | 缓存空对象 |
|---|---|---|
| 空间占用 | 极低(位数组) | 较高(存储完整Key-Value) |
| 误判率 | 存在误判(可控制) | 无误判 |
| 删除支持 | 不支持(标准实现) | 支持(直接删除Key) |
| 适用场景 | 海量数据、允许少量误判 | 数据量较小、要求精确 |
| 实现复杂度 | 中等 | 简单 |
4.6 布隆过滤器工作原理
下图展示了布隆过滤器的判断流程:
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/gorm"
"strconv"
)
var (
filter *bloom.BloomFilter
rdb *redis.Client
db *gorm.DB
ctx = context.Background()
)
// 初始化布隆过滤器
func initBloomFilter() {
// 参数:预期元素数量100万,误判率1%
filter = bloom.NewWithEstimates(1000000, 0.01)
// 预热:将数据库中所有用户ID加入过滤器
var userIDs []int
db.Model(&User{}).Pluck("id", &userIDs)
for _, id := range userIDs {
filter.Add([]byte(strconv.Itoa(id)))
}
fmt.Printf("布隆过滤器初始化完成,已加载 %d 个用户ID\n", len(userIDs))
}
// 使用布隆过滤器防止缓存穿透
func getUserWithBloom(userID int) (*User, error) {
// 1. 布隆过滤器前置判断
if !filter.Test([]byte(strconv.Itoa(userID))) {
fmt.Printf("[布隆拦截] 用户 %d 一定不存在,直接返回空\n", userID)
return nil, nil
}
// 2. 查缓存
cacheKey := fmt.Sprintf("user:%d", userID)
val, err := rdb.Get(ctx, cacheKey).Result()
if err == nil {
if val == "" {
return nil, nil
}
return &User{ID: userID, Name: val}, nil
}
// 3. 查数据库
var user User
result := db.Where("id = ?", userID).First(&user)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
// 理论上不会走到这里,因为布隆过滤器已拦截
// 但为了兜底,仍缓存空对象
rdb.Set(ctx, cacheKey, "", 30*time.Second)
return nil, nil
}
return nil, result.Error
}
// 4. 写入缓存
rdb.Set(ctx, cacheKey, user.Name, 10*time.Second)
return &user, nil
}
最佳实践建议:
- 小数据量(百万级以下):优先使用缓存空对象,实现简单,维护成本低。
- 大数据量(千万级以上):使用布隆过滤器,节省缓存空间,拦截效率高。
- 组合方案:布隆过滤器 + 缓存空对象双重防护,效果最佳。
- 布隆过滤器更新:新增数据时需同步更新过滤器,可使用定时重建或增量更新策略。
4.8 两种方案对比总结
| 对比维度 | 缓存空对象 | 布隆过滤器 |
|---|---|---|
| 实现难度 | 低 | 中 |
| 内存占用 | 较高(存储完整Key) | 极低(位数组) |
| 准确性 | 100%准确 | 存在误判率 |
| 应对恶意攻击 | 有效,但占用缓存空间 | 非常有效,拦截大部分无效请求 |
| 数据更新 | 自动过期,无需额外维护 | 需要同步更新过滤器 |
| 适用数据量 | 百万级以下 | 千万级以上 |