Redis缓存策略深度优化:从缓存穿透到热Key治理的完整防护方案

缓存穿透防护:布隆过滤器与空值缓存

缓存穿透是指大量请求查询不存在的数据,请求绕过缓存直达数据库。攻击场景下这种流量可以让数据库瞬间过载。防护方案分两层:

第一层是布隆过滤器(Bloom Filter),在缓存之前设置一个概率过滤器,将不存在的Key拦截在外:

import (
    "github.com/bits-and-blooms/bloom/v3"
    "github.com/redis/go-redis/v9"
)

type CacheWithBloom struct {
    rdb    *redis.Client
    filter *bloom.BloomFilter
}

func NewCacheWithBloom(rdb *redis.Client, expectedItems uint) *CacheWithBloom {
    return &CacheWithBloom{
        rdb:    rdb,
        filter: bloom.NewWithEstimates(expectedItems, 0.01), // 1%误判率
    }
}

func (c *CacheWithBloom) Get(ctx context.Context, key string) (string, error) {
    // 布隆过滤器前置检查
    if !c.filter.Test([]byte(key)) {
        return "", redis.Nil // Key不存在,直接返回
    }
    // 查询Redis
    val, err := c.rdb.Get(ctx, key).Result()
    if err == redis.Nil {
        // 缓存未命中,查询数据库
        // ...
    }
    return val, err
}

func (c *CacheWithBloom) Set(ctx context.Context, key string, val string) error {
    c.filter.Add([]byte(key)) // 写入时同步更新布隆过滤器
    return c.rdb.Set(ctx, key, val, 0).Err()
}

第二层是空值缓存,将数据库查询为空的Key也缓存起来,设置较短的TTL(60-120秒)。布隆过滤器有1%的误判率,空值缓存作为兜底可以拦截漏网的穿透请求。

缓存击穿防护:互斥锁与逻辑过期

缓存击穿发生在热点Key过期的瞬间,大量并发请求同时穿透到数据库。解决方案有两种:

方案一:互斥锁(Mutex Lock)——只允许一个请求构建缓存,其他请求等待:

func (c *CacheGuard) GetWithMutex(ctx context.Context, key string, ttl time.Duration, builder func() (string, error)) (string, error) {
    val, err := c.rdb.Get(ctx, key).Result()
    if err == nil {
        return val, nil
    }

    lockKey := "lock:" + key
    locked, _ := c.rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()

    if locked {
        // 获得锁,查询数据库并写入缓存
        defer c.rdb.Del(ctx, lockKey)
        val, err = builder()
        if err != nil {
            return "", err
        }
        c.rdb.Set(ctx, key, val, ttl)
        return val, nil
    }

    // 未获得锁,短暂等待后重试读取缓存
    time.Sleep(50 * time.Millisecond)
    return c.GetWithMutex(ctx, key, ttl, builder)
}

方案二:逻辑过期(Logical Expiration)——缓存永不过期,在值中存储逻辑过期时间,异步更新:

type CacheItem struct {
    Data       string    `json:"data"`
    ExpireAt   time.Time `json:"expire_at"`
}

