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

Redis 学习笔记 3:黑马点评

Redis 学习笔记 3:黑马点评

准备工作

需要先导入项目相关资源:

  • 数据库文件 hmdp.sql
  • 后端代码 hm-dianping.zip
  • 包括前端代码的 Nginx

启动后端代码和 Nginx。

短信登录

发送验证码

@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {// 发送短信验证码并保存验证码return userService.sendCode(phone, session);
}
@Log4j2
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Overridepublic Result sendCode(String phone, HttpSession session) {if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("不是合法的手机号!");}String code = RandomUtil.randomNumbers(6);session.setAttribute("code", code);// 发送短信log.debug("发送短信验证码:{}", code);return Result.ok();}
}

登录

@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {// 实现登录功能return userService.login(loginForm, session);
}
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {// 验证手机号和验证码if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {return Result.fail("手机号不合法!");}String code = (String) session.getAttribute("code");if (code == null || !code.equals(loginForm.getCode())) {return Result.fail("验证码不正确!");}// 检查用户是否存在QueryWrapper<User> qw = new QueryWrapper<>();qw.eq("phone", loginForm.getPhone());User user = this.baseMapper.selectOne(qw);if (user == null) {user = this.createUserByPhone(loginForm.getPhone());}// 将用户信息保存到 sessionsession.setAttribute("user", user);return Result.ok();
}private User createUserByPhone(String phone) {User user = new User();user.setPhone(phone);user.setNickName("user_" + RandomUtil.randomString(5));this.baseMapper.insert(user);return user;
}

统一身份校验

定义拦截器:

public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 从 session 获取用户信息HttpSession session = request.getSession();User user = (User) session.getAttribute("user");if (user == null) {response.setStatus(401);return false;}// 将用户信息保存到 ThreadLocalUserDTO userDTO = new UserDTO();userDTO.setIcon(user.getIcon());userDTO.setId(user.getId());userDTO.setNickName(user.getNickName());UserHolder.saveUser(userDTO);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}

添加拦截器:

@Configuration
public class WebMVCConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login");}
}

使用 Redis 存储验证码和用户信息

用 Session 存储验证码和用户信息的系统,无法进行横向扩展,因为多台 Tomcat 无法共享 Session。如果改用 Redis 存储就可以解决这个问题。

修改后的 UserService:

@Log4j2
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();@Overridepublic Result sendCode(String phone, HttpSession session) {if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("不是合法的手机号!");}String code = RandomUtil.randomNumbers(6);stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL);// 发送短信log.debug("发送短信验证码:{}", code);return Result.ok();}@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {// 验证手机号和验证码if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {return Result.fail("手机号不合法!");}String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());if (code == null || !code.equals(loginForm.getCode())) {return Result.fail("验证码不正确!");}// 检查用户是否存在QueryWrapper<User> qw = new QueryWrapper<>();qw.eq("phone", loginForm.getPhone());User user = this.baseMapper.selectOne(qw);if (user == null) {user = this.createUserByPhone(loginForm.getPhone());}// 将用户信息保存到 sessionString token = UUID.randomUUID().toString(true);UserDTO userDTO = new UserDTO();BeanUtils.copyProperties(user, userDTO);try {stringRedisTemplate.opsForValue().set(LOGIN_USER_KEY + token,OBJECT_MAPPER.writeValueAsString(userDTO), LOGIN_USER_TTL);} catch (JsonProcessingException e) {e.printStackTrace();throw new RuntimeException(e);}return Result.ok(token);}private User createUserByPhone(String phone) {User user = new User();user.setPhone(phone);user.setNickName("user_" + RandomUtil.randomString(5));this.baseMapper.insert(user);return user;}
}

修改后的登录校验拦截器:

public class LoginInterceptor implements HandlerInterceptor {private final StringRedisTemplate stringRedisTemplate;private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 从头信息获取 tokenString token = request.getHeader("Authorization");if (ObjectUtils.isEmpty(token)) {// 缺少 tokenresponse.setStatus(401);return false;}// 从 Redis 获取用户信息String jsonUser = this.stringRedisTemplate.opsForValue().get(LOGIN_USER_KEY + token);UserDTO userDTO = OBJECT_MAPPER.readValue(jsonUser, UserDTO.class);if (userDTO == null) {response.setStatus(401);return false;}// 将用户信息保存到 ThreadLocalUserHolder.saveUser(userDTO);// 刷新 token 有效期stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}

还需要添加一个更新用户信息有效期的拦截器:

public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 如果请求头中有 token,且 redis 中有 token 相关的用户信息,刷新其有效期String token = request.getHeader("Authorization");if (ObjectUtils.isEmpty(token)) {return true;}if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(LOGIN_USER_KEY + token))) {stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);}return true;}
}

添加这个新的拦截器,并且确保其位于登录验证拦截器之前:

@Configuration
public class WebMVCConfig implements WebMvcConfigurer {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**");registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login");}
}

商户查询

缓存

对商户类型查询使用 Redis 缓存以提高查询效率:

