Redis之缓存击穿

Redis之缓存击穿

文章目录

一、什么是缓存击穿

缓存击穿(Cache Breakdown)是指某个热点 Key 在缓存中过期后,大量并发请求同时绕过缓存直接访问数据库,导致数据库压力骤增的现象。

通常发生在以下场景:

  • 某个 Key 是高频访问的「热点数据」。
  • Key 的缓存过期时间到期,此时大并发请求同时到达。
  • 缓存失效瞬间,所有请求都去查询数据库并重建缓存。

线程1 线程2 线程3 线程4 1.查询缓存,未命中 2.查询数据库,重建缓存数据 3.查询缓存,未命中 4.查询数据库,重建缓存数据 5.查询缓存,未命中 7.查询缓存,未命中 6.查询数据库,重建缓存数据 数据查询耗时等待200ms 8.查询数据库,重建缓存数据 9.写入缓存 线程1 线程2 线程3 线程4

二、缓存击穿常见解决方案

1. 互斥锁(Mutex Lock)

  • 原理 :当缓存失效时,只允许一个线程去加载数据,其他线程等待缓存更新完成后再读取缓存。

    • 优点: 确保数据强一致性
    • 缺点 :线程需要等待,可能成为性能瓶颈(锁竞争)
  • 流程图

缓存命中 缓存未命中 成功 失败 请求缓存 返回数据 获取互斥锁 查询数据库 更新缓存 释放锁 等待并重试

  • 伪代码
java 复制代码
public Object getData(String key) {
    Object data = cache.get(key);
    if (data != null) return data;

    // 加锁(如Redis的SETNX)
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 10)) { // 10秒锁超时
        try {
            // 二次检查缓存(防止锁竞争期间其他线程已加载)
            data = cache.get(key);
            if (data != null) return data;

            data = db.query(key);
            cache.set(key, data);
        } finally {
            redis.del(lockKey); // 释放锁
        }
    } else {
        // 等待重试
        Thread.sleep(100);
        return getData(key);
    }
    return data;
}

2. 永不过期 + 后台刷新

  • 原理 :为缓存设置永不过期时间,同时通过后台线程主动更新缓存。

    • 优点:无阻塞,适合对一致性要求低场景
    • 缺点 :数据可能短暂陈旧
  • 流程图

缓存命中 缓存未命中 请求缓存 返回数据 查询数据库并更新缓存 后台定时任务

  • 伪代码
java 复制代码
// 初始化时设置缓存永不过期
cache.set("hot_key", data)

// 后台线程定期更新
public void backgroundRefresh() {
    while(true) {
        Thread.sleep(5 * 60 * 1000)  // 每5分钟更新一次
        newData = db.query("hot_key")
        cache.set("hot_key", newData)
    }
}

3. 逻辑过期(异步更新)

  • 原理 :在缓存中存储数据的逻辑过期时间,即使缓存未物理过期,若逻辑过期则异步更新。

    • 优点:无阻塞,兼容性强
    • 缺点 :不保证一致性,实现复杂度较高
  • 时序图

线程1 线程2 线程3 线程4 1.查询缓存 发现逻辑时间已过期 2.获取互斥锁成功 3.开启新线程 (异步操作) 1.查询缓存 发现逻辑时间已过期 2.获取互斥锁失败 3.返回过期数据 4.返回过期数据 1.查询数据库 重建缓存数据 2.写入缓存 重置逻辑过期时间 1.命中缓存,并且没有过期 2.返回缓存数据 3.释放锁 线程1 线程2 线程3 线程4

  • 伪代码

缓存条目类

java 复制代码
@Data
public class CacheEntry {
    private final String data;
    private final long expireTime;
}
java 复制代码
    // 获取当前时间戳(毫秒)
    private static long now() {
        return System.currentTimeMillis();
    }

    public static String getData(String key) {
        CacheEntry entry = cache.get(key);
        
        // 缓存未命中
        if (entry == null) {
            String data = Database.query(key);
            long expireTime = now() + 300_000; // 5分钟过期(300秒 * 1000)
            cache.put(key, new CacheEntry(data, expireTime));
            return data;
        }

        // 检查逻辑过期
        if (entry.getExpireTime() < now()) {
            // 启动异步更新线程
            new Thread(() -> asyncUpdate(key)).start();
        }
        // 返回过期数据
        return entry.getData();
    }

    private static void asyncUpdate(String key) {
        String newData = Database.query(key);
        long newExpireTime = now() + 300_000;
        cache.put(key, new CacheEntry(newData, newExpireTime));
    }

