该项目来自于黑马程序员的Redis课程里面的一个实战项目

1、短信登录
1.1、导入黑马点评项目
1.1.1 、导入SQL

在这个地方我们导入数据库时候产生了报错

这是因为之前苍穹外卖时候,他这个sql语句里面
有这个建表语句

而这次点评项目sql里面没有建表语句
所以我们可以先写一个建表语句
CREATE DATABASE IF NOT EXISTS hmdp;
USE hmdp;

然后选好数据库就能导入了
.1.2、有关当前模型
手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的事HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。
在tomcat支撑起并发流量后,我们如果让tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。

1.1.3、导入后端项目
在资料中提供了一个项目源码:

1.1.4、导入前端工程

1.1.5 运行前端项目

1.2 、基于Session实现登录流程
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
校验登录状态:
用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行

实现发送短信验证码功能
页面流程

具体代码如下
贴心小提示:
具体逻辑上文已经分析,我们仅仅只需要按照提示的逻辑写出代码即可。
- 发送验证码
我们是把这个主要功能放到service里面去做
UserServiceImpl
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到 session
session.setAttribute("code",code);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
2. 校验手机号
Java
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
- 这里调用了一个叫
RegexUtils的工具类(通常里面封装了正则表达式)来判断手机号格式对不对(比如是不是 11 位、是不是 1 开头)。 - 如果
isPhoneInvalid返回true(无效手机号),就直接打断后续操作,返回给前端一个带有错误提示的Result.fail。这一步是为了防止乱填数据,并节省调用真实短信接口的费用。
3. 生成验证码
Java
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
- 代码走到这里,说明手机号没问题。
- 调用
RandomUtil(通常是 Hutool 等开源工具包里的类)生成一个 6位数的随机纯数字 字符串,并赋值给变量code。
4. 保存验证码
Java
// 4.保存验证码到 session
session.setAttribute("code",code);
- 这是非常关键的一步! 为什么要存起来?因为等会儿用户收到短信后,要把验证码填在网页上发回来。服务器必须记住刚才发给这个用户的验证码是多少,才能进行对比校验。
session就像是服务器为当前用户开的一个"私人储物柜"。这行代码把生成的code放进了储物柜,并贴上了一个叫"code"的标签。
5. 模拟发送并返回结果
Java
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
-
这里就是您上一题问到的日志代码。在真实的商业项目中,这里应该是一段调用阿里云、腾讯云等短信服务商 API 的代码。但为了本地测试方便和省钱,这里直接用日志打印来模拟发送成功。
-
最后,返回
Result.ok()告诉前端:"一切顺利,验证码已经发了,你提示用户注意查收吧"。 -
登录
UserServiceImpl
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode == null || !cacheCode.toString().equals(code)){
//3.不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if(user == null){
//不存在,则创建
user = createUserWithPhone(phone);
}
//7.保存用户信息到session中
session.setAttribute("user",user);
return Result.ok();
}
然后就在下面创建一个createUserWithphone的方法
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;
}
这个save就是mybatisplus 的提供的一个方法,它的作用就和insert一样
mybatisPlus 初窥门径
这里面用到了mybati splus ,下面我们来好好讲讲这个东西
你可以把 MyBatis-Plus 理解为 MyBatis 的"超级外挂"或"增强包" 。它底层依然是 MyBatis,但它帮你把单表的"增删改查"全自动写好了,你再也不用为了写一个简单的 SELECT * FROM ... WHERE ... 去建 XML 文件或者写 @Select 注解了。
下面我为你把这段代码和类声明"翻译"成你能秒懂的 MyBatis 逻辑。
1. 拆解这段代码干了啥
Java
// 一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
如果只用原生 MyBatis,为了实现这个功能,你需要做三件事:
- 在
UserMapper.java里写:User selectByPhone(String phone); - 在
UserMapper.xml里写:<select id="selectByPhone" resultType="User"> SELECT * FROM tb_user WHERE phone = #{phone} </select> - 在 Service 里调用:
userMapper.selectByPhone(phone);
但是用了 MyBatis-Plus,上面的 1 和 2 全都省了!你只需要写这一行代码。
它的链式调用翻译过来就是:
- query(): 告诉框架,"我要开始构建一个查询条件了"。
- .eq("phone", phone):
eq是 equals(等于)的缩写。意思是 SQL 里的WHERE phone = #{phone}。也就是 eq(数据库列名, 要匹配的值) - .one(): 告诉框架,"执行这个查询,并且我确信结果最多只有一条,请给我返回一个
User对象(如果找不到就返回 null)"。既然有.one()那就有.list 意思就是返回一个list集合
2. 为什么要继承 ServiceImpl**?泛型又是什么意思?**
这个是mybaitsplus 提供的 可以帮助我们实现单表的增删改查
之前我记得usermapper 还会写一点方法,现在还需写吗
你记得非常准确!以前用原生 MyBatis 的时候,UserMapper 里面密密麻麻写满了各种增删改查的方法。
现在用了 MyBatis-Plus,绝大多数情况下你不需要写方法了,但遇到特殊情况依然要手写。
我们可以把情况分为两类:
1. 什么时候 不需要 写任何方法?(大约占日常开发的 80%~90%)
只要你的业务需求是单表操作(只操作 tb_user 这一张表),你就不需要在 UserMapper 里写哪怕一行代码。
因为你继承的 BaseMapper<User> 已经默默帮你写好了几十个常用的方法,比如:
insert(user)(新增用户)deleteById(id)(根据ID删除)updateById(user)(根据ID更新)selectById(id)(根据ID查询)selectList(queryWrapper)(条件查询/列表查询)
所以,在处理这些常规需求时,你的 UserMapper 就是一个干干净净的"空壳":
Java
@Mapper
public interface UserMapper extends BaseMapper<User> {
// 里面什么都不用写!这就够了!
}
2. 什么时候 依然需要 自己写方法?(大约占 10%~20%)
MyBatis-Plus 的设计理念是 "单表 MP 搞定,多表手写 SQL"。当你遇到以下两种情况时,就需要像以前一样在里面写方法了:
场景一:多表联查 (JOIN)
假设你需要查询"某个用户的基本信息,以及他最近购买的订单记录"。这就涉及到了 tb_user 和 tb_order 两张表。MyBatis-Plus 原生不支持多表 JOIN。
这时候,你就要自己动手了:
Java
@Mapper
public interface UserMapper extends BaseMapper<User> {
// 你依然可以像以前一样写 @Select 注解
@Select("SELECT u.*, o.order_no FROM tb_user u JOIN tb_order o ON u.id = o.user_id WHERE u.id = #{userId}")
List<UserOrderDTO> selectUserWithOrders(Long userId);
}
场景二:非常复杂的单表查询(或性能优化)
有时候单表查询的条件极其复杂(比如一大堆嵌套的 AND 和 OR,或者复杂的聚合函数 GROUP BY、HAVING),如果强行用 MyBatis-Plus 的链式代码(Wrapper)去拼接,代码会变得极其难读。
这时候,为了代码清晰或者专门优化 SQL 性能,我们也会选择退回到原生 MyBatis 的写法,在 Mapper 里定义方法,然后去 XML 文件里写复杂的 SQL。
你提到的类的头部通常长这样:
Java
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService
为什么要 extends ServiceImpl**?**
你上面能直接调用 query(),或者在其他地方调用 save(user)、removeById(id) 等等这些现成的方法,全是因为你继承了 ServiceImpl。
ServiceImpl 是 MyBatis-Plus 官方提前帮你写好的一个类,里面包含了成百上千行实现增删改查的代码。你一继承它,你的 UserServiceImpl 就瞬间白嫖了所有这些单表操作的方法,不需要自己一行行去写了。
为什么泛型是 <UserMapper, User>****?
既然 ServiceImpl 是官方写好的一个通用类,官方在写这个类的时候,根本不知道未来你会用它来操作"用户表"还是"订单表",也不知道你的 Mapper 叫什么名字。
所以官方用了泛型,让你在使用时"填空":
- 第一个填空位 UserMapper:告诉
ServiceImpl:"哥们,当你底层真正需要去操作数据库时,请使用我提供的UserMapper这个接口去执行。"(相当于自动帮你做了@Autowired UserMapper的注入)。
我们来回忆一下之前纯mybatis的时候
1. 以前(只用原生 MyBatis 时)你是怎么写的
在标准的 Spring + MyBatis 架构中,Service 层需要调用 Mapper 层去查数据库。所以你必须在 Service 类里手动注入 Mapper:
Java
@Service
public class UserServiceImpl implements IUserService {
// 👇 就是这里!你以前一定会写这段代码把 Mapper 注入进来
@Autowired
private UserMapper userMapper;
@Override
public void login(String phone) {
// 然后你才能在方法里使用 userMapper 去查数据库
User user = userMapper.selectByPhone(phone);
// ... 其他逻辑
}
}
如果没有 @Autowired 把 userMapper 拿过来,你在 Service 里根本没法和数据库打交道。
2. 现在(用了 MyBatis-Plus 时)变成了什么样
用了 MyBatis-Plus 之后,你的代码变成了这样:
Java
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
// 👆 注意看,这里面干干净净,没有 @Autowired private UserMapper userMapper; 这一行了!
@Override
public void login(String phone) {
// 你直接调用了 query() 就能查数据库
User user = query().eq("phone", phone).one();
}
}
💡 核心解密:它是怎么"自动注入"的?
当你写了 extends ServiceImpl<UserMapper, User> 的时候,你继承的那个父类 ServiceImpl 的源码里,其实偷偷写了类似这样的代码:
Java
// 这是 MyBatis-Plus 官方源码的简化版
public class ServiceImpl<M extends BaseMapper<T>, T> {
@Autowired
protected M baseMapper; // 这里把传进来的 UserMapper 注入了,取名叫 baseMapper
// 你调用的 query() 方法,底层其实就是在使用这个 baseMapper
}
总结一下: 以前,你是自己当大厨,必须亲自去仓库(Spring 容器)把菜刀(UserMapper)拿过来( @Autowired**)才能切菜。 现在,你继承了一个"全自动厨房(ServiceImpl)"** ,你在门口(泛型 <UserMapper, ...>)告诉厨房:"我今天切菜要用那把特定的菜刀",厨房内部就已经把菜刀给你摆好(自动注入为 baseMapper),你直接喊一声"切菜(query())"就可以了!
- 第二个填空位 User:告诉
ServiceImpl:"哥们,我们这次要操作的数据表映射的实体类是User类。查询结果请帮我封装成User对象,新增数据时我也传给你User对象。"
💡 总结对照表
|---------------|-----------------------------------|------------------------------------------------|
| 概念 | 原生 MyBatis | MyBatis-Plus |
| Mapper 层 | 自己写接口,自己写 XML/注解 | 继承 BaseMapper<User> ,全自动搞定单表 SQL |
| Service 层 | 注入 Mapper,自己写各种增删改查方法 | 继承 ServiceImpl<UserMapper, User> ,白嫖全套增删改查方法 |
| 根据手机号查询 | userMapper.selectByPhone(phone) | query().eq("phone", phone).one() |
这就是为什么大家爱用 MyBatis-Plus 的原因:少写重复代码,准点下班。 复杂的多表联查(JOIN)你依然可以像以前一样去写 XML,两者完全不冲突!
还有一个
当你写下 ServiceImpl<UserMapper, User> 时,你已经完成了两件大事:
- 锁定目标: 你告诉框架,这个 Service 处理的实体类是
User。 - 触发扫描: MyBatis-Plus 在项目启动时,会通过 Java 反射机制(Reflection) 去"解剖"这个
User类。
框架内部的逻辑大概是这样的:
- 第一步(找到类): "哦,泛型里的
T是User类。" - 第二步(看头顶): "我来看看
User类头顶上有没有贴@TableName标签?" - 第三步(读取内容): * 如果有,比如
@TableName("tb_user"),框架就记下:"以后这个 Service 发出的 SQL 语句,表名都写tb_user。"
-
- 如果没有,框架就启动备份方案:"既然没贴名牌,那我就默认表名和类名一样,也叫
user吧。"
- 如果没有,框架就启动备份方案:"既然没贴名牌,那我就默认表名和类名一样,也叫
不仅仅是表名
通过泛型找到这个类后,框架还会继续往下扫描类里面的成员变量(Fields):
- 看到
@TableId:它就知道了数据库里的主键是哪一列,主键是怎么生成的(比如自增)。 - 看到普通变量
phone:它就知道了数据库里有一个叫phone的列。 - 看到
@TableField(exist = false):它就知道了这个变量只是代码里临时用的,不要去数据库里查这一列。
当没有定义注解的时候默认规则

