Redis进阶学习02---Redis替代Session和Redis缓存

2022-05-09 14:05:49 浏览数 (1)

Redis进阶学习02---Redis替代Session和Redis缓存

  • 基于Session登录流程
    • 集群session共享问题
      • 基于Redis实现session共享
      • 解决状态登录刷新问题
  • Redis缓存应用
    • 什么是缓存
    • 添加redis缓存
    • 缓存更新策略
      • 主动更新策略
        • 先操作缓存,还是先操作数据库
      • 总结
      • 案例
    • 缓存穿透
      • 缓存空对象解决缓存穿透
      • 缓存穿透小结
    • 缓存雪崩
    • 缓存击穿
      • 互斥锁和逻辑过期解决缓存击穿的思路
      • 互斥锁解决缓存击穿问题
        • 案例
      • 逻辑过期解决缓存击穿问题
  • 封装redis工具类

参考b站虎哥redis视频

本系列项目源码将会保存在gitee上面,仓库链接如下:

https://gitee.com/DaHuYuXiXi/redis-combat-project

基于Session登录流程

我们先来看一下基于Session实现登录的模板流程是什么样子的:

  • 发送短信验证码

核心逻辑:

代码语言:javascript复制
    public Result sendCode(String phone, HttpSession session) {
        //1.校验手机号
        if(RegexUtils.isPhoneInvalid(phone))
        {
            //2.如果不符合,返回错误信息
            return Result.fail(getErrMsg("01",UserServiceImpl.class));
        }
        //3.符合,生成验证码
        String code = RandomUtil.randomNumbers(6);
        //4.保存验证码到session
        session.setAttribute("code",code);
        //5.发送验证码
         log.debug("发送短信验证码成功,code {}",code);
         //6.返回ok
        return Result.ok();
    }
  • 短信验证码的登录和注册
代码语言:javascript复制
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果不符合,返回错误信息
            return Result.fail(getErrMsg("01", UserServiceImpl.class));
        }
        //3.校验验证码
        Object cacheCode = session.getAttribute("code");
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.toString().equals(code)) {
            //4.不一致报错
            return Result.fail(getErrMsg("02", UserServiceImpl.class));
        }
        //5.一致,根据手机号查询用户
        User user = query().eq("phone", phone).one();

        //6.判断用户是否存在
        if (user == null) {
            //7.不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }
        //8.保存用户信息到session中
        session.setAttribute("user", user);
        return Result.ok();
    }

    private User createUserWithPhone(String phone) {
        //1.创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX   RandomUtil.randomString(10));
        //2.保存用户
        save(user);
        return user;
    }
  • 校验登录状态

我们需要把验证功能放到拦截器中实现:

代码语言:javascript复制
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
      //1.获取session
        HttpSession session = request.getSession();
      //2.获取session中的用户
        Object user = session.getAttribute("user");
      //3.判断用户是否存在
        if(user==null)
        {
            //4.不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        //5.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(getUserDTO(user));
        //6.放行
        return true;
    }

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


    private UserDTO getUserDTO(Object user) {
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(user,userDTO);
        return userDTO;
    }
}

保存用户信息到ThreadLocal可以确保当前请求从开始到结束这段时间,我们可以轻松从ThreadLocal中获取当前用户信息,而不需要每次用到的时候,还去查询一遍

本节项目完整代码,参考2.0版本

集群session共享问题

既然多台tomcat之间的session存在隔离问题,那么我们是否可以将session中存储的内容移动到redis中进行存放,即用redis代替session

基于Redis实现session共享

这里说一下: 登录成功后,会将用户保存到redis中,这和上面讲用户保存到session中的思想是一致的,都是一种缓存思想,防止每次都需要拦截器拦截请求时,都需要去数据库查找,而是直接通过token去redis中获取即可

注意,这里的token不是jwt的token,这里的token只是随机生成的一段字符串,我们无法通过解析这个字符串拿到用户信息,而是只能通过这个token作为key,去redis中获取到对应用户的信息。