三、案例

1.基于互斥锁解决缓存击穿

命中 未命中 是 否 开始 提交商铺id 从Redis查询商铺缓存 判断缓存是否命中 返回数据 结束 尝试获取互斥锁 判断是否获取锁 根据id查询数据库 休眠一段时间 将商铺数据写入Redis 释放互斥锁

java 复制代码
public Shop queryWithMutex(Long id) {
        // 1.从redis查询商铺缓存
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        // 判断命中的是否是空值
        if(shopJson != null) {
            // 返回错误信息,解决缓存穿透问题
            return null;
        }

        // 4.实现缓存重建
        // 4.1 获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        Shop shop;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2 判断是否获取成功
            if (!isLock) {
                // 4.3 失败,则休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            // 4.4 成功,根据id查询数据库,返回数据
            shop = getById(id);
            if (shop == null) {
                // 5.数据库不存在,将空字串写入Redis,设置过期时间,解决缓存穿透问题
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息,解决缓存穿透问题
                return null;
            }
            // 6.存在,写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 7.释放锁
            unLock(lockKey);
        }
        return shop;
    }

2.基于逻辑过期解决缓存击穿

未命中 命中 未过期 过期 否 是 开始 提交商铺id 从Redis查询商铺缓存 判断缓存是否命中 返回空 结束 判断缓存是否过期 返回商铺信息 尝试获取互斥锁 判断是否获取锁 开启独立线程 根据id查询数据库 将商铺数据写入Redis,并设置逻辑过期时间 释放互斥锁

java 复制代码
public Shop queryWithLogicalExpire(Long id) {
        // 1.从redis查询商铺缓存
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            // 3.不存在,直接返回null
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);

        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1 未过期,直接返回数据
            return shop;
        }
        // 5.2 过期,需要缓存重建
        // 6. 缓存重建
        String lockKey = LOCK_SHOP_KEY + id;
        // 6.1 获取互斥锁
        boolean isLock = tryLock(lockKey);
        // 6.2 判断是否获取锁成功
        if (isLock) {
            // 6.3 成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    this.saveShop2Redis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 6.5 释放锁
                    unLock(lockKey);
                }
            });
        }
        // 6.4 返回过期的商铺信息
        return shop;
    }

四、注意事项

1.​ 锁的选择

  • 单机环境用 ReentrantLocksynchronized
  • 分布式环境需用 Redis 分布式锁(如 Redisson 的 RLock)。

2. 递归重试风险

  • 示例中递归调用,可能导致栈溢出,实际生产环境应改用循环重试。

3. 锁超时时间

  • 分布式锁需设置合理超时时间(如 300ms),防止死锁。

​4. 缓存过期时间随机化

  • 可对缓存 TTL 添加随机值(如 300 + rand.nextInt(100)),避免缓存雪崩。
相关推荐
今天多喝热水2 小时前
Redis适用场景
数据库·redis
小袁拒绝摆烂3 小时前
Redis-高级篇(分布式缓存/持久化)
redis·分布式·缓存
E___V___E3 小时前
黑马点评redis改 part 2
数据库·redis·缓存
JhonKI3 小时前
【从零实现高并发内存池】Central Cache从理解设计到全面实现
数据库·redis·缓存
洛神灬殇4 小时前
【Redis技术进阶之路】「原理分析系列开篇」探索事件驱动枚型与数据特久化原理实现(时间事件驱动执行控制)
redis·后端
没逻辑4 小时前
👀 Redis 实时调优与监控实践:基于 MONITOR、INFO、SLOWLOG
redis
没逻辑5 小时前
⏰ Redis 在支付系统中作为延迟任务队列的实践
redis·后端
颯沓如流星6 小时前
MySQL 缓存机制全解析:从磁盘 I/O 到性能优化
mysql·缓存·性能优化
小袁拒绝摆烂8 小时前
分布式锁+秒杀异步优化
redis·分布式