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);
    }
}
相关推荐
卷Java几秒前
Python字典:键值对、get()方法、defaultdict,附通讯录实战
开发语言·数据库·python
wanhengidc3 分钟前
跨境云手机适用于哪些场景
大数据·运维·服务器·数据库·科技·智能手机
Bdygsl34 分钟前
MySQL(6)—— 视图
数据库·mysql
oradh36 分钟前
数据库入门概述
数据库·oracle·数据库基础·数据库入门
BullSmall43 分钟前
一套定制化高级 payload 合集
数据库·安全性测试
哆啦A梦15881 小时前
统一返回包装类 Result和异常处理
java·前端·后端·springboot
zbdx不知名菜鸡1 小时前
postgre sql 数据库查询优化
数据库·postgresql
9稳1 小时前
基于PLC的生产线自动升降机设计
开发语言·网络·数据库·嵌入式硬件·plc
zb200641201 小时前
spring security 超详细使用教程(接入springboot、前后端分离)
java·spring boot·spring
四七伵2 小时前
Spring Boot项目中varchar字段为什么不用NULL?告别空指针从建表开始
数据库·后端