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

x33g5p2x  于2022-04-28 转载在 Redis  
字(21.5k)|赞(0)|评价(0)|浏览(713)

参考b站虎哥redis视频

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

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

基于Session登录流程

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

  • 发送短信验证码

核心逻辑:

  1. public Result sendCode(String phone, HttpSession session) {
  2. //1.校验手机号
  3. if(RegexUtils.isPhoneInvalid(phone))
  4. {
  5. //2.如果不符合,返回错误信息
  6. return Result.fail(getErrMsg("01",UserServiceImpl.class));
  7. }
  8. //3.符合,生成验证码
  9. String code = RandomUtil.randomNumbers(6);
  10. //4.保存验证码到session
  11. session.setAttribute("code",code);
  12. //5.发送验证码
  13. log.debug("发送短信验证码成功,code {}",code);
  14. //6.返回ok
  15. return Result.ok();
  16. }
  • 短信验证码的登录和注册

  1. @Override
  2. public Result login(LoginFormDTO loginForm, HttpSession session) {
  3. //1.校验手机号
  4. String phone = loginForm.getPhone();
  5. if (RegexUtils.isPhoneInvalid(phone)) {
  6. //2.如果不符合,返回错误信息
  7. return Result.fail(getErrMsg("01", UserServiceImpl.class));
  8. }
  9. //3.校验验证码
  10. Object cacheCode = session.getAttribute("code");
  11. String code = loginForm.getCode();
  12. if (cacheCode == null || !cacheCode.toString().equals(code)) {
  13. //4.不一致报错
  14. return Result.fail(getErrMsg("02", UserServiceImpl.class));
  15. }
  16. //5.一致,根据手机号查询用户
  17. User user = query().eq("phone", phone).one();
  18. //6.判断用户是否存在
  19. if (user == null) {
  20. //7.不存在,创建新用户并保存
  21. user = createUserWithPhone(phone);
  22. }
  23. //8.保存用户信息到session中
  24. session.setAttribute("user", user);
  25. return Result.ok();
  26. }
  27. private User createUserWithPhone(String phone) {
  28. //1.创建用户
  29. User user = new User();
  30. user.setPhone(phone);
  31. user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
  32. //2.保存用户
  33. save(user);
  34. return user;
  35. }
  • 校验登录状态

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

  1. public class LoginInterceptor implements HandlerInterceptor {
  2. @Override
  3. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  4. //1.获取session
  5. HttpSession session = request.getSession();
  6. //2.获取session中的用户
  7. Object user = session.getAttribute("user");
  8. //3.判断用户是否存在
  9. if(user==null)
  10. {
  11. //4.不存在,拦截,返回401状态码
  12. response.setStatus(401);
  13. return false;
  14. }
  15. //5.存在,保存用户信息到ThreadLocal
  16. UserHolder.saveUser(getUserDTO(user));
  17. //6.放行
  18. return true;
  19. }
  20. @Override
  21. public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
  22. //清空ThreadLocal
  23. UserHolder.removeUser();
  24. }
  25. private UserDTO getUserDTO(Object user) {
  26. UserDTO userDTO = new UserDTO();
  27. BeanUtils.copyProperties(user,userDTO);
  28. return userDTO;
  29. }
  30. }

