Redis02-分布式session、缓存查询及缓存问题的解决

时间:2022-10-05 21:52:03

一、短信登录及分布式session

Redis02-分布式session、缓存查询及缓存问题的解决
验证码缓存

 @Override
    public Result sendCode(String phone, HttpSession session) {
        //1.校验手机号
        boolean invalid = RegexUtils.isPhoneInvalid(phone);

        if(invalid){
            return Result.fail("手机号格式不正确");
        }

        //2.生成验证码
        String code = RandomUtil.randomNumbers(6);

        //3.保存验证码到session或redis
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY +phone,code,2, TimeUnit.MINUTES);
        //发送验证码
        SMSUtils.sendCode(phome,code);
        return Result.ok(new LoginFormDTO(phone,code));
    }

登录/注册

 @Override
    public Result loginService(LoginFormDTO loginForm,HttpSession session) {
        // TODO 实现登录功能
        //1.验证手机号
        if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
            return Result.fail("手机号格式不正确");
        }

        //2.校验验证码
//        Object cacheCode = session.getAttribute("code");
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+loginForm.getPhone());
        if(cacheCode == null || !cacheCode.toString().equals(loginForm.getCode())){
            return Result.fail("验证码错误");
        }

        //3.查询该用户是否存在
        User user = query().eq("phone", loginForm.getPhone()).one();

        //4.若不存在:创建用户并添加到数据库
        if(user == null){
            user= createUserWithPhone(loginForm.getPhone());
        }

        //5.保存数据
        //5.1 将user属性拷贝到userDto中
        UserDTO dto = BeanUtil.copyProperties(user,UserDTO.class);

        //5.2生成token作为登陆令牌
        String token = UUID.randomUUID().toString();
        //5.3将数据保存到redis中
        //由于使用stringRedisTemplate,所以map的key和val都要转成string类型才能被正确序列化和反序列化
        Map<String, Object> map = BeanUtil.beanToMap(dto, new HashMap<>(),
                CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((key, val) -> val.toString()));
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
        //5.3设置token有效期
        stringRedisTemplate.expire(LOGIN_USER_KEY+token,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
        return Result.ok(token);
    }

二、登录拦截器以及刷新

Redis02-分布式session、缓存查询及缓存问题的解决

我们定义两个拦截器,一是FlushTokenInterceptor,二是loginInterceptor,作用如下:

  1. FlushTokenInterceptor:根据token从redis中获取用户信息并将它封装成User对象放入ThreadLocal中,同时刷新过期时间
  2. loginInterceptor:对需要登录的路径进行拦截,从ThreadLocal中获取User对象,若获取不到说明没有登录
  3. FlushToken拦截器的优先级高于login拦截器

MvcConfig

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
                "/user/code",
                "/user/login",
                "/blog/hot",
                "/shop/**",
                "/voucher/**",
                "/shop-type/**").order(1);
        registry.addInterceptor(new FlushTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

FlushTokenInterceptor

public class FlushTokenInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.获取token
//        HttpSession session = request.getSession();
        String token = request.getHeader("authorization");
        if(StrUtil.isBlank(token)){
            return true;
        }
        //2.基于token取出user信息
//        UserDTO user = (UserDTO) session.getAttribute("user");
        Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
        //3.判单用户是否存在
        if(entries.isEmpty()){
            return true;
        }
        //4。保存用户信息到threadLocal
        UserDTO userDTO = BeanUtil.fillBeanWithMap(entries,new UserDTO(),false);
        UserHolder.saveUser(userDTO);


        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,30, TimeUnit.MINUTES);



        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

LoginInterceptor

public class LoginInterceptor implements HandlerInterceptor {



    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserDTO user = UserHolder.getUser();
        if(user == null){
            response.setStatus(401);
            return false;
        }


        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

三、商户查询缓存

缓存

引入缓存的目的是提高读数据的性能,降低响应时间。

但不代表任何数据我们都可以通过添加缓存来提高性能,对于那些经常修改的数据,若是添加缓存反而会降低性能。再者,缓存一般占用的是内存,滥用缓存也会对内存造成浪费。
可以考虑添加缓存的数据:读多写少的数据、热点数据

缓存带来的数据不一致问题

该问题主要是由于更新缓存策略不当造成的
缓存更新策略:先修改数据库,再删除缓存
这样能够最大限度的保证数据一致性,但仍然存在数据不一致的问题,如下:
Redis02-分布式session、缓存查询及缓存问题的解决
造成数据不一致是由于读key时key过期了,并且读数据库的操作先于修改数据库的操作、写缓存的操作晚于删除缓存的操作,这是几乎不可能的发生的,理由如下:
在多线程并行下,更新数据库的时间都要远远大于写入缓存的时间,所以写入缓存的执行时机应该先于更新数据库。

缓存穿透

当用户频繁访问数据库中不存在的数据时,请求直接穿透缓存直接到达数据库,增加了数据库的压力

解决方法:1. 对数据库中不存在的数据缓存null 2. 布隆过滤器
注意:缓存空值设置的过期时间尽量短,当数据库新增数据时可能会造成数据不一致,所以尽量缩短数据不一致的时间

缓存雪崩

大量缓存在同一时间内过期,导致大量请求到达数据库

解决方法:1. 给不同key的TTL加上一个随机值 2. 给缓存业务添加降级限流

缓存击穿

热点数据过期导致大量请求到达数据库

解决方法:1. 添加互斥锁 2. 设置逻辑过期时间
Redis02-分布式session、缓存查询及缓存问题的解决
Redis02-分布式session、缓存查询及缓存问题的解决
由于逻辑过期策略不会因为结果为null而去查询数据库,只会在缓存过期时才去查询数据库,因此需要注意以下事项
逻辑过期注意事项:

