手机号加验证码这个登录组合,看起来是整个系统里最不起眼的一块功能——表单两个输入框、一个倒计时按钮,后端无非就是发个码、比个对、种个登录态。我最初也是这么想的,直到有天下午,测试同学在群里甩了张截图:同一个验证码,被两个不同的浏览器窗口先后登录成功了。那一刻我才意识到,这东西的难点从来不在"发短信"上,而在"状态该存在哪、以什么形态存在、什么时候销毁"这三件事上。这篇就围绕 Redis 实战里的短信登录模块,把验证码存储、登录态载体、拦截器刷新、集群一致性这几层拆开讲透,从 Key 怎么命名到并发校验怎么保证原子性,从 Session 时代的天然缺陷到 Token 方案的取舍,全部给到可落地的细节。适合已经会写增删改查、但还没认真处理过登录态的中级开发者,也适合想复盘自己那套登录逻辑有没有埋雷的老手。
1. 短信登录绕不开 Redis 的根因:Session 在扩容那一刻就废了
1.1 单机 Session 的舒适区有多窄
很多人第一次实现短信登录,用的都是最朴素的路子:验证码丢进HttpSession,登录成功后把用户对象也塞进去,后面接口靠session.getAttribute()拿当前用户。在本地跑的时候这套逻辑顺畅得让人怀疑人生,一行配置都不用加,Tomcat 自己就把会话管得好好的。问题在于,这个舒适区窄得离谱——它只在"单实例、单进程、不做任何水平扩容"的前提下成立。
一旦你把服务部署成两台机器,前面挂个负载均衡,麻烦立刻出现。用户第一次请求打到 A 机器,验证码存在 A 的 Session 里;第二次请求因为轮询策略落到 B 机器,B 的 Session 里空空如也,校验直接失败。你会看到一种非常诡异的现象:用户明明收到了验证码,刷新两次页面就提示"验证码错误",再刷新一次又好了。这种间歇性故障最难查,因为它和用户的操作路径强相关,复现全靠运气。
有人会说,那不是有会话粘滞(sticky session)和 Session 复制吗?能解决,但代价不划算。会话粘滞意味着某台机器挂了,上面所有用户的登录态一起丢;Session 复制则是每次会话变更都要广播到所有节点,节点数一多,网络开销呈平方级增长,而且复制本身有延迟窗口,在高并发写入时依然会出现读到旧值的情况。这两种方案本质上都是在"尽量让多台机器表现得像一台",属于用架构复杂度去对抗一个本可以不存在的问题。
1.2 把状态外置之后,问题性质变了
Redis 介入之后,变化的不是"多了一个存储",而是整个问题的性质。Session 是进程内状态,天生和"哪台机器处理请求"绑定;而 Redis 是独立的进程外存储,所有实例看到的是同一份数据。这一转变带来的直接收益是服务真正做到了无状态——你可以随意增删实例、滚动发布、做灰度,用户的登录态不受任何影响。
从数据结构上看,这个场景对 Redis 的要求其实很低:验证码是一条带过期时间的短字符串,登录态是一组带过期时间的用户字段。前者用 String 就够,后者用 Hash 更合适。真正需要动脑子的,是过期时间的粒度、Key 的命名规范、以及校验动作的原子性。这三个点如果没想清楚,Redis 反而会把 Session 时代隐藏的问题放大——因为以前单机环境下你根本遇不到并发和过期竞态。
提示:判断一个状态该不该外置到 Redis,标准不是"数据量大不大",而是"这个状态是否需要被多个进程共享,或者是否需要在进程重启后存活"。验证码和登录态两条都占。
2. 验证码存储层:Key 设计、过期窗口与发送节流
2.1 Key 命名与 TTL 的取值依据
先说 Key。我见过不少项目直接拿手机号当 Key,或者写成code_13800000000这种随手拼的形式。前者的问题是和登录态 Key 有撞名风险,后者的问题是没有统一前缀,一旦同一个 Redis 实例被多个业务共用,运维排查时根本分不清谁是谁。稳妥的写法是加业务前缀和层级分隔:
login:code:13800000000冒号分层的好处是 Redis 的可视化工具会按层级折叠展示,排查时一眼就能定位。前缀login表示归属模块,code表示用途,后面才是业务主键。将来如果要做登录相关的批量操作,SCAN login:code:*就能精准捞出来,不会误伤其他数据。
TTL 的取值是个需要权衡的参数。太短用户来不及输入,太长则给了暴力破解更宽的时间窗。业界常见的做法是 2 到 5 分钟,我个人的习惯是 2 分钟——短信到达通常几秒内完成,用户真正在输入框前面犹豫的时间很少超过一分钟。真要超时了,让用户点一下重发,体验损失远小于安全收益。
这里有个容易忽略的细节:TTL 应该从"验证码生成"那一刻开始算,而不是从"发送成功"开始算。很多短信服务商的接口是异步的,你调完接口拿到的是一个受理结果,真正的下发可能要等一两秒。如果以发送成功为起点,等于给用户额外赠送了这段不确定的时间。
2.2 发送节流:两道防线比一道管用
短信是有成本的,而且是被刷的重灾区。只做"验证码 2 分钟过期"这一道防线远远不够,因为攻击者可以不停地请求新验证码,把过期逻辑绕过去。实际项目里至少要加两道限流:
| 防线 | Key 结构 | 策略 | 目的 |
|---|---|---|---|
| 单手机号频控 | login:code:limit:{phone} | SETNX 60 秒 | 防止同一号码被连续轰炸 |
| 单手机号日限 | login:code:count:{phone}:{yyyyMMdd} | INCR + 首次 EXPIRE 到当日结束 | 控制单日总成本 |
| 单 IP 限流 | login:code:ip:{ip} | 滑动窗口或计数 | 防止换号刷量 |
第一道用SET NX EX 60实现最简洁:写入成功说明 60 秒内没发过,可以发;写入失败说明还在冷却期,直接返回提示。这里千万不能用"先 GET 判断存在与否,再 SET"的两步写法,中间的空窗期足够并发请求同时通过。SETNX加过期时间在 Redis 2.6.12 之后支持一条命令完成,必须是原子的。
日限计数器要注意首次创建时设置过期时间,否则这个 Key 会永久驻留,几个月后 Redis 里堆满垃圾。写法上先INCR,如果返回值等于 1 说明是今天第一次,此时再EXPIRE到当天 23:59:59。虽然严格说INCR和EXPIRE之间有极小的失败窗口(比如进程在这一瞬间被 kill),但影响可控,属于可以接受的工程妥协。
注意:手机号在日志里必须脱敏。我见过排查问题时顺手
log.info("发送验证码 {}", phone)的写法,日志平台一旦被更多人访问,用户隐私就是裸奔状态。
2.3 校验动作的原子性:GETDEL 或者 Lua
这是我在开头提到的那个 bug 的直接来源。最直觉的校验代码是这样的:
String cached = redis.opsForValue().get(key); if (cached == null) return Result.fail("验证码已过期"); if (!cached.equals(inputCode)) return Result.fail("验证码错误"); redis.delete(key); // 校验通过,继续登录流程单线程思维下这段代码没毛病。但两个请求同时到达时,它们可能都读到了同一个cached值,都通过了比较,都执行了 delete,最终都走进登录流程。这就是"一个验证码登录两个账号"的成因(如果两次请求带的是不同手机号但恰好验证码相同,概率极低;更常见的是同一验证码被用来重复触发登录,造成重复发券、重复积分这类业务损失)。
修复思路有两种。第一种是借助 Redis 6.2 引入的GETDEL,读和删一条命令搞定:
String cached = redis.opsForValue().getAndDelete(key);拿到的值为 null 就说明已经被别人抢先用掉了,直接拒绝。第二种是兼容老版本的 Lua 脚本,把 GET 和 DEL 打包成一次原子执行:
local v = redis.call('GET', KEYS[1]) if v then redis.call('DEL', KEYS[1]) end return vSpring Data Redis 里用DefaultRedisScript注册这段脚本即可。我倾向用GETDEL,代码更干净,少一层脚本加载的维护成本;但如果你的 Redis 版本还在 6.0 以下,Lua 是唯一选择。
还有一个更隐蔽的点:验证码校验失败时要不要删除?我一开始是不删的,想着让用户有重试机会。后来改成"失败也删",原因是六位数字只有一百万种组合,如果允许在 2 分钟内无限次尝试,理论上是可以撞开的。删掉之后强制用户重新获取,配合前面的 60 秒频控,等于把尝试次数压到了个位数,风险直接归零。代价是用户输错一次就得等冷却,体验略差——但这个取舍在安全面前没得商量。折中方案是记录失败次数,累计三次再删,实现上多一个计数器 Key,如果对体验要求高可以这么做。
3. 登录态载体:为什么随机 Token 比 SessionId 更可控
3.1 Token 生成必须用密码学安全的随机源
登录校验通过之后,要生成一个凭证返回给前端,后续请求带着它来标识身份。这个凭证的本质是"一把能打开用户数据的钥匙",所以它的不可预测性是第一要求。
我见过用UUID.randomUUID()的,也见过用时间戳加手机号做 MD5 的。前者勉强能用,但UUID.randomUUID()在部分 JDK 实现里用的是SecureRandom,性能上会有锁竞争,高并发下会成为一个隐形瓶颈;后者则是彻底的反面教材——时间戳和手机号都可枚举,MD5 又是确定性算法,攻击者只要猜到生成规则就能伪造任意用户的 Token。
比较稳妥的做法是用SecureRandom生成 32 位十六进制字符串,或者用ThreadLocalRandom生成足够长度的随机串(前提是长度够,比如 32 位以上,碰撞概率可忽略)。长度上不要吝啬,Token 只在网络和 Redis 之间流转,多 16 个字节的传输成本远比被猜中的风险便宜。
private static final SecureRandom RANDOM = new SecureRandom(); private String generateToken() { byte[] bytes = new byte[16]; RANDOM.nextBytes(bytes); return HexFormat.of().formatHex(bytes); }SecureRandom实例要作为静态字段复用,不要每次调用都 new 一个,否则熵池初始化的开销会拖慢响应。如果确实遇到高并发下的锁竞争,可以考虑ThreadLocal<SecureRandom>,但一般量级下没必要。
3.2 Hash 存用户信息:字段选择和序列化陷阱
Token 生成后,要把用户信息存进去。这里有两个选择:String 存 JSON,或者 Hash 存字段。我先说结论——用 Hash,理由有三点。
第一是更新粒度。用户改了昵称,如果整体存 JSON,你得先读出来、反序列化、改字段、再序列化写回去,整个过程不是原子的,并发修改会丢更新;用 Hash 的话一条HSET就改一个字段,天然原子。
第二是内存效率。Redis 对小 Hash 有特殊的紧凑编码(元素少且值短时会用 ziplist/listpack),比同样内容的 JSON 字符串省不少内存。登录态这种字段不多(id、昵称、头像,顶多再加个手机号)的结构,正好落在紧凑编码的甜区里。
第三是可读性。排查问题时HGETALL login:token:xxx直接看到字段名和值,比对着一段 JSON 字符串数字符舒服得多。
存储结构大概是这样:
login:token:9f86d081884c7d65 -> { id: 1024, nickName: "某开发者", icon: "/avatar/default.png" }字段选择上有个原则:只存展示和鉴权必需的,不要把整个用户实体塞进去。密码哈希、手机号明文、余额这类敏感字段一律不进缓存。缓存里的用户信息是"快照",它的作用是在不查库的前提下渲染页面,不是数据源。这个定位清楚了,字段边界自然就清晰了。
序列化方式是另一个高频踩坑点。Spring Data Redis 默认用 JDK 序列化,存进去的 Key 和 Value 前面都带着一串二进制头,在redis-cli里看是乱码,而且跨语言、跨版本都不兼容。正确的做法是显式配置StringRedisSerializer作为 Key 和 Hash Key 的序列化器,Value 也用 String 序列化器,自己控制 JSON 转换:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer serializer = new StringRedisSerializer(); template.setKeySerializer(serializer); template.setHashKeySerializer(serializer); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }如果觉得每次手动转 JSON 麻烦,可以用GenericJackson2JsonRedisSerializer作为 Value 序列化器,但要注意它会在 JSON 里写入@class字段,跨版本升级时类名变动会导致反序列化失败。我的习惯是老老实实用 String 序列化器,用ObjectMapper手动转,多写两行代码换取长期的确定性。
4. 拦截器分层:让鉴权逻辑对业务代码完全透明
4.1 两级拦截器各自解决什么问题
登录态建好之后,接下来的问题是:每个需要身份的接口怎么拿到当前用户?最差的做法是在每个 Controller 方法里写一遍"从请求头取 Token、查 Redis、转成 User 对象"。重复代码不说,一旦某处漏写,就是越权漏洞。
Interceptor 的价值在于把这个动作收敛到一处。实际项目里我习惯分两级:
| 拦截器 | Order | 拦截路径 | 职责 |
|---|---|---|---|
| 刷新拦截器 | 0 | 全部路径 | 取 Token、查 Redis、命中则写入 ThreadLocal、按需续期 |
| 登录拦截器 | 1 | 需要登录的路径 | 检查 ThreadLocal 是否有用户,没有则返回 401 |
分成两级的核心原因是:有些接口需要"知道用户是谁"但不强制要求登录。比如商品详情页,登录用户能看到自己的收藏状态,游客也能浏览。如果只有一个拦截器,你要么对所有路径强制登录(伤害游客体验),要么完全不拦(拿不到用户)。
刷新拦截器负责"尽力而为"——有 Token 就解析,解析成功就放进ThreadLocal,解析失败就放过,不阻断请求。登录拦截器负责"强制边界"——只拦那些确实需要身份的路由,检查ThreadLocal是否为空。
public class RefreshTokenInterceptor implements HandlerInterceptor { private final StringRedisTemplate redis; @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { String token = req.getHeader("authorization"); if (StrUtil.isBlank(token)) return true; String key = "login:token:" + token; Map<Object, Object> entries = redis.opsForHash().entries(key); if (entries.isEmpty()) return true; UserDTO user = new UserDTO(); user.setId(Long.valueOf((String) entries.get("id"))); user.setNickName((String) entries.get("nickName")); user.setIcon((String) entries.get("icon")); UserHolder.save(user); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { UserHolder.remove(); } }afterCompletion里的UserHolder.remove()绝对不能省。线程池会复用线程,如果不清理,下一个请求可能读到上一个用户的数据——这属于最严重的数据越界,而且因为它依赖线程复用时机,测试环境很难稳定复现。
4.2 Token 续期的时机与代价
登录态设置了 30 分钟过期,用户如果在第 29 分钟操作了一下,逻辑上应该重新计时,否则会出现"一直在用却突然被踢下线"的糟糕体验。这就是滑动过期。
最朴素的实现是每次请求都EXPIRE一次:
redis.expire(key, 30, TimeUnit.MINUTES);这样能工作,但代价是每个请求都要多一次写命令。Redis 虽然快,但写命令要走主从复制,量大了以后这部分开销不可忽略。更重要的是,如果 Token 有效期设得很长(比如 7 天),每次请求都刷新的意义就很小了。
我的做法是加一个阈值判断:只有当剩余 TTL 小于某个值(比如 10 分钟)时才续期。这样大部分请求只读不写,只有快过期的请求才触发一次写的动作。把 TTL 和剩余时间都取出来算一下:
Long expire = redis.getExpire(key, TimeUnit.SECONDS); if (expire != null && expire < 600) { redis.expire(key, 1800, TimeUnit.SECONDS); }这里要注意getExpire本身也是一次网络往返。如果对延迟极其敏感,可以用 Lua 把"判断 + 续期"打包成一次调用,减少 RTT。不过在多实例部署下,还有个更隐蔽的问题——不同机器的本地时钟存在漂移,用一个实例算出来的剩余时间未必准确。好在 TTL 是 Redis 服务端自己维护的,EXPIR命令返回的是服务端视角的剩余秒数,所以这个方案天然免疫时钟漂移,只有在你本地用System.currentTimeMillis()去推算过期时间时才会踩坑。
还有一个设计层面的选择:要不要设绝对过期上限?滑动过期理论上可以让一次登录永远有效,只要用户不停操作。对安全敏感的系统(比如涉及支付的),通常会再加一个"绝对过期时间"字段,比如 7 天,无论是否活跃都强制重新登录。实现上可以在 Hash 里多存一个loginTime,续期前先比对。
5. 实测踩出来的坑:Demo 跑通之后才暴露的那几个
5.1 验证码被复用的完整排查链路
回到开头那个 bug。当时的现象是:测试同学用同一个手机号获取验证码,在两个浏览器窗口几乎同时点击登录,两个窗口都登录成功了,而且生成的是两个不同的 Token。
第一反应是怀疑校验逻辑写错了,翻代码发现就是我上面贴的那种"读、比、删"三步走。第二个怀疑点是前端发了两次请求,抓包看了下,确实是两个独立请求,间隔不到 100 毫秒。
接下来在本地复现:写了个脚本,起 20 个线程同时拿同一个验证码去调登录接口,跑十轮,每轮都有几个成功。到这一步基本确认是并发读写的问题——多个线程读到了同一个值,都通过了比较。
验证方式很简单,在删除之前打一行日志带上线程名,日志里能看到多个不同线程都打印了"校验通过",然后才各自执行删除。这时候delete返回的是 1(第一个删掉的)还是 0(后面的),就一目了然了。
修复方案就是前面说的GETDEL。改完之后再跑同样的并发脚本,20 个线程只有 1 个成功,其余全部返回"验证码已失效"。这才算闭环。
这个排查链路里,我认为最有价值的一步是"写并发脚本复现"。很多并发问题在手工测试里是碰运气,只有把并发度拉起来,问题才会稳定暴露。以后遇到"偶发""间歇性"这类词,第一反应就该是写脚本压一下。
5.2 集群环境下的 Key 前缀与序列化不一致
第二个坑更隐蔽。本地单机跑得好好的,上了测试环境(三台应用实例 + 一个 Redis 集群)之后,登录成功率大概只有三分之二。规律很明显:三分之一的概率失败,正好对应三台机器里的一台。
排查思路是给每台机器加个标识,在写 Redis 之前打日志。结果发现失败的那台机器写进去的 Key,和另外两台读的 Key 长得不一样——具体来说,那台机器的配置类里注入的是另一个RedisTemplateBean,序列化器没配对,写进去的 Key 前面多了一段二进制前缀。
这里暴露的是两个问题:一是多个RedisTemplateBean 并存时,@Autowired按类型注入会报错,有人用@Resource按名字注入,结果名字对上了但配置不一样;二是配置分散在不同模块,改了一处忘了另一处。
解决的要点是统一:全项目只保留一个RedisTemplateBean,所有模块通过一个工具类访问,Key 的拼接收敛到一个常量类里。序列化器在 Bean 定义处一次性配好,别在业务代码里零散设置。
public final class RedisKeys { private RedisKeys() {} public static final String LOGIN_CODE = "login:code:"; public static final String LOGIN_TOKEN = "login:token:"; public static final String SEND_LIMIT = "login:code:limit:"; public static String code(String phone) { return LOGIN_CODE + phone; } public static String token(String t) { return LOGIN_TOKEN + t; } }看起来是过度设计,但等到项目里有十几处地方拼 Key,某个地方少写一个冒号导致扫不出来的时候,你会感谢当初那个常量类。
5.3 用户信息快照与数据库的一致性缺口
第三个坑属于业务层面的认知偏差。登录时把昵称和头像写进了 Redis,用户在个人中心改了昵称,数据库更新了,但缓存里的还是旧的——于是页头显示老昵称,个人中心显示新昵称,用户以为系统出 bug 了。
根因是把缓存当成了数据源。缓存里的用户信息只是一个"渲染快照",它的生命周期由 Token 决定,和数据库的更新时机完全解耦。要修的话有三条路:
一是改完昵称后主动刷新缓存。拿到当前 Token,HSET更新对应字段。简单直接,但如果用户在多端登录,每个 Token 对应的 Hash 都要刷,得先能枚举出来。
二是缩短缓存的身份信息字段,只存 id,昵称头像每次查库。这样最准确,但等于把缓存的收益让出去了,高频接口的数据库压力会上去。
三是接受这个偏差,在业务上做兜底——比如昵称展示允许有几分钟延迟,但涉及资金、权限的操作一律查库。
我倾向第一条加第三条的组合:常规展示用缓存,改资料时顺手刷新当前端的缓存,敏感操作走实时查询。多端登录的刷新问题,可以在 Redis 里维护一个login:user:{userId}的 Set,记录这个用户当前所有的 Token,刷新时遍历一遍。Set 里的元素也要有过期清理,否则会无限增长——可以在每次写入时顺手清理一下 set 中已经失效的 token,或者干脆给 Set 本身设一个比 Token 更长的 TTL 兜底。
6. 退出登录、多端互踢与几个容易漏掉的收尾动作
退出登录的实现看起来就是把 Token 对应的 Key 删掉,但实际要考虑的场景比想象的多。用户点"退出"时,前端清掉本地存储,同时调一个后端接口删 Redis 里的 Key。如果只做前端清理,Token 在服务端依然有效,只要有人拿到这个 Token 就能继续访问——这在共享设备场景下是真实风险。
删除的时候除了删login:token:{token},别忘了同步清理login:user:{userId}里对应的成员。如果不清理,那个 Set 会一直膨胀,而且后续做"强制下线"时会对着一堆已失效的 Token 发删除命令,虽然不出错,但全是无用功。
多端登录的策略有三种,选哪种取决于产品定位。第一种是"新登录踢掉旧登录"——登录成功时先查login:user:{userId}里现有的 Token,全部删掉再写入新的。适合对账号安全要求高、不希望账号被多人同时使用的场景。第二种是"允许多端并存"——每个端一个 Token,互不影响,退出时只删自己那个。第三种是"限制并发数"——比如最多三个端,超过就踢最早的那个,实现上可以用 List 或 ZSet 按登录时间排序。这些策略本身没有优劣,关键是产品层面要想清楚,别做到一半才发现需求和实现打架。
还有一个细节是 Token 在前端存哪里。localStorage 方便但容易被 XSS 读取,HttpOnly Cookie 安全但跨域配置麻烦。如果选择 localStorage,后端返回的 Token 要做长度控制,别把用户信息一起塞进出;如果选择 Cookie,要注意 SameSite 属性,否则跨站请求会带不上。
最后提一个和性能相关的小优化。刷新拦截器在每个请求上都会执行,包括静态资源、健康检查这些完全不需要身份的路径。把这些路径通过excludePathPatterns排掉,能省下不少无谓的 Redis 查询。我曾经见过一个项目,健康检查探针每秒打十几次,每次都去 Redis 查一遍 Token,白白占用了不少连接资源。这类问题不会报错,只会在监控图上表现为一个莫名奇妙的 QPS 底座。
我在实际项目里踩过几次坑之后,形成了一个固定的检查习惯:凡是涉及"读出来判断再写回去"的逻辑,先问一句"这中间有没有可能被别人插进来";凡是涉及"缓存和数据库两份数据"的地方,先问一句"哪份是权威的、允许不一致多久"。这两个问题问下来,短信登录这块基本不会留隐患。至于验证码位数、Token 长度、TTL 具体取值这些参数,各家可以按自己的风控要求调整,但底层的那几条原则——原子性、单一数据源、状态收敛——是不能动的。