保存用户信息到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代码:

  1. @Service
  2. @Slf4j
  3. @RequiredArgsConstructor
  4. public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
  5. private final StringRedisTemplate stringRedisTemplate;
  6. @Override
  7. public Result sendCode(String phone, HttpSession session) {
  8. //1.校验手机号
  9. if (RegexUtils.isPhoneInvalid(phone)) {
  10. //2.如果不符合,返回错误信息
  11. return Result.fail(getErrMsg("01", UserServiceImpl.class));
  12. }
  13. //3.符合,生成验证码
  14. String code = RandomUtil.randomNumbers(6);
  15. //4.保存验证码到redis
  16. stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,2, TimeUnit.MINUTES);
  17. //5.发送验证码
  18. log.debug("phone code {}", code);
  19. //6.返回ok
  20. return Result.ok();
  21. }
  22. @Override
  23. public Result login(LoginFormDTO loginForm, HttpSession session) {
  24. //1.校验手机号
  25. String phone = loginForm.getPhone();
  26. if (RegexUtils.isPhoneInvalid(phone)) {
  27. //2.如果不符合,返回错误信息
  28. return Result.fail(getErrMsg("01", UserServiceImpl.class));
  29. }
  30. //3.从redis中获取验证码然后进行校验
  31. String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
  32. String code = loginForm.getCode();
  33. if (cacheCode == null || !cacheCode.toString().equals(code)) {
  34. //4.不一致报错
  35. return Result.fail(getErrMsg("02", UserServiceImpl.class));
  36. }
  37. //5.一致,根据手机号查询用户
  38. User user = query().eq("phone", phone).one();
  39. //6.判断用户是否存在
  40. if (user == null) {
  41. //7.不存在,创建新用户并保存
  42. user = createUserWithPhone(phone);
  43. }
  44. //7.保存用户信息到redis
  45. //7.1 随机生成token,作为登录令牌
  46. String token = generateToken();
  47. //7.2 将User对象转换为Hash对象
  48. Map map = beanToMap(BeanUtil.copyProperties(user, UserDTO.class));
  49. String key=LOGIN_USER_KEY+token;
  50. stringRedisTemplate.opsForHash().putAll(key,map);
  51. //设置有效期
  52. stringRedisTemplate.expire(key,LOGIN_USER_TTL,TimeUnit.MINUTES);
  53. //7.3 存储
  54. return Result.ok();
  55. }
  56. private Map<String, Object> beanToMap(UserDTO user) {
  57. return BeanUtil.beanToMap(user, new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true)
  58. //解决long转String报错的问题
  59. .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
  60. }
  61. private String generateToken() {
  62. return UUID.randomUUID().toString(true);
  63. }
  64. private User createUserWithPhone(String phone) {
  65. //1.创建用户
  66. User user = new User();
  67. user.setPhone(phone);
  68. user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
  69. //2.保存用户
  70. save(user);
  71. return user;
  72. }
  73. }

LoginInterceptor 代码:

  1. public class LoginInterceptor implements HandlerInterceptor {
  2. private StringRedisTemplate stringRedisTemplate;
  3. public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
  4. this.stringRedisTemplate = stringRedisTemplate;
  5. }
  6. @Override
  7. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  8. //1.获取请求头中的token
  9. String token = request.getHeader("authorization");
  10. if (StrUtil.isBlank(token)) {
  11. //不存在,拦截,返回401状态码
  12. response.setStatus(401);
  13. return false;
  14. }
  15. token = LOGIN_USER_KEY + token;
  16. //2.基于Token获取redis中的用户
  17. Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
  18. //3.判断用户是否存在
  19. if (userMap.isEmpty()) {
  20. //不存在,拦截,返回401状态码
  21. response.setStatus(401);
  22. return false;
  23. }
  24. //5.map转换为userDto
  25. UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
  26. //6.用户信息保存到threadLocal
  27. UserHolder.saveUser(userDTO);
  28. //7.刷新token的有效期
  29. stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
  30. return true;
  31. }
  32. @Override
  33. public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
  34. UserHolder.removeUser();
  35. }
  36. }

解决状态登录刷新问题

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

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

  1. public class RefreshTokenInterceptor implements HandlerInterceptor {
  2. private StringRedisTemplate stringRedisTemplate;
  3. public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
  4. this.stringRedisTemplate = stringRedisTemplate;
  5. }
  6. @Override
  7. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  8. //1.获取请求头中的token
  9. String token = request.getHeader("authorization");
  10. if (StrUtil.isBlank(token)) {
  11. //不存在,拦截,返回401状态码
  12. response.setStatus(401);
  13. return false;
  14. }
  15. token = LOGIN_USER_KEY + token;
  16. //2.基于Token获取redis中的用户
  17. Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(token);
  18. //3.判断用户是否存在
  19. if (userMap.isEmpty()) {
  20. //不存在,拦截,返回401状态码
  21. response.setStatus(401);
  22. return false;
  23. }
  24. //5.map转换为userDto
  25. UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
  26. //6.用户信息保存到threadLocal
  27. UserHolder.saveUser(userDTO);
  28. //7.刷新token的有效期
  29. stringRedisTemplate.expire(token, LOGIN_USER_TTL, TimeUnit.MINUTES);
  30. return true;
  31. }
  32. @Override
  33. public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
  34. UserHolder.removeUser();
  35. }
  36. }
  1. public class LoginInterceptor implements HandlerInterceptor {
  2. @Override
  3. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  4. //判断是否需要去拦截
  5. if(UserHolder.getUser()==null)
  6. {
  7. response.setStatus(401);
  8. return false;
  9. }
  10. return true;
  11. }
  12. }

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

  1. @Configuration
  2. @RequiredArgsConstructor
  3. public class WebMvcConfig implements WebMvcConfigurer {
  4. private final StringRedisTemplate stringRedisTemplate;
  5. @Override
  6. public void addInterceptors(InterceptorRegistry registry) {
  7. registry.addInterceptor(new LoginInterceptor())
  8. .excludePathPatterns(
  9. "/shop/**",
  10. "/voucher/**",
  11. "/shop-type/**",
  12. "/upload/**",
  13. "/blog/hot",
  14. "/user/code",
  15. "/user/login"
  16. )
  17. //指定拦截器的执行顺序---数字越小,优先级越高
  18. .order(2);
  19. registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(1);
  20. }
  21. }

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

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