当我们通过注解定义了查哪张表的时候

这里有一个小技巧,找实现这里有两种方式

对着这个ctrl+Alt+B
或者直接点左边绿色I按钮(这个之前常用)

然后在数据库中点击这个ddl也可以直接打开这个建表语句,查看相关的内容
在这里我们加入了mybatis plus的内容
实现登录拦截功能
注意登陆这里是我们自己写到登陆的业务层里面自己拦,后面其它功能,不可能都像登陆这样一个个controller
都这样亲历亲为,所以写一个拦截器全局拦截,然后把登陆功能放行就行了
提到拦截器这里我们又不由的想到过滤器,接下来我们把它们放在一张图片里面再来对比一下

温馨小贴士:tomcat的运行原理

当用户发起请求时,会访问我们像tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket相互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应
通过以上讲解,我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据
温馨小贴士:关于threadlocal
黑马说每一个进入tomcat的请求都是一个独立的线程
如果小伙伴们看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离
正是因为 map 不一样,所以最后得到的数据是绝对不一样的

我们通过拦截器校验完成之后,需要把校验后得到结果放在threadlocal里面最后再由threadlocal传给这几个controller
这里面登陆的凭证也就是session_id 实际上就在cookie里面
拦截器代码
我们是把这个拦截器代码写在utils包里面

我们需要重写它的一下方法,但是在这里我们发现一个问题,我们使用alt+enter好像已经不管用了,这是因为
alt+enter只在修复错误时候才管用
这里我就要用到ctlr+i ctrl+O也可以

