当前位置: 首页 > news >正文

【黑马点评|项目】万字总结(上)

前言

昨天终于是把大名鼎鼎的Redis实战项目黑马点评写完了,刚写完回顾前面的知识发现忘得差不多了,今天写个笔记回顾一下。

项目资料:黑马点评百度网盘,提取码:eh11

项目功能架构

在这里插入图片描述
在这里插入图片描述

通过nginx负载均衡和动态代理发起请求,数据层使用集群的方式

基于Session实现短信验证码登录

短信验证码登录

在这里插入图片描述

/**
 * 发送验证码
 */
@Override
public Result sendCode(String phone, HttpSession session) {
    // 1、判断手机号是否合法
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式不正确");
    }
    // 2、手机号合法,生成验证码,并保存到Session中
    String code = RandomUtil.randomNumbers(6);
    session.setAttribute(SystemConstants.VERIFY_CODE, code);
    // 3、发送验证码
    log.info("验证码:{}", code);
    return Result.ok();
}

/**
 * 用户登录
 */
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    String phone = loginForm.getPhone();
    String code = loginForm.getCode();
    // 1、判断手机号是否合法
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式不正确");
    }
    // 2、判断验证码是否正确
    String sessionCode = (String) session.getAttribute(LOGIN_CODE);
    if (code == null || !code.equals(sessionCode)) {
        return Result.fail("验证码不正确");
    }
    // 3、判断手机号是否是已存在的用户
    User user = this.getOne(new LambdaQueryWrapper<User>()
            .eq(User::getPassword, phone));
    if (Objects.isNull(user)) {
        // 用户不存在,需要注册
        user = createUserWithPhone(phone);
    }
    // 4、保存用户信息到Session中,便于后面逻辑的判断(比如登录判断、随时取用户信息,减少对数据库的查询)
    session.setAttribute(LOGIN_USER, user);
    return Result.ok();
}

/**
 * 根据手机号创建用户
 */
private User createUserWithPhone(String phone) {
    User user = new User();
    user.setPhone(phone);
    user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    this.save(user);
    return user;
}

配置拦截器

在这里插入图片描述