个人想法:即便是jwt的token,因为一般不会在里面token中保存完整的用户信息,并且每次请求打进拦截器的时候,还是需要去解析token,并去数据库查一下,防止token伪造,但是这样太浪费性能了,可以考虑在登录成功后,将用户信息存入redis,并且规定过期时间,然后拦截器每次根据token去redis获取用户完整信息,如果成功获取,那么刷新token过期时间,否则,从数据库重新获取,然后再放入缓存中。

我们这里选用HASH来存储User对象的信息:

UserServiceImpl代码:

代码语言:javascript复制
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    private final StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1.校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果不符合,返回错误信息
            return Result.fail(getErrMsg("01", UserServiceImpl.class));
        }
        //3.符合,生成验证码
        String code = RandomUtil.randomNumbers(6);
        //4.保存验证码到redis
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY phone,code,2, TimeUnit.MINUTES);
        //5.发送验证码
        log.debug("phone code {}", code);
        //6.返回ok
        return Result.ok();
    }

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果不符合,返回错误信息
            return Result.fail(getErrMsg("01", UserServiceImpl.class));
        }
        //3.从redis中获取验证码然后进行校验
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY phone);
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.toString().equals(code)) {
            //4.不一致报错
            return Result.fail(getErrMsg("02", UserServiceImpl.class));
        }
        //5.一致,根据手机号查询用户
        User user = query().eq("phone", phone).one();

        //6.判断用户是否存在
        if (user == null) {
            //7.不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }

        //7.保存用户信息到redis
        //7.1 随机生成token,作为登录令牌
        String token = generateToken();
        //7.2 将User对象转换为Hash对象
        Map map = beanToMap(BeanUtil.copyProperties(user, UserDTO.class));
        String key=LOGIN_USER_KEY token;
        stringRedisTemplate.opsForHash().putAll(key,map);
        //设置有效期
        stringRedisTemplate.expire(key,LOGIN_USER_TTL,TimeUnit.MINUTES);
        //7.3 存储
        return Result.ok();
    }

    private Map<String, Object> beanToMap(UserDTO user) {
        return BeanUtil.beanToMap(user, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true)
                //解决long转String报错的问题
                .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    }

    private String generateToken() {
        return UUID.randomUUID().toString(true);
    }


    private User createUserWithPhone(String phone) {
        //1.创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX   RandomUtil.randomString(10));
        //2.保存用户
        save(user);
        return user;
    }

}

LoginInterceptor 代码:

代码语言:javascript复制
public class LoginInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            //不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        token = LOGIN_USER_KEY   token;
        //2.基于Token获取redis中的用户
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
        //3.判断用户是否存在
        if (userMap.isEmpty()) {
            //不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        //5.map转换为userDto
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        //6.用户信息保存到threadLocal
        UserHolder.saveUser(userDTO);

        //7.刷新token的有效期
        stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }

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

解决状态登录刷新问题

上面的代码设计思路: 如果用户长时间都在请求不需要拦截的请求,那么token就不会被刷新,进而导致用户浏览浏览着,token就过期了

优化后:分离拦截器职责,用一个单独的拦截器拦截所有请求,每次都刷新token,另一个拦截器就负责需要登录的请求进行拦截即可

代码语言:javascript复制
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            //不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        token = LOGIN_USER_KEY   token;

        //2.基于Token获取redis中的用户
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
        //3.判断用户是否存在
        if (userMap.isEmpty()) {
            //不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        //5.map转换为userDto
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        //6.用户信息保存到threadLocal
        UserHolder.saveUser(userDTO);

        //7.刷新token的有效期
        stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }
       
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}
代码语言:javascript复制
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断是否需要去拦截
        if(UserHolder.getUser()==null)
        {
            response.setStatus(401);
            return false;
        }
        return true;
    }
}

RefreshTokenInterceptor 要先于LoginInterceptor 执行,否则LoginInterceptor 中无法中ThreadLocal中获取用户信息

代码语言:javascript复制
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
    private final StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                     "/shop/**",
                     "/voucher/**",
                     "/shop-type/**",
                       "/upload/**",
                       "/blog/hot",
                       "/user/code",
                       "/user/login"
                )
                //指定拦截器的执行顺序---数字越小,优先级越高
                .order(2);
        
      registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(1);
    }

}