重写pre方法和after方法
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((User)user);
//6.放行
return true;
}
}
这里黑马漏了一个afterCompletetion
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户,防止内存泄漏和数据串位
UserHolder.removeUser();
}
为什么要加这个,前面那个是要在拦截之前生效,后面这个是
1. 防止"数据串位"(极其严重的安全 Bug)
还记得我们刚才说的吗?Tomcat 里的线程是**"用完即还,重复利用"**的。
- 假设用户【张三】发起了请求,Tomcat 派出了【线程 A】来接待。拦截器把张三的信息存进了【线程 A】的私有口袋(ThreadLocalMap)里。
- 请求结束了,如果你不清理口袋,【线程 A】就带着张三的信息回到了线程池待命。
- 过了一会儿,用户【李四】发起了请求。Tomcat 恰好又把【线程 A】派出去接待李四。
- 如果后面的代码直接去读 ThreadLocal,系统就会误以为当前操作的人是张三! 这就导致李四看到了张三的隐私,或者花掉了张三的钱。
所以,必须在每次请求结束时,把口袋翻过来抖干净。
也就是说threadlocha里面的数据我们只在这一次线程里面使用就行了,线程结束就要清空换回线池,不清空,下次别的线程就会看到
从这里其实也回答了为什么通过treadlocal可以做到线程隔离
让拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private 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(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
这里后面的.order(1)是指拦截器执行的顺序
隐藏用户敏感信息
我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
在登录方法处修改
//7.保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));
注意这里和我们之前苍穹外卖学的
BeanUtils.copyProperties(源, 目标)有点不一样,目标这里之前我们不是传字节码对象,而是一个实例化对象
// 1. 必须自己手动 new 一个空的目标对象
UserDTO userDTO = new UserDTO();
// 2. 将 user 的属性拷贝到 userDTO 中 (注意:没有返回值)
BeanUtils.copyProperties(user, userDTO);
// 3. 把填满数据的 userDTO 存入 session
session.setAttribute("user", userDTO);
在拦截器处:
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);
在UserHolder处:将user对象换成UserDTO
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}

这里有一个小技巧查找用法,看看getUser在哪写地方被用了
这是最标准的查找方式,会在底部打开一个专用的 Find 窗口,方便你慢慢看。
- Windows / Linux:
Alt + F7
session共享问题
核心思路分析:
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
其实jwt也可以解决,并且是最适合企业的

Redis代替session的业务流程
设计key的结构
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。

设计key的具体细节
所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code了
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了
整体访问流程
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

基于Redis实现短信登录
这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。
UserServiceImpl代码
@Override
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2. 如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3. 符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4. 保存验证码到 redis (原注释写的是session,但代码实际是存redis)
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 5. 发送验证码 (这里并没有真正发短信,而是用日志模拟发送)
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
这里黑马漏一段sendcode方法
UserServiceImpl代码
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户信息到 redis中
// 7.1.随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 7.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
然后在这里我们看到了
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), ...);
里面BeanUtil的另一种用法
在这里 String token = UUID.randomUUID().toString(true);
这里输入true的目的是把横杠去掉
比如默认没有true是这样
550e8400-e29b-41d4-a716-446655440000
然后这里的返回值也和之前不一样
因为在新的方案里,你必须亲手把"开门钥匙(Token)"交给前端,否则前端下次来请求时,服务器就不认识它了。
为了让你彻底明白,我们把这两种方式比作"去酒店开房":
1. 之前的代码(Session 方案):为什么什么都不返回?
Java
session.setAttribute("user", ...);
return Result.ok(); // 空手而归
- 底层原理: 当你调用 session.setAttribute****时,Tomcat 服务器会在底层悄悄帮你做一件事------它会在返回给浏览器的响应头里,自动塞进一个叫 JSESSIONID****的 Cookie(就像一张隐形的 VIP 会员卡)。
- 前端动作: 浏览器非常听话,它会自动把这个 Cookie 存起来。下次再发请求时,浏览器会自动带上这个 Cookie。
- 总结: 因为 Tomcat 和浏览器 在底层达成了自动发卡、自动亮卡的默契 ,所以你写的业务代码根本不需要操心"把凭证传给前端"这件事,直接 **return Result.ok()**即可。
2. 现在的代码(Redis + Token 方案):为什么必须返回 Token?
Java
String token = UUID.randomUUID().toString(true);
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
return Result.ok(token); // 把钥匙递给前端
- 背景变化: 现在你不用 Session 了,你把用户信息存到了 Redis 这个"第三方大仓库"里。并且,你自己用代码生成了一把专属钥匙( token**)。**
- 底层变化: Redis 可不像 Tomcat 那么"贴心",它 不会 自动帮你往浏览器的 Cookie 里写东西。
- 必须返回的原因: 既然这把 token****钥匙是你自己生成的,并且没有任何机制会自动把它传给前端,那你就 必须在代码里显式地把它作为数据返回给前端 (也就是 Result.ok(token)****)。
3. 前端拿到这个 Token 之后会做什么?
当你 **return Result.ok(token)**之后,前端(比如 Vue、React 或小程序)接下来的动作是至关重要的闭环:
- 保存钥匙: 前端会在代码里,把这个 token****存到浏览器的本地存储中(通常是 localStorage****或 sessionStorage**)。**
- 每次请求都带着钥匙: 之后前端每次向后端发送请求(比如查询购物车、修改资料),都会在 HTTP 请求的 请求头(Header) 里面,手动把这个 token****塞进去(通常放在叫 Authorization****的请求头里)。
- 后端验明正身: 后端收到请求,从头里面拿出 token**,去 Redis 里一查:** "哦!这把钥匙对应的是张三!" 这样就完成了身份验证。
解决状态登录刷新问题
初始方案思路总结:
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

优化方案
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