func (c *CacheGuard) GetWithLogicalExpire(ctx context.Context, key string, builder func() (string, error)) (string, error) {
    raw, err := c.rdb.Get(ctx, key).Result()
    if err == redis.Nil {
        return "", redis.Nil
    }

    var item CacheItem
    json.Unmarshal([]byte(raw), &item)

    if time.Now().Before(item.ExpireAt) {
        return item.Data, nil // 未过期,直接返回
    }

    // 逻辑过期,触发异步更新
    go func() {
        lockKey := "lock:" + key
        locked, _ := c.rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
        if locked {
            defer c.rdb.Del(ctx, lockKey)
            newVal, _ := builder()
            newItem := CacheItem{
                Data:     newVal,
                ExpireAt: time.Now().Add(30 * time.Minute),
            }
            data, _ := json.Marshal(newItem)
            c.rdb.Set(context.Background(), key, string(data), 0)
        }
    }()

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

互斥锁方案保证数据强一致但吞吐受限,逻辑过期方案吞吐高但容忍短暂数据不一致。电商商品详情页用逻辑过期,库存查询用互斥锁。

缓存雪崩防护:随机TTL与多级缓存

缓存雪崩是大量Key同时过期的场景。最直接的防护是给TTL添加随机偏移量:

import "math/rand"

func SetWithRandomTTL(rdb *redis.Client, ctx context.Context, key, val string, baseTTL time.Duration) {
    jitter := time.Duration(rand.Intn(300)) * time.Second // 0-300秒随机偏移
    rdb.Set(ctx, key, val, baseTTL+jitter)
}

多级缓存架构进一步降低雪崩风险:

type MultiLevelCache struct {
    l1    *freecache.Cache // 进程内本地缓存,10ms级
    l2    *redis.Client    // Redis集群,1ms级
}

func (c *MultiLevelCache) Get(ctx context.Context, key string) (string, error) {
    // L1: 本地缓存
    if val, err := c.l1.Get([]byte(key)); err == nil {
        return string(val), nil
    }

    // L2: Redis
    val, err := c.l2.Get(ctx, key).Result()
    if err == nil {
        c.l1.Set([]byte(key), []byte(val), 60) // 本地缓存60秒
        return val, nil
    }

    return "", redis.Nil
}

L1本地缓存的TTL远短于L2 Redis缓存(60秒 vs 30分钟),避免本地缓存与Redis大面积同时失效。

热Key探测与本地缓存治理

热Key(Hot Key)是访问量远超平均的Key,可能导致单个Redis分片过载。Redis 7.0+提供了redis-cli --hotkeys命令检测热Key,但仅限于单节点。生产环境推荐使用代理层统计:

# Redis Proxy统计Key访问频率
# 使用Redis的MONITOR命令采样(低峰期)
redis-cli MONITOR | \
  awk '{print $4}' | \
  sort | uniq -c | sort -rn | head -20

# 更安全的方式:使用INFO commandstats
redis-cli INFO commandstats | grep keyspace_hits

确认热Key后的处理策略:

1. 本地缓存+短TTL:在应用进程内缓存热Key数据,TTL设5-10秒,减少Redis读取频率

2. Key打散:将热Key拆分为多个子Key,读取时随机选择一个,写入时更新所有子Key

// 热Key打散写入
func SetShardedHotKey(ctx context.Context, rdb *redis.Client, key string, val string, shards int) {
    pipe := rdb.Pipeline()
    for i := 0; i < shards; i++ {
        shardKey := fmt.Sprintf("%s:shard:%d", key, i)
        pipe.Set(ctx, shardKey, val, 30*time.Minute)
    }
    pipe.Exec(ctx)
}

// 热Key打散读取
func GetShardedHotKey(ctx context.Context, rdb *redis.Client, key string, shards int) (string, error) {
    shardIdx := rand.Intn(shards)
    shardKey := fmt.Sprintf("%s:shard:%d", key, shardIdx)
    return rdb.Get(ctx, shardKey).Result()
}

3. 只读从库分担读流量:热Key读请求路由到从库,写请求走主库。Redis Cluster的READONLY模式或Codis集群的从库读都可以实现。

缓存一致性:延迟双删与Canal订阅

缓存与数据库的一致性是缓存策略中争议最多的部分。工程上有两种可靠方案:

延迟双删:先删缓存,再更新数据库,延迟一段时间再删一次缓存。延迟时间需大于一次数据库读+缓存写的耗时:

func UpdateWithDoubleDelete(ctx context.Context, rdb *redis.Client, db *sql.DB, key string, updateSQL string) {
    cacheKey := "cache:" + key
    // 1. 删除缓存
    rdb.Del(ctx, cacheKey)
    // 2. 更新数据库
    db.ExecContext(ctx, updateSQL)
    // 3. 延迟再删(覆盖并发读带来的脏缓存)
    go func() {
        time.Sleep(500 * time.Millisecond)
        rdb.Del(context.Background(), cacheKey)
    }()
}

Canal订阅Binlog:通过Canal监听MySQL Binlog变更,异步删除或更新对应缓存。一致性最强,但引入了Canal组件的运维成本。方案选择标准:QPS<1万用延迟双删,QPS>1万或对一致性要求极高用Canal。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-shen-du-you-hua-cong-huan-cun-chuan/

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

相关推荐