Redis实战(黑马点评)——关于缓存(缓存更新策略、缓存穿透、缓存雪崩、缓存击穿、Redis工具)

redis实现查询缓存的业务逻辑

service层实现

java 复制代码
@Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        // 现查询redis内有没有数据
        String shopJson = (String) redisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(shopJson)){ // 如果redis的数据为存在,那么解析为对象
            // 将json转为对象
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 如果不存在,就先查数据库,再存入redis
        Shop shop = getById(id);
        if(shop == null){
            return Result.fail("店铺不存在");
        }
        // 存在就写入redis,包括将对象转为json
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
        return Result.ok(shop);
    }

缓存更新策略

缓存更新策略的最佳实践方案:

  1. 低一致性需求:使用Redis自带的内存淘汰机制
  2. 高一致性需求:主动更新,并以超时删除作为处理方案
读操作:(查询)
  • 缓存命中则直接返回
  • 缓存未命中则查询数据库,并写入缓存,设定超时时间
写操作:(增删改)
  • 先写数据库,然后再删除缓存
  • 要确保数据库与缓存操作的原子性

缓存穿透

缓存穿透产生的原因是什么?

  • 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。

缓存穿透的解决方案有哪些?

  • 缓存null值
  • 布隆过滤器
  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基本格式校验
  • 加强用户权限校验
  • 做好热点参数的限流

缓存空对象的方法解决缓存穿透

java 复制代码
@Override  
public Result queryById(Long id) {  
    // 使用店铺ID构建缓存键  
    String key = CACHE_SHOP_KEY + id;  

    // 检查店铺信息是否已经缓存到Redis中  
    String shopJson = (String) redisTemplate.opsForValue().get(key);  
    
    // 如果缓存中存在数据,则将JSON字符串解析为Shop对象  
    if (StrUtil.isNotBlank(shopJson)) {   
        // 将JSON转换为Shop对象  
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);  
        return Result.ok(shop); // 返回店铺对象作为成功结果  
    }  
    
    // 如果缓存中包含表示无数据的占位符,则返回错误信息  
    if ("#".equals(shopJson)) {  
        return Result.fail("没有店铺相关信息"); // 没有店铺信息可用  
    }  

    // 如果缓存中未找到店铺数据,则查询数据库  
    Shop shop = getById(id);  
    
    // 如果数据库中不存在该店铺  
    if (shop == null) {  
        // 在缓存中存储一个占位符,以表示该店铺不存在  
        // 这可以防止对同一ID的进一步查询再次访问数据库  
        redisTemplate.opsForValue().set(key, "#", CACHE_NULL_TTL, TimeUnit.MINUTES);  
        return Result.fail("店铺不存在"); // 返回错误,指示店铺不存在  
    }  
    
    // 如果找到店铺,则将店铺对象以JSON字符串的形式存储到缓存中  
    redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);  
    return Result.ok(shop); // 返回店铺对象作为成功结果  
}

缓存雪崩

缓存击穿

缓存击穿是指在高并发环境下,某个热点数据的缓存同时失效,导致大量请求直接访问数据库,从而造成数据库压力骤增的现象。

缓存击穿解决方案

解决方案 优点 缺点
互斥锁 - 没有额外的内存消耗 - 保证一致性 - 实现简单 - 线程需要等待,性能受到影响 - 可能有死锁风险
逻辑锁 - 线程无需等待,性能较好 - 不保证一致性 - 有额外的内存消耗 - 实现复杂

基于互斥锁解决缓存击穿

基于互斥锁解决缓存击穿 + 缓存空对象的方法解决缓存穿透