  1. 逻辑过期的数据需要提前预热,建立逻辑过期缓存
  2. 查询该业务的数据时先使用逻辑过期策略,若返回null再使用其它策略
  3. 修改数据时不用删除逻辑过期的key
  4. 删除数据时需要删除逻辑过期的key和不同缓存的key

缓存查询工具类

该类封装了不同的方法去做缓存的查询以及添加,目的是为了解决不同的缓存问题

@Component
public class CacheClient {
    @Autowired
    private  StringRedisTemplate stringRedisTemplate;

    private ExecutorService CACHE_REBUILD_POOL = Executors.newFixedThreadPool(10);
    /**
     * 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
     */
    public <T> void setKeyWithExpire(String key, T data, long time, TimeUnit unit){
        if(data.getClass() != Integer.class && data.getClass() != Long.class && data.getClass() != Double.class && data.getClass() != String.class ) {
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data), time, unit);
        }
        stringRedisTemplate.opsForValue().set(key, data.toString(), time, unit);

    }

    /**
     * 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
     */
    public <T> void setKeyWithLogicExpire(String key,T data, long time){
        //1.构建redisData并设置逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(data);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(time));

        //2.将redisData添加到redis中
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
    }


    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
     */
    public <T,ID> T queryDataThroughPass(String keyPrefix, ID id, Class<T> clazz, Function<ID,T> dbCallback,long time,TimeUnit unit){
        //1.通过key从redis中获取json
        String json = stringRedisTemplate.opsForValue().get(keyPrefix+id);
        //2.若json不为null和空白字符串则代表redis命中,返回结果
        if(StrUtil.isNotBlank(json)){
            return JSONUtil.toBean(json,clazz);
        }

        //3.若redis没命中则判断未命中情况
        if(json != null){ //3.1 redis中存在该key的空白字符串,说明是缓存穿透的key,返回null
            return null;
        }else { //3.2 redis中没有该key,需要去数据库中查找数据
            T data = dbCallback.apply(id);
            // 1.存在该数据,缓存到redis
            if(data != null){
                setKeyWithExpire(keyPrefix + id,JSONUtil.toJsonStr(data), time,unit);
            }else {
                //2.没有该数据,对该key设为""
               setKeyWithExpire(keyPrefix+id,"",time,unit);
            }
            return data;
        }
    }

    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,利用互斥锁的方式解决缓存击穿问题
     **/
    public <T,ID> T queryDataByMutex(String keyPrefix, ID id, Class<T> clazz, Function<ID,T> dbCallback,long time,TimeUnit unit){
        //1.通过key获取json
        String json = stringRedisTemplate.opsForValue().get(keyPrefix + id);
        //2.判断redis是否命中,命中直接返回
        if(StrUtil.isNotBlank(json)){

            return JSONUtil.toBean(json,clazz);
        }
        //3.若没命中
         if(json != null) {//3.1 查看redis是否对该key缓存空值,是则返回null
             return null;
         }

         T data = null;
         //3.2 若不是则重建缓存
        try {
            boolean lock = tryLock(id);
            if (!lock) {
                Thread.sleep(50);
                return queryDataByMutex(keyPrefix, id, clazz, dbCallback, time, unit);
            }
            data = dbCallback.apply(id);
            if (data != null) {  //1. 从数据库中查询数据,若存在则添加到redis中
                setKeyWithExpire(keyPrefix + id, data, time, unit);
            } else {
                //2. 若不存在则对该key缓存空值
                setKeyWithExpire(keyPrefix + id, "", time, unit);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();

        }finally {
            unlock(id);
        }
        return data;
    }

    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
     */
    public <T,ID> T queryDataByLogicExpire(String keyPrefix, ID id, Class<T> clazz, Function<ID,T> dbCallback,long time){
        //1.从redis中获取json
        String json = stringRedisTemplate.opsForValue().get(keyPrefix + id);
        //2.若该json不存在则说明没有对热点信息预热,直接返回null
        if(StrUtil.isBlank(json)){
            return null;
        }
        //3.将json转化成redisData,查看是否过期
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        T data = JSONUtil.toBean((JSONObject) redisData.getData(), clazz);
        // 3.1 若没有过期则直接返回
        if(redisData.getExpireTime().isAfter(LocalDateTime.now())){
            return data;
        }else { // 3.2 若过期则重建缓存
            //1.获取互斥锁
            boolean lock = tryLock(id);
            if(!lock){ //若不成功则直接返回旧数据
                return data;
            }

            //2.若成功则异步重建缓存
            CACHE_REBUILD_POOL.submit(()->{
                T res = dbCallback.apply(id);
                setKeyWithLogicExpire(keyPrefix+id,res,time);
                unlock(id);
            });
            return data;
        }
    }

    private <T> boolean tryLock(T id){
        String key = RedisConstants.LOCK_SHOP_KEY + id;
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    private <T> void unlock(T id){
        String key = RedisConstants.LOCK_SHOP_KEY + id;
        stringRedisTemplate.delete(key);
    }
}

业务流程

Redis02-分布式session、缓存查询及缓存问题的解决

public Shop findShopThroughRedis(long id){
        String key = RedisConstants.CACHE_SHOP + id;
        //1.查询redis中是否有该商铺
        String json = stringRedisTemplate.opsForValue().get(key);

        //2.如果有则返回
        if(!StrUtil.isBlank(json)){
            return JSONUtil.toBean(json,Shop.class);
        }

        //3.若不存在则去数据库查询该店铺
        Shop shop = this.getById(id);
        //3.1 若该店铺不存在则对该id保存null,防止缓存穿透
        if(shop == null){
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            return null;
        }

        //4.将查询到的shop保存到redis中
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL*60 + RandomUtil.randomInt(-30,30), TimeUnit.SECONDS);
        return shop;
    }