public class LoginInterceptor implements HandlerInterceptor {
    /**
     * 前置拦截器,用于判断用户是否登录
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession();
        // 1、判断用户是否存在
        User user = (User) session.getAttribute(LOGIN_USER);
        if (Objects.isNull(user)){
            // 用户不存在,直接拦截
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            return false;
        }
        // 2、用户存在,则将用户信息保存到ThreadLocal中,方便后续逻辑处理
        // 比如:方便获取和使用用户信息,session获取用户信息是具有侵入性的
        ThreadLocalUtls.saveUser(user);

        return HandlerInterceptor.super.preHandle(request, response, handler);
    }
}

写完拦截器之后需要在SpringMVC配置当中配置才能生效

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                // 设置放行请求
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                );
    }
}

消除敏感数据

@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

Session共享问题

     ~~~~     分布式集群环境中,会话(Session)共享是一个常见的挑战。默认情况下,Web 应用程序的会话是保存在单个服务器上的,当请求不经过该服务器时,会话信息无法被访问。
     ~~~~     nginx中我们我们配置了负载均衡,用户的多次请求可能被分发到不同的后端应用上,这时候我们仍然使用Session这个对象来存储用户状态,那么只有处理了用户登录请求的后端程序记住了用户的登录状态,其他后端程序无法获取到这个Session数据,导致他们并不能判断用户是否登录,当用户的其他请求被nginx分发到其他后端程序的时候,用户的正常请求就会被拦截器拦截。下面我们有两种解决方案。

如何解决Session集群共享问题?

  • 方案一:Session拷贝(不推荐)
    Tomcat提供了Session拷贝功能,通过配置Tomcat可以实现Session的拷贝,但是这会增加服务器的额外内存开销,同时会带来数据一致性问题
  • 方案二:Redis缓存(推荐)
    Redis缓存具有Session存储一样的特点,基于内存、存储结构可以是key-value结构、数据共享

Redis缓存相较于传统Session存储的优点:
     ~~~~     高性能和可伸缩性:Redis 是一个内存数据库,具有快速的读写能力。相比于传统的 Session 存储方式,将会话数据存储在 Redis 中可以大大提高读写速度和处理能力。此外,Redis 还支持集群和分片技术,可以实现水平扩展,处理大规模的并发请求。
     ~~~~     可靠性和持久性:Redis 提供了持久化机制,可以将内存中的数据定期或异步地写入磁盘,以保证数据的持久性。这样即使发生服务器崩溃或重启,会话数据也可以被恢复。
     ~~~~     丰富的数据结构:Redis 不仅仅是一个键值存储数据库,它还支持多种数据结构,如字符串、列表、哈希、集合和有序集合等。这些数据结构的灵活性使得可以更方便地存储和操作复杂的会话数据。
     ~~~~     分布式缓存功能:Redis 作为一个高效的缓存解决方案,可以用于缓存会话数据,减轻后端服务器的负载。与传统的 Session 存储方式相比,使用 Redis 缓存会话数据可以大幅提高系统的性能和可扩展性。
     ~~~~     可用性和可部署性:Redis 是一个强大而成熟的开源工具,有丰富的社区支持和活跃的开发者社区。它可以轻松地与各种编程语言和框架集成,并且可以在多个操作系统上运行。

基于Redis实现短信验证码登录

短信验证码登录

在这里插入图片描述
从前面的分析来看,显然Redis是要优于Session的,但是Redis中有很多数据结构,我们应该选择哪种数据结构来存储用户信息才能够更优呢?可能大多数同学都会想到使用 String 类型的数据据结构,但是这里我推荐使用 Hash结构!

@Override
public Result sendCode(String phone) {
    // 1 校验手机号
    if(RegexUtils.isPhoneInvalid(phone)){
        return Result.fail("手机号格式不合法");
    }
    // 2 符合,生成验证码,不符合返回
    String code = RandomUtil.randomNumbers(6);
    // 3 保存验证码到session
    stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone,code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
    // 4 发送验证码
    log.info("发送验证码成功:{}",code);
    return Result.ok();
}

@Override
public Result login(LoginFormDTO loginForm) {
    String phone = loginForm.getPhone();
    // 1 校验手机号
    if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
        return Result.fail("手机号格式不合法");
    }
    // 2 校验验证码
    String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if(cacheCode == null || !cacheCode.equals(code)){
        return Result.fail("验证码错误");
    }
    // 3 一致,根据用户手机号查询用户
    User user = query().eq("phone", phone).one();
    if(user == null){
       user = createUserwithPhone(phone);
    }
    UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
    Map<String, Object> userDtomap = BeanUtil.beanToMap(userDTO,new HashMap<>() ,
            CopyOptions.create().setIgnoreNullValue(true)
                                .setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
    // 4 生成token
    String token = UUID.randomUUID().toString();
    stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token,userDtomap);
    stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
    return Result.ok(token);
}
// 这里我们需要注意这行代码
Map<String, Object> userDtomap = BeanUtil.beanToMap(userDTO,newHashMap<>() 
 ,CopyOptions.create().setIgnoreNullValue(true)
 .setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
// 由于我们使用stringRedisTemplate,它要求我们的key和value都是String
//类型,但是我们的UserDto的id是Long类型的,这时候我们再转换成Map形式的时
//候就需要吧它转换成字符串的类型

配置登录拦截器

在这里插入图片描述

单独配置一个拦截器用户刷新Redis中的token:在基于Session实现短信验证码登录时,我们只配置了一个拦截器,这里需要另外再配置一个拦截器专门用户刷新存入Redis中的 token,因为我们现在改用Redis了,为了防止用户在操作网站时突然由于Redis中的 token 过期,导致直接退出网站,严重影响用户体验。那为什么不把刷新的操作放到一个拦截器中呢,因为之前的那个拦截器只是用来拦截一些需要进行登录校验的请求,对于哪些不需要登录校验的请求是不会走拦截器的,刷新操作显然是要针对所有请求比较合理,所以单独创建一个拦截器拦截一切请求,刷新Redis中的Key

登录拦截器

public class LoginInterceptor implements HandlerInterceptor {

    /**
     * 前置拦截器,用于判断用户是否登录
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判断当前用户是否已登录
        if (ThreadLocalUtls.getUser() == null){
            // 当前用户未登录,直接拦截
            response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);
            return false;
        }
        // 用户存在,直接放行
        return true;
    }
}

刷新Token拦截器

public class RefreshTokenInterceptor implements HandlerInterceptor {

    // new出来的对象是无法直接注入IOC容器的(LoginInterceptor是直接new出来的)
    // 所以这里需要再配置类中注入,然后通过构造器传入到当前类中
    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1、获取token,并判断token是否存在
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)){
            // token不存在,说明当前用户未登录,不需要刷新直接放行
            return true;
        }
        // 2、判断用户是否存在
        String tokenKey = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        if (userMap.isEmpty()){
            // 用户不存在,说明当前用户未登录,不需要刷新直接放行
            return true;
        }
        // 3、用户存在,则将用户信息保存到ThreadLocal中,方便后续逻辑处理,比如:方便获取和使用用户信息,Redis获取用户信息是具有侵入性的
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        ThreadLocalUtls.saveUser(BeanUtil.copyProperties(userMap, UserDTO.class));
        // 4、刷新token有效期
        stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }
}

在刷新Token的拦截器当中,我们根据token从Redis缓存当中获取到UserDto对象存入到ThreadLocal中

店铺数据查询

什么是缓存

缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。

在这里插入图片描述

如果不使用缓存我们每次就会去数据库查询信息,但是有一些信息是一直不变的,我们频繁去数据库查询会大大降低数据库的性能

根据id查询店铺信息

在这里插入图片描述

 /**
  * 根据id查询商铺数据
  *
  * @param id
  * @return
  */
 @Override
 public Result queryById(Long id) {
     String key = CACHE_SHOP_KEY + id;
     // 1、从Redis中查询店铺数据
     String shopJson = stringRedisTemplate.opsForValue().get(key);

     Shop shop = null;
     // 2、判断缓存是否命中
     if (StrUtil.isNotBlank(shopJson)) {
         // 2.1 缓存命中,直接返回店铺数据
         shop = JSONUtil.toBean(shopJson, Shop.class);
         return Result.ok(shop);
     }
     // 2.2 缓存未命中,从数据库中查询店铺数据
     shop = this.getById(id);

     // 4、判断数据库是否存在店铺数据
     if (Objects.isNull(shop)) {
         // 4.1 数据库中不存在,返回失败信息
         return Result.fail("店铺不存在");
     }
     // 4.2 数据库中存在,写入Redis,并返回店铺数据
     stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
     return Result.ok(shop);
 }

对于店铺的详细数据,这种数据变化比较大,店家可能会随时修改店铺的相关信息(比如宣传语,店铺名等),所以对于这类变动较为频繁的数据,我们是直接存入Redis中,并且设置合适的有效期(后面还会进行优化,确保Redis和MySQL的数据一致性,以及解决缓存常见的三大问题)

查询店铺类型

对于店铺类型数据,一般变动会比较小,所以这里我们直接将店铺类型的数据持久化存储到Redis中

/**
 * 查询店铺的类型
 *
 * @return
 */
@Override
public Result queryTypeList() {
    // 1、从Redis中查询店铺类型
    String key = CACHE_SHOP_TYPE_KEY + UUID.randomUUID().toString(true);
    String shopTypeJSON = stringRedisTemplate.opsForValue().get(key);

    List<ShopType> typeList = null;
    // 2、判断缓存是否命中
    if (StrUtil.isNotBlank(shopTypeJSON)) {
        // 2.1 缓存命中,直接返回缓存数据
        typeList = JSONUtil.toList(shopTypeJSON, ShopType.class);
        return Result.ok(typeList);
    }
    // 2.1 缓存未命中,查询数据库
    typeList = this.list(new LambdaQueryWrapper<ShopType>()
            .orderByAsc(ShopType::getSort));

    // 3、判断数据库中是否存在该数据
    if (Objects.isNull(typeList)) {
        // 3.1 数据库中不存在该数据,返回失败信息
        return Result.fail("店铺类型不存在");
    }
    // 3.2 店铺数据存在,写入Redis,并返回查询的数据
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(typeList),
            CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
    return Result.ok(typeList);
}

数据一致性问题

缓存的使用降低了后端负载,提高了读写的效率,降低了响应的时间,这么看来缓存是不是就一本万利呢?答案是否定的!并不是说缓存有这么多优点项目中就可以无脑使用缓存了,我们还需要考虑缓存带来的问题,比如:缓存的添加提高了系统的维护成本,同时也带来了数据一致性问题……总的来讲,系统使用引入缓存需要经过前期的测试、预算,判断引入缓存后带来的价值是否会超过引入缓存带来的代价

那么我们该如何解决数据一致性问题呢?首先我们需要明确数据一致性问题的主要原因是什么,从主要原因入手才是解决问题的关键!数据一致性的根本原因是 缓存和数据库中的数据不同步,那么我们该如何让 缓存 和 数据库 中的数据尽可能的即时同步?这就需要选择一个比较好的缓存更新策略了。常见的缓存更新策略如下

在这里插入图片描述

在这里插入图片描述

内存淘汰(全自动):利用Redis的内存淘汰机制实现缓存更新,Redis的内存淘汰机制是当Redis发现内存不足时,会根据一定的策略自动淘汰部分数据
Redis中常见的淘汰策略:
     ~~~~     noeviction(默认):当达到内存限制并且客户端尝试执行写入操作时,Redis 会返回错误信息,拒绝新数据的写入,保证数据完整性和一致性
     ~~~~     allkeys-lru:从所有的键中选择最近最少使用(Least Recently Used,LRU)的数据进行淘汰。即优先淘汰最长时间未被访问的数据
     ~~~~     allkeys-random:从所有的键中随机选择数据进行淘汰
     ~~~~     volatile-lru:从设置了过期时间的键中选择最近最少使用的数据进行淘汰
     ~~~~     volatile-random:从设置了过期时间的键中随机选择数据进行淘汰
     ~~~~     volatile-ttl:从设置了过期时间的键中选择剩余生存时间(Time To Live,TTL)最短的数据进行淘汰

超时剔除(半自动):手动给缓存数据添加TTL,到期后Redis自动删除缓存

主动更新(手动):手动编码实现缓存更新,在修改数据库的同时更新缓存
     ~~~~     
     ~~~~     双写方案(Cache Aside Pattern):人工编码方式,缓存调用者在更新完数据库后再去更新缓存。使用困难,灵活度高。
     ~~~~     读取(Read):当需要读取数据时,首先检查缓存是否存在该数据。如果缓存中存在,直接返回缓存中的数据。如果缓存中不存在,则从底层数据存储(如数据库)中获取数据,并将数据存储到缓存中,以便以后的读取操作可以更快地访问该数据。
     ~~~~     写入(Write):当进行数据写入操作时,首先更新底层数据存储中的数据。然后,根据具体情况,可以选择直接更新缓存中的数据(使缓存与底层数据存储保持同步),或者是简单地将缓存中与修改数据相关的条目标记为无效状态(缓存失效),以便下一次读取时重新加载最新数据
     ~~~~     使用双写方案需要考虑以下几个问题:
     ~~~~     (1)是使用更新缓存模式还是使用删除缓存模式?
     ~~~~     更新缓存模式:每次更新数据库都更新缓存,无效写操作较多(不推荐使用)假如我们执行上百次更新数据库操作,那么就要执行上百次写入缓存的操作,而在这期间并没有查询请求,那么这上百次写入缓存的操作就显得没有什么意义
     ~~~~     删除缓存模式:更新数据时更新数据库并删除缓存,查询时更新缓存,无效写操作较少(推荐使用)
     ~~~~     (2)选择使用删除缓存模式,那么是先操作缓存还是先操作数据库?
     ~~~~     先操作缓存:先删缓存,再更新数据库(不推荐使用,详细原因看P38)当线程1删除缓存到更新数据库之间的时间段,会有其它线程进来查询数据,由于没有加锁,且前面的线程将缓存删除了,这就导致请求会直接打到数据库上,给数据库带来巨大压力。这个事件发生的概率很大,因为缓存的读写速度块,而数据库的读写较慢。
     ~~~~     这种方式的不足之处:存在缓存击穿问题,且概率较大
     ~~~~     先操作数据库:先更新数据库,再删缓存(推荐使用,详细原因看P38)当线程1在查询缓存且未命中,此时线程1查询数据,查询完准备写入缓存时,由于没有加锁线程2乘虚而入,线程2在这期间对数据库进行了更新,此时线程1将旧数据返回了,出现了脏读,这个事件发生的概率很低,因为先是需要满足缓存未命中,且在写入缓存的那段事件内有一个线程进行更新操作,缓存的查询很快,这段空隙时间很小,所以出现脏读现象的概率也很低
     ~~~~     这种方式的不足之处:存在脏读现象,但概率较小
     ~~~~     (3)选择先更新数据库,再删除缓存。那么如何保证缓存与数据库的操作的原子性(同时成功或失败)?
     ~~~~     对于单体系统,将缓存与数据库操作放在同一个事务中(当前项目就是一个单体项目,所以选择这种方式)
     ~~~~     对于分布式系统2,利用TCC(Try-Confirm-Cancel)等分布式事务方案