java 复制代码
// 查询店铺信息,使用互斥锁解决缓存击穿问题
public Shop queryWithMutex(Long id) {
    // 构造缓存的key
    String key = CACHE_SHOP_KEY + id;

    // 1. 先从Redis中查询店铺信息
    String shopJson = (String) redisTemplate.opsForValue().get(key);

    // 如果Redis中存在缓存数据,直接解析JSON并返回对象
    if (StrUtil.isNotBlank(shopJson)) {
        // 将JSON字符串转换为Shop对象
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return shop;
    }

    // 如果Redis中缓存的值为"#", 表示数据库中没有该店铺信息
    if ("#".equals(shopJson)) {
        return null;
    }

    // 2. 构造互斥锁的key
    String lockKey = "lock:shop:" + id;

    // 定义店铺对象
    Shop shop = null;

    try {
        // 尝试获取互斥锁
        boolean isLock = tryLock(lockKey);

        // 如果获取锁失败,线程休眠50ms后重试
        if (!isLock) {
            Thread.sleep(50); // 等待50ms
            return queryWithMutex(id); // 递归调用,再次尝试获取锁
        }

        // 3. 如果没有获取到缓存数据,查询数据库
        shop = getById(id);

        // 模拟数据库查询的延时,生产环境应该去掉这段代码
        // Thread.sleep(200);

        // 4. 如果数据库中没有该店铺信息
        if (shop == null) {
            // 在Redis中存储一个特殊的标记值"#", 表示该店铺不存在
            redisTemplate.opsForValue().set(key, "#", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }

        // 5. 如果数据库中有数据,将店铺信息存入Redis
        // 将Shop对象转换为JSON字符串
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        // 捕获线程中断异常
        throw new RuntimeException(e);
    } finally {
        // 释放互斥锁
        unlock(lockKey);
    }

    // 返回查询到的店铺信息
    return shop;
}

// 封装获取锁,释放锁
// 尝试获取互斥锁
private boolean tryLock(String key) {
    // 使用Redis的setIfAbsent方法尝试设置锁
    // 如果key不存在,则设置成功并返回true;否则返回false
    Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    // 并设置一个过期时间(这里是 10 秒)
    return BooleanUtil.isTrue(flag);
}

// 释放互斥锁
private void unlock(String key) {
    // 删除锁对应的key
    redisTemplate.delete(key);
}

setIfAbsent 方法是 Redis 中的一种操作,用于设置一个键的值,仅在该键不存在的情况下进行设置。具体来说,它的功能如下:

  • 键不存在时 :如果指定的键(key)在 Redis 中不存在,则将其设置为指定的值(在这个例子中是 "1"),并可以指定该键的过期时间(这里是 10 秒)。此时,方法返回 true

  • 键已存在时 :如果指定的键已经存在于 Redis 中,则不会进行任何操作,保持原有的值不变,方法返回 false

基于逻辑锁解决缓存击穿

java 复制代码
 // 创建一个固定大小的线程池,用于缓存重建任务,避免频繁创建线程带来的开销
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 查询商铺信息,考虑逻辑过期
     * @param id 商铺ID
     * @return 商铺信息,如果不存在或已过期则返回null
     */
    public Shop queryWithLogicalExpire(Long id){
        // 构建缓存的key,用于从Redis中查询对应的商铺信息
        String key = CACHE_SHOP_KEY + id;
        // 1. 从Redis查询商铺缓存,获取商铺信息的JSON字符串
        String shopJson = (String) redisTemplate.opsForValue().get(key);
        // 2. 判断缓存是否存在
        if (StrUtil.isBlank(shopJson)) {
            // 3. 缓存不存在,直接返回null
            return null;
        }
        // 4. 缓存命中,需要先将JSON字符串反序列化为RedisData对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        // 从RedisData对象中提取商铺信息,并将其反序列化为Shop对象
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        // 获取缓存的过期时间
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5. 判断缓存是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1. 缓存未过期,直接返回商铺信息
            return shop;
        }
        // 构建锁的key,用于控制缓存重建的并发访问
        String lockKey = LOCK_SHOP_KEY + id;
        // 尝试获取锁,确保缓存重建操作的线程安全
        boolean isLock = tryLock(lockKey);
        // 6.2. 判断是否成功获取锁
        if (isLock) {
            // 6.3. 成功获取锁,开启独立线程进行缓存重建,避免阻塞主线程
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 重建缓存,将最新的商铺信息保存到Redis中,并设置过期时间
                    this.saveShop2Redis(id, 20L); // 假设20L是过期时间,单位为秒
                } catch (Exception e) {
                    // 如果在缓存重建过程中发生异常,抛出运行时异常,并记录日志
                    throw new RuntimeException(e);
                } finally {
                    // 无论缓存重建成功与否,都需要释放锁,避免死锁
                    unlock(lockKey);
                }
            });
        }
        // 返回当前查询到的商铺信息(可能已过期)
        return shop;
    }

    public void saveShop2Redis(Long id, Long expireSeconds) {
        // 1. 查询店铺数据
        Shop shop = getById(id);
        // 2. 封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        // 3. 写入Redis
        redisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

RedisData类

java 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

封装redis工具类

java 复制代码
    @Resource
    private CacheClient cacheClient;

    public static final String CACHE_SHOP_KEY = "cache:shop:";
    public static final Long CACHE_SHOP_TTL = 30L;
    @Override
    public Result queryById(Long id)  {
        // 解决缓存穿透
//        Shop shop = cacheClient // 传入一个从数据库内获取Shop对象的函数:this::getById
//                .queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
        
        
        // 互斥锁解决缓存击穿
        Shop shop = cacheClient
                .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);


        // 逻辑过期解决缓存击穿
//        Shop shop = cacheClient
//                .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
       
         if(shop == null){
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }

1. queryWithPassThrough

这个方法用于处理缓存穿透问题。缓存穿透是指查询一个数据库中不存在的数据,由于缓存中也没有这个数据,所以每次查询都会直接打到数据库上,增加数据库的压力。

  • 参数

    • keyPrefix:缓存的前缀。

    • id:缓存的ID。

    • type:返回对象的类型。

    • dbFallback:数据库查询的回调函数。

    • time:缓存时间。

    • unit:时间单位。

2. queryWithLogicalExpire

这个方法用于处理缓存击穿问题。缓存击穿是指一个缓存中非常热门的数据突然过期,导致大量请求同时打到数据库上,增加数据库的压力。

  • 参数 :与 queryWithPassThrough 相同。

最大的缺点是运行前要把所有缓存加到redis内,不然怎么查都是null

3. queryWithMutex(互斥锁)

这个方法结合了 queryWithPassThroughqueryWithLogicalExpire 的功能,用于处理缓存穿透和缓存击穿问题。

  • 参数 :与 queryWithPassThrough 相同。
java 复制代码
@Slf4j
@Component
public class CacheClient {
    public static final Long CACHE_NULL_TTL = 2L;
    public static final String LOCK_SHOP_KEY = "lock:shop:";
    private final RedisTemplate redisTemplate;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public CacheClient(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void set(String key, Object value, Long time, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        // 设置逻辑过期
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入Redis
        redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = (String) redisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if (json != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            redisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        return r;
    }

    public <R, ID> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = (String) redisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return r;
        }
        // 5.2.已过期,需要缓存重建
        // 6.缓存重建
        // 6.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2.判断是否获取锁成功
        if (isLock){
            // 6.3.成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R newR = dbFallback.apply(id);
                    // 重建缓存
                    this.setWithLogicalExpire(key, newR, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4.返回过期的商铺信息
        return r;
    }

    public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String shopJson = (String) redisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, type);
        }
        // 判断命中的是否是空值
        if (shopJson != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4.获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                redisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6.存在,写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放锁
            unlock(lockKey);
        }
        // 8.返回
        return r;
    }

    private boolean tryLock(String key) {
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    private void unlock(String key) {
        redisTemplate.delete(key);
    }
}
相关推荐
幸好我会魔法1 小时前
人格分裂(交互问答)-小白想懂Elasticsearch
大数据·spring boot·后端·elasticsearch·搜索引擎·全文检索
危险、2 小时前
Spring Boot 无缝集成SpringAI的函数调用模块
人工智能·spring boot·函数调用·springai
SomeB1oody2 小时前
【Rust自学】15.2. Deref trait Pt.1:什么是Deref、解引用运算符*与实现Deref trait
开发语言·后端·rust
何中应2 小时前
从管道符到Java编程
java·spring boot·后端
boonya3 小时前
Yearning开源MySQL SQL审核平台
数据库·mysql·开源
组合缺一3 小时前
Solon Cloud Gateway 开发:Route 的过滤器与定制
java·后端·gateway·reactor·solon
SomeB1oody3 小时前
【Rust自学】15.4. Drop trait:告别手动清理,释放即安全
开发语言·后端·rust
customer083 小时前
【开源免费】基于SpringBoot+Vue.JS贸易行业crm系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源
CPU NULL4 小时前
新版IDEA创建数据库表
java·数据库·spring boot·sql·学习·mysql·intellij-idea
J不A秃V头A4 小时前
MySQL 中开启二进制日志(Binlog)
数据库·mysql