Redis缓存应用

什么是缓存

添加redis缓存

下面给出一个例子:

  1. @Service
  2. @RequiredArgsConstructor
  3. public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
  4. @Autowired
  5. private final StringRedisTemplate stringRedisTemplate;
  6. @Override
  7. public Result queryById(Long id) {
  8. String key=CACHE_SHOP_KEY+id;
  9. //1.从redis中查询商铺缓存
  10. String shopJson=stringRedisTemplate.opsForValue().get(key);
  11. //2.判断是否存在
  12. if(StrUtil.isNotBlank(shopJson))
  13. {
  14. //3.存在,直接返回
  15. Shop shop = JSONUtil.toBean(shopJson, Shop.class);
  16. return Result.ok(shop);
  17. }
  18. //4.不存在,根据id查询数据库
  19. Shop shop = getById(id);
  20. //5.不存在,返回错误
  21. if(shop==null)
  22. {
  23. return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
  24. }
  25. //6.存在,写入redis
  26. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
  27. return Result.ok(shop);
  28. }
  29. }

缓存更新策略

主动更新策略

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

总结

案例

  1. @Override
  2. public Result queryById(Long id) {
  3. String key=CACHE_SHOP_KEY+id;
  4. //1.从redis中查询商铺缓存
  5. String shopJson=stringRedisTemplate.opsForValue().get(key);
  6. //2.判断是否存在
  7. if(StrUtil.isNotBlank(shopJson))
  8. {
  9. //3.存在,直接返回
  10. Shop shop = JSONUtil.toBean(shopJson, Shop.class);
  11. return Result.ok(shop);
  12. }
  13. //4.不存在,根据id查询数据库
  14. Shop shop = getById(id);
  15. //5.不存在,返回错误
  16. if(shop==null)
  17. {
  18. return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
  19. }
  20. //6.存在,写入redis
  21. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
  22. return Result.ok(shop);
  23. }
  1. @Override
  2. public Result update(Shop shop) {
  3. Long id = shop.getId();
  4. if(id==null)
  5. {
  6. return Result.fail(ErrorMsgHandler.getErrMsg("04",ShopServiceImpl.class));
  7. }
  8. //1.更新数据库
  9. updateById(shop);
  10. //2.删除缓存
  11. stringRedisTemplate.delete(CACHE_SHOP_KEY+id);
  12. return Result.ok();
  13. }

缓存穿透

缓存空对象解决缓存穿透

  1. @Override
  2. public Result queryById(Long id) {
  3. String key=CACHE_SHOP_KEY+id;
  4. //1.从redis中查询商铺缓存
  5. String shopJson=stringRedisTemplate.opsForValue().get(key);
  6. //2.判断是否存在
  7. if(StrUtil.isNotBlank(shopJson))
  8. {
  9. //3.存在,直接返回
  10. Shop shop = JSONUtil.toBean(shopJson, Shop.class);
  11. return Result.ok(shop);
  12. }
  13. //判断命中的是否是空值
  14. if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
  15. {
  16. return Result.fail(ErrorMsgHandler.getErrMsg("05",ShopServiceImpl.class));
  17. }
  18. //4.不存在,根据id查询数据库
  19. Shop shop = getById(id);
  20. //5.不存在,返回错误
  21. if(shop==null)
  22. {
  23. //将空值写入到redis
  24. stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,CACHE_NULL_TTL,TimeUnit.MINUTES);
  25. return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
  26. }
  27. //6.存在,写入redis
  28. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
  29. return Result.ok(shop);
  30. }