     ~~~~     读写穿透方案(Read/Write Through Pattern):将读取和写入操作首先在缓存中执行,然后再传播到数据存储
     ~~~~     (1)读取穿透(Read Through):当进行读取请求时,首先检查缓存。如果所请求的数据在缓存中找到,直接返回数据。如果缓存中没有找到数据,则将请求转发给数据存储以获取数据。获取到的数据随后存储在缓存中,然后返回给调用者。
     ~~~~     (2)写入穿透(Write Through):当进行写入请求时,首先将数据写入缓存。缓存立即将写操作传播到数据存储,确保缓存和数据存储之间的数据保持一致。这样保证了后续的读取请求从缓存中返回更新后的数据。

     ~~~~     写回方案(Write Behind Caching Pattern):调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
     ~~~~     (1)读取(Read):先检查缓存中是否存在数据,如果不存在,则从底层数据存储中获取数据,并将数据存储到缓存中。
     ~~~~     (2)写入(Write):先更新底层数据存储,然后将待写入的数据放入一个缓存队列中。在适当的时机,通过批量操作或异步处理,将缓存队列中的数据写入底层数据存储

缓存主动更新策略的实现

上一节,我们了解了数据一致性问题,并了解了如何解决数据一致性问题的几种常见策略,最终经过我们的讨论得出采用缓存主动更新来解决数据一致性问题,是相较于其它两种方案更好的选择,同时也选择使用双写方案的删除缓存模式来减少线程安全问题发生的概率,采用TTL过期+内存淘汰机制作为兜底方案,同时将缓存和数据库的操作放到同一个事务来保障操作的原子性,现在就让我们来通过下面的案例进行实现

在这里插入图片描述

/**
 * 根据id查询商铺数据(查询时,重建缓存)
 *
 * @param id
 * @return
 */
@Override
public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1、从Redis中查询店铺数据
    String shopJson = stringRedisTemplate.opsForValue().get(key);

