黑马点评项目学习一短信登录模块 扩展双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 中获取用户UserDTOuserUserHolder.getUser();// 2. 没有用户拦截并返回 401if(usernull){response.setStatus(HttpStatus.UNAUTHORIZED.value());returnfalse;}// 3. 有用户放行returntrue;}OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{// 防止内存泄漏和线程复用时的数据污染UserHolder.removeUser();}}RefreshTokenInterceptor刷新拦截器Slf4jComponentpublicclassRefreshTokenInterceptorimplementsHandlerInterceptor{AutowiredprivateStringRedisTemplatestringRedisTemplate;OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{// 1. 从请求头获取 tokenStringtokenrequest.getHeader(authorization);if(StrUtil.isBlank(token)){returntrue;// 没有 token放行让 LoginInterceptor 去拦截}// 2. 基于 token 从 Redis 获取用户信息StringkeyRedisConstants.LOGIN_USER_KEYtoken;MapObject,ObjectuserMapstringRedisTemplate.opsForHash().entries(key);// 3. 用户不存在放行让 LoginInterceptor 去拦截if(userMap.isEmpty()){returntrue;}// 4. 将 Hash 数据转为 UserDTOUserDTOuserDTOBeanUtil.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 过期了但系统在不打扰用户的情况下自动续期。这个方案中用户每次请求都携带 tokenRefreshTokenInterceptor 从 Redis 查到用户后自动续期 token 有效期用户无感知也不会被踢下线这就是一个完整的“无感刷新”机制只不过实现方式比较轻量。5.2 为什么不做双 TokenAccess 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实现简单可随时踢人下线每次请求都要查 RedisToken 本身不携带信息双 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实战 —— 为什么说缓存是性能优化的第一把刀