缓存穿透小结

缓存雪崩

缓存击穿

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

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

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

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

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

互斥锁解决缓存击穿问题

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

案例

  1. @Override
  2. public Result queryById(Long id) {
  3. //缓存穿透逻辑
  4. //Shop shop=passThrough(id);
  5. //缓存击穿逻辑
  6. Shop shop = queryWithMutex(id);
  7. if(shop==null)
  8. {
  9. return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
  10. }
  11. return Result.ok(shop);
  12. }
  13. private Shop queryWithMutex(Long id) {
  14. Shop shop=null;
  15. String key=CACHE_SHOP_KEY+id;
  16. //1.从redis中查询商铺缓存
  17. String shopJson=stringRedisTemplate.opsForValue().get(key);
  18. //2.判断是否存在
  19. if(StrUtil.isNotBlank(shopJson))
  20. {
  21. //3.存在,直接返回
  22. shop = JSONUtil.toBean(shopJson, Shop.class);
  23. return shop;
  24. }
  25. //判断命中的是否是空值---解决缓存雪崩
  26. if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
  27. {
  28. return null;
  29. }
  30. //4.缓存重建---解决缓存击穿
  31. //4.1 获取互斥锁
  32. String lockKey=LOCK_SHOP_KEY+id;
  33. try{
  34. //4.2判断是否获取成功
  35. if(!tryLock(lockKey)){
  36. //4.3失败,则休眠并重试
  37. Thread.sleep(50);
  38. //重试
  39. return queryWithMutex(id);
  40. }
  41. //4.3 成功,根据id查询数据库
  42. shop = getById(id);
  43. //5.不存在,返回错误
  44. if(shop==null)
  45. {
  46. //将空值写入到redis---解决缓存雪崩
  47. stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,CACHE_NULL_TTL,TimeUnit.MINUTES);
  48. return null;
  49. }
  50. //6.存在,写入redis
  51. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
  52. return shop;
  53. } catch (InterruptedException e) {
  54. e.printStackTrace();
  55. }finally {
  56. unLock(key);
  57. }
  58. return shop;
  59. }
  60. /**
  61. * 尝试获取锁
  62. */
  63. private boolean tryLock(String key) {
  64. return BooleanUtil.isTrue(stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS));
  65. }
  66. /**
  67. * 解锁
  68. */
  69. private void unLock(String key)
  70. {
  71. stringRedisTemplate.delete(key);
  72. }

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

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

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

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

  1. @Data
  2. public class RedisData<T> {
  3. private LocalDateTime expireTime;
  4. //data封装任何想要作为热点key的对象
  5. private T data;
  6. }

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

  1. public void saveShopToRedis(Long id,Long expireSeconds){
  2. //1.查询店铺数据
  3. Shop shop = getById(id);
  4. //2.封装逻辑过期时间
  5. RedisData<Shop> shopRedisObj = new RedisData<>();
  6. shopRedisObj.setData(shop);
  7. shopRedisObj.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
  8. //3.写入redis
  9. stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shopRedisObj));
  10. }

