Redis缓存三大问题实战:穿透、雪崩、击穿怎么解决

面试必问三件套:缓存穿透、缓存雪崩、缓存击穿。

但实际生产中踩过坑才知道,这三个问题不只是面试题,是真的会让服务挂掉的。


先搞清楚概念

问题 原因 后果
缓存穿透 查询不存在的数据 请求全打到数据库
缓存雪崩 大量缓存同时失效 瞬间压垮数据库
缓存击穿 热点key突然过期 并发请求打穿数据库

一张图理解:

vbnet 复制代码
正常情况:
请求 → Redis命中 → 返回

缓存穿透:
请求(不存在的key) → Redis未命中 → DB未命中 → 每次都查DB

缓存雪崩:
大量请求 → Redis大面积失效 → 全部打到DB → DB崩溃

缓存击穿:
大量并发请求(同一个热点key) → key刚好过期 → 全部打到DB

缓存穿透

什么是穿透

查询一个根本不存在的数据。缓存里没有,数据库里也没有。

vbnet 复制代码
请求: GET /user/999999999
Redis: 没有这个key
MySQL: SELECT * FROM users WHERE id = 999999999 → 空
返回: null
下次请求: 还是走MySQL

恶意攻击者可以用大量不存在的ID疯狂请求,每个请求都打到数据库。

解决方案一:缓存空值

go 复制代码
func GetUser(ctx context.Context, userID int64) (*User, error) {
    key := fmt.Sprintf("user:%d", userID)
    
    // 1. 查缓存
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        if val == "" {
            return nil, nil  // 空值,说明DB也没有
        }
        var user User
        json.Unmarshal([]byte(val), &user)
        return &user, nil
    }
    
    // 2. 查数据库
    user, err := db.GetUser(userID)
    if err != nil {
        return nil, err
    }
    
    // 3. 写缓存
    if user == nil {
        // 缓存空值,设置较短过期时间
        rdb.Set(ctx, key, "", 5*time.Minute)
        return nil, nil
    }
    
    data, _ := json.Marshal(user)
    rdb.Set(ctx, key, data, 30*time.Minute)
    return user, nil
}

缺点:如果攻击者用随机key,空值缓存会占用大量内存。

解决方案二:布隆过滤器

布隆过滤器可以判断一个元素一定不存在可能存在

go 复制代码
import "github.com/bits-and-blooms/bloom/v3"

// 初始化布隆过滤器,存放所有存在的用户ID
var userBloom *bloom.BloomFilter

func InitBloom() {
    userBloom = bloom.NewWithEstimates(10000000, 0.01)  // 1000万数据,1%误判率
    
    // 加载所有用户ID
    userIDs, _ := db.GetAllUserIDs()
    for _, id := range userIDs {
        userBloom.AddString(fmt.Sprintf("%d", id))
    }
}

func GetUser(ctx context.Context, userID int64) (*User, error) {
    // 先过布隆过滤器
    if !userBloom.TestString(fmt.Sprintf("%d", userID)) {
        return nil, nil  // 一定不存在
    }
    
    // 可能存在,走正常逻辑
    // ...
}

Redis也有布隆过滤器模块:

bash 复制代码
# 安装RedisBloom模块后
BF.ADD users 123
BF.EXISTS users 123  # 返回1
BF.EXISTS users 999  # 返回0
go 复制代码
// Go代码
func CheckUserExists(ctx context.Context, userID int64) bool {
    exists, _ := rdb.Do(ctx, "BF.EXISTS", "users", userID).Bool()
    return exists
}

解决方案三:参数校验

最简单但最有效:

go 复制代码
func GetUser(ctx context.Context, userID int64) (*User, error) {
    // 参数校验
    if userID <= 0 || userID > 10000000000 {
        return nil, errors.New("invalid user id")
    }
    
    // 正常逻辑...
}

缓存雪崩

什么是雪崩

大量缓存在同一时间失效,请求全部打到数据库。

常见原因:

  1. 缓存设置了相同的过期时间
  2. Redis服务宕机

解决方案一:过期时间加随机值

go 复制代码
func SetUserCache(ctx context.Context, userID int64, user *User) error {
    key := fmt.Sprintf("user:%d", userID)
    data, _ := json.Marshal(user)
    
    // 基础过期时间 + 随机时间
    baseExpire := 30 * time.Minute
    randomExpire := time.Duration(rand.Intn(300)) * time.Second  // 0~5分钟随机
    expire := baseExpire + randomExpire
    
    return rdb.Set(ctx, key, data, expire).Err()
}

这样缓存过期时间分散开,不会同时失效。

解决方案二:多级缓存

scss 复制代码
请求 → 本地缓存(L1) → Redis(L2) → 数据库
go 复制代码
import "github.com/patrickmn/go-cache"