代码
RefreshTokenInterceptor
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)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
//这里不用null是因为stringRedisTemplate查询到为空会返回一个空map而不是null
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
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 {
// 1.判断是否需要拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null) {
// 没有,需要拦截,设置状态码
response.setStatus(401);
// 拦截
return false;
}
// 有用户,则放行
return true;
}
}
然后黑马这里还漏一个
MvcConfig
package com.hmdp.config;
import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
// 1. 让 Spring 把刷新 Token 的拦截器塞进来
@Autowired
private RefreshTokenInterceptor refreshTokenInterceptor;
// 2. 让 Spring 把校验登录的拦截器塞进来
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// ==========================================
// 第一道关卡:全局刷新 Token 拦截器
// ==========================================
registry.addInterceptor(refreshTokenInterceptor)
.addPathPatterns("/**") // 拦截所有请求
.order(0); // order(0) 优先级最高,最先执行!
// ==========================================
// 第二道关卡:校验登录拦截器
// ==========================================
registry.addInterceptor(loginInterceptor)
.excludePathPatterns( // 放行不需要登录的接口
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
)
.order(1); // order(1) 优先级排在后面,等 Token 刷新完再执行
}
}
一些值得注意的细节
首先拦截器这里,为什么不用是有原因的
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
作者说这里不能用 @Autowired 注入,根本原因只有一条:
因为这个 LoginInterceptor****对象通常是你手动 new****出来的,它并没有交由 Spring 容器(IoC)管理。
为了让你彻底明白,我们来拆解一下背后的原理:
1. Spring @Autowired****的工作前提
Spring 的依赖注入(@Autowired)有一个铁律:Spring 只能给它自己创建和管理的 Bean 注入属性。 如果一个对象是你用 new 关键字手动创建出来的,那么 Spring 就"管不着"这个对象,它里面的 @Autowired 注解就会完全失效,对应的属性就会是 null,一用就会报空指针异常(NullPointerException)。
2. 拦截器(Interceptor)通常是怎么配置的?
想一想你是在哪里把这个 LoginInterceptor 注册到系统里的?你一定有一个类似 MvcConfig 的配置类(实现了 WebMvcConfigurer 接口),里面的代码大概是这样的:
Java
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 【注意看这里!】这里使用了 new 关键字!
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/user/login", "/user/code");
}
}
发现了没?由于拦截器是通过 new LoginInterceptor() 实例化的,它脱离了 Spring 容器的控制 。这就是为什么你在 LoginInterceptor 里面写 @Autowired StringRedisTemplate 会没用的原因。
3. 作者的巧妙解法:通过构造函数"传递"
既然在 LoginInterceptor 里用不了 @Autowired,那我们怎么拿到 StringRedisTemplate 去操作 Redis 呢?
思路转换: 虽然 LoginInterceptor 不是 Spring 管理的,但你的配置类 MvcConfig 是有 @Configuration 注解的,它是被 Spring 管理的!
所以,作者的做法是:
- 先在
MvcConfig里面,利用 Spring 的力量把StringRedisTemplate注入进来。 - 然后,在
new LoginInterceptor()的时候,通过构造函数 ,把这个模板对象当做参数传递进去。
整个流程的代码配合起来是这样的:
在 MvcConfig 中(被 Spring 管理,可以注入):
Java
@Configuration
public class MvcConfig implements WebMvcConfigurer {
// 1. 在这里注入,因为 MvcConfig 是 Spring 管理的 Bean
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 2. 通过构造函数,把拿到的 stringRedisTemplate 塞给拦截器
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.addPathPatterns("/**");
}
}
在 LoginInterceptor 中(你截图里的代码):
Java
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
// 接收从 MvcConfig 传过来的 stringRedisTemplate
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// ... 下面就可以愉快地使用 stringRedisTemplate 了
}
总结
不是 @Autowired 坏了,而是对象创建的控制权不在 Spring 手里。通过构造函数传参,完美解决了"非 Spring 容器管理的对象"想要使用"Spring 容器管理的组件"的矛盾。
但是在这里我个人更加倾向于把所有的bean都教给spring容器去管理
但是mvc那里就不能用new RefreshToeknInterceptor() 方式去创建了,要用下面代码这种方式去创建了,因为我们的stirngRedisTemplate 是autowired出来 ,你new方式创建的并不是spring容器里面的那个早就创建好的RefreshToeknInterceptor 对象 ,而是一个全新的对象 这时候里面 stirngRedisTemplate 用new方式创建里面值其实是null的 ,除非new 的时候写一个有参构造,但是这样的话就回到了之前那个了
@Autowired
private RefreshTokenInterceptor refreshTokenInterceptor;
// 2. 让 Spring 把校验登录的拦截器塞进来
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// ==========================================
// 第一道关卡:全局刷新 Token 拦截器
// ==========================================
registry.addInterceptor(refreshTokenInterceptor)
.addPathPatterns("/**") // 拦截所有请求
.order(0); // order(0
这里有一个比苍穹外卖做的好的地方
if (StrUtil.isBlank(token)) {
return true;
}
1. 为什么 == null判断****不够用?
如果你只写 if (token == null),它仅仅 能拦截这一种情况:前端完全没有传 authorization 这个请求头。
但是在实际的 Web 开发中,前端传过来的数据经常会"作妖",比如:
- 情况 A: 前端传了请求头,但是内容是空的(例如
Authorization:)。这时候 Java 后端拿到的token会是一个空字符串""。此时token == null得到的结果是false(因为它确实是个字符串对象,只是里面没内容),这就漏判了! - 情况 B: 前端或者用户不小心敲了几个空格传过来(例如
Authorization:)。此时token是" "。token == null依然是false,再次漏判!
如果漏判了,拿着空字符串或者一堆空格去 Redis 里查数据,肯定会查不到或者报错。
2. **StrUtil.isBlank()**到底做了什么?
图中的 StrUtil 通常是国内非常火的 Java 工具包 Hutool 里的字符串工具类(或者类似 Apache Commons Lang 的 StringUtils)。
isBlank(token) 这个方法内部帮我们做了三重拦截。它等价于下面这一长串代码:
Java
// isBlank 的底层逻辑大致是这样的:
if (token == null || token.length() == 0 || token.trim().length() == 0) {
return true;
}
它能同时防住三种"无效"的字符串:
- null (完全不存在)
- "" (空字符串,长度为 0)
- " " (全是空白字符,包括空格、制表符 \t、换行符 \n 等)
这里有一个小技巧

图中弹出的窗口是 IDEA 中的 "Recent Files"(最近打开的文件) 列表。
呼出这个窗口的默认快捷键是 Ctrl + E (Mac 系统上是 Cmd + E)。
它和你提到的"Shift 搜索"(即双击 Shift 呼出的 "Search Everywhere" / 随处搜索)虽然都是用来找东西的,但设计目的和使用场景完全不同。
以下是它们的核心区别:
1. Ctrl + E**(最近打开的文件) ------ 专注"上下文切换"**
- 作用范围: 非常小。它只显示你最近刚刚打开或编辑过的文件,以及最近使用过的工具窗口(比如左侧的 Project 目录、Database 等)。
- 核心优势: 极速的来回切换 。在实际开发中,你通常是在 2~3 个文件之间反复横跳(比如 Controller、Service、Mapper)。使用
Ctrl + E,你可以闭着眼睛按回车,瞬间回到上一个编辑的文件,效率极高。 - 图中小细节: 你看图片右上角有个
Show edited only Ctrl+E。这意味着如果你在打开这个窗口后再按一次Ctrl + E,它会过滤掉那些你只是"看过"的文件,只保留你"修改过"的文件,这在找刚才改了哪里时非常管用。
2. 双击 Shift (Search Everywhere) ------ 专注"全局检索"
- 作用范围: 极大。顾名思义"随处搜索",它会在整个项目和 IDE 系统中进行地毯式搜索。它不仅搜文件名,还搜类名、方法名、变量名(Symbols),甚至搜 IDEA 的设置项和操作命令(Actions)。
- 核心优势:找未知或遗忘的东西 。当你知道项目里大概有个
User相关的类,但不知道它在哪,或者你想修改 IDEA 的某个字体设置却找不到菜单,双击 Shift 是万能的入口。
实现商家查询缓存的功能
2.1 什么是缓存?
前言 :什么是缓存?
就像自行车,越野车的避震器

