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

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);
    }

可以参考以下流程图:

相关推荐
Jabes.yang30 分钟前
Java求职面试: 互联网医疗场景中的缓存技术与监控运维应用
java·redis·spring security·grafana·prometheus·oauth2·互联网医疗
摇滚侠43 分钟前
Spring Boot 3零基础教程,yml配置文件,笔记13
spring boot·redis·笔记
初级炼丹师(爱说实话版)1 小时前
内存泄漏与内存溢出
java
CryptoRzz1 小时前
越南k线历史数据、IPO新股股票数据接口文档
java·数据库·后端·python·区块链
学Java的bb1 小时前
MybatisPlus
java·开发语言·数据库
讓丄帝愛伱1 小时前
Mybatis Log Free插件使用
java·开发语言·mybatis
重生之我要当java大帝1 小时前
java微服务-尚医通-编写医院设置接口上
java·数据库·微服务
夫唯不争,故无尤也1 小时前
Tomcat 内嵌启动时找不到 Web 应用的路径
java·前端·tomcat
心之伊始1 小时前
Netty线程模型与Tomcat线程模型对比分析
java·开发语言
gaoshan123456789101 小时前
‌MyBatis-Plus 的 LambdaQueryWrapper 可以实现 OR 条件查询‌
java·tomcat·mybatis