短信登录几乎是每套实战项目里都绕不开的模块也是我学完点评项目之后觉得最有代表性的一块。它不像权限框架那么重也没有支付链路那样复杂的对账逻辑但麻雀虽小五脏俱全——验证码的生成与存储、发送频率限制、登录态会话设计、Token下发、登录拦截与续期一个模块把Web开发里最常用的几套功夫全过了一遍。这篇笔记我打算围绕自己实际敲代码、反复改bug的过程来写把从流程图到落地实现中间遇到的那些坑都摊开讲。先说清楚这套点评项目里的短信登录到底要做什么。用户打开App输入手机号点获取验证码输入6位数字完成登录。如果这个手机号没注册过系统要顺手把他变成注册用户也就是说登录和注册在一条流程里解决。用户登录成功之后后续拿着手机看商家、写点评、下单请求都要能识别出当前是谁并且在一段时间内免登录。听着简单真动手之后你会发现每个环节都能写出花来。1. 短信登录的业务边界与核心流程拆解1.1 为什么用验证码而不是账号密码传统PC时代几乎所有产品都是账号密码体系用户要记一堆密码还得应对找回密码的流程。而移动App时代手机号成为天然的唯一标识验证码登录的核心逻辑是手机号能证明你拥有这个号码而能收到短信验证码就等于证明了这个号码属于你。这省掉了密码的注册、存储、加密、找回等一系列问题。在点评类App场景里大部分使用者都是普通消费者他们不会像用网银一样去记密码。如果用账号密码新用户首次使用要完成“注册-设置密码-登录”三步每一步都在流失用户。短信验证码把这三步压缩成一步手机号加验证码校验通过就是登录首次则自动注册。从产品经理的角度看这是转化率最高的一种登录方式从后端开发者的角度看这意味着我们要处理用户表中不存在的手机号不报错而是走到注册逻辑里去创建账号。1.2 完整链路的关键节点清单我把短信登录拆成下面几个节点每个节点都有明确的需求前端获取手机号校验手机格式11位1开头第二位的范围3-9后端生成6位随机验证码后端将验证码存入Redis设置有效期5分钟后端发送验证码真实项目接云服务商本课程里直接日志输出模拟前端提交手机号验证码后端比对验证码错误则拒绝比对成功根据手机号查用户表不存在则创建新用户生成Token把用户信息存入Redis将Token返回前端前端后续请求携带Token拦截器校验Token恢复用户上下文后面所有代码都是围绕这条链路展开的。你要是在自己项目里写登录模块不管业务是外卖还是打车核心骨架不会偏离这几个节点太远。2. 验证码下发随机数、Redis键设计与60秒限流2.1 生成验证码前先想清楚存哪里这是个很容易被新手带偏的地方。很多初学者一上来就把验证码放数据库表里每次发码insert一条记录然后比对的时候去查最新一条。不是不行但要用额外的字段标记过期时间、用定时任务清理过期记录明明几行Redis代码能解决的事情偏要建表、写Mapper、写清理逻辑凭空多了两三倍工作量。Redis天然适合这个场景验证码有生命周期过期就让它自动消失验证码要支持覆盖用户可能点了两次获取验证码要支持快速读取和删除。课程里用的是String类型key设计成sms:code:{phone}value是6位数字TTL设为5分钟。我实际用的也是这个方案再补充一个关键点String类型足够用了别在这里用Hash。因为每次读写都是整体操作不存在要单独更新某个字段的需求String更直观。生成验证码的方式我起初直接用了UUID.randomUUID()切割字符串但这样生成的数字分布不均匀而且换算麻烦。后来改成String.format(%06d, new Random().nextInt(999999))但极端情况下可能生成不足6位或者前导零被格式化掉最稳的还是这样// 生成6位数字验证码 int code (int) ((Math.random() * 9 1) * 100000); String codeStr String.valueOf(code);Math.random()范围是[0,1)乘9加1后范围是[1,10)再乘100000就是100000到999999之间保证了6位数字。后来我在项目里看到还有用ThreadLocalRandom的写法并发更高时更推荐String code String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999));这个写法直接把范围写在nextInt参数里100000到999999之间兼顾安全和可读性。2.2 60秒限流的实现与取值时机陷阱发验证码必须有限流否则运维电话会被打爆。最常见的限流规则是同一手机号60秒内只能发一次。课程里实现方式很聪明把发送标记也放进Rediskey是sms:code:{phone}在发送时设置5分钟过期但是只在TTL大于240秒才允许重新发送也就是留够验证码剩余有效期。我一开始没细想简单在发码前先get一次有值就拒绝没有就发。问题出现在并发调用上两个请求同时进来都get到空值然后都执行发送逻辑。更合理的做法是用setIfAbsent也就是SETNX谁抢到了锁谁才能发Boolean isLocked stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX phone, codeStr, Duration.ofMinutes(5)); if (Boolean.FALSE.equals(isLocked)) { // 说明该手机号已经有未过期的验证码拒绝发送 return Result.fail(验证码已发送请稍后再试); }注意一个坑当Redis里已存在相同key时setIfAbsent返回false同时不会更新过期时间也不会覆盖value。这正好符合我们的预期——5分钟内的第二次点击会被拒绝同时原来的验证码不会被新的覆盖掉。不过这里有一个取巧但实用的变化很多项目要求“重新获取验证码等待60秒”而不是“等待验证码过期”那就要用独立的限流key比如sms:limit:{phone}设置60秒TTL验证码另外存。课程到这里用的是同一个key逻辑是可行的只是用户等待时间可能会拉到接近5分钟。真实产品里建议拆成两个key一个控制发送频率一个控制验证码有效期这样用户60秒后可以重新获取旧验证码直接作废新的。两种方案没有绝对对错看产品要求。2.3 发送短信的模拟与扩展点真实生产环境要对接短信平台国内常见的有阿里云、腾讯云这类服务商需要申请签名、模板每条短信都要钱而且有严格的频控规则。点评课程用的是控制台打印模拟log.debug(短信验证码{}, codeStr);我在自己练习的时候往里面加了一个扩展点把发送动作抽成接口SmsSender模拟实现和真实实现可以随时切换。这一步改动很小但面试时被问到“如果以后要接真实短信服务怎么办”就能答得有底气——面向接口编程不改业务逻辑只换实现类。3. 登录/注册二合一一个接口处理新老用户3.1 校验验证码的正确姿势提交登录后前端会把手机号和验证码一起传过来。后端要做的事是从Redis取出这个手机号对应的验证码与用户提交的验证码比对同时处理验证码不存在和验证码不一致两种情况这里有个很细节但特别重要的点比对成功之后要立刻删除Redis里的验证码。如果不删验证码在剩余有效期内可以重复使用别人拿到你的验证码截图在5分钟里能反复登录。删除还有一个好处防止同一个验证码在多个设备上同时登录。Redis的操作要两步走先get判断再delete这里不存在原子性问题就算判断和删除之间有人并发挤进来删掉之后再提交的请求也会因为code为空而被拒绝。课程里这条删除操作放在校验成功后我在项目里还顺手做了一件事不管校验成功还是失败都把提交上来的验证码跟Redis里的对比一下如果相等立刻删除。原因很简单验证码是一次性凭证只要验证通过就被消费掉如果验证不通过也不能把原来的旧验证码留着让人继续试。不过要注意如果每次失败都删掉旧验证码用户输错一次就得重新获取体验略差。折中方案是失败时只累加错误次数超过三次才删除验证码并要求重新获取。点评项目沿用默认简单处理开发时不用过度设计知道有这层取舍就行。3.2 新用户自动注册的落库设计验证码校验通过后按手机号从tb_user表查用户。这里数据库查询建议走LambdaQueryWrapper写起来简洁LambdaQueryWrapperUser queryWrapper new LambdaQueryWrapper(); queryWrapper.eq(User::getPhone, phone); User user userMapper.selectOne(queryWrapper); if (user null) { user createUserWithPhone(phone); }创建用户时要初始化头像、昵称、性别这些字段。点评课程里直接用默认昵称加随机数字String defaultNickName USER_NICK_NAME_PREFIX RandomUtil.randomString(6);这种随机昵称是很多产品的通用做法注册后再引导用户去完善资料。数据库里的手机号字段需要建唯一索引虽然业务逻辑上做了“查不到才插入”但并发场景下可能两个线程同时查到null然后同时插入唯一索引是兜底方案。3.3 保存用户信息时的脱敏意识登录态里保存用户信息绝不能把完整的User对象存进Redis。User表里有手机号、密码、邮箱等信息如果你把整个对象序列化后存进去一旦Redis被拖库用户敏感信息就全泄露了。点评项目的做法是抽取一个UserDTO只保留id、昵称、头像这三个在界面上常用的字段。这个脱敏意识是很多自学者容易忽略的。我在自己模仿的时候故意在UserDTO里留了一个phone字段但设为null只保留id和脱敏后的信息字段。别看这是一行代码的事面试官问“为什么不用User而用UserDTO”时你能把“最小化存储敏感数据”这条原则讲清楚会很加分。4. 无状态会话从Session到RedisToken4.1 为什么不用传统的HttpSession传统JavaWeb里登录成功就是session.setAttribute(user, user)后续请求通过同一个cookie里的JSESSIONID找到session。这个方案在单体架构、单机部署时没毛病但有几个痛点Session默认存在内存里服务重启就全丢了多实例部署时Session不同步用户可能被踢下线移动端App里Cookie机制并不友好很多网络库需要特殊处理点评项目的做法是把Session换成Redis自己签发Token。登录成功时生成一个UUID作为Token把UserDTO以Hash结构存入Rediskey设计为login:token:{token}TTL为30分钟。前端拿到Token后存到本地后续请求在请求头authorization里携带这个Token。用Hash结构存用户信息而不是String好处是可以只更新某个字段。比如后续头像变了可以直接hset更新头像字段而不是反序列化整个对象再塞回去。虽然点评项目更新频率不高但养成用合适数据结构的习惯很重要。4.2 Token的生成方式与安全性Token不能可预测否则别人可以遍历Token冒充用户。最省事的生成方式是UUID碰撞概率低到可以忽略String token UUID.randomUUID().toString().replace(-, );我在练习时往里面加了一个前缀比如用户ID和时间戳拼进去再加密但后来发现这在分布式场景下反而引入复杂度。UUID已经足够安全且不依赖额外组件别过度设计。保存用户信息的时候课程里用了opsForHash().putAll(key, userMap)把字段一个个放进Hash里。我踩过一个坑直接put一个对象时如果对象的getter返回nullHashMap里会有null值Redis序列化层可能报错或存储脏数据。建议先把对象转成MapString, Object过滤掉null字段再塞Redis。4.3 刷新有效期滑动过期还是固定过期30分钟TTL有个问题用户在30分钟内的操作会积累一定访问量如果他不活跃会话会自然过期这没问题。但如果用户一直在操作超过30分钟后依然会被强制下线这就很烦了。合理的策略是每次请求成功时观察剩余过期时间如果低于某个阈值就续期。点评课程里的实现思路是这样Token对应的key在30分钟过期每次请求经过拦截器时如果Redis中还能查到用户并且剩余时间不足20分钟也就是距离过期不到20分钟就重新设置30分钟TTL。实际操作时用getExpire拿到剩余秒数判断是否小于指定值再expire续期。Long expire stringRedisTemplate.getExpire(key); if (expire ! null expire 20 * 60) { stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES); }这个逻辑放在拦截器里最合适因为拦截器是所有受保护接口的统一入口。这也是点评项目里“刷新拦截器”和“登录拦截器”分开的原因刷新动作不需要校验登录态只需要尽量拿到用户信息并续期而真正的登录校验必须确认用户存在才放行。5. 两段式拦截器刷新登录态与强制登录校验解耦5.1 为什么一个拦截器不够如果只有一个登录拦截器没登录的人直接返回401登录了的人放行看起来没问题。但这样会漏掉一个重要场景用户登录后处于活跃状态但Token即将过期如果不及时续期用户操作到第31分钟就被踢下线。续期的动作必须发生在每个请求上而不仅仅是登录接口上。点评项目把拦截器拆成了两个RefreshTokenInterceptor刷新拦截器从请求头获取Token尝试从Redis查用户查到则保存到ThreadLocal并续期查不到也不拦截直接放行LoginInterceptor登录拦截器从ThreadLocal取用户取不到则拦截返回401/未登录取到则放行这样设计后刷新逻辑不关心这个接口到底需不需要登录只管能不能查到用户。登录校验逻辑也不用管Token格式和Redis读取只从ThreadLocal取现成结果。两者各司其职非常干净。5.2 拦截器注册顺序的血泪教训我在第一次用SpringMVC配置拦截器时把两个拦截器的注册顺序写反了结果登录接口本身也被拦截了返回“未登录”。后来才搞清楚拦截器的执行顺序和注册顺序有关addInterceptor先注册的先执行。必须把RefreshTokenInterceptor加在前面LoginInterceptor加在后面这样所有请求先经过刷新拦截器再走登录校验。Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private RefreshTokenInterceptor refreshTokenInterceptor; Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { // 先注册的在外层后注册的在内层 registry.addInterceptor(refreshTokenInterceptor) .addPathPatterns(/**) .order(0); registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns( /user/login, /user/code, /shop/**, /voucher/** ) .order(1); } }很多人在做这一块时只注册一个拦截器或者把路径排除逻辑写得很复杂。其实核心是理解外层的刷新拦截器不做拦截判断只负责读取和续期内层的登录拦截器才做准入判断。路径排除时要把所有游客可访问的接口都加进去否则登录页都打不开。5.3 ThreadLocal的正确使用与内存泄漏预防ThreadLocal是保存当前线程用户信息的好东西。在一个请求的生命周期内Tomcat的线程池可能复用同一个线程如果不清理ThreadLocal下一个请求会读到上一个请求的用户信息产生串号问题。这就是著名的ThreadLocal内存泄漏隐患。点评课程里在拦截器的afterCompletion方法里调用UserHolder.remove()我一开始以为这是多此一举直到自己写了一个测试接口手动往ThreadLocal里塞数据后不清理连续请求两次发现第二次还能读到第一次的数据才彻底明白清理的必要性。Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserHolder.remove(); }UserHolder的实现也很简单就是个静态ThreadLocal变量包装器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 remove() { TL.remove(); } }这套模式在很多主流框架里都能看到理解了它后面看那些复杂授权框架的实现也会轻松很多。6. 实测环节并发请求、序列化和其他常见问题排查6.1 登录成功却拿不到用户信息Redis里的乱码UUID我刚跑通登录时前端每次都登录失败后端日志显示Redis里能查到验证码但登录接口对比时获取到的验证码和日志里打印的不一样。排查了半小时发现是Redis序列化器没配置。默认的RedisTemplate用的是JdkSerializationRedisSerializer会把key序列化成一串带\xAC\xED\x00\x05前缀的字节控制台里看到的key是乱码但通过工具连上去手动set的key是原始字符串两边对不上。解决方案是显式配置StringRedisTemplate或者为RedisTemplate设置String序列化器Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; }课程里用的是StringRedisTemplate它默认就是String序列化只支持String类型的value对于验证码和Token存储完全够用。如果直接new一个StringRedisTemplate里面的hash操作也天然避免了序列化器的坑。我自己后来重新读源码才意识到很多“诡异”的登录问题根源都在序列化这一层。6.2 并发点击获取验证码Observable里的小姿势我在JMeter里模拟50个并发请求同时获取验证码时发现Redis里有多个验证码记录的迹象。检查自己的代码问题出在先get后set的非原子操作上。后来切换到setIfAbsent方案后并发场景下同一手机号只会有一个key被写入其余请求全部返回“发送过于频繁”。这一步的教训是涉及判断后再写入的场景要考虑原子性不能靠“先查后写”赌运气。6.3 跨域请求与Token携带前端跨域时浏览器会先发起一个OPTIONS预检请求。这个预检请求不带业务Token如果拦截器把它当成普通业务请求拦截前端会收到CORS错误。我调接口时遇到过一次莫名的“跨域失败”排查到最后发现是登录拦截器把OPTIONS请求拦住了。解决方案是保留刷新拦截器对所有路径生效登录拦截器要放行OPTIONS请求if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; }有些项目直接在拦截器里判断HttpMethod.OPTIONS这招简单有效。不过在SpringMVC里配置CorsRegistry之后预检请求通常不会进入业务拦截器但保底判断还是值得加的能省去很多奇奇怪怪的前后端联调时间。6.4 登录后的用户头像昵称为什么一直不更新还有一个容易忽略的问题登录成功后传入UserHolder和Redis的是UserDTO快照用户修改了昵称头像后如果只更新数据库不同步更新Redis里的UserDTO前端看到的还是旧数据。我在扩展模块时发现了这个隐藏bug解决办法很多最直接的是在用户资料更新的接口里同步更新Redis Hash中的对应字段。这个同步动作要放在一个事务边界内考虑但不是本文的核心知道有这个问题就够。我在敲这个模块前前后后花了差不多三个晚上第一个晚上搭接口和Redis第二个晚上调拦截器和Token续期第三个晚上专门和并发、序列化过不去。回头复盘短信登录这个模块真正值钱的地方不在“能跑通”而在于它逼着你把浏览器请求、Spring MVC拦截器、Redis数据结构、序列化、线程上下文这些看似零散的知识点串成一条线哪一个环节出了问题整个登录链路都会亮红灯。建议你自己动手的时候先别急着复制代码把流程图自己画一遍重点标出Token在哪里生成、在哪里校验、在哪里续期再开始写事半功倍。如果你也卡在某个具体报错上欢迎把异常信息发出来一起探讨。