手动往redis中存入热点key:

  1. @SpringBootTest
  2. class HmDianPingApplicationTests {
  3. @Autowired
  4. private ShopServiceImpl service;
  5. @Test
  6. public void testRedisLogicTTL(){
  7. service.saveShopToRedis(1L,10L);
  8. }
  9. }

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

  1. @Override
  2. public Result queryById(Long id) {
  3. //缓存穿透逻辑
  4. //Shop shop=passThrough(id);
  5. //缓存击穿逻辑
  6. //Shop shop = queryWithMutex(id);
  7. Shop shop=queryWithLogicExpire(id);
  8. if(shop==null)
  9. {
  10. return Result.fail(ErrorMsgHandler.getErrMsg("03",ShopServiceImpl.class));
  11. }
  12. return Result.ok(shop);
  13. }
  14. private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
  15. private Shop queryWithLogicExpire(Long id) {
  16. String key=CACHE_SHOP_KEY+id;
  17. //1.从redis查询商铺缓存
  18. String shopJson=stringRedisTemplate.opsForValue().get(key);
  19. //2.判断是否存在---不存在需要去数据库重新创建缓存,这里是针对热点Key处理,没有完善
  20. //这里还需要考虑缓存穿透的问题处理--得到的是否是缓存空对象
  21. if(StrUtil.isBlank(shopJson)) {
  22. //3.不存在,直接返回
  23. return null;
  24. }
  25. //4.命中,需要先把JSON反序列化为对象
  26. RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
  27. Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
  28. LocalDateTime expireTime = redisData.getExpireTime();
  29. //5.判断是否过期
  30. if(expireTime.isAfter(LocalDateTime.now())) {
  31. //5.1未过期,直接返回
  32. return shop;
  33. }
  34. //5.2过期了,需要缓存重建
  35. //6.缓存重建
  36. //6.1获取互斥锁
  37. String lockKey=LOCK_SHOP_KEY+id;
  38. boolean lock = tryLock(lockKey);
  39. //6.2判断是否获取锁成功
  40. if(lock){
  41. //6.3成功,开启独立线程,实现缓存重建
  42. CACHE_REBUILD_EXECUTOR.submit(()->{
  43. //重建缓存
  44. this.saveShopToRedis(id,LOCK_SHOP_TTL);
  45. //释放锁
  46. unLock(lockKey);
  47. });
  48. }
  49. //6.4返回过期的店铺信息
  50. return shop;
  51. }
  52. public void saveShopToRedis(Long id,Long expireSeconds){
  53. //1.查询店铺数据
  54. Shop shop = getById(id);
  55. try {
  56. //200ms的延迟,模拟长时间数据库重建
  57. Thread.sleep(200L);
  58. } catch (InterruptedException e) {
  59. e.printStackTrace();
  60. }
  61. //2.封装逻辑过期时间
  62. RedisData<Shop> shopRedisObj = new RedisData<>();
  63. shopRedisObj.setData(shop);
  64. shopRedisObj.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
  65. //3.写入redis
  66. stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shopRedisObj));
  67. }

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

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

