仿黑马点评项目(二、商品查询缓存 String)

文章介绍了缓存的基本概念,包括在数据库查询中如何使用Redis作为缓存,以及缓存更新策略,如先更新数据库再删除缓存以避免数据不一致。接着讨论了缓存穿透问题,提出缓存空对象和使用布隆过滤器的解决方案。此外,还涉及了缓存雪崩和缓存击穿问题,提出了互斥锁和逻辑过期的解决思路。
摘要由CSDN通过智能技术生成

1. 什么是缓存?

数据交换的缓冲区(cache),是存贮数据的临时地方,一般读写性能比较高。
缓存可以大大降低用户访问并发量带来的服务器读写压力。
在这里插入图片描述

2.添加商户缓存

  • 缓存模型
    在这里插入图片描述
  • 缓存流程
    在这里插入图片描述
  • 代码实现
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        // 1.从redis查询商铺缓存(这里采用String类型,然后要确保店铺id的唯一)
        String shopJson = stringRedisTemplate.opsForValue().get(SystemConstants.SHOP_KEY_PRE + id);
        // 2.判断商铺是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,将从redis去到的json字符串转换成shop对象返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 4.不存在,根据id查询数据库
        Shop shop = getById(id);// 这是MP中继承了ServiceImpl中有的方法
        if (shop == null) {
            // 5.数据库不存在商铺信息,返回错误
            return Result.fail("店铺不存在!");
        }
        // 6.存在,将商铺信息写入到redis(要将shop对象转成json字符串)
        String jsonStr = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(SystemConstants.SHOP_KEY_PRE + id, jsonStr);
        // 7.返回
        return Result.ok(shop);
    }
}

3.缓存更新策略

当缓存中数据过多,需要对部分数据清除;
要实现数据库数据更新后,数据库和缓存数据一致。

在这里插入图片描述

  • 主动更新策略
    需要考虑三个问题:
    • 选择更新缓存?还是删除缓存?
    • 如何保证更新数据库和删除缓存同时成功或失败?
    • 先更新数据库还是先删除缓存?

在这里插入图片描述

应当是先操作数据库,再删除缓存
第一种发生极端情况发生的概率较大。因为redis写入数据是微秒级别的,相较于数据库写入是非常快的。在更新数据库时,用时较长,这时其他线程有可能抢到CPU使用权,进行查询缓存,但是线程1已经删除了缓存,所以线程2也去查询数据库,但是线程1 更新的数据还没有提交,因此线程2 查到的数据还是旧数据,再次将旧数据写入缓存。
第二种情况发生的概率较小。因为线程2先更新数据库,然后删除缓存(redis中读写速度快,所以在删除缓存时线程1争夺CPU使用权的概率较小)。所以即使在查询数据库时,数据读取到了,但是还没写入到缓存,线程2这时抢到了CPU使用权更新数据库,删除缓存,所以线程1读到的是旧数据,但是我在写入缓存的时候设置TTL过期兜底,这样就能有效减少旧数据的影响。

  • 代码实现:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        // 1.从redis查询商铺缓存(这里采用String类型,然后要确保店铺id的唯一)
        String shopJson = stringRedisTemplate.opsForValue().get(SystemConstants.SHOP_KEY_PRE + id);
        // 2.判断商铺是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,将从redis去到的json字符串转换成shop对象返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 4.不存在,根据id查询数据库
        Shop shop = getById(id);
        if (shop == null) {
            // 5.数据库不存在商铺信息,返回错误
            return Result.fail("店铺不存在!");
        }
        // 6.存在,将商铺信息写入到redis(要将shop对象转成json字符串)
        String jsonStr = JSONUtil.toJsonStr(shop);
        // 6.1设置TTL超时剔除
        stringRedisTemplate.opsForValue().set(SystemConstants.SHOP_KEY_PRE + id, jsonStr, SystemConstants.SHOP_TTL, TimeUnit.MINUTES);
        // 7.返回
        return Result.ok(shop);
    }
}
@Override
@Transactional
/**
 * 缓存更新
 * 先更新数据库,后删除缓存
 * */
