Redis缓存策略实战:缓存穿透、击穿与雪崩解决方案

Redis缓存是数据库性能和数据库高可用架构的重要补充。缓存引入后,收益是读取压力减轻、响应速度提升,代价是要处理缓存与数据库的一致性,以及穿透、击穿、雪崩三个经典问题。本文从缓存架构设计讲起,逐一给出实战解决方案。

Redis缓存架构设计与Key规范

先明确数据何时缓存:读多写少、实时性要求不苛刻的数据适合缓存,如商品详情、配置信息、热点列表。Key命名规范:业务:领域:ID(例如 user:profile:10001),并统一TTL策略,避免全局永久缓存。代码示例(Java/RedisTemplate):

@Service
public class CacheService {
    @Autowired StringRedisTemplate redis;

    public String getUserProfile(Long uid) {
        String key = "user:profile:" + uid;
        String cached = redis.opsForValue().get(key);
        if (cached != null) return cached;
        String value = loadFromDb(uid);      // 缓存未命中走DB
        redis.opsForValue().set(key, value, Duration.ofMinutes(30));
        return value;
    }
}

缓存穿透:无效请求击穿缓存层

攻击或脏数据查询,请求直连数据库,缓存形同虚设。对策:缓存空值(短TTL)、布隆过滤器预判、参数校验拦截:

// 1. 空值缓存
if (cached != null) {
    if ("EMPTY".equals(cached)) return null;   // 兜底空值
    return cached;
}
value = loadFromDb(uid);
redis.set(key, value != null ? value : "EMPTY", Duration.ofMinutes(5));
// 2. 布隆过滤器拦截不存在ID
if (!bloomFilter.mightContain(id)) return null;

缓存击穿:热点Key失效瞬间

一个热点key失效瞬间,大量并发请求同一时间打穿缓存直冲DB。解决:互斥锁重建缓存(只让一个请求回源)、逻辑过期主动刷新:

// 互斥锁重建
String lockKey = "lock:" + key;
if (cached == null) {
    Boolean lock = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
    if (Boolean.TRUE.equals(lock)) {
        try {
            value = loadFromDb(id);
            redis.set(key, value, Duration.ofMinutes(30));
        } finally {
            redis.delete(lockKey);
        }
    } else {
        Thread.sleep(50);           // 等待持有锁线程回填
        return redis.opsForValue().get(key);
    }
}

缓存雪崩:大量key同时过期或Redis宕机

雪崩原因两类:批量key TTL同时到期(加随机过期时间)、Redis不可用(高可用架构与降级)。随机过期时间:

long baseTtl = 1800; // 30分钟
long jitter = ThreadLocalRandom.current().nextLong(0, 600);
redis.set(key, value, Duration.ofSeconds(baseTtl + jitter));

Redis不可用时:本地缓存兜底(Caffeine),数据库熔断+限流保护(Sentinel/Hystrix),并部署Redis Sentinel/Cluster保证高可用。缓存策略需要分层兜底,逐级降级。

缓存与数据库一致性方案

一致性最稳做法是Cache Aside:读缓存,未命中读库回填;写库后删除缓存(延迟双删/异步MQ删除)。强一致场景(资金类)不要用缓存,或使用带版本号的缓存比对。业界通行实践:

@Transactional
public void updateProfile(Profile p) {
    // 1. 更新DB
    dao.update(p);
    // 2. 删除缓存(简单可靠)
    redis.delete("user:profile:" + p.getId());
    // 3. 延迟双删:异步再删一次,解决并发写回旧值
    scheduleDelete(key, 500);
}

缓存性能监控与命中率优化

上线后监控:缓存命中率(info命令、redis_exporter),低于80%检查Key与过期时间;命令耗时(SLOWLOG);内存使用。优化:热点数据延长TTL、优化序列化(Jackson/Protostuff减少体积)、合理使用pipeline批量。缓存是把双刃剑,监控指标齐了才能长期稳定。

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

赞 (0)
小编小编
上一篇 2026年9月1日
下一篇 2026年9月1日

相关推荐

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)
小编小编
上一篇 2026年8月24日
下一篇 2026年8月24日

相关推荐