    Shop shop = null;
    // 2、判断缓存是否命中
    if (StrUtil.isNotBlank(shopJson)) {
        // 2.1 缓存命中,直接返回店铺数据
        shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 2.2 缓存未命中,从数据库中查询店铺数据
    shop = this.getById(id);

    // 4、判断数据库是否存在店铺数据
    if (Objects.isNull(shop)) {
        // 4.1 数据库中不存在,返回失败信息
        return Result.fail("店铺不存在");
    }
    // 4.2 数据库中存在,重建缓存,并返回店铺数据
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return Result.ok(shop);
}

/**
 * 更新商铺数据(更新时,更新数据库,删除缓存)
 *
 * @param shop
 * @return
 */
@Transactional
@Override
public Result updateShop(Shop shop) {
    // 参数校验, 略

    // 1、更新数据库中的店铺数据
    boolean f = this.updateById(shop);
    if (!f){
        // 缓存更新失败,抛出异常,事务回滚
        throw new RuntimeException("数据库更新失败");
    }
    // 2、删除缓存
    f = stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
    if (!f){
        // 缓存删除失败,抛出异常,事务回滚
        throw new RuntimeException("缓存删除失败");
    }
    return Result.ok();
}

缓存穿透的解决方案

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
在这里插入图片描述
常见解决缓存穿透的解决方案:

缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗,可能造成短期的不一致

布隆过滤
优点:内存占用较少,没有多余key
缺点:实现复杂,存在误判可能(有穿透的风险),无法删除数据
上面两种方式都是被动的解决缓存穿透方案,此外我们还可以采用主动的方案预防缓存穿透,比如:增强id的复杂度避免被猜测id规律、做好数据的基础格式校验、加强用户权限校验、做好热点参数的限流

在这里插入图片描述
这里我们使用方案一
在这里插入图片描述

public Shop querywithPassThrough(Long id){
    // 从Redis查缓存
    String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
    // 判断是否存在
    if(StrUtil.isNotBlank(shopJson)){
        // 存在
        return  JSONUtil.toBean(shopJson, Shop.class);
    }
    // 命中的是否是null
    if (shopJson != null) {
    // 当前数据是空字符串(说明该数据是之前缓存的空对象),直接返回失败信息
        return null;
    }
    // 不存在 根据id查数据库
    Shop shop = getById(id);
    // 不存在,返回错误
    if(shop == null){
        // 空值写入
        stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,"", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    // 存在,吸入Redis
    stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
    return shop;
}

缓存雪崩的解决方案

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

在这里插入图片描述

缓存雪崩的常见解决方案:
     ~~~~     给不同的Key的TTL添加随机值
     ~~~~     利用Redis集群提高服务的可用性
     ~~~~     给缓存业务添加降级限流策略,比如快速失败机制,让请求尽可能打不到数据库上
     ~~~~     给业务添加多级缓存

缓存击穿的解决方案

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

在这里插入图片描述

这时候我们发现,当多个请求都发现缓存失效的时候,就会都去数据查询数据,然后重新建立缓存。导致数据库短时间内涌入大量查询命令。数据库可能会宕机。

缓存击穿的常见解决方案:

互斥锁(时间换空间)
     ~~~~     优点:内存占用小,一致性高,实现简单
     ~~~~     缺点:性能较低,容易出现死锁
逻辑过期(空间换时间)
     ~~~~     优点:性能高
     ~~~~     缺点:内存占用较大,容易出现脏读
两者相比较,互斥锁更加易于实现,但是容易发生死锁,且锁导致并行变成串行,导致系统性能下降,逻辑过期实现起来相较复杂,且需要耗费额外的内存,但是通过开启子线程重建缓存,使原来的同步阻塞变成异步,提高系统的响应速度,但是容易出现脏读

在这里插入图片描述

利用互斥锁的方式解决缓存击穿

在这里插入图片描述

/**
 * 根据id查询商铺数据
 *
 * @param id
 * @return
 */
@Override
public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1、从Redis中查询店铺数据,并判断缓存是否命中
    Result result = getShopFromCache(key);
    if (Objects.nonNull(result)) {
        // 缓存命中,直接返回
        return result;
    }
    try {
        // 2、缓存未命中,需要重建缓存,判断能否能够获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        if (!isLock) {
            // 2.1 获取锁失败,已有线程在重建缓存,则休眠重试
            Thread.sleep(50);
            return queryById(id);
        }
        // 2.2 获取锁成功,判断缓存是否重建,防止堆积的线程全部请求数据库(所以说双检是很有必要的)
        result = getShopFromCache(key);
        if (Objects.nonNull(result)) {
            // 缓存命中,直接返回
            return result;
        }

        // 3、从数据库中查询店铺数据,并判断数据库是否存在店铺数据
        Shop shop = this.getById(id);
        if (Objects.isNull(shop)) {
            // 数据库中不存在,缓存空对象(解决缓存穿透),返回失败信息
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
            return Result.fail("店铺不存在");
        }

        // 4、数据库中存在,重建缓存,响应数据
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),
                CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }catch (Exception e){
        throw new RuntimeException("发生异常");
    } finally {
        // 5、释放锁(释放锁一定要记得放在finally中,防止死锁)
        unlock(key);
    }
}