public Result saveShop(Shop shop) {
    Long id = shop.getId();
    if (id == null){
        return Result.fail("店铺 id 不能为空!");
    }
    // 更新数据库
    updateById(shop);
    // 删除缓存
    stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
    return Result.ok(shop.getId());
}

4.缓存穿透的解决思路

缓存穿透:是指客户端请求的数据在缓存中额数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。所以有些不怀好意的人可能会利用该穿透,并发向服务器发送一些不存在的数据,这样可能会搞垮数据库。

解决方案:
缓存空对象;布隆过滤器

  • 缓存空对象缺点详细描述:首先有可能造成大量额外的内存消耗,因为可能会有人恶意大量请求不存在的数据;第二点,有可能刚请求完返回了Redis的空数据,而这时数据库插入了该条数据,导致数据库与Redis数据的不一致。可以设置TTL,减少这种额外内存消耗和错误的影响。
  • 布隆过滤器:使用二进制形式存放数据库中对数据哈希后的哈希值,但这是概率算法,如果不用过滤器拒绝,则表示数据库真的不存在该数据,但过滤器放行了,数据库不一定存在该数据,所以还是存在穿透风险。

编码解决商品查询的缓存穿透问题:

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryById(Long id) {
    // 1.从redis查询商铺缓存(这里采用String类型,然后要确保店铺id的唯一)
    String shopJson = stringRedisTemplate.opsForValue().get(SystemConstants.SHOP_KEY_PRE + id);
    // 2.判断商铺是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        // 3.存在,将从redis去到的json字符串转换成shop对象返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // --穿透解决,判断命中的是否是空值
    if(shopJson != null){
        //返回错误信息
        return Result.fail("店铺信息不存在!");
    }
    // 4.不存在,根据id查询数据库
    Shop shop = getById(id);
    if (shop == null) {
        // 将空值写入缓存
        stringRedisTemplate.opsForValue().set(SystemConstants.SHOP_KEY_PRE + id, "", SystemConstants.SHOP_NULL_TTL, TimeUnit.MINUTES);
        // 5.数据库不存在商铺信息,返回错误
        return Result.fail("店铺不存在!");
    }
    // 6.存在,将商铺信息写入到redis(要将shop对象转成json字符串)
    String jsonStr = JSONUtil.toJsonStr(shop);
    // 6.1设置TTL超时剔除
    stringRedisTemplate.opsForValue().set(SystemConstants.SHOP_KEY_PRE + id, jsonStr, SystemConstants.SHOP_TTL, TimeUnit.MINUTES);
    // 7.返回
    return Result.ok(shop);
}

5.缓存雪崩问题及解决思路

在这里插入图片描述

6.缓存击穿问题及解决思路

在这里插入图片描述

缓存击穿和缓存雪崩的区别就在于:缓存雪崩是同一时间段大量key同时失效或redis服务宕机,导致给数据库造成巨大压力;而缓存击穿是某些热点(一个可以被高并发访问并且缓存重建业务较复杂,耗时较长)的key在同一时间段突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。比如上述图片,缓存查询(读操作)未命中,所需时间较短,但是查询数据库(其中需要的准备)的时间较长,可能其他线程也在查询未命中的情况下查询数据库并重建缓存,导致数据库垮掉。

常见的解决方案有两种:互斥锁;逻辑过期
在这里插入图片描述

  • 互斥锁
    添加互斥锁,缓存未命中的线程只有获取到互斥锁才可以查询数据库,重建缓存数据。但需要等待线程结束后另一个线程才能获得锁查询,效率较低。
/**
 * 互斥锁  获取锁
 * 使用 redis 的 setnx 命令达到加锁的效果
 * */
private boolean tryLock(String key){
    // 使用redis中的SETNX key value digital timeUnit来模拟获得锁
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1",
                                            RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
    // 不能直接返回,有可能null的时候自动装箱,然后就变成false
    return BooleanUtil.isTrue(flag);
}

/**
 * 互斥锁  释放锁
 * */