封装redis工具类

  1. package com.hmdp.cache;
  2. import cn.hutool.core.util.BooleanUtil;
  3. import cn.hutool.core.util.StrUtil;
  4. import cn.hutool.json.JSONObject;
  5. import cn.hutool.json.JSONUtil;
  6. import com.hmdp.utilObj.RedisData;
  7. import org.springframework.data.redis.core.StringRedisTemplate;
  8. import org.springframework.stereotype.Component;
  9. import java.time.LocalDateTime;
  10. import java.time.temporal.TemporalUnit;
  11. import java.util.concurrent.ExecutorService;
  12. import java.util.concurrent.Executors;
  13. import java.util.concurrent.TimeUnit;
  14. import java.util.function.Function;
  15. /**
  16. * @author 大忽悠
  17. * @create 2022/4/27 13:35
  18. */
  19. @Component
  20. public class RedisCacheClient {
  21. private final StringRedisTemplate stringRedisTemplate;
  22. /**
  23. * 默认线程池大小
  24. */
  25. private static final int DEFAULT_THREAD_SIZE=10;
  26. /**
  27. * 负责缓存重建工作的线程池
  28. */
  29. private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(DEFAULT_THREAD_SIZE);
  30. /**
  31. * 缓存空对象存入的标记
  32. */
  33. private static final String NULL_OBJ_TAG="nullObjSaveTag";
  34. public RedisCacheClient(StringRedisTemplate stringRedisTemplate) {
  35. this.stringRedisTemplate = stringRedisTemplate;
  36. }
  37. /**
  38. * @param key redis中存入的key
  39. * @param value redis中存入的value
  40. * @param expireTime 过期时间
  41. * @param timeUnit 过期时间的单位
  42. */
  43. public void set(String key,Object value,Long expireTime,TimeUnit timeUnit){
  44. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(value),expireTime,timeUnit);
  45. }
  46. /**
  47. * <p>
  48. * 这里的过期时间,是增加一个逻辑过期字段,传入的value会使用RedisData封装起来
  49. * </p>
  50. * @param key redis中存入的key
  51. * @param value redis中存入的value
  52. * @param expireTime 过期时间
  53. * @param timeUnit 过期时间的单位
  54. */
  55. public void setWithLogicalExpire(String key,Object value,Long expireTime,TimeUnit timeUnit){
  56. //设置逻辑过期
  57. RedisData redisData = new RedisData();
  58. redisData.setData(value);
  59. redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime)));
  60. //写入redis
  61. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
  62. }
  63. /**
  64. * <p>
  65. * 查询过程中会处理缓存穿透问题,解决redis和数据库都不存在的key的查询问题
  66. * 利用空对象缓存来解决这个问题
  67. * </p>
  68. * @param keyPrefix key的前缀
  69. * @param id 去数据库查询的具体对象id
  70. * @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
  71. * @param dataClass 目标对象的类型
  72. * @param expireTime 过期时间
  73. * @param timeUnit 过期时间单位
  74. * @param nullObjExpireTime 缓存的空对象的过期时间
  75. * @param nullObjTimeunit 缓存的空对象的过期时间单位
  76. * @param <ID> id的类型
  77. * @param <R> 返回值类型
  78. * @return 返回的是查询到的对象
  79. */
  80. public <ID,R> R queryWithPassThrough(String keyPrefix, ID id, Function<ID,R> dbCallBack, Class<R> dataClass, Long expireTime, TimeUnit timeUnit,
  81. Long nullObjExpireTime,TimeUnit nullObjTimeunit){
  82. //0.缓存key
  83. String key=keyPrefix+id;
  84. //1.从redis中查询商铺缓存
  85. String shopJson=stringRedisTemplate.opsForValue().get(key);
  86. //2.判断是否存在
  87. if(StrUtil.isNotBlank(shopJson))
  88. {
  89. //3.存在,直接返回
  90. R bean = JSONUtil.toBean(shopJson, dataClass);
  91. return bean;
  92. }
  93. //判断命中的是否是空值
  94. if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
  95. {
  96. return null;
  97. }
  98. //4.不存在,根据id查询数据库
  99. R bean = dbCallBack.apply(id);
  100. //5.不存在,返回错误
  101. if(bean==null)
  102. {
  103. //将空值写入到redis
  104. stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,nullObjExpireTime,nullObjTimeunit);
  105. return null;
  106. }
  107. //6.存在,写入redis
  108. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(bean),expireTime, timeUnit);
  109. return bean;
  110. }
  111. /**
  112. * <p>
  113. * 查询过程中会处理缓存击穿问题,解决热点key的查询问题
  114. * 利用逻辑过期来解决这个问题
  115. * </p>
  116. * @param keyPrefix 缓存对象关联的key前缀
  117. * @param lockKeyPrefix 锁住当前对象重构过程的锁前缀
  118. * @param id 对象id
  119. * @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
  120. * @param dataClass 对象类型
  121. * @param expireTime 过期时间
  122. * @param timeUnit 过期时间单位
  123. * @param <ID> id的类型
  124. * @param <R> 返回值类型
  125. * @return 返回的是查询到的对象
  126. */
  127. public <ID,R> R queryWithLogicExpire(String keyPrefix,String lockKeyPrefix, ID id, Function<ID,R> dbCallBack, Class<R> dataClass
  128. ,Long expireTime, TemporalUnit timeUnit) {
  129. String key=keyPrefix+id;
  130. //1.从redis查询商铺缓存
  131. String shopJson=stringRedisTemplate.opsForValue().get(key);
  132. //2.判断是否存在
  133. if(StrUtil.isBlank(shopJson)) {
  134. //3.不存在,直接返回
  135. return null;
  136. }
  137. //4.命中,需要先把JSON反序列化为对象
  138. RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
  139. R bean = JSONUtil.toBean((JSONObject) redisData.getData(), dataClass);
  140. LocalDateTime getExpireTime = redisData.getExpireTime();
  141. //5.判断是否过期
  142. if(getExpireTime.isAfter(LocalDateTime.now())) {
  143. //5.1未过期,直接返回
  144. return bean;
  145. }
  146. //5.2过期了,需要缓存重建
  147. //6.缓存重建
  148. //6.1获取互斥锁
  149. String lockKey=lockKeyPrefix+id;
  150. boolean lock = tryLock(lockKey);
  151. //6.2判断是否获取锁成功
  152. if(lock){
  153. //6.3成功,开启独立线程,实现缓存重建
  154. CACHE_REBUILD_EXECUTOR.submit(()->{
  155. //重建缓存
  156. this.saveHotBeanToRedisWithLogicTag(keyPrefix,id,dbCallBack,expireTime,timeUnit);
  157. //释放锁
  158. unLock(lockKey);
  159. });
  160. }
  161. //6.4返回过期的店铺信息
  162. return bean;
  163. }
  164. /**
  165. * <P>
  166. * 利用互斥锁解决缓存击穿问题
  167. * </P>
  168. * @param keyPrefix key的前缀
  169. * @param id 去数据库查询的具体对象id
  170. * @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
  171. * @param dataClass 目标对象的类型
  172. * @param expireTime 过期时间
  173. * @param timeUnit 过期时间单位
  174. * @param nullObjExpireTime 缓存的空对象的过期时间
  175. * @param nullObjTimeunit 缓存的空对象的过期时间单位
  176. * @param <ID> id的类型
  177. * @param <R> 返回值类型
  178. * @return 返回的是查询到的对象
  179. */
  180. public <ID,R> R queryWithMutex(String keyPrefix,String lockKeyPrefix,ID id,Class<R> dataClass,Function<ID,R> dbCallBack,Long expireTime,
  181. TimeUnit timeUnit,Long nullObjExpireTime,TimeUnit nullObjTimeunit) {
  182. R bean=null;
  183. String key=keyPrefix+id;
  184. //1.从redis中查询商铺缓存
  185. String shopJson=stringRedisTemplate.opsForValue().get(key);
  186. //2.判断是否存在
  187. if(StrUtil.isNotBlank(shopJson))
  188. {
  189. //3.存在,直接返回
  190. bean = JSONUtil.toBean(shopJson, dataClass);
  191. return bean;
  192. }
  193. //判断命中的是否是空值---解决缓存雪崩
  194. if(shopJson!=null&&shopJson.equals(NULL_OBJ_TAG))
  195. {
  196. return null;
  197. }
  198. //4.缓存重建---解决缓存击穿
  199. //4.1 获取互斥锁
  200. String lockKey=lockKeyPrefix+id;
  201. try{
  202. //4.2判断是否获取成功
  203. if(!tryLock(lockKey)){
  204. //4.3失败,则休眠并重试
  205. Thread.sleep(50);
  206. //重试
  207. return queryWithMutex(keyPrefix,lockKeyPrefix,id,dataClass,dbCallBack,expireTime,timeUnit,nullObjExpireTime,nullObjTimeunit);
  208. }
  209. //4.3 成功,根据id查询数据库
  210. bean = dbCallBack.apply(id);
  211. //5.不存在,返回错误
  212. if(bean==null)
  213. {
  214. //将空值写入到redis---解决缓存雪崩
  215. stringRedisTemplate.opsForValue().set(key,NULL_OBJ_TAG,nullObjExpireTime,nullObjTimeunit);
  216. return null;
  217. }
  218. //6.存在,写入redis
  219. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(bean),expireTime,timeUnit);
  220. return bean;
  221. } catch (InterruptedException e) {
  222. e.printStackTrace();
  223. }finally {
  224. unLock(key);
  225. }
  226. return bean;
  227. }
  228. /**
  229. * <p>
  230. * 可以通过该方法,设置一个热点key进redis
  231. * </p>
  232. * @param keyPrefix 缓存对象关联的key前缀
  233. * @param id 对象id
  234. * @param dbCallBack 数据库的回调接口,向该回调接口传入一个id,然后获取到该回调接口查询出来的对象
  235. * @param expireTime 过期时间
  236. * @param timeUnit 过期时间单位
  237. * @param <ID> id的类型
  238. * @param <R> 返回值类型
  239. * @return 返回的是查询到的对象
  240. */
  241. public <ID,R> void saveHotBeanToRedisWithLogicTag(String keyPrefix,ID id, Function<ID,R> dbCallBack, Long expireTime, TemporalUnit timeUnit){
  242. String key=keyPrefix+id;
  243. //1.查询店铺数据
  244. R bean = dbCallBack.apply(id);
  245. //2.封装逻辑过期时间
  246. RedisData redisData = new RedisData<>();
  247. redisData.setData(bean);
  248. redisData.setExpireTime(LocalDateTime.now().plus(expireTime,timeUnit));
  249. //3.写入redis
  250. stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
  251. }
  252. /**
  253. * 尝试获取锁
  254. */
  255. private boolean tryLock(String key) {
  256. return BooleanUtil.isTrue(stringRedisTemplate.opsForValue().setIfAbsent(key,"locked",10,TimeUnit.SECONDS));
  257. }
  258. /**
  259. * 解锁
  260. */
  261. private void unLock(String key)
  262. {
  263. stringRedisTemplate.delete(key);
  264. }
  265. }

相关文章