点评项目——商户查询缓存

2023.12.7

redis实现商户查询缓存

在企业开发中,用户的访问量动辄成百上千万,如果没有缓存机制,数据库将承受很大的压力。本章我们使用redis来实现商户查询缓存。

原来的操作是根据商铺id直接从数据库查询商铺信息,为了防止频繁地对数据库访问,我们使用redis进行缓存,大致流程图如下:

需要改变的地方就两个:①之前是直接从数据库中查,现在是先尝试从redis中查,没查到再去查数据库。②如果查数据库查到了的话,需要将查到的商铺数据先存到redis中,再将数据返回。 代码如下:

java 复制代码
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        //1、从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2、判断是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //3、存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //4、不存在,根据id查询数据库
        Shop shop = getById(id);
        //5、数据库没查到数据,返回错误信息
        if (shop == null){
            return Result.fail("商铺不存在!");
        }
        //6、数据库查到信息了,写入redis并返回商铺信息
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));

        return Result.ok(shop);

    }
}

redis实现商户类型数据缓存

解决商户数据缓存之后,我们趁热打铁也完成一下商户类型数据缓存,即下面这张图中数据的缓存:

而且这个页面数据也不会经常变动,很适合做缓存,需要变更的代码如下:

首先修改 ShopTypeController.java文件,原来是直接从数据库中查数据,这里我们在Controller中自定义一个方法,在service实现类中去编写具体业务代码:

java 复制代码
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
    @Resource
    private IShopTypeService typeService;

    @GetMapping("list")
    public Result queryTypeList() {
//        List<ShopType> typeList = typeService
//                .query().orderByAsc("sort").list();
//        return Result.ok(typeList);
        return typeService.queryList();
    }
}

对应的接口需要增加该方法:

java 复制代码
public interface IShopTypeService extends IService<ShopType> {

    Result queryList();
}

在对应的实现类ShopTypeServiceImpl.java中编写具体业务代码:

java 复制代码
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result queryList() {
        //1.尝试从redis中查询商户类型数据
        List<String> shopTypes = stringRedisTemplate.opsForList().range(CACHE_SHOP_TYPE_KEY, 0, -1);
        //2.在redis中查到数据了,返回ShopType类型数据
        if(!shopTypes.isEmpty()){
            List<ShopType> list = new ArrayList<>();
            for(String shopType : shopTypes){
                ShopType bean = JSONUtil.toBean(shopType, ShopType.class);
                list.add(bean);
            }
            return Result.ok(list);
        }
        //3.在redis中没查到数据,那就去数据库查
        List<ShopType> list = query().orderByAsc("sort").list(); //从数据库中按照sort字段升序查询
        //3.1 数据库也没查到,返回错误信息
        if(list == null){
            return Result.fail("店铺类型不存在!");
        }
        //3.2 数据库查到数据了,存入redis中并返回给用户
        for (ShopType shopType : list){
            String jsonStr = JSONUtil.toJsonStr(shopType);
            shopTypes.add(jsonStr);
        }
        stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOP_TYPE_KEY,shopTypes);

        return Result.ok(list);
    }
}

本人新手用的笨方法for-each循环逐个转换,高手可以用stream流来简化代码。

缓存更新策略

由于内存资源比较宝贵,向其插入过多数据的话可能导致内存空间爆满,所以需要某种机制对内存的部分数据进行更新或者移除。下面介绍三种缓存更新数据:

  • 内存淘汰:Redis自动进行,当Redis内存大到某个阈值时,会自动触发淘汰机制,淘汰掉一些不重要的数据(这个机制可以自定义)
  • 超时剔除:当我们给Redis设置了过期时间TTL之后,Redis会将超时的数据进行删除。
  • 主动更新:我们可以手动调用方法把缓存删除掉,通常用于解决缓存和数据库不一致问题,该方法一致性较好,但是维护成本高。

业务场景:

  • 在低一致性场景下:使用内存淘汰机制,因为该场景下的数据很长一段时间都不需要更新。
  • 在高一致性场景下:使用主动更新策略 ,即自己编写代码实现高一致性,但也不能100%的保证一致性,所以还需要使用超时剔除策略兜底。

数据库与缓存不一致的解决方案

由于我们的缓存数据来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步更新,此时存在数据的一致性问题。

有三种解决方案:

  1. Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库之后再去更新缓存,也称之为双写方案
  2. Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
  3. Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了

实际开发中,一般还是使用方案一,但是如果我们每次操作完数据库之后,都去更新一下缓存,而此期间并没有人查询数据,那么这个更新动作意义就不大了,所以我们可以把缓存直接删除,等到有人再次查询时,再更新缓存

还有个问题,我们应该先删缓存还是先更新数据库呢?理论上是都可以,如果先删缓存再更新数据库的话,由于删缓存的速度比更新数据库的速度快很多,所以两个操作之间有一段较长的空档期,此期间如果有其他线程进来查询数据库的话查的就是脏数据了。先更新数据库再删缓存当然也存在安全问题,但是几率会比上述小很多,这里不再细说,结论就是采用先更新数据库再删缓存的策略。

