黑马点评项目学习(一):短信登录模块 + 扩展(双token无感刷新)
最近开始跟练黑马点评项目,把短信登录这一块的一些思考整理成笔记,分享给正在学习这个项目的朋友。
一、项目背景与数据库设计
这是一个基于 Spring Boot + Redis + MyBatis-Plus 的模拟点评项目。先来看一下整体的数据库表设计,以及我学到的一些“为什么这么设计”。
1.1 表结构概览
从整体来看,这个项目涵盖了用户、商户、优惠券、社交互动等常见业务场景,一共设计了7张核心表。之所以这样拆分,也是遵循了数据库设计的一些基本原则:
| 表名 | 用途 |
|---|---|
| 用户表 | 存储用户账号、手机号、密码等信息 |
| 用户详情表 | 存储用户昵称、头像、简介等扩展信息 |
| 商户信息表 | 存储入驻商家的详细信息 |
| 商户类型表 | 商户分类(如美食、酒店、景点等) |
| 用户日记表 | 用户发布的探店笔记/动态 |
| 用户关注表 | 用户之间的关注关系 |
| 优惠券表 | 商户发放的优惠券信息 |
| 优惠券的订单表 | 用户领取/购买优惠券的订单记录 |
1.2 扩展思考:MySQL 到底能不能“水平扩展”?
在学习表设计时,我想到一个问题:如果一个表数据量太大了怎么办?MySQL能不能像Redis那样无限加机器扩展?
答案是:MySQL原生不支持无缝水平扩展,但可以通过一些手段实现“伪水平扩展”。
方案一:读写分离(主从复制)
增加多台从库(Slave)负责读,主库(Master)负责写。
- 优点:扩展了读能力,配置相对简单
- 缺点:写能力依然卡在单台主库上,且存在主从延迟问题
方案二:分库分表
通过 ShardingSphere / MyCat 等中间件,将一张大表拆成多张小表分散到不同服务器。
- 优点:理论上可以无限扩展
- 缺点:代码复杂度暴增,跨库事务、复杂JOIN、分页排序等能力受限
结论:MySQL 做水平扩展是有代价的,不像 NoSQL 那么“天然”。所以在项目初期,优先考虑索引优化、缓存、冷热分离,等数据量真正达到瓶颈再考虑分库分表。对于大多数学习项目来说,单库+Redis缓存已经够用了。
二、核心知识点梳理
在开始写登录功能之前,有几个前置概念需要先搞懂。
2.1 Nginx 反向代理
Nginx 不就是请求转发吗?为什么叫“反向代理”?
正向代理 vs 反向代理的本质区别:
| 维度 | 正向代理 | 反向代理 |
|---|---|---|
| 谁知道自己要访问谁? | 客户端知道目标服务器 | 客户端不知道,只知道代理地址 |
| 代理服务谁? | 代理客户端 | 代理服务器 |
| 生活中类比 | 跑腿小哥帮你买东西 | 打 400 客服电话,接线员转接具体专员 |
形象比喻:
- 正向代理像“跑腿小哥”:你(客户端)想买东西但出不去,找了小哥帮你买。商家只知道小哥来买,不知道是你买的,但你心里清楚买的是谁家的。
- 反向代理像“公司前台/400客服”:你拨通 400 电话,接线员(Nginx)帮你转接给具体售后专员(Tomcat)。你自始至终只拨了 400 这个号码,根本不知道背后是谁在处理。
在我们这个项目里,前端请求 → Nginx(反向代理)→ Tomcat(后端服务),用户只跟 Nginx 打交道,不需要知道背后有几台服务器。这就是反向代理的核心价值:负载均衡 + 隐藏后端细节。
2.2 ThreadLocal 线程域对象
为什么需要 ThreadLocal?直接用局部变量不行吗?
在 Web 开发中,每个请求是一个独立的线程。如果没有 ThreadLocal,在多线程并发场景下,共享变量会出现线程安全问题。
ThreadLocal 的本质:
ThreadLocal 是一个 Map Key:当前线程 Value:线程独有的数据副本每个线程都有自己的 ThreadLocalMap,数据被隔离在各自线程中,互不干扰。下图展示了 ThreadLocal 的数据隔离机制:
┌─────────────────────────────────────────────────────────────────┐ │ JVM 进程空间 │ │ │ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │ │ 用户线程 A │ │ 用户线程 B │ │ │ │ ┌─────────────────┐ │ │ ┌─────────────────┐ │ │ │ │ │ ThreadLocalMap │ │ │ │ ThreadLocalMap │ │ │ │ │ │ (key: ThreadLocal)│ │ │ (key: ThreadLocal)│ │ │ │ │ │ (value: 用户A) │ │ │ │ (value: 用户B) │ │ │ │ │ └─────────────────┘ │ │ └─────────────────┘ │ │ │ └─────────────────────┘ └─────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘典型应用场景:在登录拦截器中,把当前登录用户信息存入 ThreadLocal,在 Controller、Service 层随时可以获取,无需层层传参。
三、短信验证码登录流程
这个项目的登录/注册功能做得很巧妙:登录和注册合二为一。
3.1 发送验证码
开始 → 提交手机号 → 校验手机号格式 → 生成6位验证码 → 保存验证码到Session → 发送验证码 → 结束3.2 登录/注册(合二为一)
开始 → 提交手机号 + 验证码 → 校验验证码是否正确 → 根据手机号查询用户 → 用户是否存在? → 不存在:创建新用户并保存到数据库 → 存在:直接取出用户信息 → 保存用户信息到 Session → 登录成功为什么说是“合二为一”?因为对于前端来说,只需要一个登录接口,后端自动判断是登录还是注册,这种设计在移动端很常见,减少了前端判断逻辑。
四、登录状态校验与拦截器
4.1 核心流程图
┌─────────────────────────────────────────────────────────────────────────────────────┐ │ 登录状态校验流程 │ ├─────────────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────┐ │ │ │ 浏览器请求 │───▶│ RefreshToken │───▶│ Login │───▶│ Controller │ │ │ │ (携带token)│ │ Interceptor │ │ Interceptor │ │ 业务处理 │ │ │ └──────────┘ │ (优先级0) │ │ (优先级1) │ └─────────────┘ │ │ └──────────────────┘ └──────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ 1.从Header取token │ │ 1.从ThreadLocal │ │ │ │ 2.Redis查用户 │ │ 取用户信息 │ │ │ │ 3.存在则刷新TTL │ │ 2.不存在则拦截 │ │ │ │ 4.存入ThreadLocal│ │ 返回401 │ │ │ └──────────────────┘ └──────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────────────────────────────┐ │ │ │ afterCompletion │ │ │ │ 无论成功/失败,最终移除ThreadLocal │ │ │ │ 防止内存泄漏和数据污染 │ │ │ └──────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────────────┘4.2 为什么需要两层拦截器?
单体 Session 模式的问题:
每次请求通过 SessionId 判断是否有登录用户,但项目部署在多台 Tomcat 上时,Session 不共享,请求切换到不同服务器会导致用户数据丢失,用户被迫重新登录。
解决方案:用 Redis 代替 Tomcat Session 存储用户信息。
我设计了两层拦截器,职责分工明确:
RefreshTokenInterceptor(刷新拦截器)
- 从请求头获取 token
- 基于 token 从 Redis 中查询用户信息
- 如果存在:刷新 token 有效期,并将用户信息存入 ThreadLocal
- 无论是否找到用户,都放行(真正的登录校验交给第二层)
- 存在目的:保证用户访问任何请求时,只要携带有效 token,就能刷新有效期,避免用户正在操作时突然过期。
LoginInterceptor(登录校验拦截器)
- 从 ThreadLocal 中获取用户
- 如果不存在:返回 401 状态码,拦截请求
- 如果存在:放行
拦截器配置:
@ConfigurationpublicclassMvcConfigimplementsWebMvcConfigurer{@OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){// 第一层:刷新拦截器 — 拦截所有请求,刷新 token 有效期registry.addInterceptor(refreshTokenInterceptor).addPathPatterns("/**").order(0);// 优先级最高// 第二层:登录校验拦截器 — 排除不需要登录的接口registry.addInterceptor(loginInterceptor).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login").order(1);}}关键点:
- RefreshTokenInterceptor 的
order(0)先执行 - LoginInterceptor 的
order(1)后执行 - 登录校验拦截器需要排除掉登录、注册、商铺查询等不需要登录就能访问的接口
4.3 登录拦截器代码实现
LoginInterceptor(登录校验拦截器):
publicclassLoginInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{// 1. 从 ThreadLocal 中获取用户UserDTOuser=UserHolder.getUser();// 2. 没有用户,拦截并返回 401if(user==null){response.setStatus(HttpStatus.UNAUTHORIZED.value());returnfalse;}// 3. 有用户,放行returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{// 防止内存泄漏和线程复用时的数据污染UserHolder.removeUser();}}RefreshTokenInterceptor(刷新拦截器):
@Slf4j@ComponentpublicclassRefreshTokenInterceptorimplementsHandlerInterceptor{@AutowiredprivateStringRedisTemplatestringRedisTemplate;@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{// 1. 从请求头获取 tokenStringtoken=request.getHeader("authorization");if(StrUtil.isBlank(token)){returntrue;// 没有 token,放行(让 LoginInterceptor 去拦截)}// 2. 基于 token 从 Redis 获取用户信息Stringkey=RedisConstants.LOGIN_USER_KEY+token;Map<Object,Object>userMap=stringRedisTemplate.opsForHash().entries(key);// 3. 用户不存在,放行(让 LoginInterceptor 去拦截)if(userMap.isEmpty()){returntrue;}// 4. 将 Hash 数据转为 UserDTOUserDTOuserDTO=BeanUtil.fillBeanWithMap(userMap,newUserDTO(),false);// 5. 存入 ThreadLocalUserHolder.saveUser(userDTO);// 6. 刷新 token 有效期(30分钟)stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);returntrue;// 放行}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{UserHolder.removeUser();}}五、关于无感刷新的思考
5.1 这个方案算“无感刷新”吗?
算!而且已经实现了无感刷新的核心逻辑。
所谓的“无感刷新”是指:用户在请求过程中,Token 过期了,但系统在不打扰用户的情况下自动续期。
这个方案中:
- 用户每次请求都携带 token
- RefreshTokenInterceptor 从 Redis 查到用户后,自动续期 token 有效期
- 用户无感知,也不会被踢下线
这就是一个完整的“无感刷新”机制,只不过实现方式比较轻量。
5.2 为什么不做双 Token(Access Token + Refresh Token)?
你提到的双 Token 方案是标准的 JWT 无感刷新方案:
- Access Token:短期有效(如 15 分钟),用于日常请求
- Refresh Token:长期有效(如 7 天),只在 Access Token 过期时用来换取新的 Access Token
双 Token 方案的流程:
┌─────────────────────────────────────────────────────────────────────┐ │ 双Token无感刷新流程 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ 1.登录请求 ┌──────────────┐ │ │ │ 前端 │ ───────────▶ │ 后端 │ │ │ │ │ ◀─────────── │ 返回 access+ │ │ │ │ │ 2.返回双Token│ refresh_token │ │ │ └─────────┘ └──────────────┘ │ │ │ │ │ │ │ 3.携带 access_token 请求 │ │ │ │ ──────────────────────▶ │ │ │ │ │ 4.校验 access_token │ │ │ │ 5.过期?使用 refresh_token 续期 │ │ │ ◀────────────────────── │ 6.返回新的 access_token │ │ │ 7.返回新access_token │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘这个项目的 Token 其实是“Token + Redis”模式,而不是纯 JWT 方案。
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 黑马点评方案(低配版) | Token 作为 Redis 的 Key,用户信息存 Redis | 实现简单,可随时踢人下线 | 每次请求都要查 Redis,Token 本身不携带信息 |
| 双 Token 方案(高配版) | Access Token + Refresh Token 都是 JWT | 不依赖 Redis 存储,性能更好 | 实现复杂,无法主动踢人下线 |
为什么黑马点评这个项目没有用双 Token 方案?
- 课时限制:这个项目是教学项目,重点在于讲清楚 Redis 在登录中的应用场景
- 复杂度控制:双 Token 涉及 JWT 解析、过期逻辑判断、前端配合刷新等,对于新手来说理解成本较高
- 其实已经够用:对于绝大多数业务系统,Redis 存储 Token + 自动续期完全够用了,只是双 Token 方案更“专业”
但在真正的生产环境,尤其是追求极致性能的大规模分布式系统中,往往倾向于用双 Token 方案,将登录状态校验从 Redis 中解放出来,同时兼顾性能与安全性。
5.3 如何将这个项目升级为双 Token 方案?
如果你学有余力,可以尝试把这个项目升级为双 Token 方案,主要改动点:
登录接口改造: ├── 生成 Access Token(有效期 15-30 分钟) ├── 生成 Refresh Token(有效期 7 天) └── 两个 Token 都返回给前端 刷新接口新增: ├── /user/refresh 接口 ├── 接收 Refresh Token ├── 校验 Refresh Token 是否有效 ├── 生成新的 Access Token 返回 前端改造: ├── 请求拦截器:每次请求携带 Access Token ├── 响应拦截器:收到 401 时自动调用刷新接口 ├── 刷新成功后重试原请求 └── 刷新失败则跳转登录页六、总结
这一节学了短信登录模块的完整实现,核心收获:
| 知识点 | 要点 |
|---|---|
| MySQL水平扩展 | 读写分离扩展读能力,分库分表扩展写能力,但有代价 |
| 正向/反向代理 | 正向代理代理客户端,反向代理代理服务器,Nginx是反向代理 |
| ThreadLocal | 每个线程独立的数据副本,用于请求链路中传递用户信息 |
| 短信登录 | 登录注册合二为一,通过手机号+验证码完成认证 |
| 集群Session问题 | 多台Tomcat不共享Session,用Redis统一存储解决 |
| 两层拦截器 | 第一层刷新Token有效期,第二层校验登录状态 |
| 无感刷新 | 通过拦截器自动续期Token,用户无感知 |
一个小建议:这个项目的登录方案其实已经实现了无感刷新的核心逻辑。如果你是初学者,先把这套方案吃透,再去研究双 Token 方案,会轻松很多。毕竟先搞懂“为什么这么做”,再研究“怎么做得更好”,学习效率最高。
下一篇预告:黑马点评(二):商户缓存与Redis实战 —— 为什么说缓存是性能优化的第一把刀?