Redis缓存策略实战:缓存穿透击穿雪崩解决方案与热点Key优化

Redis作为应用层缓存能大幅降低数据库压力,但缓存穿透、缓存击穿和缓存雪崩是三个经典问题。穿透指查询不存在的数据导致请求直达数据库;击穿指热点Key过期瞬间大量并发请求穿透到数据库;雪崩指大量Key同时过期导致数据库压力骤增。本文通过具体配置和代码实现三种问题的解决方案,并覆盖热点Key发现与缓存一致性保障。

缓存穿透问题与布隆过滤器方案

缓存穿透的典型场景:恶意请求查询不存在的ID(如负数ID或不存在的用户ID),缓存层无数据,每次请求都打到数据库。布隆过滤器(Bloom Filter)通过概率性数据结构,在缓存层之前拦截不存在的Key。

布隆过滤器原理:初始化一个长度为N的位数组(全为0)和K个哈希函数。写入元素时,用K个哈希函数计算K个位置,设为1。查询时,同样计算K个位置,如果全部为1则可能存在,任意一个为0则一定不存在。

Redis中通过RedisBloom模块或Lua脚本实现布隆过滤器:

# 安装RedisBloom模块
# 从 https://github.com/RedisBloom/RedisBloom 下载编译
# redis.conf 添加: loadmodule /path/to/redisbloom.so

# 创建布隆过滤器(容量100万,误判率0.1%)
redis-cli BF.RESERVE user_filter 0.001 1000000

# 添加元素
redis-cli BF.ADD user_filter "user:1001"
redis-cli BF.ADD user_filter "user:1002"

# 检查元素是否存在
redis-cli BF.EXISTS user_filter "user:1001"  # 返回1
redis-cli BF.EXISTS user_filter "user:9999"  # 返回0

Go代码实现查询拦截:

func GetUserByID(ctx context.Context, rdb *redis.Client, bf *BloomFilter, userID string) (*User, error) {
    // 第一步:布隆过滤器检查
    exists, err := bf.Exists(ctx, "user:"+userID)
    if err != nil || !exists {
        return nil, ErrUserNotFound  // 一定不存在,直接返回
    }

    // 第二步:查询缓存
    cached, err := rdb.Get(ctx, "user:"+userID).Bytes()
    if err == nil {
        var user User
        json.Unmarshal(cached, &user)
        return &user, nil
    }

    // 第三步:查询数据库
    user, err := db.GetUserByID(ctx, userID)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            // 数据库也不存在,设置空值标记(短过期时间)
            rdb.Set(ctx, "user:"+userID, "", 5*time.Minute)
            return nil, ErrUserNotFound
        }
        return nil, err
    }

    // 写入缓存
    data, _ := json.Marshal(user)
    rdb.Set(ctx, "user:"+userID, data, 30*time.Minute)
    return user, nil
}

空值缓存方案是对布隆过滤器的补充:查询数据库不存在的Key也缓存空值(短过期时间5分钟),防止同一不存在的Key反复打数据库。两种方案可组合使用:布隆过滤器拦截绝大部分不存在的Key,空值缓存处理漏网的情况。

缓存击穿与互斥锁保护策略

缓存击穿发生在热点Key过期瞬间,大量并发请求同时穿过缓存查询数据库。解决方案是互斥锁(Mutex Lock):第一个穿透请求获取锁后查询数据库并重建缓存,其余请求等待锁释放后直接读缓存。

基于Redis SETNX实现互斥锁:

func GetHotProduct(ctx context.Context, rdb *redis.Client, productID string) (*Product, error) {
    cacheKey := "product:" + productID
    lockKey := "lock:" + cacheKey

    // 查询缓存
    if cached, err := rdb.Get(ctx, cacheKey).Bytes(); err == nil {
        var p Product
        json.Unmarshal(cached, &p)
        return &p, nil
    }

    // 缓存未命中,尝试获取互斥锁
    lockOK, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
    if err != nil {
        return nil, err
    }

    if lockOK {
        // 获取锁成功,查询数据库
        defer rdb.Del(ctx, lockKey)  // 释放锁

        product, err := db.GetProductByID(ctx, productID)
        if err != nil {
            return nil, err
        }

        // 写入缓存(逻辑过期:不设置TTL,通过后台刷新)
        data, _ := json.Marshal(product)
        rdb.Set(ctx, cacheKey, data, 30*time.Minute)
        return product, nil
    }

    // 获取锁失败,短暂等待后重试读缓存
    time.Sleep(50 * time.Millisecond)
    return GetHotProduct(ctx, rdb, productID)  // 递归重试
}

逻辑过期方案(无锁设计):缓存不设TTL,但在value中存储逻辑过期时间。请求读到缓存后检查逻辑过期时间,若已过期则异步刷新缓存,当前请求返回旧数据。该方案不阻塞请求,但短时间返回旧数据。

type CacheData struct {
    Data      interface{} `json:"data"`
    ExpireAt  int64       `json:"expire_at"`  // 逻辑过期时间戳
}

func GetWithLogicalExpire(ctx context.Context, rdb *redis.Client, key string) (interface{}, error) {
    raw, err := rdb.Get(ctx, key).Bytes()
    if err != nil {
        // 缓存未命中,走正常查询流程
        return loadFromDB(ctx, rdb, key)
    }

    var cd CacheData
    json.Unmarshal(raw, &cd)

    // 检查逻辑是否过期
    if time.Now().Unix() > cd.ExpireAt {
        // 异步刷新(使用互斥锁避免重复刷新)
        lockKey := "lock:" + key
        if ok, _ := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result(); ok {
            go refreshCache(ctx, rdb, key)  // 后台刷新
        }
    }

    return cd.Data, nil  // 返回旧数据,不阻塞
}