/**
 * 从缓存中获取店铺数据
 * @param key
 * @return
 */
private Result getShopFromCache(String key) {
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 判断缓存是否命中
    if (StrUtil.isNotBlank(shopJson)) {
        // 缓存数据有值,说明缓存命中了,直接返回店铺数据
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    // 判断缓存中查询的数据是否是空字符串(isNotBlank把 null 和 空字符串 给排除了)
    if (Objects.nonNull(shopJson)) {
        // 当前数据是空字符串,说明缓存也命中了(该数据是之前缓存的空对象),直接返回失败信息
        return Result.fail("店铺不存在");
    }
    // 缓存未命中(缓存数据既没有值,又不是空字符串)
    return null;
}


/**
 * 获取锁
 *
 * @param key
 * @return
 */
private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    // 拆箱要判空,防止NPE
    return BooleanUtil.isTrue(flag);
}

/**
 * 释放锁
 *
 * @param key
 */
private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

     ~~~~     这里使用Redis中的setnx指令实现互斥锁,只有当值不存在时才能进行set操作
     ~~~~     锁的有效期更具体业务有关,需要灵活变动,一般锁的有效期是业务处理时长10~20倍
     ~~~~     线程获取锁后,还需要查询缓存(也就是所谓的双检),这样才能够真正有效保障缓存不被击穿