@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryTypeList() {String jsonTypeList = stringRedisTemplate.opsForValue().get(CACHE_TYPE_LIST_KEY);if (!StringUtils.isEmpty(jsonTypeList)) {List<ShopType> typeList = JSONUtil.toList(jsonTypeList, ShopType.class);return Result.ok(typeList);}List<ShopType> typeList = this.query().orderByAsc("sort").list();if (!typeList.isEmpty()){stringRedisTemplate.opsForValue().set(CACHE_TYPE_LIST_KEY, JSONUtil.toJsonStr(typeList), CACHE_TYPE_LIST_TTL);}return Result.ok(typeList);}
}

对商户详情使用缓存:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryById(Long id) {// 先从 Redis 中查询String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (!StringUtils.isEmpty(jsonShop)) {Shop shop = JSONUtil.toBean(jsonShop, Shop.class);return Result.ok(shop);}// Redis 中没有,从数据库查Shop shop = this.getById(id);if (shop != null) {jsonShop = JSONUtil.toJsonStr(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);}return Result.ok(shop);}
}

缓存更新策略

在编辑商户信息时,将对应的缓存删除:

@Override
public Result update(Shop shop) {if (shop.getId() == null) {return Result.fail("商户id不能为空");}// 更新商户信息this.updateById(shop);// 删除缓存stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());return Result.ok();
}

缓存穿透

缓存穿透指如果请求的数据在缓存和数据库中都不存在,就不会生成缓存数据,每次请求都不会使用缓存,会对数据库造成压力。

可以通过缓存空对象的方式解决缓存穿透问题。

在查询商铺信息时缓存空对象:

@Override
public Result queryById(Long id) {// 先从 Redis 中查询String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (!StringUtils.isEmpty(jsonShop)) {Shop shop = JSONUtil.toBean(jsonShop, Shop.class);return Result.ok(shop);}// Redis 中没有,从数据库查Shop shop = this.getById(id);if (shop != null) {jsonShop = JSONUtil.toJsonStr(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);return Result.ok(shop);} else {// 缓存空对象到缓存中stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL);return Result.fail("店铺不存在");}
}

在这里,缓存中的空对象用空字符串代替,并且将缓存存活时间设置为一个较短的值(比如说2分钟)。

在从缓存中查询到空对象时,返回商铺不存在:

@Override
public Result queryById(Long id) {// 先从 Redis 中查询String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (!StringUtils.isEmpty(jsonShop)) {Shop shop = JSONUtil.toBean(jsonShop, Shop.class);return Result.ok(shop);}// 如果从缓存中查询到空对象,表示商铺不存在if ("".equals(jsonShop)) {return Result.fail("商铺不存在");}// ...
}

缓存击穿

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

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

可以利用 Redis 做互斥锁来解决缓存击穿问题:

@Override
public Result queryById(Long id) {//        return queryWithCachePenetration(id);return queryWithCacheBreakdown(id);
}/*** 用 Redis 创建互斥锁** @param name 锁名称* @return 成功/失败*/
private boolean lock(String name) {Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(name, "1", Duration.ofSeconds(10));return BooleanUtil.isTrue(result);
}/*** 删除 Redis 互斥锁** @param name 锁名称*/
private void unlock(String name) {stringRedisTemplate.delete(name);
}/*** 查询店铺信息-缓存击穿** @param id* @return*/
private Result queryWithCacheBreakdown(Long id) {// 先查询是否存在缓存String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (!StringUtils.isEmpty(jsonShop)) {Shop shop = JSONUtil.toBean(jsonShop, Shop.class);return Result.ok(shop);}// 如果从缓存中查询到空对象,表示商铺不存在if ("".equals(jsonShop)) {return Result.fail("商铺不存在");}// 缓存不存在,尝试获取锁,并创建缓存final String lockName = "lock:shop:" + id;try {if (!lock(lockName)){// 获取互斥锁失败,休眠一段时间后重试Thread.sleep(50);return queryWithCacheBreakdown(id);}// 获取互斥锁成功,创建缓存// 模拟长时间才能创建缓存Thread.sleep(100);Shop shop = this.getById(id);if (shop != null) {jsonShop = JSONUtil.toJsonStr(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);return Result.ok(shop);} else {// 缓存空对象到缓存中stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL);return Result.fail("店铺不存在");}} catch (InterruptedException e) {throw new RuntimeException(e);} finally {// 释放锁unlock(lockName);}
}

下面是用逻辑过期解决缓存击穿问题的方式。

首先需要将热点数据的缓存提前写入 Redis(缓存预热):

public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {/*** 创建店铺缓存** @param id       店铺id* @param duration 缓存有效时长*/public void saveShopCache(Long id, Duration duration) {Shop shop = getById(id);RedisCache<Shop> redisCache = new RedisCache<>();redisCache.setExpire(LocalDateTime.now().plus(duration));redisCache.setData(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisCache));}// ...
}
@SpringBootTest
class HmDianPingApplicationTests {@Autowiredprivate ShopServiceImpl shopService;@Testpublic void testSaveShopCache(){shopService.saveShopCache(1L, Duration.ofSeconds(1));}}
@Data
public class RedisCache<T> {private LocalDateTime expire; //逻辑过期时间private T data; // 数据
}