还有一点需要注意:如果用户信息被修改了,需要清空redis中的缓存信息,让用户重新进行登录

本节项目完整代码,参考3.0版本

Redis缓存应用

什么是缓存


添加redis缓存

下面给出一个例子:

代码语言:javascript复制
@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Autowired
    private final 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(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
        }
        //6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
        return Result.ok(shop);
    }
}

缓存更新策略

主动更新策略

先操作缓存,还是先操作数据库

总结

案例

1.

代码语言:javascript复制
    @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(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
        }
        //6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }
代码语言:javascript复制
    @Override
    public Result update(Shop shop) {
        Long id = shop.getId();
        if(id==null)
        {
            return Result.fail(ErrorMsgHandler.getErrMsg("04",ShopServiceImpl.class));
        }
        //1.更新数据库
        updateById(shop);
        //2.删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY id);
        return Result.ok();
    }

缓存穿透

缓存空对象解决缓存穿透

代码语言:javascript复制
    @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&&shopJson.equals(NULL_OBJ_TAG))
        {
            return Result.fail(ErrorMsgHandler.getErrMsg("05",ShopServiceImpl.class));
        }
        //4.不存在,根据id查询数据库
        Shop shop = getById(id);
        //5.不存在,返回错误
        if(shop==null)
        {
            //将空值写入到redis
            stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
        }
        //6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }

缓存穿透小结

缓存雪崩

缓存击穿

互斥锁和逻辑过期解决缓存击穿的思路

互斥锁的实现其实很简单,既然热点key过期失效了,并且同时有很多个请求打进来,尝试重构缓存,那么就用一把锁,只让第一个请求去重构缓存,其余的请求线程就等待加重试,直到缓存重构成功

而对于逻辑过期的思路来讲,既然是因为热度key过期导致的缓存击穿,那我我就让这些热点key不会真的过期,而通过增加一个逻辑过期字段,每一次获取的时候,先去判断是否过期,如果过期了,就按照上图的流程执行

互斥锁可以实现一致性,但是牺牲了可用性。逻辑过期实现了可用性,但是牺牲了一致性。

一般是手动为热度key设置逻辑过期,然后等到热度过后,再删除这些热点key

互斥锁解决缓存击穿问题

为了防止出现死锁,我们还需要给锁设置一个过期时间,来确保锁一定会被释放掉

案例
代码语言:javascript复制
    @Override
    public Result queryById(Long id) {
        //缓存穿透逻辑
        //Shop shop=passThrough(id);
        //缓存击穿逻辑
        Shop shop = queryWithMutex(id);
        if(shop==null)
        {
            return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
        }
        return Result.ok(shop);
    }

    private Shop queryWithMutex(Long id) {
            Shop shop=null;
            String key=CACHE_SHOP_KEY id;
            //1.从redis中查询商铺缓存
            String shopJson=stringRedisTemplate.opsForValue().get(key);
            //2.判断是否存在
            if(StrUtil.isNotBlank(shopJson))
            {
                //3.存在,直接返回
                shop = JSONUtil.toBean(shopJson, Shop.class);
                return shop;
            }
            //判断命中的是否是空值---解决缓存雪崩
                 if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
            {
                return null;
            }
            //4.缓存重建---解决缓存击穿
            //4.1 获取互斥锁
            String lockKey=LOCK_SHOP_KEY id;
        try{
            //4.2判断是否获取成功
            if(!tryLock(lockKey)){
                //4.3失败,则休眠并重试
                Thread.sleep(50);
                //重试
                return queryWithMutex(id);
            }
            //4.3 成功,根据id查询数据库
            shop = getById(id);
            //5.不存在,返回错误
            if(shop==null)
            {
                //将空值写入到redis---解决缓存雪崩
                stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,CACHE_NULL_TTL,TimeUnit.MINUTES);
                return null;
            }
            //6.存在,写入redis
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
            return shop;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            unLock(key);
        }
        return shop;
    }
    /**
     * 尝试获取锁
     */
    private boolean tryLock(String key) {
        return BooleanUtil.isTrue(stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS));
    }

    /**
     * 解锁
     */
    private void unLock(String key)
    {
        stringRedisTemplate.delete(key);
    }