var localCache = cache.New(5*time.Minute, 10*time.Minute)

func GetUser(ctx context.Context, userID int64) (*User, error) {
    key := fmt.Sprintf("user:%d", userID)
    
    // 1. 查本地缓存
    if val, found := localCache.Get(key); found {
        return val.(*User), nil
    }
    
    // 2. 查Redis
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        var user User
        json.Unmarshal([]byte(val), &user)
        localCache.Set(key, &user, cache.DefaultExpiration)  // 写入本地缓存
        return &user, nil
    }
    
    // 3. 查数据库
    user, err := db.GetUser(userID)
    if err != nil {
        return nil, err
    }
    
    // 4. 写缓存
    data, _ := json.Marshal(user)
    rdb.Set(ctx, key, data, 30*time.Minute)
    localCache.Set(key, user, cache.DefaultExpiration)
    
    return user, nil
}

即使Redis挂了,本地缓存还能顶一会。

解决方案三:Redis高可用

  • Redis Sentinel:哨兵模式,自动故障转移
  • Redis Cluster:集群模式,数据分片+高可用
go 复制代码
// Sentinel模式连接
rdb := redis.NewFailoverClient(&redis.FailoverOptions{
    MasterName:    "mymaster",
    SentinelAddrs: []string{"sentinel1:26379", "sentinel2:26379", "sentinel3:26379"},
})

解决方案四:熔断降级

Redis不可用时,走降级逻辑:

go 复制代码
import "github.com/sony/gobreaker"

var cb *gobreaker.CircuitBreaker

func init() {
    cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        "redis",
        MaxRequests: 3,               // 半开状态允许的请求数
        Interval:    10 * time.Second,
        Timeout:     30 * time.Second, // 熔断后多久尝试恢复
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
            return counts.Requests >= 3 && failureRatio >= 0.6
        },
    })
}

func GetUser(ctx context.Context, userID int64) (*User, error) {
    result, err := cb.Execute(func() (interface{}, error) {
        // 正常的Redis查询逻辑
        return getFromRedis(ctx, userID)
    })
    
    if err != nil {
        // 熔断了,走降级逻辑
        return getFromDB(ctx, userID)
    }
    
    return result.(*User), nil
}

缓存击穿

什么是击穿

某个热点key突然过期,大量并发请求同时打到数据库。

和雪崩的区别:雪崩是大面积失效,击穿是单个热点key失效。

makefile 复制代码
热点key: "hot_product:123"
过期瞬间:
  请求1 → Redis Miss → 查DB
  请求2 → Redis Miss → 查DB
  请求3 → Redis Miss → 查DB
  ...
  1000个请求同时查DB

解决方案一:互斥锁

只让一个请求去查数据库,其他请求等待。

go 复制代码
func GetHotProduct(ctx context.Context, productID int64) (*Product, error) {
    key := fmt.Sprintf("product:%d", productID)
    lockKey := fmt.Sprintf("lock:product:%d", productID)
    
    // 1. 查缓存
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        var product Product
        json.Unmarshal([]byte(val), &product)
        return &product, nil
    }
    
    // 2. 获取分布式锁
    locked, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
    if err != nil {
        return nil, err
    }
    
    if !locked {
        // 没拿到锁,等待后重试
        time.Sleep(50 * time.Millisecond)
        return GetHotProduct(ctx, productID)  // 递归重试
    }
    defer rdb.Del(ctx, lockKey)  // 释放锁
    
    // 3. 双重检查(可能别人已经写入缓存了)
    val, err = rdb.Get(ctx, key).Result()
    if err == nil {
        var product Product
        json.Unmarshal([]byte(val), &product)
        return &product, nil
    }
    
    // 4. 查数据库
    product, err := db.GetProduct(productID)
    if err != nil {
        return nil, err
    }
    
    // 5. 写缓存
    data, _ := json.Marshal(product)
    rdb.Set(ctx, key, data, 30*time.Minute)
    
    return product, nil
}

解决方案二:逻辑过期

不设置真正的过期时间,而是在value里存逻辑过期时间。

go 复制代码
type CacheValue struct {
    Data       json.RawMessage `json:"data"`
    ExpireTime int64           `json:"expire_time"`  // 逻辑过期时间戳
}

func GetHotProduct(ctx context.Context, productID int64) (*Product, error) {
    key := fmt.Sprintf("product:%d", productID)
    
    val, err := rdb.Get(ctx, key).Result()
    if err != nil {
        // 缓存不存在,需要预热
        return nil, errors.New("cache not found, need warm up")
    }
    
    var cv CacheValue
    json.Unmarshal([]byte(val), &cv)
    
    var product Product
    json.Unmarshal(cv.Data, &product)
    
    // 检查是否逻辑过期
    if time.Now().Unix() > cv.ExpireTime {
        // 过期了,异步刷新
        go refreshCache(ctx, productID)
    }
    
    // 返回旧数据(不阻塞)
    return &product, nil
}