利用逻辑过期来解决缓存击穿问题

所谓的逻辑过期并不是真的过期,而是在存入的数据当中加一个物理过期时间,每次从Redis中获取数据的时候先判断逻辑过期时间是否超时,然后再进行下面的操作。

在这里插入图片描述
创建一个逻辑过期数据类

@Data
public class RedisData {
    /**
     * 过期时间
     */
    private LocalDateTime expireTime;
    /**
     * 缓存数据
     */
    private Object data;
}
/**
 * 缓存重建线程池
 */
public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

/**
 * 根据id查询商铺数据
 *
 * @param id
 * @return
 */
@Override
public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1、从Redis中查询店铺数据,并判断缓存是否命中
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isBlank(shopJson)) {
        // 1.1 缓存未命中,直接返回失败信息
        return Result.fail("店铺数据不存在");
    }
    // 1.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    // 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
    JSONObject data = (JSONObject) redisData.getData();
    Shop shop = JSONUtil.toBean(data, Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    if (expireTime.isAfter(LocalDateTime.now())) {
        // 当前缓存数据未过期,直接返回
        return Result.ok(shop);
    }

    // 2、缓存数据已过期,获取互斥锁,并且重建缓存
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    if (isLock) {
        // 获取锁成功,开启一个子线程去重建缓存
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                this.saveShopToCache(id, CACHE_SHOP_LOGICAL_TTL);
            } finally {
                unlock(lockKey);
            }
        });
    }

    // 3、获取锁失败,再次查询缓存,判断缓存是否重建(这里双检是有必要的)
    shopJson = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isBlank(shopJson)) {
        // 3.1 缓存未命中,直接返回失败信息
        return Result.fail("店铺数据不存在");
    }
    // 3.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
    redisData = JSONUtil.toBean(shopJson, RedisData.class);
    // 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
    data = (JSONObject) redisData.getData();
    shop = JSONUtil.toBean(data, Shop.class);
    expireTime = redisData.getExpireTime();
    if (expireTime.isAfter(LocalDateTime.now())) {
        // 当前缓存数据未过期,直接返回
        return Result.ok(shop);
    }

    // 4、返回过期数据
    return Result.ok(shop);
}

