4. 缓存穿透:定义、产生原因、Go代码模拟、解决方案

4.1 定义与产生原因

缓存穿透是指查询一个根本不存在的数据,导致请求绕过缓存,直接打到数据库层。由于数据库中也不存在该记录,每次查询都会穿透缓存层,对数据库造成压力。

核心特征:缓存和数据库中均无目标数据,每次请求都直接访问数据库。

产生原因通常包括:

  • 恶意攻击:攻击者构造大量不存在的 Key(如负ID、超长字符串)发起请求。
  • 业务逻辑缺陷:前端未做参数校验,允许传入非法或空值。
  • 数据冷启动:系统上线初期,缓存尚未预热,大量查询不存在的历史数据。
  • 缓存淘汰:热点数据被淘汰后,短时间内大量并发查询同一不存在 Key。

4.2 缓存穿透流程示意图

下图展示了缓存穿透的请求路径:

缓存穿透请求流程 客户端 Redis 缓存 MySQL 数据库 数据不存在 1. 查询 Key 2. 缓存未命中 3. 查询为空 4. 返回空结果(穿透) ⚠ 穿透后果: 每次请求都绕过缓存直接查询数据库,高并发下可能导致数据库连接耗尽、响应超时甚至宕机。 恶意攻击者 批量构造不存在 Key

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 布隆过滤器工作原理

下图展示了布隆过滤器的判断流程:

布隆过滤器判断流程 查询 Key 哈希函数组 Hash1, Hash2, Hash3 位数组 (Bit Array) [0,1,0,1,1,0,1,0,0,1,...] 结果 判断规则: 所有哈希函数计算的位均为1 → 可能存在(需进一步查询);任意一位为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/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%准确 存在误判率
应对恶意攻击 有效,但占用缓存空间 非常有效,拦截大部分无效请求
数据更新 自动过期,无需额外维护 需要同步更新过滤器
适用数据量 百万级以下 千万级以上