Redis 中的缓存信息包含两部分:过期时间和具体信息。大致如下:

{"data": {"area": "大关","openHours": "10:00-22:00","sold": 4215,// ...},"expire": 1708258021725
}

且其 TTL 是-1,也就是永不过期。

具体的缓存读取和重建逻辑:

/*** 用逻辑过期解决缓存击穿问题** @return*/
private Result queryWithLogicalExpiration(Long id) {//检查缓存是否存在String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if (StringUtils.isEmpty(jsonShop)) {// 缓存不存在return Result.fail("店铺不存在");}// 缓存存在,检查是否过期RedisCache<Shop> redisCache = JSONUtil.toBean(jsonShop, new TypeReference<RedisCache<Shop>>() {}, true);if (redisCache.getExpire().isBefore(LocalDateTime.now())) {// 如果过期,尝试获取互斥锁final String LOCK_NAME = LOCK_SHOP_KEY + id;if (lock(LOCK_NAME)) {// 获取互斥锁后,单独启动线程更新缓存CACHE_UPDATE_ES.execute(() -> {try {// 模拟缓存重建的延迟Thread.sleep(200);saveShopCache(id, Duration.ofSeconds(1));} catch (Exception e) {throw new RuntimeException(e);} finally {unlock(LOCK_NAME);}});}}// 无论是否过期,返回缓存对象中的信息return Result.ok(redisCache.getData());
}

封装 Redis 缓存工具类

可以对对 Redis 缓存相关逻辑进行封装,可以避免在业务代码中重复编写相关逻辑。封装后分别对应以下方法:

  • 设置缓存数据(TTL)
  • 设置缓存数据(逻辑过期时间)
  • 从缓存获取数据(用空对象解决缓存穿透问题)
  • 从缓存获取数据(用互斥锁解决缓存击穿问题)
  • 从缓存获取数据(用逻辑过期解决缓存击穿问题)

工具类的完整代码可以参考这里。

本文的完整示例代码可以从这里获取。

参考资料

  • 黑马程序员Redis入门到实战教程

相关文章:

  • 服务器部署java 的docker项目,以及常用的一些命令
  • 可视化图文报表
  • Python爬虫进阶:爬取在线电视剧信息与高级检索
  • 【论文阅读】基于图像处理和卷积神经网络的板式换热器气泡识别与跟踪
  • iOS高级理论:分类和扩展
  • 多微服务合并为一个服务
  • 如何在debian上实现一键恢复操作系统?
  • 在IDEA中创建vue hello-world项目
  • Chapter 10 Basic Magnetics Theory
  • 微服务-实用篇
  • 机器视觉运动控制一体机在光伏汇流焊机器人系统的解决方案
  • Shell echo、printf、test命令
  • 中间件-Nginx漏洞整改(限制IP访问隐藏nginx版本信息)
  • opencv图像腐蚀
  • 低代码与大语言模型的探索实践
  • 【css3】浏览器内核及其兼容性
  • canvas 绘制双线技巧
  • CSS 提示工具(Tooltip)
  • ES6系列(二)变量的解构赋值
  • ES6语法详解(一)
  • Git 使用集
  • js面向对象
  • js中forEach回调同异步问题
  • Laravel Mix运行时关于es2015报错解决方案
  • Terraform入门 - 1. 安装Terraform
  • 创建一种深思熟虑的文化
  • 复杂数据处理
  • 技术发展面试
  • 使用阿里云发布分布式网站,开发时候应该注意什么?
  • 微信开放平台全网发布【失败】的几点排查方法
  • 一些基于React、Vue、Node.js、MongoDB技术栈的实践项目
  • 国内开源镜像站点
  • $con= MySQL有关填空题_2015年计算机二级考试《MySQL》提高练习题(10)
  • $forceUpdate()函数
  • (C语言版)链表(三)——实现双向链表创建、删除、插入、释放内存等简单操作...
  • (pytorch进阶之路)扩散概率模型
  • (二)【Jmeter】专栏实战项目靶场drupal部署
  • (二十五)admin-boot项目之集成消息队列Rabbitmq
  • (附源码)ssm高校运动会管理系统 毕业设计 020419
  • (免费领源码)Python#MySQL图书馆管理系统071718-计算机毕业设计项目选题推荐
  • (转)重识new
  • (自适应手机端)响应式新闻博客知识类pbootcms网站模板 自媒体运营博客网站源码下载
  • .NET Core 2.1路线图
  • .NET Remoting学习笔记(三)信道
  • .net操作Excel出错解决
  • .net与java建立WebService再互相调用
  • /etc/sudoers (root权限管理)
  • ?
  • [ 隧道技术 ] 反弹shell的集中常见方式(二)bash反弹shell
  • [ 英语 ] 马斯克抱水槽“入主”推特总部中那句 Let that sink in 到底是什么梗?
  • [@Controller]4 详解@ModelAttribute
  • []我的函数库
  • [Android Studio 权威教程]断点调试和高级调试
  • [Angular] 笔记 7:模块
  • [Angular] 笔记 8:list/detail 页面以及@Input