/**
 * 将数据保存到缓存中
 *
 * @param id            商铺id
 * @param expireSeconds 逻辑过期时间
 */
public void saveShopToCache(Long id, Long expireSeconds) {
    // 从数据库中查询店铺数据
    Shop shop = this.getById(id);
    // 封装逻辑过期数据
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // 将逻辑过期数据存入Redis中
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}

/**
 * 获取锁
 *
 * @param key
 * @return
 */
private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    // 拆箱要判空,防止NPE
    return BooleanUtil.isTrue(flag);
}

/**
 * 释放锁
 *
 * @param key
 */
private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

这种方法需要我们缓存中提前存储了店铺数据,如果不存在就会返回店铺不存在

缓存工具类的封装

在这里插入图片描述
PS:方法1与方法3对应,负责常见的普通的缓存,用于解决缓存穿透;方法2与方法4对应,负责热点缓存,用于解决缓存击穿
使用工具类后,ShopServiceImpl的代码:
可以看到现在ShopServiceImpl中的代码就显得十分清爽干净了😄

/**
* 根据id查询商铺数据
*
* @param id
* @return
*/
@Override
public Result queryById(Long id) {
	// 调用解决缓存穿透的方法
	//        Shop shop = cacheClient.handleCachePenetration(CACHE_SHOP_KEY, id, Shop.class,
	//                this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
	//        if (Objects.isNull(shop)){
	//            return Result.fail("店铺不存在");
	//        }
	
	// 调用解决缓存击穿的方法
	Shop shop = cacheClient.handleCacheBreakdown(CACHE_SHOP_KEY, id, Shop.class,
	        this::getById, CACHE_SHOP_TTL, TimeUnit.SECONDS);
	if (Objects.isNull(shop)) {
	    return Result.fail("店铺不存在");
	}
	
	return Result.ok(shop);
}
@Component
@Slf4j
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将数据加入Redis,并设置有效期
     *
     * @param key
     * @param value
     * @param timeout
     * @param unit
     */
    public void set(String key, Object value, Long timeout, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), timeout, unit);
    }

    /**
     * 将数据加入Redis,并设置逻辑过期时间
     *
     * @param key
     * @param value
     * @param timeout
     * @param unit
     */
    public void setWithLogicalExpire(String key, Object value, Long timeout, TimeUnit unit) {
        RedisData redisData = new RedisData();
        redisData.setData(value);
        // unit.toSeconds()是为了确保计时单位是秒
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(timeout)));
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), timeout, unit);
    }

    /**
     * 根据id查询数据(处理缓存穿透)
     *
     * @param keyPrefix  key前缀
     * @param id         查询id
     * @param type       查询的数据类型
     * @param dbFallback 根据id查询数据的函数
     * @param timeout    有效期
     * @param unit       有效期的时间单位
     * @param <T>
     * @param <ID>
     * @return
     */
    public <T, ID> T handleCachePenetration(String keyPrefix, ID id, Class<T> type,
                                            Function<ID, T> dbFallback, Long timeout, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1、从Redis中查询店铺数据
        String jsonStr = stringRedisTemplate.opsForValue().get(key);

        T t = null;
        // 2、判断缓存是否命中
        if (StrUtil.isNotBlank(jsonStr)) {
            // 2.1 缓存命中,直接返回店铺数据
            t = JSONUtil.toBean(jsonStr, type);
            return t;
        }

        // 2.2 缓存未命中,判断缓存中查询的数据是否是空字符串(isNotBlank把null和空字符串给排除了)
        if (Objects.nonNull(jsonStr)) {
            // 2.2.1 当前数据是空字符串(说明该数据是之前缓存的空对象),直接返回失败信息
            return null;
        }
        // 2.2.2 当前数据是null,则从数据库中查询店铺数据
        t = dbFallback.apply(id);

        // 4、判断数据库是否存在店铺数据
        if (Objects.isNull(t)) {
            // 4.1 数据库中不存在,缓存空对象(解决缓存穿透),返回失败信息
            this.set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
            return null;
        }
        // 4.2 数据库中存在,重建缓存,并返回店铺数据
        this.set(key, t, timeout, unit);
        return t;
    }

    /**
     * 缓存重建线程池
     */
    public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 根据id查询数据(处理缓存击穿)
     *
     * @param keyPrefix  key前缀
     * @param id         查询id
     * @param type       查询的数据类型
     * @param dbFallback 根据id查询数据的函数
     * @param timeout    有效期
     * @param unit       有效期的时间单位
     * @param <T>
     * @param <ID>
     * @return
     */
    public <T, ID> T handleCacheBreakdown(String keyPrefix, ID id, Class<T> type,
                                          Function<ID, T> dbFallback, Long timeout, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1、从Redis中查询店铺数据,并判断缓存是否命中
        String jsonStr = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(jsonStr)) {
            // 1.1 缓存未命中,直接返回失败信息
            return null;
        }
        // 1.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
        RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);
        // 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
        JSONObject data = (JSONObject) redisData.getData();
        T t = JSONUtil.toBean(data, type);
        LocalDateTime expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 当前缓存数据未过期,直接返回
            return t;
        }

        // 2、缓存数据已过期,获取互斥锁,并且重建缓存
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        if (isLock) {
            // 获取锁成功,开启一个子线程去重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    T t1 = dbFallback.apply(id);
                    // 将查询到的数据保存到Redis
                    this.setWithLogicalExpire(key, t1, timeout, unit);
                } finally {
                    unlock(lockKey);
                }
            });
        }

        // 3、获取锁失败,再次查询缓存,判断缓存是否重建(这里双检是有必要的)
        jsonStr = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(jsonStr)) {
            // 3.1 缓存未命中,直接返回失败信息
            return null;
        }
        // 3.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
        redisData = JSONUtil.toBean(jsonStr, RedisData.class);
        // 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
        data = (JSONObject) redisData.getData();
        t = JSONUtil.toBean(data, type);
        expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 当前缓存数据未过期,直接返回
            return t;
        }

        // 4、返回过期数据
        return t;

    }

    /**
     * 获取锁
     *
     * @param key
     * @return
     */
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        // 拆箱要判空,防止NPE
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁
     *
     * @param key
     */
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}