举个例子:越野车,山地自行车,都拥有"避震器",防止 车体加速后因惯性,在酷似"U"字母的地形上飞跃,硬着陆导致的损害,像个弹簧一样;
同样,实际开发中,系统也需要"避震器",防止过高的数据访问猛冲系统,导致其操作线程无法及时处理信息而瘫痪;
这在实际开发中对企业讲,对产品口碑,用户评价都是致命的;所以企业非常重视缓存技术;
缓存( Cache),就是数据交换的缓冲区 ,俗称的缓存就是缓冲区内的数据 ,一般从数据库中获取,存储于本地代码(例如:
例1:Static final ConcurrentHashMap<K,V> map = new ConcurrentHashMap<>(); 本地用于高并发
例2:static final Cache<K,V> USER_CACHE = CacheBuilder.newBuilder().build(); 用于redis等缓存
例3:Static final Map<K,V> map = new HashMap(); 本地缓存
由于其被Static 修饰,所以随着类的加载而被加载到内存之中 ,作为本地缓存,由于其又被final修饰,所以其引用(例3:map)和对象(例3:new HashMap())之间的关系是固定的,不能改变,因此不用担心赋值(=)导致缓存失效;
2.1.1 为什么要使用缓存
一句话:因为速度快,好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
但是缓存也会增加代码复杂度和运营的成本:

2.1.2 如何使用缓存
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用
浏览器缓存:主要是存在于浏览器端的缓存
**应用层缓存:**可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
**数据库缓存:**在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
**CPU缓存:**当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存

2.2 添加商户缓存
在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,大致逻辑是这样,直接查询数据库那肯定慢咯,所以我们需要增加缓存
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
//这里是直接查询数据库
return shopService.queryById(id);
}
2.2.1 、缓存模型和思路
标准的操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。

代码
代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。

这个地方黑马写的不行

当我们点击跳转到整个页面时候f12 开发者工具

我们可以看到整个接口是一个路径参数,然后我们去后端项目找到这个请求的接口
应该就是这个
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return Result.ok(shopService.getById(id));
}
我们的目的应该是把直接查询数据库给添加一个缓存,用来提高查询速度
目前应该只有从数据库中查询数据

这里有一个问题,为什么mybatisplus它能够知道返回值就是shop,这是因为我们在
public interface IShopService extends IService<Shop> {
}
这个地方已经告诉它了
下面是完整代码
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String shopReids = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if (StrUtil.isNotBlank(shopReids)) {
Shop shop = JSONUtil.toBean(shopReids, Shop.class);
return Result.ok(shop);
}
Shop shop = getById(id);
if (shop == null) {
return Result.fail("未查询到店铺");
}
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set("cache:shop:"+id, jsonStr);
return Result.ok(shop);
}
}
我们发现这个getById是mybatisplus的方法,为什么之前我们在contoroler还要调用service层才能用但是在这里我们可以直接用 ,但是在这个service层实现类里面我却并没有定义这个getById方法
核心就在于 extends ServiceImpl****这几个字。
1. 为什么在 Service 里可以直接用?(继承关系)
MyBatis-Plus 的开发者提前写好了一个非常强大的父类,叫作 ServiceImpl。这个父类里面已经写好了几十个常用的数据库操作方法,其中就包括 getById()、save()、removeById() 等等。
当你的 ShopServiceImpl 使用了 extends**(继承)** 关键字认了这个父类之后,就相当于直接继承了父类的全部"家产"(公开方法)。
这里还漏了一个查询店铺的类型代码,黑马要我们自己完成
我们来重写这个

也就是上面的美食,ktv 丽人美发,这一栏的查询

ShopTypeController
我们查看原有的代码
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
@Resource
private IShopTypeService typeService;
@GetMapping("list")
public Result queryTypeList() {
List<ShopType> typeList = typeService
.query().orderByAsc("sort").list();
return Result.ok(typeList);
}
}
发现它是直接去数据库查询的所以在这里我们需要给它添加缓存机制
缓存更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
**内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
**主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题

上面说了一大堆,基本上如果我们只需要关注主动更新就行了,内存淘汰我们也维护不了
2.3.1 、数据库缓存不一致解决方案:
由于我们的缓存的数据源来自于数据库 ,而数据库的数据是会发生变化的 ,因此,如果当数据库中数据发生变化,而缓存却没有同步 ,此时就会有一致性问题存在,其后果是:
用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;怎么解决呢?有如下几种方案
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理
Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

这个三个我们主要关注01点就行了
2.3.2 、数据库和缓存不一致采用什么方案
综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题
操作缓存和数据库时有三个问题需要考虑:
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
- 删除缓存还是更新缓存?
-
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
- 如何保证缓存与数据库的操作的同时成功或失败?
-
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
应该具体操作缓存还是操作数据库,我们应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
- 先操作缓存还是先操作数据库?
-
-
先删除缓存,再操作数据库
-
先操作数据库,再删除缓存
-

-
其实这两种方案都会出现线程安全问题,但是先操作数据库,再删除缓存出现线程安全的频率明显更低
-
所以选择这种 然后我们选择的是删除缓存,而不是更新缓存,因为更新缓存,如果没人读缓存,这个更新就是无效操作,所以我们选择删除缓存
如下图所示,就是我们最终的实现方案

代码实现
核心思路如下:
修改ShopController中的业务逻辑,满足下面的需求:
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
根据id修改店铺时,先修改数据库,再删除缓存
修改重点代码1 :修改ShopServiceImpl的queryById方法
设置redis缓存时添加过期时间

修改重点代码2
代码分析:通过之前的淘汰,我们确定了采用删除策略,来解决双写问题,当我们修改了数据之后,然后把缓存中的数据进行删除,查询时发现缓存中没有数据,则会从mysql中加载最新的数据,从而避免数据库和缓存不一致的问题

因为这个项目没有管理端,所以我们修改店铺信息,需要借助postman这种工具来实现更新店铺信息的操作
缓存穿透
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
- 缓存空对象(这是一种暴力解决方法)
-
- 优点:实现简单,维护方便
- 缺点:
-
-
- 额外的内存消耗
- 可能造成短期的不一致
-
- 布隆过滤(黑马说布隆过滤实际上是一种算法) 会比前一种内存占用更少,存空对象也得占用内存,但是它这个布隆过滤只是存一些hash值 ,缺点就是不准,会误判
-
- 优点:内存占用较少,没有多余key
- 缺点:
-
-
- 实现复杂
- 存在误判可能
-
**缓存空对象思路分析:**当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
**布隆过滤:**布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,
假设布隆过滤器判断这个数据不存在,则直接返回
这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

编码解决商品查询的缓存穿透问题:
核心思路如下:
在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透问题的
现在的逻辑中:如果这个数据不存在,我们不会返回404 ,还是会把这个数据写入到Redis中,并且将value设置为空,欧当再次发起查询时,我们如果发现命中之后,判断这个value是否是null,如果是null,则是之前写入的数据,证明是缓存穿透数据,如果不是,则直接返回数据。
代码
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String shopRedis = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if (StrUtil.isNotBlank(shopRedis)) {
Shop shop = JSONUtil.toBean(shopRedis, Shop.class);
return Result.ok(shop);
}
if(shopRedis!=null)
{
return Result.fail("未查询到店铺");
}
Shop shop = getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("未查询到店铺");
}
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set("cache:shop:"+id, jsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
@Transactional
@Override
public Result update(Shop shop) {
if(shop.getId()==null){
return Result.fail("店铺id不存在");
};
updateById(shop);
stringRedisTemplate.delete("cache:shop:"+shop.getId());
return Result.ok();
}
}