下载的源码包后,请删除mapper包下面的controller包,这是因为操作不当,导致存在两个完全相同的controller包


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

首先我们需要给热点key增加一个逻辑过期字段,比如: 某个shop对象作为热点key,难道就因为几个shop对象作为热点key,我们就要给shop类增加一个逻辑过期字段吗?—显然这是极其不合理的

按照重构的思想,我们需要弄出一种方案,可以让所有的需要作为热点key的对象,都重用一个逻辑过期字段,并且与业务对象是不耦合的,这里我给出一种解决方案:

代码语言:javascript复制
@Data
public class RedisData<T> {
    private LocalDateTime expireTime;
    //data封装任何想要作为热点key的对象
    private T data;
}

我们这里还需要写一个针对店铺信息进行逻辑过期保存的功能:

代码语言:javascript复制
    public void saveShopToRedis(Long id,Long expireSeconds){
        //1.查询店铺数据
        Shop shop = getById(id);
        //2.封装逻辑过期时间
        RedisData<Shop> shopRedisObj = new RedisData<>();
        shopRedisObj.setData(shop);
        shopRedisObj.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //3.写入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id,JSONUtil.toJsonStr(shopRedisObj));
    }

手动往redis中存入热点key:

代码语言:javascript复制
@SpringBootTest
class HmDianPingApplicationTests {
    @Autowired
    private ShopServiceImpl service;
    @Test
    public void testRedisLogicTTL(){
        service.saveShopToRedis(1L,10L);
    }
}

在对店铺查询逻辑进行修改,增加逻辑过期:

