黑马点评短信登录模块核心设计:Redis与双重拦截器实战解析 很多人学黑马点评这个项目第一道坎就是短信登录模块。网上一搜「黑马点评笔记」能找到大量速通版、代码贴甚至还有带代称的资料比如有人叫它 purse wind 版说明这一节确实是大家反复咀嚼的入门关。我自己啃完这个模块后最深的感受是短信登录虽然只有两个接口却把 Redis 的基本使用、拦截器设计、ThreadLocal 线程上下文、前后端联调方式全串起来了。这篇笔记不会逐行贴课程代码而是把「为什么这么设计」讲透再给一套可以直接落在简历上的理解和话术适合正在刷黑马点评、或者准备拿它做简历项目的同学。1. 短信登录的核心设计思路拆解1.1 为什么要把 Session 换成 Redis按传统 Web 开发的思路登录状态最自然的做法是放到 Session 里后端往 Session 写数据浏览器通过 Cookie 自动携带 SessionID下次请求时服务端一查就知道是谁。这个方案在单体单机应用里完全没问题但黑马点评这类教学项目刻意把门槛抬高它要模拟真实生产环境的分布式部署。分布式环境下用户的请求会被负载均衡分发到不同的服务器。假设第一次请求落在实例 ASession 数据存在 A 的内存里第二次请求被分到实例 BB 的内存里没有这个 Session用户就变成「未登录」状态了。解决办法无非是 Session 粘滞让同一个用户的请求固定打到同一台机器或者 Session 复制多台机器之间同步数据前者在机器宕机时直接失效后者有数据一致性和性能开销的问题。相比之下把登录态抽出来放到独立的 Redis 里所有实例都从同一份数据里查问题就自然消失了。所以黑马点评的第一个设计点就是用 Redis 取代 Session 存储用户登录态。这也是面试官最爱问的「为什么不用 Session」背后的真实场景。理解了这一点你简历上写「基于 Redis 解决分布式会话共享」才有底气。1.2 验证码与登录态一次登录涉及两份 Redis 数据短信登录流程拆开其实只有两件事发验证码、用验证码换登录态。这两件事各对应一份 Redis 数据缺一不可。第一份是验证码。用户输入手机号后端生成随机验证码以login:code:{手机号}为 key 存入 RedisTTL 设 5 分钟。用户收到验证码后连同手机号一起提交后端取出 Redis 里的验证码比对比对成功后立刻删除这份数据防止同一个验证码被反复使用。这里用 Redis 而不是数据库核心原因是 Redis 天然支持过期时间验证码这种「用完即弃」的数据不需要落库过期后 Redis 自动清理也不用写定时任务。第二份是登录态。验证码校验通过后后端生成一个 UUID 作为 Token以login:token:{UUID}为 key 存入 Redisvalue 是用户信息通常只存 id、昵称、头像这类非敏感数据TTL 设 30 分钟然后把 Token 返回给前端。前端之后每次请求都在请求头Authorization里带上这个 Token后端拦截器解析 Token → 查 Redis → 拿到用户信息 → 放行。Redis KeyValue 内容TTL失效策略login:code:{phone}6位数字验证码5分钟校验成功后主动删除或到期自动删除login:token:{uuid}用户DTOid、昵称、头像30分钟到期自动删除用户再次操作时刷新这里有个容易忽略的点为什么 Token 用 UUID 随手生成而不直接用 JWTJWT 的好处是无状态、服务端不用存数据但坏处是无法主动控制失效——你没法让一个已经发出去的 JWT 立刻作废。而 UUID Redis 的方案里Token 是 Redis 的 key想踢人下线直接删 key 就行想续期直接重新设置 TTL 就行。黑马点评选了后者的教学意义很明显让你体会「有状态登录」在业务控制上的灵活性。实际上很多互联网项目的登录态确实也这么做JWT 更多用在无状态授权的场景两者没有绝对的优劣面试时说清楚取舍就行。1.3 双重拦截器把「刷新有效期」和「登录校验」解耦第一次看课程里的双拦截器设计很多人会懵一个刷新拦截器、一个登录拦截器为什么不合并成一个关键在需求。我们希望实现「滑动过期」用户只要持续操作登录态就一直有效如果 30 分钟不操作登录态才失效。那问题来了如果只做一个登录校验拦截器并且只拦截需要登录的接口那么用户访问公开页面时拦截器根本不会执行Token 的有效期不会被刷新。用户先登录然后浏览了 29 分钟公开页面第 30 分钟去点击需要登录的功能Token 刚好过期体验就很糟糕。解决方案就是拆成两层第一层 RefreshTokenInterceptor刷新拦截器拦截所有请求/​**。只要请求头带 Token 且 Redis 里查得到用户就把用户信息放到 ThreadLocal并重新设置 Token 的 TTL 为 30 分钟。无论这个接口是否需要登录只要用户带着有效 Token 在访问就帮他续期。第二层 LoginInterceptor登录拦截器只拦截需要登录的接口。从 ThreadLocal 拿用户拿不到说明未登录或 Token 失效直接返回 401。分工明确第一层管「活跃就续期」第二层管「没登录就不许进」。这样公开页面不强制登录但能享受续期核心接口强制登录安全有保障。两层拦截器配合也顺便把 ThreadLocal 的读写时机练透了——这是面试时能讲出花的设计点后面单独展开。2. 核心细节解析与实操要点2.1 验证码的生成、存储与校验到底要注意什么验证码这块看着简单实际写起来有四个细节特别容易踩坑。第一是手机号格式校验。接口一上来先校验手机号是否合法正则表达式1[3-9]\\d{9}就够了。别小看这步它能拦截掉大量无效请求省得后续白查一次 Redis。我在开发时习惯把校验抽成一个私有方法逻辑清晰也好复用。第二是验证码的生成。6 位数字验证码要保证随机性用RandomUtil.randomNumbers(6)这类工具就行不能用Math.random()拼字符串否则会有概率问题。测试环境里没有真实短信服务一般直接把验证码打到日志里方便联调。这是课程里的常见做法所谓「发送短信」在本地跑就是log.debug(验证码: {}, code)。第三是存储时的原子性。设置验证码要带过期时间最好用stringRedisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES)这种一次性设置的方式。要避免先set再单独expire两步走——如果两步之间程序异常key 就永远不过期了。第四是校验后的删除时机。验证码比对成功或者验证码过期出错时都应该删除 Redis 里的旧值。我见过不少同学只删成功不删失败结果 5 分钟内同一个验证码能反复试这就不合理了。删掉之后无论成功还是失败这个验证码都是废的下一次必须重新获取。还有一个容易忽略的并发问题用户连续点「发送验证码」会不停覆盖 Redis 里的旧验证码。生产环境通常要加发送间隔限制比如 60 秒内不能重复发送用 Redis 的setnx或者带短 TTL 的 key 实现都很简单。课程里不一定讲但面试问「短信接口如何防刷」时这个点非常加印象分。2.2 Token 与用户信息的序列化方案登录成功后要往 Redis 里写用户信息这里有两个选择存整个用户对象还是只存必要的字段。安全角度讲绝对不能把密码等敏感字段存进 Redis否则一旦 Redis 被拖库全部用户密码直接暴露。所以黑马点评的做法是定义 UserDTO只保留 id、昵称、头像三个字段存进去。这个「DTO 脱敏」的意识在真实项目中很重要面试时也可以主动提。存储结构上推荐用StringRedisTemplate JSON 字符串的方式。原因很现实项目里默认的 RedisTemplate 如果用 JDK 序列化器存进去的 key 和 value 在 Redis 客户端里全是一堆乱码调试特别痛苦。StringRedisTemplate 的 key 和 value 都是字符串人眼可读排查问题方便。存的时候把 UserDTO 转成 JSON 字符串取的时候再反序列化回来就行。代码思路大致是// 生成 token String token UUID.randomUUID().toString().replace(-, ); // 缓存用户信息30分钟过期 stringRedisTemplate.opsForValue().set( login:token: token, JSONUtil.toJsonStr(userDTO), 30, TimeUnit.MINUTES );注意 key 的命名规范业务前缀:模块名:唯一标识比如login:token:{uuid}。这样同类 key 在 Redis 里有统一前缀批量管理、按前缀清理都方便也避免了和其他业务的 key 冲突。这一习惯比代码本身值钱写任何中型项目都用得上。2.3 ThreadLocal 用户上下文set 与 remove 的时机ThreadLocal 的机制可以简单理解成「每个线程自己的私有储物柜」。在 Web 请求处理中一次请求从进入到返回全程由同一个线程处理所以可以在拦截器里把用户信息放进 ThreadLocal后面的 Controller、Service 需要时随时取不用把 user 对象当作参数层层传。这能省掉大量重复传参的代码。但这里有两个关键时机必须控制好。第一个时机是写入。第一层刷新拦截器的 preHandle 里解析出用户就调用UserHolder.saveUser(userDTO)。这样所有后续代码都能通过UserHolder.getUser()拿到当前登录用户。第二个时机是清理。必须在拦截器的afterCompletion方法里调用UserHolder.removeUser()。原因在于 Tomcat 的工作线程是复用的线程处理完一个请求不会销毁而是归还到线程池。如果不清理 ThreadLocal下一个请求复用到这个线程时还能读到上一个用户的数据——这就酿成用户数据串号的严重事故。内存泄漏倒是其次数据错乱才是致命的。Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserHolder.removeUser(); }这条「用完后必须 remove」的铁律适用于所有 ThreadLocal 使用场景不管是做登录上下文还是其他需求都要刻在脑子里。3. 实操过程与核心环节实现3.1 环境准备依赖、Redis、表结构动手之前先把基础环境准备好。黑马点评基于 Spring Boot核心依赖包括 Spring Web、MyBatis-Plus、MySQL、Redis。Redis 建议本地装好并启动默认端口 6379不用密码。MySQL 里建好tb_user表关键字段有id、phone、password、nick_name、icon、create_time、update_time。短信登录阶段未注册的用户会自动创建一条新用户记录所以phone要求唯一约束。我把配置写在application.yml里重点确认 Redis 连接配置正确。不少同学跑不起来不是代码问题而是 Redis 没启动或者密码配置不一致启动项目前先redis-cli ping一下返回 PONG能省很多排查时间。3.2 发送验证码与登录接口的实现Controller 层暴露两个接口我按课程习惯把业务逻辑放在 Service 里RestController RequestMapping(/user) public class UserController { PostMapping(/code) public Result sendCode(RequestParam(phone) String phone) { return userService.sendCode(phone); } PostMapping(/login) public Result login(RequestBody LoginForm loginForm) { return userService.login(loginForm); } }Service 里的核心逻辑我拆成三步讲。第一步是发送验证码。校验手机号格式生成 6 位验证码存 Redis 设置 5 分钟过期然后走日志打印模拟发送。代码大概长这样public Result sendCode(String phone) { if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail(手机号格式错误); } String code RandomUtil.randomNumbers(6); stringRedisTemplate.opsForValue().set( login:code: phone, code, 5, TimeUnit.MINUTES ); log.debug(向手机号 {} 发送验证码{}, phone, code); return Result.ok(); }第二步是登录接口。先校验验证码再查用户不存在就创建新用户最后保存登录态并返回 token。贴核心代码public Result login(LoginForm loginForm) { String phone loginForm.getPhone(); String code loginForm.getCode(); // 1. 校验验证码 String cachedCode stringRedisTemplate.opsForValue().get(login:code: phone); if (cachedCode null || !cachedCode.equals(code)) { return Result.fail(验证码错误); } // 验证完毕立即删除验证码 stringRedisTemplate.delete(login:code: phone); // 2. 根据手机号查用户 User user userService.query().eq(phone, phone).one(); if (user null) { // 3. 新用户自动注册默认昵称用随机用户名 user new User(); user.setPhone(phone); user.setNickName(user_ RandomUtil.randomString(6)); // 省略密码、头像等默认值 this.save(user); } // 4. 保存用户信息到 Redis 并返回 token UserDTO userDTO new UserDTO(user.getId(), user.getNickName(), user.getIcon()); String token UUID.randomUUID().toString().replace(-, ); stringRedisTemplate.opsForValue().set( login:token: token, JSONUtil.toJsonStr(userDTO), 30, TimeUnit.MINUTES ); return Result.ok(token); }第三步是前端的配合。拿到 token 后前端要在每次请求的请求头里带上Authorization: token值浏览器的 localStorage 是常见的存储位置。这一步容易漏我见过不少同学后端代码全程正确但测试时因为前端没带 token 一直 401。3.3 拦截器注册与路径规划拦截器的代码本身不难难在注册顺序和路径规划。刷新拦截器实现HandlerInterceptorpreHandle 里尝试从请求头解析 token、查 Redis、存 ThreadLocal、刷新 TTL无论结果如何都返回 true 放行因为公开页面不需要强制登录。public class RefreshTokenInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(authorization); if (token ! null) { String key login:token: token; String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { UserDTO userDTO JSONUtil.toBean(json, UserDTO.class); UserHolder.saveUser(userDTO); // 刷新有效期 stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES); } } return true; } }登录拦截器从 ThreadLocal 取用户没有就返回 401public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (UserHolder.getUser() null) { response.setStatus(401); return false; } return true; } }WebConfig 里注册这两个拦截器顺序非常关键——刷新拦截器必须注册在登录拦截器之前否则登录拦截器先执行用户信息还没写入 ThreadLocal必然判定未登录Configuration public class WebConfig implements WebMvcConfigurer { Autowired private RefreshTokenInterceptor refreshTokenInterceptor; Autowired private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(refreshTokenInterceptor).addPathPatterns(/**).order(0); registry.addInterceptor(loginInterceptor) .addPathPatterns(/user/**, /blog/**, /voucher-order/** ...) .excludePathPatterns(/user/code, /user/login) .order(1); } }order 值越小越先执行。登录拦截器只拦截需要登录的接口但放行/user/code和/user/login这两个本来就无需登录的入口。这块配置完整跑通整个登录链路就算闭环了。4. 常见问题与面试问题实录4.1 自测踩坑实录从「跑不起来」到「一次通过」我把自己和身边同学踩过的坑整理成一份速查表遇到同款问题直接对照排查。现象本质原因解决办法请求返回 401但明明登录成功了前端没把 token 放到请求头或请求头 key 大小写不一致统一用Authorization前端 axios 拦截器统一添加Redis 里的 key 显示为乱码RedisTemplate 默认用 JDK 序列化器换成 StringRedisTemplate或者自定义 JSON 序列化器验证码一直校验不过存的时候可能带了空格或 key 拼接不一致检查 phone 是否需要 trim确认 key 格式前后端统一登录后 ThreadLocal 里取不到用户刷新拦截器没有先执行或注册顺序写反确认 WebConfig 里 order 值刷新拦截器 order(0) 在前用户操作频繁但 30 分钟一到还是掉线刷新 TTL 的代码只写在登录校验拦截器里访问公开页面不刷新把刷新逻辑放在第一层全路径拦截器里日志里验证码正常但接口报错Redis 连接配置错误或服务没启动redis-cli ping检查确认 host/port/password其中 Redis 乱码这个坑我重点说一嘴。如果你遇到\xAC\xED\x00\x05t...之类的字节串基本就是 JDK 默认序列化的锅。弦外之音是所有需要人工排查的 Redis 数据都应该用字符串形态可视化存储。这也是我用 StringRedisTemplate 而非默认 RedisTemplate 的根本原因。4.2 高频面试问题与回答参考黑马点评几乎人手一份简历面试官对它的套路很熟。围绕短信登录模块我整理了 5 个高频问题和相对稳的回答。面试问题参考回答要点加分动作为什么登录态要用 Redis 存不用 Session分布式环境 Session 不共享Redis 独立存储、TTL 自动过期、支持集群天然适合保存会话数据主动提 Session 粘滞/复制方案的缺点验证码过期时间为什么设 5 分钟兼顾用户体验和安全验证码本身短时效Redis TTL 自动处理过期补充说明校验后主动删除防止复用Token 为什么用 UUID不用 JWTUUIDRedis 可以主动控制失效删 key 即下线方便续期JWT 无状态但无法作废说明技术选型讲业务场景不无脑踩 JWTThreadLocal 会内存泄漏吗线程池复用导致 ThreadLocal 数据残留在 afterCompletion 里 remove 就能避免能说出「串号」比「内存泄漏」更危险30 分钟无操作自动退出怎么实现Redis key 设 TTL 为 30 分钟用户每次访问刷新 TTL实现滑动过期点出两层拦截器的设计意图回答时别背课文把「我做了什么、为什么这么做、有什么好处」讲清楚。比如问 ThreadLocal就说「我用 ThreadLocal 保存当前用户避免层层传参同时很注意在 afterCompletion 里 remove因为 Tomcat 线程是复用的不清理的话下个请求会拿到上一个用户的数据」。这套话术比干巴巴背定义有说服力得多。4.3 简历亮点写法建议很多同学的简历黑马点评只写一句话「使用 Redis 实现短信验证码登录」。这句话毫无区分度。我建议按「职责 技术 收益」的格式展开比如负责短信验证码登录模块基于 Redis 重构 Session 会话管理解决分布式场景下登录态共享问题。设计 UUID Token Redis 缓存用户信息配合 TTL 实现 30 分钟滑动过期提升登录态安全性。使用双重拦截器 ThreadLocal 实现用户身份解析与传递优化代码可维护性避免重复查询数据库。关键词可以覆盖Redis、分布式会话、Token、TTL、拦截器、ThreadLocal、滑动过期。但注意简历上的每一个字都要经得起追问。你写了「滑动过期」就必须能画出来两层拦截器怎么配合写了「解决分布式会话共享」就得解释为什么 Session 不适合分布式。我见过不少简历写得漂亮、一深问就露馅的同学与其这样不如先把这篇笔记里的逻辑吃透再往简历上搬。最后分享一点我的实操体会把短信登录整个模块从零敲完并调试通过之后你其实已经跨过了黑马点评项目里最「劝退」的一道坎。很多人在这一节放弃不是代码有多难而是被分布式会话、拦截器、ThreadLocal 这些概念一下子砸懵了。我的建议是不要急着看速通笔记或者所谓 purse wind 版本之类的高密度资料先自己把流程走通注册拦截器、刷新 TTL、管理 ThreadLocal 清理每一步亲手碰一遍再去读别人的总结会有完全不同的感受。这个模块打通之后后面的秒杀、关注推送、附近商户在思路上都会顺很多。至少我自己就是从这一节开始才真正觉得 Redis 在项目里不是「为了用而用」而是解决实际问题的关键角色。