为了解决数据一致性问题,我们可以选择适当的缓存更新策略:
     ~~~~     以缓存主动更新(双写方案+删除缓存模式+先操作数据库后操作缓存+事务)为主,超时剔除为辅
     ~~~~     查询时,先查询缓存,缓存命中直接返回,缓存未命中查询数据库并重建缓存,返回查询结果
     ~~~~     更新时,先修改数据删除缓存,使用事务保证缓存和数据操作两者的原子性
     ~~~~     除了会遇到数据一致性问题意外,我们还会遇到缓存穿透、缓存雪崩、缓存击穿等问题
     ~~~~     对于缓存穿透,我们采用了缓存空对象解决
     ~~~~     对于缓存击穿,我们分别演示了互斥锁(setnx实现方式)和逻辑过期两种方式解决
     ~~~~     最后我们通过抽取出一个工具类,并且利用泛型编写几个通用方法,形成最终的形式

相关文章:

  • 将Dify文档中的CSV数据提取并用ECharts可视化工具开发指南
  • 甲骨文找回二次验证的方法(超简单)
  • Java 集合遍历过程中修改数据触发 Fail-Fast 机制 ,导致报ConcurrentModificationException异常
  • 电脑实用小工具推荐--屏幕录制软件Bandicam(班迪录屏)
  • ECharts中Map(地图)样式配置、渐变色生成
  • C语言交换两数
  • Dijkstra算法
  • 【蓝桥】模拟
  • Day16:字符串的排列
  • eBPF 实时捕获键盘输入
  • Day2 导论 之 「存储器,IO,微机工作原理」
  • 【测试篇】打破测试认知壁垒,从基础概念起步
  • 零基础上手Python数据分析 (5):Python文件操作 - 轻松读写,数据导入导出不再是难题
  • 【SpringMVC】常用注解:@RequestHeader
  • sentinel限流算法
  • 《DeepSeek深度使用教程:开启智能交互新体验》Deepseek深度使用教程
  • 第五章 树、2叉树
  • 這是我第一次寫關於aapenal服務器管理控制面板的文章
  • “个人陈述“的“十要“和“十不要“
  • 1、操作系统引论
  • 特朗普称加总理将很快访美,白宫:不影响将加拿大打造成“第51个州”计划
  • 莫名的硝烟|“我们最好记住1931年9月18日这个日子”
  • 解密62个“千亿县”:强者恒强,新兴产业助新晋县崛起
  • 电话费被私改成48元套餐长达数年,投诉后移动公司退补600元话费
  • 大学男生被捉奸后将女生推下高楼?桂林理工大学辟谣
  • 暗蓝评《性别打结》丨拆解性别之结需要几步?