缓存雪崩预防:过期时间随机化与多级缓存

缓存雪崩的两个常见原因:大量Key同时过期,或Redis服务宕机。过期时间随机化解决前者,多级缓存和集群部署解决后者。

过期时间随机化:

func SetCache(ctx context.Context, rdb *redis.Client, key string, value interface{}, ttl time.Duration) error {
    // 在基础TTL上增加随机偏移量,避免同时过期
    jitter := time.Duration(rand.Intn(300)) * time.Second  // 0-300秒随机
    actualTTL := ttl + jitter

    data, _ := json.Marshal(value)
    return rdb.Set(ctx, key, data, actualTTL).Err()
}

// 批量设置时使用
func BatchSetCache(ctx context.Context, rdb *redis.Client, items map[string]interface{}) error {
    pipe := rdb.Pipeline()
    for key, val := range items {
        ttl := 30*time.Minute + time.Duration(rand.Intn(600))*time.Second
        data, _ := json.Marshal(val)
        pipe.Set(ctx, key, data, ttl)
    }
    _, err := pipe.Exec(ctx)
    return err
}

多级缓存架构:本地缓存(L1)+ Redis缓存(L2)+ 数据库(L3):

type MultiLevelCache struct {
    localCache *lru.Cache     // 本地缓存(进程内)
    rdb        *redis.Client   // Redis分布式缓存
    db         *sql.DB         // 数据库
}

func (c *MultiLevelCache) Get(ctx context.Context, key string) (interface{}, error) {
    // L1: 本地缓存
    if val, ok := c.localCache.Get(key); ok {
        return val, nil
    }

    // L2: Redis缓存
    if val, err := c.rdb.Get(ctx, key).Result(); err == nil {
        c.localCache.Add(key, val)  // 回填本地缓存
        return val, nil
    }

    // L3: 数据库
    val, err := c.db.QueryRow(ctx, key)
    if err != nil {
        return nil, err
    }

    // 回填L2和L1
    c.rdb.Set(ctx, key, val, 30*time.Minute)
    c.localCache.Add(key, val)
    return val, nil
}

本地缓存使用LRU淘汰策略,容量根据JVM/Go进程内存设置。本地缓存命中率可通过Prometheus监控,命中率过低说明本地缓存容量不足或热点Key分布不均匀。

热点Key发现与本地缓存优化

热点Key指访问量远超平均水平的Key,可能导致单个Redis节点压力过大。发现热点Key的方案包括Redis的hotkeys功能和客户端统计。

# Redis 4.0+ 使用LFU淘汰策略时查看热点Key
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli OBJECT FREQ key_name  # 查看Key访问频率

# 使用redis-cli --hotkeys
redis-cli --hotkeys

# 使用MONITOR命令(生产环境慎用,影响性能)
redis-cli MONITOR | grep -o "GET [^ ]*" | sort | uniq -c | sort -rn | head -20

Go客户端侧热点Key统计:

type HotKeyDetector struct {
    counter   map[string]*atomic.Int64
    threshold int64
    mu        sync.RWMutex
}

func (d *HotKeyDetector) Record(key string) {
    d.mu.RLock()
    c, ok := d.counter[key]
    d.mu.RUnlock()
    if !ok {
        d.mu.Lock()
        if c, ok = d.counter[key]; !ok {
            c = &atomic.Int64{}
            d.counter[key] = c
        }
        d.mu.Unlock()
    }
    val := c.Add(1)
    if val == d.threshold {
        log.Printf("热点Key检测: %s 访问次数达 %d", key, val)
    }
}

发现热点Key后,在应用层本地缓存该Key的数据,减少对Redis的访问。热点Key的数据变更通过发布订阅通知所有实例更新本地缓存。

Redis缓存一致性保障方案

缓存与数据库一致性是缓存系统的核心挑战。常用方案有Cache Aside、Write Through和Write Behind。

Cache Aside(旁路缓存)是最常用的方案:读时先查缓存,未命中查数据库并回填;写时先更新数据库再删除缓存。

// Cache Aside 读
func ReadData(ctx context.Context, key string) (string, error) {
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        return val, nil
    }
    val, err = db.QueryRow(ctx, key)
    if err != nil {
        return "", err
    }
    rdb.Set(ctx, key, val, 30*time.Minute)
    return val, nil
}

// Cache Aside 写
func WriteData(ctx context.Context, key, value string) error {
    // 先更新数据库
    if err := db.Update(ctx, key, value); err != nil {
        return err
    }
    // 再删除缓存(不是更新缓存,避免并发写入不一致)
    if err := rdb.Del(ctx, key).Err(); err != nil {
        log.Printf("删除缓存失败: %v", err)
        // 发送到消息队列,异步重试删除
        mq.Publish("cache_delete", key)
    }
    return nil
}

延迟双删策略解决主从复制延迟导致的不一致:写入后先删缓存,延迟500ms后再删一次。第二次删除清理在主从复制期间被旧数据回填的缓存。

func WriteWithDoubleDelete(ctx context.Context, key, value string) error {
    rdb.Del(ctx, key)                    // 第一次删除
    if err := db.Update(ctx, key, value); err != nil {
        return err
    }
    time.AfterFunc(500*time.Millisecond, func() {
        rdb.Del(ctx, key)                // 延迟第二次删除
    })
    return nil
}

删除缓存失败时的保障机制:将删除操作写入消息队列,消费者不断重试直到成功。通过binlog监听(如Canal)自动触发缓存删除是更可靠的方案,保证数据库变更一定能同步到缓存层。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-shi-zhan-huan-cun-chuan-tou-ji-chuan/

(0)
小编小编
上一篇 21小时前
下一篇 21小时前

相关推荐