资讯详情

Redis替代Session:短信验证码登录与登录态管理实战

📅 2026/10/18 4:50:44 | 华诺云谱 👁 阅读
Redis替代Session:短信验证码登录与登录态管理实战
1. 为什么登录状态要交给 Redis 而不是 Session先把这个项目背景说清楚。这是黑马点评项目的第一篇实战核心就一件事用手机号加验证码完成登录然后把登录状态存进 Redis。很多人第一次看到这个需求会觉得没必要折腾Session 直接存不就好了但真在项目里跑起来你就会发现 Session 方案就是给自己埋雷。1.1 Session 方案的致命短板Session 的数据是存在哪的存在启动它的那一台 Tomcat 实例的内存里。这就意味着两个问题。第一个问题是分布式环境下 Session 不共享用户第一次请求落到 A 机器登录成功了第二次请求被负载均衡转发到 B 机器B 机器根本没有这个 Session用户直接变成未登录状态。第二个问题是 Session 默认有一个空闲超时时间Tomcat 默认 30 分钟只要用户在这个时间内没有新请求登录状态就没了。而且这个超时时间改起来很麻烦改少了用户骂改多了内存扛不住服务器一重启全线掉线。可能有人会说那用 Spring Session 把 Session 搬到 Redis 里不就行了确实能解决共享问题但 Spring Session 底层还是走 Cookie 里的 JSESSIONID对于前后端分离的项目iOS、Android、小程序这些客户端对 Cookie 的支持很差跨域场景下 Cookie 的处理更是让人头疼。说白了Session 这套机制天生就是给服务端渲染页面准备的强行套到现如今的 App 前端架构上怎么弄都别扭。1.2 Redis 方案的三个明显优势换成 Redis 之后前面说的几个坑全部绕开了集中式存储天然支持横向扩展。多台后端服务连同一个 Redis谁处理请求都能查到同一个用户的登录态不需要 Session 复制不需要粘性会话sticky session加机器就能扛流量。过期时间原生支持不用自己写定时清理。验证码设 5 分钟过期登录 Token 设 30 分钟过期Redis 的 TTL 机制自动帮你清理实操代码就是一行set(key, value, timeout, TimeUnit)。接口风格对前端极其友好。登录成功只返回一个 Token 字符串前端自己存着后续请求把它放到请求头的authorization字段里就行。Cookie、Session、JSESSIONID 这些概念全部不需要前端关心跨域也不受影响。说白了Redis 在这里干的活就是一个高性能、带过期能力的键值数据库用它来保存验证码和登录态就是把 Session 从服务内存挪到了一个所有服务都能访问的中央存储里。下面整个实现都是围绕这个思路展开的。2. 短信登录整体流程设计动手写代码之前先把流程想清楚。这个功能如果一开始没设计好后面补拦截器、补刷新过期时间会非常痛苦。我建议你把下面几步记在脑子里再去看代码。2.1 从发送验证码到登录成功的完整链路整个短信登录分两大块。第一块是发送验证码用户输入手机号点获取验证码后端校验手机号格式没问题后生成一个 6 位随机数以手机号为 key 存进 Redis5 分钟过期然后把验证码通过短信通道发出去。开发环境下没有真正的短信服务商直接把验证码打到日志里看就行这个后面代码会说。第二块是登录操作用户把手机号和验证码提交上来后端先去 Redis 里按手机号取出之前存的验证码跟用户提交的对一下。对上了立刻把 Redis 里的验证码删掉一次性使用防止同一个验证码被反复提交。然后拿手机号去数据库查用户查不到就自动注册一个新账号查到就直接用。最后生成一个随机 TokenUUID把用户的核心信息转成 JSON 字符串存进 Rediskey 是login:token:{token}过期时间 30 分钟再把 Token 返回给前端。这里有个关键点用户信息不是存整个用户对象。为什么因为 Redis 是内存数据库每一份冗余数据都在烧钱而且你也不想把密码哈希之类的东西放到 Redis 里。正确做法是只存一个精简的用户 DTO包含 id、手机号、昵称、头像这几个展示和鉴权需要用的字段就够了。2.2 Redis Key 如何设计才能不冲突Redis 是单一大键值空间不同的业务模块存数据很容易撞在一起。所以 key 一定要带业务前缀和标识符。我项目中用的是这两组key 示例含义过期时间login:code:13800138000手机号对应的验证码5 分钟login:token:550e8400-e29b-41d4-a716-446655440000Token 对应的用户信息30 分钟login:code:和login:token:这两个前缀建议提成常量类别在代码里到处手写字符串不然以后改前缀要全局搜索替换。我自己踩过这个坑刚开始直接拼接字符串后来想统一改成login:code:v1:的时候改了一整天才发现漏了两处。另外注意验证码的 key 用的是手机号那同一个手机号短时间内多次请求验证码后一次会把前一次的覆盖掉。这个特性正好可以用来做发送冷却后面讲防刷的时候会细说。2.3 双拦截器结构Token刷新与登录校验分离登录态校验的逻辑很多人会直接在 Controller 里写一个getCurrentUser()方法每个接口调一遍。这种方式能工作但代码非常啰嗦。更好的选择是用 Spring MVC 的拦截器在请求进入 Controller 之前统一处理。黑马点评这个项目里用的是双拦截器方案这个设计我觉得非常值得学习第一个拦截器刷新 Token对所有路径生效。它做的事情是从请求头里拿 Token如果存在就去 Redis 查用户查到就把用户信息放入 ThreadLocal顺便把这次访问作为活跃信号刷新这个 Token 的过期时间也就是重新设置 30 分钟 TTL。如果没查到直接放行不拦截。第二个拦截器登录校验只拦截需要登录才能访问的路径。它做的事情非常简单检查 ThreadLocal 里有没有用户有就放行没有就返回 401。为什么要拆成两个拦截器因为如果只有一个登录校验拦截器用户访问不需要登录的接口比如看商铺列表时Token 就不会被刷新那用户在一个页面停留超过 30 分钟后去点收藏才发现登录态已经过期了。拆成两个之后只要用户打开了 App 并且带着 Token 在访问任何接口他的登录态就会一直续期体验好很多。而且两个拦截器职责单一第一个负责“续命”第二个负责“把关”逻辑上不耦合。3. 验证码的生成、存储与校验细节都在这里这一段我会把验证码相关的操作细节掰开揉碎讲包括手机号校验、验证码生成、Redis 存取、发送冷却、一次性删除。每一步都有为什么。3.1 手机号格式校验不能省手机号不校验会出什么问题用户随便输一串数字后端就去发短信短信费你是要真金白银掏的。所以第一步一定是格式校验。校验规则一般是^1[3-9]\d{9}$也就是 1 开头第二位 3 到 9后面跟 9 位数字。有些项目会写得松一点只判断长度但那挡不住类似12345678901这种垃圾输入。实现的时候直接用现成工具类不要自己写正则。我用的是 Hutool 的RegexUtils.isPhoneInvalid(phone)项目里已经引入了 Hutool直接用就行。自己写正则容易漏边界情况而且代码可读性差。3.2 验证码存储TTL 和一次性使用缺一不可验证码生成方式RandomUtil.randomNumbers(6)Hutool 提供了随机数工具生成的每一位都是 0-9 的数字作为验证码完全够用。有人喜欢用ThreadLocalRandom.nextInt(1000000)并格式化前导零也行但 Hutool 一行就搞定自然优先。存储这一步很多教程喜欢用 Session我们要用 RedisstringRedisTemplate.opsForValue().set( RedisConstants.LOGIN_CODE_KEY phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES );LOGIN_CODE_KEY是login:code:LOGIN_CODE_TTL是 5。5 分钟是最常用的验证码有效期太长了不安全验证码被截获后长时间有效太短了用户还没看清短信就过期了体验极差。验证码校验逻辑里最容易被忽略的一步是校验成功后必须立即删除这个 key。因为 Redis 里的验证码不会因为你校验过就自动失效如果不删同一个验证码在 5 分钟内可以无限次使用等于给攻击者留了后门。删除操作很简单stringRedisTemplate.delete(RedisConstants.LOGIN_CODE_KEY phone);而且注意删除操作要在校验通过之后立刻做不要等到用户信息查完再删。校验过了说明这个验证码的使命已经完成果断删掉避免下一秒同一个请求重放。3.3 防刷的最小实现发送冷却短信接口是最容易被刷的接口因为成本是真实发生的。最简单的防刷手段就是利用验证码 key 本身做冷却。逻辑是这样的发送验证码之前先判断 Redis 里有没有这个手机号对应的验证码如果有说明用户刚发过直接返回“发送过于频繁”之类的提示不再生成新验证码。只有 key 不存在或已过期时才允许再次发送。这样就天然形成了一个 5 分钟冷却窗口。当然如果产品经理说 5 分钟内不能重发太苛刻了可以把冷却时间做成 60 秒用一个单独的前缀login:cooldown:{phone}存一个 60 秒过期的标记发送前检查这个标记即可。这种多 key 组合的防刷策略在 Redis 里做成本很低效果却立竿见影。再补充一个进阶做法给验证码校验加错误次数限制。比如同一个手机号验证码连续输错 5 次就强制删除这个验证码并触发冷却。这个逻辑可以放在校验分支里用一个login:code:error:{phone}的计数 key 实现。项目初级阶段可以不写但心里要有这跟弦。4. 登录态管理核心代码实现这一节是重头戏我会把登录接口、拦截器、ThreadLocal 工具类的关键代码都贴出来并逐段讲解。建议你对照着代码敲一遍不要复制完就过。4.1 登录接口校验验证码、自动注册、颁发 Token登录接口的代码是整个功能的核心我把注释写在代码里然后逐段拆解。Override public Result login(LoginFormDTO loginForm, HttpSession session) { String phone loginForm.getPhone(); String code loginForm.getCode(); // 1. 校验手机号格式 if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail(手机号格式错误); } // 2. 从 Redis 获取验证码并比对 String cacheCode stringRedisTemplate.opsForValue() .get(RedisConstants.LOGIN_CODE_KEY phone); if (cacheCode null || !cacheCode.equals(code)) { return Result.fail(验证码错误或已过期); } // 3. 验证码一次性使用校验通过立即删除 stringRedisTemplate.delete(RedisConstants.LOGIN_CODE_KEY phone); // 4. 根据手机号查询用户 User user query().eq(phone, phone).one(); // 5. 用户不存在则自动注册 if (user null) { user createUserWithPhone(phone); } // 6. 生成 Token 并保存用户信息到 Redis String token UUID.randomUUID().toString(true); UserDTO userDTO BeanUtil.copyProperties(user, UserDTO.class); stringRedisTemplate.opsForValue().set( RedisConstants.LOGIN_USER_KEY token, JSONUtil.toJsonStr(userDTO), RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES ); // 7. 返回 Token 给前端 return Result.ok(token); }步骤 2 和 3 是核心。先判断cacheCode是否为空为空说明验证码过期或者压根没发过不为空再比对内容内容不对直接失败。这里有个细节判断为空必须放在equals之前否则cacheCode为 null 时调用equals会抛空指针。我见过有同事把代码写成code.equals(cacheCode)这个顺序一旦cacheCode为 null 就炸了写成cacheCode.equals(code)才对。步骤 4 和 5 是自动注册逻辑。query().eq(phone, phone).one()是 MyBatis-Plus 的写法按手机号查用户表。查不到就调用createUserWithPhone创建新用户。建用户时手机号肯定要存昵称可以默认生成一个随机名称比如 “用户_手机号后四位”这样用户在第一次打开 App 时不用填注册表单就能直接用这是很多互联网产品常见的“无感注册”策略。private User createUserWithPhone(String phone) { User user new User(); user.setPhone(phone); user.setNickName(用户_ RandomUtil.randomNumbers(4)); user.setIcon(); save(user); return user; }步骤 6 里有两个关键操作。第一是生成 Token 用UUID.randomUUID().toString(true)Hutool 会去掉中间的短横线得到一个 32 位随机字符串。为什么不直接用手机号当 key因为手机号是敏感信息前端每次请求都带着手机号在网络上传等于把隐私裸奔了而且一旦手机号出现在 URL 里还会进各种日志。用无意义的随机 Token 做凭证服务端拿手机号当 key 查询用户信息两边一对照身份就确认了。第二是把用户对象转成UserDTO再序列化存入 Redis前面讲过这是为了瘦身存储和避免敏感字段泄露。最后返回Result.ok(token)前端拿到 Token 后自己保存在本地缓存里后续所有请求的请求头带上authorization字段。这个约定前后端要提前对齐字段名别一会儿叫token一会儿叫authorization会害死人。4.2 拦截器实现两个拦截器各司其职先看刷新 Token 的拦截器。这里有个实现细节要注意它的构造函数需要传StringRedisTemplate因为拦截器是由new手动创建的不是 Spring 管理的 Bean所以要用构造器注入。你千万别在拦截器里写Resource注解那会是 null因为这个对象不走 Spring 容器。public class RefreshTokenInterceptor implements HandlerInterceptor { private final StringRedisTemplate stringRedisTemplate; public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 1. 获取请求头中的 Token String token request.getHeader(authorization); if (StrUtil.isBlank(token)) { return true; } // 2. 基于 Token 获取 Redis 中的用户信息 String key RedisConstants.LOGIN_USER_KEY token; String userJson stringRedisTemplate.opsForValue().get(key); // 3. 用户存在则保存到 ThreadLocal 并刷新有效期 if (StrUtil.isNotBlank(userJson)) { UserDTO userDTO JSONUtil.toBean(userJson, UserDTO.class); UserHolder.saveUser(userDTO); stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); } // 4. 无论是否登录都放行由下一个拦截器判断是否需要登录 return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserHolder.removeUser(); } }这里要重点解释expire这行。它没有破坏原来的 value只是重新设置 key 的过期时间也就是滑动续期。用户只要在 30 分钟内发过一次请求他的登录态就往后顺延 30 分钟。这样用户一直用就不会掉线离开超过 30 分钟才会过期体验和安全性平衡得挺好。再看登录校验拦截器它的逻辑极度简单因为脏活累活都被刷新拦截器干完了public 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; } }为什么这个拦截器能从 ThreadLocal 里拿到用户因为两个拦截器的执行有先后顺序必须把刷新拦截器注册在前面。Spring MVC 的拦截器是按注册顺序执行的先执行第一个再执行第二个。这样第一个拦截器已经把用户放进 ThreadLocal 了第二个才能读到。顺序反了第二个拦截器每次都会拦下所有请求效果就是所有需要登录的接口全部 401。注册两个拦截器的配置类如下Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private StringRedisTemplate stringRedisTemplate; Override public void addInterceptors(InterceptorRegistry registry) { // 刷新 Token 的拦截器拦截所有请求 registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)) .addPathPatterns(/**); // 登录校验的拦截器放行无需登录的路径 registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /shop/**, /voucher/**, /upload/**, /blog/hot, /user/code, /user/login ); } }/user/code和/user/login是短信验证码和登录接口必须放行不然用户根本登不进来。/shop/**这类商铺查询接口如果产品设计为游客可看也需要放行。这个放行清单要跟产品需求对齐不要拍脑袋写。4.3 ThreadLocal 保存用户上下文与内存泄漏预防ThreadLocal 的作用是让同一个线程在处理请求的全程都能拿到当前用户。Spring MVC 处理一个请求从拦截器到 Controller 到 Service全都在同一个线程里执行所以 ThreadLocal 能完美穿透这些层次。你不需要在每个方法里都传一遍 userDTO 参数在任何一层调用UserHolder.getUser()就能拿到。工具类代码如下public class UserHolder { private static final ThreadLocalUserDTO 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(); } }ThreadLocal 最大的坑就是内存泄漏。Tomcat 的工作线程是复用的处理完一个请求线程不会销毁。如果我们在afterCompletion里不调用removeUser上一个用户的信息会残留在线程上下一个请求复用这个线程时getUser()拿到的就是上一个用户的数据。轻则数据串号重则把 A 用户的请求当成 B 用户处理属于生产事故级别的 bug。所以afterCompletion里的UserHolder.removeUser()是必须的不是可选项。还有一个细节afterCompletion并不总是在preHandle返回true之后才执行吗是的如果preHandle返回falseafterCompletion不会执行。在我们的双拦截器设计中刷新拦截器的preHandle总是返回true所以它的afterCompletion一定会执行removeUser一定会被调用。这就保证了无论后面逻辑怎么走ThreadLocal 都能被清理干净。5. 常见问题与排查实录这一段是我自己实践过程中真实踩过、也帮别人排查过的坑整理成速查表希望你能绕开。5.1 问题速查表现象可能原因解决方案登录接口报“验证码错误”验证码已过期、被后一次发送覆盖、校验成功后已删除确认发送和登录间隔在 5 分钟内检查 key 前缀是否一致不要重复使用同一个验证码所有需要登录的接口都返回 401请求头没带authorizationToken 过期拦截器注册顺序写反前端检查本地是否存储了 Token抓请求看 header检查addInterceptor顺序刷新拦截器在前登录成功后过一会儿就掉线没有做滑动续期或续期只会被登录校验拦截器刷新而用户访问了放行接口按双拦截器方案实现刷新逻辑对所有请求生效接口偶尔返回别人的用户信息ThreadLocal 未清理线程复用时数据串号确保afterCompletion中调用UserHolder.removeUser()Redis 里积累了无数无法自动清理的 key存 key 时没设过期时间或 TTL 设置失败检查set是否传了 timeout确认 Redis 配置无异常验证码可以重复使用校验通过后没有删除验证码 key增加stringRedisTemplate.delete(LOGIN_CODE_KEY phone)5.2 容易被忽略的三个细节第一个细节是 Redis 序列化问题。用StringRedisTemplate时value 存进去的就是普通字符串这个最直观也最不容易出问题。但如果你换成RedisTemplate而没有配置序列化器存进去的 key 和 value 可能会带一堆\xac\xed\x00\x05t\x00之类的二进制前缀因为默认用的是 JDK 序列化。不光肉眼看着难受本项目的两个应用之间如果序列化规则不一致甚至互相读不到数据。所以实战中我强烈建议除非有特殊需求一律用StringRedisTemplate JSON 字符串省心且跨语言通用。第二个细节是验证码发送的日志打印。开发环境没有短信服务商验证码必须能通过日志看到否则测试时你永远不知道验证码是什么登录接口永远无法联调。打成 debug 级别日志生产环境不会刷屏开发环境又能清楚看到。有些公司会把验证码直接放在接口响应里方便测试这个不推荐容易上线忘了删变成安全隐患。第三个细节是 Token 过期时间设置的粒度。我项目里登录 Token 的 TTL 是 30 分钟验证码是 5 分钟这几个值尽量定义成常量并集中管理。业务方如果想要“记住我”功能7 天免登录可以让登录接口根据前端传参决定 TTL 是 30 分钟还是 7 天。Redis 的set支持在运行时指定不同的过期时间这一点实现起来特别灵活。6. 我的一些实操体会这个功能做完我最深的体会是登录态管理这件事关键是“把状态集中到一个地方”这个思维模式。验证码、登录 Token、防刷冷却标记这些其实都是状态放到 Redis 里统一管理之后加过期时间、加防刷、做分布式扩展都变得非常自然。Session 方案不是不能用但它的设计模型限制了你后续在分布式架构上的操作空间。第二点体会是拦截器的分层设计值得推广。把“刷新 Token”和“校验登录”拆成两个拦截器之后后续加管理员权限、加接口级细粒度权限都是在第二个拦截器附近继续叠加完全不用动业务代码。我后来做权限扩展时给某些接口加了额外的角色判断就是在 LoginInterceptor 之后再加一个角色拦截器改动非常小。后续扩展方向上可以考虑把 Token 换成 JWT 和 Redis 的组合方案。JWT 负责携带用户基本信息Redis 负责记录 Token 是否有效、是否被手动退出可以解决 JWT 无法主动失效的问题。也可以做多端登录互踢同一个用户在新设备登录后旧 Token 对应的 Redis key 直接删除业务上就是“你的账号在其他设备登录”的效果。这些都是建立在这套 Redis 登录态模型之上的衍生玩法。最后分享一个调试技巧。联调阶段如果接口总是 401先在浏览器里打开开发者工具看请求头里authorization字段有没有值。如果没值问题在前端没存 Token不在后端如果有值再用 Redis 客户端查一下login:token:{token}这个 key 是否存在、是否过期。按这个顺序排查基本五分钟内定位问题不用瞎猜。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