private void unlock(String key){
    stringRedisTemplate.delete(key);
}
/**
 * 缓存击穿 互斥锁
 * 缓存穿透 缓存 null 值
 * */
public Shop queryWithMutex(Long id) {
    // 从 redis 查询商铺缓存
    String shopJSON = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
    // 判断是否存在
    if (StrUtil.isNotBlank(shopJSON)){
        return JSONUtil.toBean(shopJSON, Shop.class);
    }
    // 判断是否命中
    if (shopJSON != null){
        // 此时为“”,即发生缓存穿透
        return null;
    }

    // 数据库查询
    // 获取互斥锁
    String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
    Shop shop = null;
    try {
        boolean isLock = tryLock(lockKey);
        if (!isLock){
            // 获取锁失败,休眠后重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }

        // 成功获取互斥锁,查询数据库
        shop = this.getById(id);

        // 数据库不存在该数据
        if (shop == null){
            // 为了避免缓存穿透,缓存 null 值
            stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,
                    "", RedisConstants.CACHE_NULL_TTL,
                    TimeUnit.MINUTES);
            return null;
        }

        // 放入缓存
        String str = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,
                str, RedisConstants.CACHE_SHOP_TTL,
                TimeUnit.MINUTES);

    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }finally {
        // 释放锁
        unlock(lockKey);
    }

    return shop;
}
@Override
 /**
  * 缓存穿透 缓存 null 值
  * */
 public Result queryById(Long id) {
     Shop shop = queryWithMutex(id);
     if (shop == null){
         return Result.fail("店铺不存在!");
     }
     return Result.ok(shop);
 }
  • 逻辑过期
    在设置缓存的时候,不设置TTL,但是在key对应的value上添加expire字段值,就是在查询到该缓存时,查看expire值,通过判断expire值来判断该缓存是否过期,如果过期表明这是旧的数据,需要更新,但是更新操作通知另一个线程操作,也需要获得互斥锁,当前线程就先返回旧数据。这样客户端用户就不会因为查询数据库,重建缓存数据这步操作而等待了。

关于在value(Shop对象)中添加过期时间如果解决:

  • 在Shop对象中添加一个字段:不推荐,对原来代码需要作出修改,不友好
  • 在util中新建一个RedisData,然后定义一个属性字段LocalDateTime,然后Shop对象继承LocalDateTime,但这样还是需要修改源代码
  • 在util中新建一个RedisData,然后定义一个属性字段LocalDateTime,然后再添加一个Object data
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

先创建一个方法,将商铺保存至Redis中,

/**
* 缓存击穿-逻辑过期-缓存预热,将热点 key 提前放入 redis
* */
public void saveShopRedis(Long id, Long expireSecond) {
   RedisData redisData = new RedisData();
   redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSecond));
   redisData.setData(this.getById(id));
   stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,
                                           JSONUtil.toJsonStr(redisData));
}

然后开启一个线程池,里面有10个线程,

/** 线程池 */
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);;

逻辑过期方法,

/**
* 缓存击穿 逻辑过期
* */
public Shop queryLogicExpire(Long id){
   String key = RedisConstants.CACHE_SHOP_KEY + id;

   // 从 redis 缓存获取
   String jsonRedisData = stringRedisTemplate.opsForValue().get(key);

   // 判断缓存是否命中
   if (StrUtil.isBlank(jsonRedisData)){
       // 未命中
       return null;
   }

   // 命中
   RedisData redisData = JSONUtil.toBean(jsonRedisData, RedisData.class);
   Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
   LocalDateTime expireTime = redisData.getExpireTime();

   // 判断是否过期
   if (expireTime.isAfter(LocalDateTime.now())){
       // 未过期
       return shop;
   }

   // 过期,进行缓存重建
   // 判断是否获取锁
   String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
   boolean isLock = tryLock(lockKey);
   if (isLock){
       // 开启线程重建缓存
       CACHE_REBUILD_EXECUTOR.submit( () -> {
           try {
               this.saveShopRedis(id, 20L);
           }catch (Exception e){
               throw new RuntimeException(e);
           }finally {
               unlock(lockKey);
           }
       });
   }

   return shop;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值