小总结:
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
- 缓存null值
- 布隆过滤
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
这里有一个小技巧

我们鼠标悬浮在方法上面或者按住ctrl+q就可以查看这个方法一些重要详细信息
但是一开始会没有,是因为我们没有下载源码,解决方法如下
- 按住键盘的
Ctrl键,然后鼠标左键点击isNotBlank这个方法名。 - 这时你会进入这个方法的底层代码页面(你会发现里面的代码全是只读的,而且没有注释)。
- 注意看代码编辑器的顶部横幅 或者右上角 ,通常会有一个黄色的提示条,上面写着 "Download Sources" (下载源码)或 "Choose Sources"。
- 点击 Download Sources ,等待底下进度条跑完。再回到你自己的代码里按
Ctrl + Q,详细文档就出来了!
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值,这样就不会一下子同时过期
- 利用Redis集群提高服务的可用性(用很有b格话来说就是提高redis的高可用性)
- 给缓存业务添加降级限流策略( 这个它说黑马微服务的课里面会讲 )
- 给业务添加多级缓存 (比如nigx也可以做缓存用来弥补 )(这个它说黑马微服务的课里面也会讲 )

这一部分没有代码 因为后面基本要有微服务框架,咋们这个课没有这个框架,加随机数没啥学习的价值
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。

解决方案二、逻辑过期方案
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。


进行对比
**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

利用互斥锁解决缓存击穿问题
这里不能用javase里面学的锁,要用我们自定义的互斥锁
为什么不能用 Java 自带的 synchronized**?**
在真实的生产环境中,为了抗住高并发,"订单服务"或者"商品服务"绝对不可能只部署在一台服务器上,而是会同时部署在多台机器上(比如部署了 3 台机器,即 3 个 JVM 进程)。
如果你在这个查询方法上加了 synchronized:
- 锁的范围太小(只锁自己):
synchronized的作用域仅仅是当前的 JVM 进程。 - 场景模拟: 当缓存突然失效,3000 个请求同时打过来,负载均衡把请求平均分发给了这 3 台机器,每台机器 1000 个请求。
- 失效结果:
-
- 机器 A 的 JVM 里,
synchronized拦住了 999 个线程,只放行了第 1 个线程去查数据库。 - 机器 B 的 JVM 里,也放行了第 1 个线程去查数据库。
- 机器 C 的 JVM 里,同样放行了第 1 个线程去查数据库。
- 机器 A 的 JVM 里,
- 结论: 最终还是有 3 个并发请求同时打到了数据库上去重建相同的缓存。虽然情况比 3000 个好多了,但如果你们部署了 50 台甚至 100 台机器,那瞬间打到数据库的请求依然可能是灾难性的。
为什么要"自定义锁(分布式锁)"?
因为我们要找一个能管得住所有 JVM 进程的"大管家"。
大家都不在同一个 JVM 里,互相看不见对方的锁。所以必须把这把锁放到一个大家都能访问到的公共区域。
这个公共区域最常见的选择就是 Redis。
分布式互斥锁的工作原理(对应你截图的流程图)
在 Redis 里,我们通常利用 SETNX (Set if Not eXists) 命令来实现自定义互斥锁。
- 尝试获取互斥锁(SETNX): 所有机器上的所有线程,在发现缓存失效后,都立刻去 Redis 里尝试设置一个特殊的 Key(比如叫
lock:shop:101),值为随意(比如 "1")。 因为 Redis 的单线程特性和SETNX命令的原子性,这 3000 个线程里,绝对只有 1 个线程能设置成功! - 判断是否获取锁:
-
- 是(设置成功的那个天选之子): 意味着它抢到了这把"全网唯一的分布式锁"。它就可以放心地去查询数据库,重建缓存,最后把 Redis 里那个锁 Key 删掉(释放锁)。
- 否(剩下的 2999 个兄弟): 意味着锁已经被别人抢走了。现在别人正在辛勤地查数据库建缓存呢。既然别人在努力,那自己就没必要再去给数据库添乱了。
- 休眠一段时间后重试(关键步骤): 这 2999 个没抢到锁的线程怎么办?直接报错吗?当然不行,用户还等着看商品详情呢。 看你的截图,没抢到锁的流程是:休眠一段时间(比如 50 毫秒) 。 醒来之后干嘛?重新去走第一步:从 Redis 查询商铺缓存! 因为那个抢到锁的兄弟大概率已经查完数据库并把缓存写好了,所以这 2999 个休眠醒来的线程,第二次去查缓存时就会"命中",直接满载而归,完美避开了对数据库的冲击。
总结一下:Java 原生锁只能防住同一个屋檐下(同一个 JVM)的人,而分布式锁(借助 Redis)能防住全天下所有服务器上的人同时去访问同一个数据库。
也就是如果部署到了100台服务器,synchronized只能锁住自己这台服务器,别的服务器锁不住,所以到时候数据库仍然会收到100次请求,而分布式锁就不会这样,直接一台服务器就可以直接锁死,别的都动不了
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿

操作锁的代码:
关键就在于这个setnx 这个指令,如果有数百上千个人执行setnx这个操作,只会有一个人成功,也就是第一个人成功 释放锁其实就是用del 指令把这个锁删掉就会成功
核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
这里面这个value值随便几都可以
这里我又调用了hutool工具类里面拆箱工具类,为什么要这么多此一举呢?
1. 什么是"装箱"和"拆箱"?
在 Java 中,数据类型分为两大家族:
- 基本数据类型(Primitive): 比如
boolean,int,double。它们非常单纯,只能存具体的值(比如 true/false,1,2.5),绝对不能存 null。 - 包装类(Wrapper Class): 比如
Boolean,Integer,Double。它们是高级的"对象",除了能存具体的值,还可以存 null**(表示空、没有)**。
装箱(Boxing): 把基本类型包装成对象。比如把 boolean 的 true 变成 Boolean 对象。 拆箱(Unboxing): 把对象里的值拆出来,变成基本类型。比如把 Boolean 对象拆成 boolean。
2. 为什么不能直接 return flag;****?
仔细看你的代码:
setIfAbsent方法的返回值类型是包装类 Boolean(注意大写),这意味着它返回的结果可能是true,可能是false,也有可能是 null(比如 Redis 连接突然断了,或者发生了一些底层异常)。- 而你整个
tryLock方法定义的返回值类型是基本类型 boolean(注意小写)。
如果你直接写 return flag;,Java 编译器会非常"贴心"地帮你做一个隐式的动作:自动拆箱 。它在底层其实执行了 flag.booleanValue()。
致命危险来了: 如果 Redis 正常,flag 是 true 或 false,自动拆箱没问题。 但如果某天系统抽风,setIfAbsent 返回了一个 null 给 flag。此时 Java 依然会傻乎乎地去执行"自动拆箱",试图调用 null.booleanValue()。 结果就是:系统当场抛出 NullPointerException**(空指针异常)**,你的程序直接崩溃!
3. **BooleanUtil.isTrue(flag)**做了什么?
为了防止这种悲剧,我们不能信任 Java 的自动拆箱,而要使用 Hutool 工具包里的 BooleanUtil.isTrue()。
如果你去看这个工具方法的底层源码,它大概长这样:
Java
public static boolean isTrue(Boolean bool) {
if (bool == null) {
return false; // 如果是 null,安全地返回 false
}
return bool; // 只有在绝对不为空的情况下,才进行拆箱
}
总结: 不能直接返回,是因为直接返回会触发"自动拆箱"。在拆箱过程中,如果对象是 null,程序就会报空指针异常崩溃。使用 BooleanUtil 工具类,是为了优雅且安全地处理 null 值,保证系统的健壮性。
操作代码:
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("key");
// 2、判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的值是否是空值
if (shopJson != null) {
//返回一个错误信息
return null;
}
// 4.实现缓存重构
//4.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断否获取成功
if(!isLock){
//4.3 失败,则休眠重试
Thread.sleep(50);
//这里用了递归
return queryWithMutex(id);
}
//4.4 成功,根据id查询数据库
shop = getById(id);
// 5.不存在,返回错误
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}
finally {
//7.释放互斥锁
unlock(lockKey);
}
return shop;
}
在这里有几个问题值得提问考究一下
我们看到这个地方有个休眠,于是我想问?
如果这里不休眠会发生什么
如果你把这一行删掉(或者注释掉),让它获取锁失败后立刻执行 return queryWithMutex(id);,你的程序在一瞬间就会面临三个灾难性的后果:
致命报错:栈溢出 ( java.lang.StackOverflowError**)**
这是最直接、最快发生的崩溃。
- 原因: 你这里用的是 递归 。如果获取不到锁,当前方法不结束,而是立刻在内部再调用一次自己。
- 没有休眠的情况: 计算机的执行速度极快。如果另一个线程需要 100 毫秒才能查完数据库并释放锁,在没有 sleep****的情况下,你失败的这台机器在这 100 毫秒内,可能会瞬间递归调用 queryWithMutex****几千甚至上万次!
- 结果: Java 虚拟机的线程栈深度是有限的。短时间内几千次的方法嵌套调用,会瞬间撑爆栈内存,直接抛出 StackOverflowError**,导致你的程序报错终止。**
这里有一个try catch 快捷键 是ctrl +alt+t,为什么要用try catch 是想在java最后执行时候也要即使发生了异常,也要执行释放锁的代码,不然会导致别的进程永远进不来
然后这个代码实际上是有瑕疵的
漏掉了一个双重检查
1. 什么是双重检查(Double-Check)?
一句话解释:在多线程排队抢锁的场景下,拿到锁的人,在干活(查数据库)之前,必须再看一眼之前的结果(查缓存)是不是已经被上一个拿到锁的人做好了。
场景还原(为什么需要它): 假设你的某件爆款商品缓存刚好过期了,就在这零点零几秒内,有 1000 个用户的请求 同时到达了这行代码。
- 第一道门(第一次查缓存): 这 1000 个请求同时去查 Redis,发现都没数据。
- 抢锁: 这 1000 个请求同时去执行 trylock()****。
- 一人得道,999人排队: 线程 A 运气好,抢到了锁。剩下的 999 个线程没抢到,去执行了 **Thread.sleep(50)**然后重试(排队等待)。
- A 去干活: 线程 A 慢悠悠地查了数据库,把数据写回了 Redis,然后释放了锁。
- 灾难发生: 线程 A 释放锁之后,前面排队的 999 个线程里,线程 B 睡醒了,它拿到了锁。
-
- 如果没有双重检查: 线程 B 拿到锁后,会 直接执行 **getById(id)**去查数据库 !紧接着线程 C、D、E 拿到锁后都会去查一次数据库。这 999 个请求依然会全部打到数据库上,把数据库打死。你的这把锁就白加了。
- 如果有双重检查: 线程 B 拿到锁后, 先回头看一眼 Redis ,发现"哎?线程 A 已经把数据放进去了!"。于是线程 B 就不去查数据库了,直接拿 Redis 的数据返回。剩下的 998 个线程拿到锁后也是同理。
我们手动补上双重检查后
双重检查完整代码
public Shop QueryWithMutex(Long id) {
// ================== 第 1 次检查 ==================
String shopRedis = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if (StrUtil.isNotBlank(shopRedis)) {
Shop shop = JSONUtil.toBean(shopRedis, Shop.class);
return shop;
}
if (shopRedis != null) {
return null;
}
String key = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
if (!trylock(key))
{
Thread.sleep(150);
return QueryWithMutex(id);
}
// ================== 第 2 次检查 ==================
shopRedis = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if (StrUtil.isNotBlank(shopRedis)) {
shop = JSONUtil.toBean(shopRedis, Shop.class);
return shop;
}
if (shopRedis != null) {
return null;
}
shop = getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set("cache:shop:" + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
delock(key);
}
return shop;
}
但是你会发现加这个双重检查之后,其实我们正常情况下也是会多查一次redis ,但是redis是存储在内存里面的,所以一点都不亏
接下来做一个高并发测试 图片里面用到的是jmeter

1. 线程数:1000
在 JMeter 里,1 个线程就代表 1 个真实的用户 。 设置 1000 个线程,就意味着你要模拟 1000 个用户同时去访问你的 QueryWithMutex 接口(比如同时点进某家热门商铺的详情页)。
2. Ramp-Up 时间(秒):5
很多新手会误以为这是"测试总共运行 5 秒钟",其实完全不是。 Ramp-Up 的意思是"启动这 1000 个用户需要花费多长时间"。
- 如果设为 0:代表 1000 个用户在第 1 毫秒内瞬间"砸"向服务器(极端的绝对并发)。
- 你设置了 5 秒:代表 JMeter 会在这 5 秒钟内,平滑、均匀地把这 1000 个用户启动完毕。
- 意思就是Ramp-Up 时间(5秒)绝对不等于总测试时间,总时间一定是大于 5 秒的。
我们来拉一条"时间线",你就彻底明白这多出来的时间去哪了:
根据上面截图中设置的参数(1000个线程,Ramp-Up 5秒,循环次数为 1):
- 第 0.00 秒: 第 1 个用户启动,向服务器发送请求。
- 第 1.00 秒: 已经有 200 个用户启动完毕并发送了请求。
- ...
- 第 5.00 秒: 第 1000 个用户(也就是最后一个用户)刚刚被启动,准备向服务器发送请求。
3. "Q什么到达 200":指的是 QPS(每秒并发请求数)
由于你设置了 1000 个用户在 5 秒内启动,这里存在一个非常简单的除法: 1000 (用户) ÷ 5 (秒) = 200 (个/秒)
这意味着:
- 第 1 秒:有 200 个用户发起了请求。
- 第 2 秒:又有 200 个用户发起请求。
- 以此类推,直到第 5 秒,1000 个请求全部发送完毕。
我们不想再下载一个jmeter来做一个高并发测试了,这里我们采用apifox来做这个高并发测试
你当前的测试配置,正在以大约 200 QPS 的压力(每秒 200 个并发请求),对你的代码进行轰炸。
后端测试工具
我们发现apifox的介绍里面 Apifox = Postman + Swagger + Mock + JMeter
至此为止后端用到测试工具我们全部都接触到了