代码语言:javascript复制
@Override
    public Result queryById(Long id) {
        //缓存穿透逻辑
        //Shop shop=passThrough(id);
        //缓存击穿逻辑
        //Shop shop = queryWithMutex(id);
        Shop shop=queryWithLogicExpire(id);
        if(shop==null)
        {
            return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
        }
        return Result.ok(shop);
    }

    private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);

    private Shop queryWithLogicExpire(Long id) {
        String key=CACHE_SHOP_KEY id;
        //1.从redis查询商铺缓存
        String shopJson=stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在---不存在需要去数据库重新创建缓存,这里是针对热点Key处理,没有完善
        //这里还需要考虑缓存穿透的问题处理--得到的是否是缓存空对象
        if(StrUtil.isBlank(shopJson)) {
            //3.不存在,直接返回
            return null;
        }
        //4.命中,需要先把JSON反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            //5.1未过期,直接返回
            return shop;
        }
        //5.2过期了,需要缓存重建
        //6.缓存重建
        //6.1获取互斥锁
        String lockKey=LOCK_SHOP_KEY id;
        boolean lock = tryLock(lockKey);
        //6.2判断是否获取锁成功
        if(lock){
            //6.3成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
               //重建缓存
               this.saveShopToRedis(id,LOCK_SHOP_TTL);
               //释放锁
                unLock(lockKey);
            });
        }
        //6.4返回过期的店铺信息
        return shop;
    }
    
    public void saveShopToRedis(Long id,Long expireSeconds){
        //1.查询店铺数据
        Shop shop = getById(id);
        try {
            //200ms的延迟,模拟长时间数据库重建
            Thread.sleep(200L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //2.封装逻辑过期时间
        RedisData<Shop> shopRedisObj = new RedisData<>();
        shopRedisObj.setData(shop);
        shopRedisObj.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //3.写入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY id,JSONUtil.toJsonStr(shopRedisObj));
    }

下面我们启动项目,然后使用jemeter进行压力测试:

我们来测试一下,看是否只会触发一次数据库查询,并且缓存重建成功:

封装redis工具类

代码语言:javascript复制
package com.hmdp.cache;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.utilObj.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.temporal.TemporalUnit;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

/**
 * @author 大忽悠
 * @create 2022/4/27 13:35
 */
@Component
public class RedisCacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 默认线程池大小
     */
    private static final int DEFAULT_THREAD_SIZE=10;

    /**
     * 负责缓存重建工作的线程池
     */
    private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(DEFAULT_THREAD_SIZE);

    /**
     * 缓存空对象存入的标记
     */
    private static final String NULL_OBJ_TAG="nullObjSaveTag";

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

    /**
     * @param key redis中存入的key
     * @param value redis中存入的value
     * @param expireTime 过期时间
     * @param timeUnit 过期时间的单位
     */
    public void set(String key,Object value,Long expireTime,TimeUnit timeUnit){
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(value),expireTime,timeUnit);
    }

    /**
     * <p>
     *     这里的过期时间,是增加一个逻辑过期字段,传入的value会使用RedisData封装起来
     * </p>
     * @param key redis中存入的key
     * @param value redis中存入的value
     * @param expireTime 过期时间
     * @param timeUnit 过期时间的单位
     */
    public void setWithLogicalExpire(String key,Object value,Long expireTime,TimeUnit timeUnit){
        //设置逻辑过期
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime)));
        //写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
    }


    /**
     * <p>
     *     查询过程中会处理缓存穿透问题,解决redis和数据库都不存在的key的查询问题
     *     利用空对象缓存来解决这个问题
     * </p>
     * @param keyPrefix key的前缀
     * @param id 去数据库查询的具体对象id
     * @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
     * @param dataClass 目标对象的类型
     * @param expireTime 过期时间
     * @param timeUnit 过期时间单位
     * @param nullObjExpireTime 缓存的空对象的过期时间
     * @param nullObjTimeunit 缓存的空对象的过期时间单位
     * @param <ID> id的类型
     * @param <R> 返回值类型
     * @return 返回的是查询到的对象
     */
    public  <ID,R>  R  queryWithPassThrough(String keyPrefix, ID id, Function<ID,R> dbCallBack, Class<R> dataClass, Long expireTime, TimeUnit timeUnit,
                                            Long nullObjExpireTime,TimeUnit nullObjTimeunit){
        //0.缓存key
        String key=keyPrefix id;
        //1.从redis中查询商铺缓存
        String shopJson=stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isNotBlank(shopJson))
        {
            //3.存在,直接返回
            R bean = JSONUtil.toBean(shopJson, dataClass);
            return bean;
        }
        //判断命中的是否是空值
        if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
        {
            return null;
        }
        //4.不存在,根据id查询数据库
        R bean = dbCallBack.apply(id);
        //5.不存在,返回错误
        if(bean==null)
        {
            //将空值写入到redis
            stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,nullObjExpireTime,nullObjTimeunit);
            return null;
        }
        //6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(bean),expireTime, timeUnit);

        return bean;
    }

    /**
     * <p>
     *     查询过程中会处理缓存击穿问题,解决热点key的查询问题
     *     利用逻辑过期来解决这个问题
     * </p>
     * @param keyPrefix 缓存对象关联的key前缀
     * @param lockKeyPrefix  锁住当前对象重构过程的锁前缀
     * @param id 对象id
     * @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
     * @param dataClass 对象类型
     * @param expireTime 过期时间
     * @param timeUnit 过期时间单位
     * @param <ID> id的类型
     * @param <R> 返回值类型
     * @return 返回的是查询到的对象
     */
    public <ID,R> R queryWithLogicExpire(String keyPrefix,String lockKeyPrefix, ID id, Function<ID,R> dbCallBack, Class<R> dataClass
            ,Long expireTime, TemporalUnit timeUnit) {
        String key=keyPrefix id;
        //1.从redis查询商铺缓存
        String shopJson=stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isBlank(shopJson)) {
            //3.不存在,直接返回
            return null;
        }
        //4.命中,需要先把JSON反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        R bean = JSONUtil.toBean((JSONObject) redisData.getData(), dataClass);
        LocalDateTime getExpireTime = redisData.getExpireTime();
        //5.判断是否过期
        if(getExpireTime.isAfter(LocalDateTime.now())) {
            //5.1未过期,直接返回
            return bean;
        }
        //5.2过期了,需要缓存重建
        //6.缓存重建
        //6.1获取互斥锁
        String lockKey=lockKeyPrefix id;
        boolean lock = tryLock(lockKey);
        //6.2判断是否获取锁成功
        if(lock){
            //6.3成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                //重建缓存
                this.saveHotBeanToRedisWithLogicTag(keyPrefix,id,dbCallBack,expireTime,timeUnit);
                //释放锁
                unLock(lockKey);
            });
        }
        //6.4返回过期的店铺信息
        return bean;
    }


    /**
     * <P>
     *     利用互斥锁解决缓存击穿问题
     * </P>
     * @param keyPrefix key的前缀
     * @param id 去数据库查询的具体对象id
     * @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
     * @param dataClass 目标对象的类型
     * @param expireTime 过期时间
     * @param timeUnit 过期时间单位
     * @param nullObjExpireTime 缓存的空对象的过期时间
     * @param nullObjTimeunit 缓存的空对象的过期时间单位
     * @param <ID> id的类型
     * @param <R> 返回值类型
     * @return 返回的是查询到的对象
     */
    public  <ID,R>  R queryWithMutex(String keyPrefix,String lockKeyPrefix,ID id,Class<R> dataClass,Function<ID,R> dbCallBack,Long expireTime,
                                     TimeUnit timeUnit,Long nullObjExpireTime,TimeUnit nullObjTimeunit) {
        R bean=null;
        String key=keyPrefix id;
        //1.从redis中查询商铺缓存
        String shopJson=stringRedisTemplate.opsForValue().get(key);
        //2.判断是否存在
        if(StrUtil.isNotBlank(shopJson))
        {
            //3.存在,直接返回
            bean = JSONUtil.toBean(shopJson, dataClass);
            return bean;
        }
        //判断命中的是否是空值---解决缓存雪崩
        if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
        {
            return null;
        }
        //4.缓存重建---解决缓存击穿
        //4.1 获取互斥锁
        String lockKey=lockKeyPrefix id;
        try{
            //4.2判断是否获取成功
            if(!tryLock(lockKey)){
                //4.3失败,则休眠并重试
                Thread.sleep(50);
                //重试
                return queryWithMutex(keyPrefix,lockKeyPrefix,id,dataClass,dbCallBack,expireTime,timeUnit,nullObjExpireTime,nullObjTimeunit);
            }
            //4.3 成功,根据id查询数据库
            bean = dbCallBack.apply(id);
            //5.不存在,返回错误
            if(bean==null)
            {
                //将空值写入到redis---解决缓存雪崩
                stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,nullObjExpireTime,nullObjTimeunit);
                return null;
            }
            //6.存在,写入redis
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(bean),expireTime,timeUnit);
            return bean;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            unLock(key);
        }
        return bean;
    }

    /**
     * <p>
     *     可以通过该方法,设置一个热点key进redis
     * </p>
     * @param keyPrefix 缓存对象关联的key前缀
     * @param id 对象id
     * @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
     * @param expireTime 过期时间
     * @param timeUnit 过期时间单位
     * @param <ID> id的类型
     * @param <R> 返回值类型
     * @return 返回的是查询到的对象
     */
    public <ID,R> void saveHotBeanToRedisWithLogicTag(String keyPrefix,ID id, Function<ID,R> dbCallBack, Long expireTime, TemporalUnit timeUnit){
        String key=keyPrefix id;
        //1.查询店铺数据
        R bean = dbCallBack.apply(id);
        //2.封装逻辑过期时间
        RedisData redisData = new RedisData<>();
        redisData.setData(bean);
        redisData.setExpireTime(LocalDateTime.now().plus(expireTime,timeUnit));
        //3.写入redis
        stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
    }

    /**
     * 尝试获取锁
     */
    private boolean tryLock(String key) {
        return BooleanUtil.isTrue(stringRedisTemplate.opsForValue().setIfAbsent(key,"locked",10,TimeUnit.SECONDS));
    }

    /**
     * 解锁
     */
    private void unLock(String key)
    {
        stringRedisTemplate.delete(key);
    }
}

0 人点赞