实现商铺缓存与数据库的双写一致

主要需要修改两处地方:

  • 根据id查询商铺时,将数据库结果写入缓存时,需要设置超时时间 。(超时剔除策略)
  • 根据id修改店铺时,先修改数据库,再删除缓存。

在ShopServiceImpl.java代码中设置超时时间:

java 复制代码
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);

修改店铺操作时,先修改数据库,再删除缓存:

java 复制代码
    @Override
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if(id == null){
            return Result.fail("店铺id不能为空!");
        }
        //1.更新数据库
        updateById(shop);
        //2.删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);

        return Result.ok();
    }

缓存穿透问题及解决办法

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存就形同虚设(只有数据库查到了,才会让redis缓存,但现在的问题是查不到),会频繁的去访问数据库。

常见的解决方案有两种:

  • 缓存空对象:如果该数据在缓存和数据库中都不存在,就缓存一个空值到redis中,并且超时时间设置得短一点,如2分钟。
  • 布隆过滤:布隆过滤器是处于redis之前的一段过滤器,底层是根据哈希来实现的,客户端的所有请求都会通过该过滤器进行过滤,由于哈希的性质,若该过滤器都查不到数据,则直接返回错误信息;若查到了则放行,但也不一定存在该数据(存在哈希冲突)。

下面使用缓存空对象解决缓存穿透问题,先看一下流程图:

与之前相比需要增加两个操作:

  • 数据库也查不到商铺的话,需要将空值写入redis。
  • 缓存命中之后可能为空,需要进行判空操作。

代码修改如下:

java 复制代码
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        //1、从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2、判断是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //3、存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //判断命中的是否是空值
        if(shopJson != null){
            return Result.fail("店铺信息不存在");
        }
        //4、不存在,根据id查询数据库
        Shop shop = getById(id);
        //5、数据库没查到数据,返回错误信息
        if (shop == null){
            //应对缓存穿透问题,将空值写入redis,并且有效期需要设置得短一点。
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail("商铺不存在!");
        }
        //6、数据库查到信息了,写入redis并返回商铺信息
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);

        return Result.ok(shop);

    }

缓存雪崩问题及解决办法

缓存雪崩是指在同一时间段,大量缓存的key同时失效,或者Redis服务宕机,导致大量请求到达数据库。

解决方案:

  • 给不同的Key的TTL添加随机值,让其在不同时间段分批失效
  • 利用Redis集群提高服务的可用性(使用一个或者多个哨兵(Sentinel)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存,可以理解为穿了好几件防弹衣。

缓存击穿问题及解决办法

缓存击穿也叫热点Key问题,一个热点的Key,有大并发集中对其进行访问,突然间这个Key失效了,导致大并发全部打在数据库上,导致数据库压力剧增。

  • 常见的解决方案有两种

    1. 互斥锁
    2. 如果业务允许的话,对于热点的key可以设置永不过期的key。

互斥锁:只有拿到锁才可以查询数据库,降低了在同一时刻打在数据库上的请求,当然这样会导致系统的性能变差。

这里放一下使用互斥锁的代码:

java 复制代码
    public Shop queryWithMutex(Long id){
        String key = CACHE_SHOP_KEY + id;
        //1、从redis查询商铺缓存
        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:" + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            //4.2判断是否获取成功
            if(!isLock){
                //4.3 失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //4.4 成功,根据id查询数据库
            shop = getById(id);

            //5、数据库没查到数据,返回错误信息
            if (shop == null){
                //应对缓存穿透问题,将空值写入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;
    }

定义上锁和放锁的代码:

java 复制代码
//定义加锁和删锁的操作
    private boolean tryLock(String key){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }

可以参考以下流程图:

相关推荐
蓝天星空40 分钟前
spring cloud gateway 3
java·spring cloud
罗政1 小时前
PDF书籍《手写调用链监控APM系统-Java版》第9章 插件与链路的结合:Mysql插件实现
java·mysql·pdf
一根稻草君1 小时前
利用poi写一个工具类导出逐级合并的单元格的Excel(通用)
java·excel
kirito学长-Java1 小时前
springboot/ssm网上宠物店系统Java代码编写web宠物用品商城项目
java·spring boot·后端
木头没有瓜1 小时前
ruoyi 请求参数类型不匹配,参数[giftId]要求类型为:‘java.lang.Long‘,但输入值为:‘orderGiftUnionList
android·java·okhttp
奋斗的老史1 小时前
Spring Retry + Redis Watch实现高并发乐观锁
java·redis·spring
high20111 小时前
【Java 基础】-- ArrayList 和 Linkedlist
java·开发语言
老马啸西风1 小时前
NLP 中文拼写检测纠正论文 C-LLM Learn to CSC Errors Character by Character
java
Cosmoshhhyyy2 小时前
LeetCode:3083. 字符串及其反转中是否存在同一子字符串(哈希 Java)
java·leetcode·哈希算法