这里100个用户和jmeter 不一样 jmeter是只发一次 而这个100个用户是持续发送qps其实也挺恐怖的

从测试结果我们可以看到平均qps是39.75

然后我们发现查询语句只执行了一次
利用逻辑过期解决缓存击穿问题
实际热点key数据库中有,的redis中都有
实际并没有ttl过期时间,只是我们人为的设计的一格expire字段用来记录它的过期时间
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

如果封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你
步骤一、
新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
步骤二、
在ShopServiceImpl 新增此方法,利用单元测试进行缓存预热

在测试类中

但是这个逻辑过期我们心里面必须得有一个谱,没有缓存预热的数据,正常查都会查不到,正是因为如此
我们才不需要管缓存击穿空数据的问题
步骤三:正式代码
ShopServiceImpl
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, 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 isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
//重建缓存 这里不用this也可以
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
我们发现逻辑过期是不是抢锁那里不用递归了,因为并不是每次请求都得枪锁执行
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
仔细分析一下这里
为什么我们这里要把它强转成一个(JSONObject)类型呢,这是因为
在源码里面
hutool 它的方法 第一个参数只支持下面这两种写法
在 Hutool(或者 Fastjson、Jackson)的源码里,JSONUtil.toBean 这个方法是有明确的参数类型限制的。它的源码里大概只定义了这么几个"入口":
// 入口 1:专门接收普通字符串
public static <T> T toBean(String jsonString, Class<T> beanClass) { ... }
// 入口 2:专门接收 JSON 对象(JSONObject 实现了 JSON 接口)
public static <T> T toBean(JSON json, Class<T> beanClass) { ... }
而JSONObject恰好可以实现了JSON接口,参数对应才可以传进去,直接传object会报错
因为redis的更新我需要另外一个独立线程来执行
这里我们是采用线程池来创建一个独立的新线程,比我们自己创建一个线程性能更好,要不断创建销毁
易错点
我自己在写这个代码的时候,遇到了一个非常易错的点
致命错误 锁释放的位置(最容易踩的坑)
Java
// 你的代码
try {
CACHE_REBUILD_EXECUTOR.submit(() -> { ... });
} finally {
delock(key); // 主线程瞬间释放了锁!
}
错误原因是我是在主线程里面释放锁的,实际上应该在我们开启的那个线程释放锁 为什么因为 我们的任务是教给另一个线程做的必须得等它把任务做完才可以释放锁
- 产生后果: 主线程把任务"扔"给线程池后,是不会等待 线程池执行完的。主线程瞬间走到
finally,把锁释放了!
总结
其实这三种总结来说,我们害怕的就是对数据库造成大量请求
第一种穿透就是因为请求那种数据库根本不存在的数据,如果有别有用心的人,一直那这种请求,我们数据库就会吃不消 这种就是穿透
第二种就是 首先请求是对很多不同数据的请求 但是这些请求的缓存在同一时间全部失效了,所以数据库会受到大量请求,所以这个就叫雪崩
第三种就是 请求只对一个数据请求,但是这个请求很多 ,与上面不同的是上面那个是对一种数据请求并不多,然后恰好这个时候,这个缓存失效了,然后数据库就被大量访问了 这就是击穿
然后记忆口诀是:只要记忆一个穿透就行了
缓存穿透 (Penetration) ------ 重点在"透"
- 字面解析: "透"有"穿透过去、一透到底、透明"的意思。说明这个请求不仅穿过了缓存,还穿过了数据库,最后什么也没捞着。
封装Redis工具类
前面我已经解决了缓存使用过程中的各种各样的问题,但是每次开发的过程中都去写这些逻辑实际上都挺复杂的,如果每次开发的时候都去写这些逻辑,其实开发的成本还是挺高的。于是我需要将这些开发的解决方案封装成工具

实际上方法一和方法三是配对的解决缓存穿透问题,方法二方法四是配对的,用解决热点击穿的问题
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}

、
我们在传参数的时候经常会看到这个TimeUnit ,实际上它就是时间的单位
然后这里用到了函数式编程,这个高级用法是我之前从来没见过的

Function<ID, R>
其实这个之前看源码也见到过,
- 第一个字母( ID**)** :代表入参(Input)的类型。也就是参数的类型。
- 第二个字母( R**)** :代表返回值(Result/Return)的类型。
Lambda 表达式是怎么和 **<ID, R>**对应的?
// 在 UserServiceImpl.java 中调用同一个工具类
public User queryUserById(Long id) {
return cacheClient.queryWithPassThrough(
"cache:user:",
id,
User.class,
(id2) -> shopMapper.selectById(id2)
);
}
我们把你刚才看到的 Lambda 拿过来,一句句跟泛型对齐:
它这里用lambda表达式代替了之前匿名内部类的写法,之前我一直以为lambda表达式就是匿名内部类,实际上不是,只是代替了匿名内部类
但是本质上都是创了一个实现function接口的对象
这就是函数式编程
// 你的 Lambda 写法:
(id2) -> shopMapper.selectById(id2)
这里用了lambda表达式 ()括号里面是方法的参数 ,右边是返回值,因为lambda可以省略return,所以这里就没有return
在这里我们好好感受了函数式编程,和泛型收获颇丰
在ShopServiceImpl 中
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 解决缓存穿透
Shop shop = cacheClient
.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
// Shop shop = cacheClient
// .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿
// Shop shop = cacheClient
// .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 7.返回
return Result.ok(shop);
}
我们可以看到用了一个这个方法
this::getById ,这个就是方法引用
使用方法引用( ::****)有一个极其苛刻的前提: Lambda 表达式的输入参数,必须和你要调用的方法的参数,在个数、类型和顺序上完全一一对应。
它是怎么从 Lambda 演变过来的?
咱们把这三种形态放在一起对比,你就能秒懂它的进化过程了:
第一代(匿名内部类) :啰嗦的废话。
-
Java
new Function<Long, Shop>() {
@Override
public Shop apply(Long id2) {
return this.getById(id2);
}
}
第二代(Lambda 表达式) :去掉外壳,保留灵魂。
-
Java
(id2) -> this.getById(id2)
第三代(方法引用) : 连参数都懒得写了,直接指路!
-
Java
this::getById
二、 为什么可以把参数 **(id2)**省略掉?
这是 Java 编译器极其聪明的体现。
在第二代 Lambda 表达式 (id2) -> this.getById(id2)中,你有没有发现一个现象: 这个 Lambda 表达式本身什么逻辑都没做,它纯粹就是一个"二传手"(传话筒)。 它左手接过来一个 id2,右手就 原封不动 地把这个 id2****塞给了 getById****方法。