func refreshCache(ctx context.Context, productID int64) {
    key := fmt.Sprintf("product:%d", productID)
    lockKey := fmt.Sprintf("refresh:product:%d", productID)
    
    // 获取刷新锁
    locked, _ := rdb.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
    if !locked {
        return  // 已经有人在刷新了
    }
    defer rdb.Del(ctx, lockKey)
    
    // 查数据库
    product, _ := db.GetProduct(productID)
    
    // 写缓存
    cv := CacheValue{
        ExpireTime: time.Now().Add(30 * time.Minute).Unix(),
    }
    cv.Data, _ = json.Marshal(product)
    data, _ := json.Marshal(cv)
    
    rdb.Set(ctx, key, data, 0)  // 不设置过期时间
}

特点:用户永远不会等待,总是返回数据(可能是旧的)。

解决方案三:热点数据永不过期

对于真正的热点数据,不设置过期时间,通过后台任务定时更新。

go 复制代码
// 后台任务定时刷新热点数据
func RefreshHotData() {
    ticker := time.NewTicker(5 * time.Minute)
    for range ticker.C {
        hotProductIDs := getHotProductIDs()
        for _, id := range hotProductIDs {
            product, _ := db.GetProduct(id)
            key := fmt.Sprintf("product:%d", id)
            data, _ := json.Marshal(product)
            rdb.Set(context.Background(), key, data, 0)
        }
    }
}

实战:热点Key问题

除了击穿,热点Key还有另一个问题:单个Redis节点压力过大

发现热点Key

bash 复制代码
# Redis 4.0+
redis-cli --hotkeys

# 或者用monitor命令(生产慎用,性能影响大)
redis-cli monitor | head -n 10000 | awk '{print $4}' | sort | uniq -c | sort -rn | head -20

解决方案:本地缓存+多副本

go 复制代码
// 热点key多副本,分散请求
func GetHotProduct(ctx context.Context, productID int64) (*Product, error) {
    // 先查本地缓存
    localKey := fmt.Sprintf("product:%d", productID)
    if val, found := localCache.Get(localKey); found {
        return val.(*Product), nil
    }
    
    // 随机选一个副本查询
    replica := rand.Intn(3)
    key := fmt.Sprintf("product:%d:r%d", productID, replica)
    
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        var product Product
        json.Unmarshal([]byte(val), &product)
        localCache.Set(localKey, &product, 30*time.Second)
        return &product, nil
    }
    
    // 查数据库...
}

// 写入时写多个副本
func SetHotProduct(ctx context.Context, productID int64, product *Product) error {
    data, _ := json.Marshal(product)
    pipe := rdb.Pipeline()
    for i := 0; i < 3; i++ {
        key := fmt.Sprintf("product:%d:r%d", productID, i)
        pipe.Set(ctx, key, data, 30*time.Minute)
    }
    _, err := pipe.Exec(ctx)
    return err
}

总结

问题 原因 解决方案
穿透 查不存在的数据 缓存空值、布隆过滤器、参数校验
雪崩 大量缓存同时失效 过期时间随机化、多级缓存、Redis高可用、熔断降级
击穿 热点key过期 互斥锁、逻辑过期、永不过期+后台刷新

实际生产中,这三个问题往往要组合解决:

  1. 所有缓存都加随机过期时间(防雪崩)
  2. 查询前做参数校验(防穿透)
  3. 缓存空值(防穿透)
  4. 热点数据用互斥锁或逻辑过期(防击穿)
  5. Redis做好高可用(防雪崩)
  6. 加上熔断降级兜底

有问题评论区聊。

相关推荐
Cache技术分享1 天前
282. Java Stream API - 从 Collection 或 Iterator 创建 Stream
前端·后端
用户3074596982071 天前
ThinkPHP 6.0 多应用模式下的中间件机制详解
后端·thinkphp
格格步入1 天前
支付幂等:一锁二判三更新
后端
技术小泽1 天前
搜索系统架构入门篇
java·后端·算法·搜索引擎
+VX:Fegn08951 天前
计算机毕业设计|基于springboot + vue酒店预约系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
踏浪无痕1 天前
告警的艺术:从 node_exporter 指标到生产级规则
后端·架构·监控
源码获取_wx:Fegn08951 天前
基于springboot + vue酒店预约系统
java·vue.js·spring boot·后端·spring
我想问问天1 天前
【从0到1大模型应用开发实战】03|写一个可解释的RAG规则检索器
后端·aigc
豆浆Whisky1 天前
6小时从0到1:我用AI造了个分片SQL生成器
后端·sql·ai编程