黑马点评短信登录二:为什么单机 Session 能用,集群下却要换成 Redis Token?
本文继续整理黑马点评 Redis 实战篇第 1 章「短信登录」。
上一篇讲的是 Session 版短信登录,重点解释了一个初学者很容易卡住的问题:浏览器为什么能通过
JSESSIONID找到服务端之前保存验证码的那个 Session。这一篇继续往后走:既然 Session 版已经能跑,为什么项目还要改成 Redis token?Redis 到底替代了 Session 的哪一部分?token 和
JSESSIONID到底有什么相似和不同?
1. 这篇文章解决什么问题
上一版 Session 登录的核心是:
text
浏览器 Cookie 保存 JSESSIONID
服务端 Session 保存验证码和用户信息
浏览器下次请求携带 JSESSIONID
服务端根据 JSESSIONID 找回 Session
这套方案在单机项目里很好理解,也能正常工作。
但黑马点评不是只想写一个单机 demo。它后面会逐步进入缓存、高并发、秒杀、分布式锁这些场景。真实项目里,服务端通常不止一台 Tomcat。
于是问题就来了:
text
如果验证码存在 Tomcat A 的 Session 里,
下一次登录请求却被 Nginx 转发到了 Tomcat B,
Tomcat B 怎么拿到 Tomcat A 的 Session?
这篇文章主要讲清楚:
text
1. 为什么单机 Session 能用,集群下会出问题。
2. Session 复制为什么不是最理想方案。
3. Redis 代替 Session,本质上替代了什么。
4. token 和 JSESSIONID 有什么相似点和区别。
5. 验证码为什么存成 login:code:{phone}。
6. 用户登录态为什么存成 login:token:{token}。
7. 为什么登录 token 不能直接用手机号。
8. Redis Hash 存 UserDTO 的代码到底在做什么。
先给结论:
Redis token 登录的本质是:把原本放在 Tomcat Session 里的登录数据,转移到所有服务实例都能访问的 Redis 中;浏览器不再携带
JSESSIONID,而是携带后端生成的随机 token。
2. 先复习一下 Session 版登录
Session 版登录可以简化成这样:
text
发送验证码:
手机号 -> 生成验证码 -> 保存到 Session
登录:
手机号 + 验证码 -> 从 Session 取验证码 -> 校验 -> 保存 user 到 Session
后续请求:
Cookie 携带 JSESSIONID -> 找到 Session -> 取 user -> 判断是否登录
流程图如下:
浏览器发送验证码请求
Tomcat 创建/获取 Session
验证码 code 保存到 Session
浏览器保存 JSESSIONID
浏览器发送登录请求
携带 JSESSIONID
Tomcat 找到同一个 Session
取出 code 校验
登录成功后 user 保存到 Session
访问需要登录的接口
继续携带 JSESSIONID
拦截器从 Session 取 user
有 user 则放行
这套方案有一个隐含前提:
text
每次请求都能访问到保存了 Session 数据的那台 Tomcat。
单机时,这个前提天然成立。
集群时,这个前提就不一定成立。
3. 为什么单机 Session 能用
单机部署时,所有请求都进入同一台 Tomcat。
比如:
text
浏览器 -> Tomcat A
发送验证码请求进入 Tomcat A:
text
验证码保存到 Tomcat A 的 Session
登录请求也进入 Tomcat A:
text
Tomcat A 根据 JSESSIONID 找到同一个 Session
访问 /user/me 还是进入 Tomcat A:
text
Tomcat A 从 Session 中取 user
所以整个链路没问题。
浏览器
Tomcat A
Session: code
Session: user
这时 Session 存在 Tomcat 内存里反而很方便:
text
读写速度快
代码简单
不需要额外中间件
但它的问题也藏在这里:
Session 数据属于某一台 Tomcat。
只要服务端从一台变成多台,这个问题就会暴露出来。
4. 为什么集群下 Session 会失效
真实项目里,为了支撑更多请求,通常会部署多台 Tomcat。
请求入口可能是:
text
浏览器 -> Nginx -> 多台 Tomcat
Nginx 负责负载均衡,可能把不同请求转发到不同 Tomcat。
比如第一次请求:
text
浏览器 -> Nginx -> Tomcat A
验证码保存到了 Tomcat A 的 Session:
text
Tomcat A Session:
code = 123456
第二次登录请求:
text
浏览器 -> Nginx -> Tomcat B
Tomcat B 的内存里没有 Tomcat A 的 Session 数据:
text
Tomcat B Session:
没有 code
于是后端从 Session 中取验证码时,就可能取不到。
集群下 Session 问题示意图
浏览器请求 /user/code
Nginx
Tomcat A
code 保存到 Tomcat A 的 Session
浏览器请求 /user/login
Nginx
Tomcat B
Tomcat B 没有 Tomcat A 的 Session 数据
验证码校验失败
这就是 Session 共享问题。
它不是说 JSESSIONID 没用了,而是说:
text
JSESSIONID 找的是服务端 Session。
但不同 Tomcat 的 Session 存储空间不是天然共享的。
浏览器即使带了同一个 JSESSIONID,请求落到没有对应 Session 数据的 Tomcat 上,仍然会出问题。
5. Session 复制为什么不是最优解
一个直观想法是:
那就让 Tomcat 之间互相同步 Session。
比如 Tomcat A 保存了验证码后,把 Session 同步给 Tomcat B、Tomcat C。
这样每台 Tomcat 都有一份 Session 数据。
Tomcat A 修改 Session
同步到 Tomcat B
同步到 Tomcat C
同步到 Tomcat D
这就是 Session 复制。
它可以解决一部分问题,但缺点也很明显。
缺点 1:每台 Tomcat 都要保存完整 Session
假设有 4 台 Tomcat。
如果每台都保存全量 Session,那么同一份登录数据会被复制 4 份。
用户越多,内存压力越大。
缺点 2:同步有延迟
Tomcat A 刚写入 Session,Tomcat B 未必立刻同步完成。
如果用户下一秒请求就打到 Tomcat B,仍然可能读不到最新数据。
缺点 3:服务器越多,同步越复杂
Tomcat 数量越多,Session 复制成本越高。
这和我们扩容 Tomcat 的初衷有点冲突:
text
扩容是为了提升处理能力。
但 Session 复制会让每台机器承担更多同步压力。
所以更常见的方案是:
不让每台 Tomcat 各自保存登录态,而是把登录态放到一个共享存储中。
在黑马点评这个项目里,共享存储就是 Redis。
6. Redis 代替 Session,到底替代了什么
很多初学者听到"Redis 代替 Session"时,会误以为:
text
Session 这个机制完全消失了。
其实更准确的理解是:
原来存在 Tomcat Session 里的业务数据,现在改存到 Redis 里。
在 Session 版中:
text
验证码 code -> 存在 Session
登录用户 user -> 存在 Session
在 Redis 版中:
text
验证码 code -> 存在 Redis
登录用户 UserDTO -> 存在 Redis
也就是说,Redis 替代的是:
text
服务端保存会话数据的位置。
原来:
text
浏览器 Cookie 中的 JSESSIONID
↓
Tomcat 内存中的 Session
↓
取出 code / user
现在:
text
浏览器请求参数或请求头中的凭证
↓
Redis 中对应的 key
↓
取出 code / UserDTO
Session 版和 Redis 版对比
text
Session 版:
浏览器保存 JSESSIONID
Tomcat 根据 JSESSIONID 找 Session
Session 里存 code 和 user
Redis 版:
浏览器提交手机号或携带 token
后端根据手机号/token 拼 Redis key
Redis 里存 code 和 UserDTO
所以 Redis 代替 Session 不是魔法。
它只是把登录相关状态从单台 Tomcat 内存中挪到了共享 Redis 中。
7. Redis 版登录整体流程
Redis 版短信登录分成两部分:
text
1. 验证码保存到 Redis。
2. 登录成功后的用户信息保存到 Redis。
发送验证码:
text
用户提交手机号
↓
后端校验手机号
↓
生成验证码
↓
Redis 保存 login:code:{phone} -> code
↓
设置验证码过期时间
登录:
text
用户提交手机号和验证码
↓
后端从 Redis 取 login:code:{phone}
↓
校验验证码
↓
根据手机号查用户,不存在则创建
↓
生成随机 token
↓
User 转 UserDTO
↓
UserDTO 保存到 Redis Hash
↓
返回 token 给前端
后续请求:
text
前端在请求头携带 token
↓
后端拼接 login:token:{token}
↓
Redis 查询用户信息
↓
查到则说明已登录
Redis 版整体流程图
否
是
发送验证码 /user/code
校验手机号
生成 6 位验证码
写入 Redis: login:code:{phone}
设置验证码 TTL
登录 /user/login
提交手机号和验证码
从 Redis 取 login:code:{phone}
验证码是否一致?
返回验证码错误
查询或创建用户
生成随机 token
User 转 UserDTO
UserDTO 写入 Redis Hash
设置 token TTL
返回 token 给前端
后续请求
请求头携带 token
Redis 查询 login:token:{token}
查到用户则认为已登录
8. Redis key 怎么设计
在项目中,登录相关 Redis key 定义在 RedisConstants 中:
java
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;
}
这里有两类 key。
第一类:验证码 key。
text
login:code:{phone}
比如:
text
login:code:13800000000
它表示:
text
手机号 13800000000 当前对应的验证码。
第二类:登录用户 key。
text
login:token:{token}
比如:
text
login:token:0f9a3c5b6d...
它表示:
text
某个登录 token 对应的用户信息。
为什么要分两类 key
因为验证码和登录态是两个不同阶段的数据。
验证码 key 用来解决:
text
用户提交验证码时,后端拿什么进行比较?
登录用户 key 用来解决:
text
用户登录成功后,后续请求怎么识别当前用户?
它们的生命周期也不一样。
验证码通常很短:
text
2 分钟
登录态通常稍长:
text
30 分钟
所以它们必须分开存。
9. 发送验证码:从 Session 改成 Redis
当前项目最终版的发送验证码方法是:
java
@Override
public Result sendCode(String phone) {
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(
LOGIN_CODE_KEY + phone,
code,
LOGIN_CODE_TTL,
TimeUnit.MINUTES
);
log.debug("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
和 Session 版相比,核心变化是这句:
java
stringRedisTemplate.opsForValue().set(
LOGIN_CODE_KEY + phone,
code,
LOGIN_CODE_TTL,
TimeUnit.MINUTES
);
原来是:
java
session.setAttribute("code", code);
现在是:
java
Redis: login:code:{phone} -> code
为什么验证码 key 用手机号
因为用户登录时会提交:
text
手机号 + 验证码
后端要根据手机号找到之前发给这个手机号的验证码。
所以 key 设计成:
text
login:code:{phone}
很自然。
比如用户提交手机号:
text
13800000000
后端生成验证码:
text
482913
Redis 中保存:
text
login:code:13800000000 -> 482913
登录时再用同一个手机号拼 key:
text
login:code:13800000000
就能取出验证码进行比较。
为什么验证码要设置过期时间
验证码不能永久有效。
项目中设置:
java
LOGIN_CODE_TTL = 2L
单位是:
java
TimeUnit.MINUTES
所以验证码有效期是 2 分钟。
这有两个作用:
text
1. 降低验证码被长期复用的风险。
2. 避免 Redis 中堆积大量过期验证码。
10. opsForValue 是什么
发送验证码时用到:
java
stringRedisTemplate.opsForValue().set(...)
这里的 opsForValue() 是 Spring Data Redis 对 Redis String 类型操作的封装。
可以简单理解为:
text
我要操作 Redis 的 String 类型。
Redis 中保存验证码时,就是普通的 key-value:
text
key = login:code:13800000000
value = 482913
所以用 opsForValue() 很合适。
输入是什么
java
set(String key, String value, long timeout, TimeUnit unit)
这里传入:
text
key:login:code:{phone}
value:验证码
timeout:2
unit:分钟
输出是什么
这个 set 方法主要是向 Redis 写数据,业务上不关心返回值。
执行后 Redis 里会出现一条带过期时间的数据。
为什么这里使用
验证码只是一个简单字符串,不需要复杂结构。
所以用 Redis String 最直接。
11. 登录:从 Redis 获取验证码并校验
当前项目最终版的登录方法开头是:
java
@Override
public Result login(LoginFormDTO loginForm) {
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
// 后面继续处理用户和 token
}
这段代码和 Session 版的区别很明显。
Session 版是:
java
Object cacheCode = session.getAttribute("code");
Redis 版是:
java
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
也就是说:
text
原来从 Session 取验证码。
现在从 Redis 取验证码。
为什么登录成功后删除验证码
当前源码里有这一句:
java
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
这一步很有意义。
验证码校验成功后,删除 Redis 中的验证码,可以避免同一个验证码在有效期内被重复使用。
虽然验证码本身有 2 分钟过期时间,但登录成功后主动删除更干净。
可以理解成:
text
验证码是一次性凭证。
用完就删。
12. 查询用户,不存在就自动注册
验证码通过后,后端根据手机号查询用户:
java
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
这部分和 Session 版没有本质区别。
短信登录通常把"注册"和"登录"合并了:
text
如果手机号已经存在:直接登录。
如果手机号不存在:创建用户后登录。
创建用户的方法:
java
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName("user_" + RandomUtil.randomString(10));
save(user);
return user;
}
这段代码做了三件事:
text
1. 创建 User 对象。
2. 设置手机号和随机昵称。
3. 保存到数据库。
注意:Redis 在这里不是用来替代数据库保存用户的。
真正的用户基础信息仍然保存在 MySQL。
Redis 保存的是:
text
验证码
登录态
13. 登录成功后,为什么要生成 token
验证码校验通过、用户也查到了,下一步就是保存登录态。
Session 版是:
java
session.setAttribute("user", userDTO);
Redis 版不能这么做,因为我们不再依赖 Tomcat Session。
那浏览器后续请求靠什么证明自己登录过?
答案是:
text
token
项目中生成 token 的代码是:
java
String token = UUID.randomUUID().toString(true);
这个 token 是后端生成的随机字符串。
登录成功后,后端把 token 返回给前端:
java
return Result.ok(token);
前端后续请求时,在请求头中携带:
text
authorization: token
后端再根据 token 去 Redis 找用户信息。
token 可以理解成什么
在这个项目里,可以把 token 理解成:
登录成功后,后端发给浏览器的一张随机凭证。
它本身不是用户信息。
它只是一个随机字符串。
真正的用户信息保存在 Redis:
text
login:token:{token} -> UserDTO
所以后续请求的识别过程是:
text
前端带 token
后端拼 Redis key
Redis 查 UserDTO
查到说明登录态存在
14. token 和 JSESSIONID 有什么相似点
token 和 JSESSIONID 很像。
它们都是:
text
客户端保存一个凭证。
服务端根据这个凭证找到用户状态。
Session 版:
text
客户端保存 JSESSIONID
服务端根据 JSESSIONID 找 Session
Session 中保存 user
Redis token 版:
text
客户端保存 token
服务端根据 token 拼 Redis key
Redis 中保存 UserDTO
对比图:
Session 版
浏览器保存 JSESSIONID
Tomcat 根据 JSESSIONID 找 Session
Session 中取 user
Redis Token 版
前端保存 token
后端根据 token 拼 Redis key
Redis 中取 UserDTO
所以可以这样理解:
token 在这个项目里承担了类似
JSESSIONID的角色,都是用来找到服务端保存的登录状态。
15. token 和 JSESSIONID 有什么不同
虽然 token 和 JSESSIONID 很像,但它们不是一个东西。
不同点 1:谁生成
JSESSIONID 通常由 Tomcat 的 Session 机制生成。
token 是我们在业务代码里主动生成的:
java
String token = UUID.randomUUID().toString(true);
不同点 2:存在哪里
Session 版登录态默认存在 Tomcat 内存中。
Redis token 版登录态存在 Redis 中。
不同点 3:客户端怎么携带
JSESSIONID 通常通过 Cookie 自动携带。
token 通常由前端保存,然后放在请求头中:
text
authorization: token
不同点 4:更适合前后端分离
在前后端分离项目里,前端显式保存 token、请求时放入 header,是很常见的方案。
它不强依赖浏览器 Cookie 的自动机制。
对比表
text
维度 JSESSIONID token
生成方 Tomcat Session 机制 后端业务代码
客户端保存位置 Cookie 前端自行保存
请求携带方式 Cookie 自动携带 请求头 authorization
服务端存储位置 Tomcat Session Redis
集群友好度 默认不友好 友好,因为 Redis 共享
16. 为什么不能直接用手机号当 token
这是一个非常值得问的问题。
既然登录时已经有手机号了,那后续请求直接带手机号不行吗?
比如:
text
authorization: 13800000000
然后后端根据手机号查用户。
这个方案看起来简单,但问题很大。
问题 1:手机号是敏感信息
手机号不应该作为登录凭证在前端和网络请求中反复暴露。
虽然 HTTPS 能保护传输过程,但从设计上说,登录凭证最好不要直接使用用户敏感信息。
问题 2:容易伪造
如果后端只认手机号,那别人只要知道某个手机号,就可能伪造请求头。
而随机 token 不容易猜。
问题 3:不方便控制登录态
token 可以做到:
text
一个用户多端登录,每个端有不同 token。
退出登录时只删除当前 token。
token 可以设置独立过期时间。
如果直接用手机号,控制粒度会很粗。
问题 4:不符合"凭证随机化"的习惯
登录凭证应该是随机、不可预测、可过期、可删除的。
手机号是用户身份标识,不适合作为登录凭证。
所以项目选择:
java
String token = UUID.randomUUID().toString(true);
让 token 成为一个随机字符串。
17. 为什么用户信息用 Redis Hash 保存
登录成功后,项目会把 UserDTO 保存到 Redis。
当前代码是:
java
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(
userDTO,
new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
);
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
这里做了几件事:
text
1. User 转 UserDTO。
2. UserDTO 转 Map。
3. Redis key 拼成 login:token:{token}。
4. 用 Hash 结构保存用户字段。
5. 设置登录态过期时间。
为什么不是直接存 User
因为 User 是数据库实体,可能包含敏感字段。
登录上下文和前端通常只需要:
text
id
nickName
icon
所以项目先转成 UserDTO:
java
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
UserDTO 的结构是:
java
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
这样可以避免把完整用户实体放进 Redis 登录态里。
为什么 UserDTO 要转成 Map
因为 Redis Hash 的结构类似:
text
key -> field -> value
我们希望保存成:
text
login:token:{token}
id -> 5
nickName -> user_xxx
icon -> xxx
所以要把 Java 对象转成 Map。
代码:
java
Map<String, Object> userMap = BeanUtil.beanToMap(
userDTO,
new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
);
这个方法可以理解为:
text
把 userDTO 的字段拆成 Map。
比如:
java
UserDTO userDTO = new UserDTO();
userDTO.setId(5L);
userDTO.setNickName("user_abc");
userDTO.setIcon("");
转换后大概是:
text
{
"id": "5",
"nickName": "user_abc",
"icon": ""
}
为什么要把 fieldValue 转成 String
当前项目使用的是 StringRedisTemplate。
StringRedisTemplate 更适合处理字符串类型的 key 和 value。
如果 Map 里有 Long 类型,例如:
text
id = 5L
直接写入可能会出现序列化或类型转换问题。
所以这里通过:
java
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
把所有字段值都转成字符串。
这样写入 Redis Hash 更稳定。
18. 为什么用 Hash,而不是 String
Redis 保存用户信息有两种常见方式。
第一种:String。
text
login:token:{token} -> JSON字符串
例如:
json
{"id":5,"nickName":"user_abc","icon":""}
第二种:Hash。
text
login:token:{token}
id -> 5
nickName -> user_abc
icon -> ""
黑马点评这里选择的是 Hash。
Hash 的好处是:
text
1. 字段结构更清晰。
2. 读取后可以直接得到 Map。
3. 小对象存储比较自然。
String JSON 也不是不能用,只是需要自己做 JSON 序列化和反序列化。
在这个登录场景里,用户信息字段少,用 Hash 很直观。
Hash 结构示意图
login:token:随机token
id: 5
nickName: user_abc
icon: xxx
19. 完整登录代码解释
把当前项目最终版的 login 方法放完整看:
java
@Override
public Result login(LoginFormDTO loginForm) {
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(
userDTO,
new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
);
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);
}
可以按 8 步理解:
text
1. 从 loginForm 中获取手机号。
2. 校验手机号格式。
3. 从 Redis 中取出验证码。
4. 比较用户提交的验证码和 Redis 中的验证码。
5. 验证通过后删除验证码。
6. 根据手机号查询或创建用户。
7. 生成 token,把 UserDTO 存入 Redis。
8. 返回 token 给前端。
完整登录流程图
否
是
否
是
否
是
请求 /user/login
获取手机号 phone
手机号格式正确?
返回手机号格式错误
从 Redis 获取 login:code:{phone}
获取用户提交的 code
验证码是否一致?
返回验证码错误
删除验证码 key
根据手机号查询用户
用户是否存在?
创建新用户
得到用户对象
生成随机 token
User 转 UserDTO
UserDTO 转 Map
写入 Redis Hash login:token:{token}
设置 token TTL
返回 token
这张图一定要和 Session 版对比着看。
Session 版登录成功后:
text
user 存入 Session
Redis 版登录成功后:
text
UserDTO 存入 Redis,token 返回给前端
20. Redis 里到底保存了哪些东西
短信登录这一章,Redis 里主要有两类数据。
第一类:验证码
text
key = login:code:{phone}
value = 验证码
TTL = 2 分钟
类型 = String
例如:
text
login:code:13800000000 -> 482913
它的作用是:
text
登录时校验用户提交的验证码是否正确。
第二类:登录用户信息
text
key = login:token:{token}
value = UserDTO 字段
TTL = 30 分钟
类型 = Hash
例如:
text
login:token:0f9a3c...
id -> 5
nickName -> user_abc
icon -> xxx
它的作用是:
text
后续请求根据 token 判断用户是否已经登录,并获取当前用户信息。
两类 key 的关系
发送验证码阶段
login:code:{phone}
保存验证码 code
2 分钟过期
登录成功阶段
login:token:{token}
保存 UserDTO
30 分钟过期
这两类 key 不要混在一起。
验证码 key 是登录前的临时校验数据。
token key 是登录后的用户状态数据。
21. 前端拿到 token 后怎么用
登录成功后,后端返回:
java
return Result.ok(token);
前端拿到 token 后,后续请求需要携带它。
在这个项目中,后端从请求头读取:
java
String token = request.getHeader("authorization");
所以前端请求头应该类似:
text
authorization: 0f9a3c5b6d...
后端拿到 token 后拼 key:
java
String key = LOGIN_USER_KEY + token;
也就是:
text
login:token:0f9a3c5b6d...
然后去 Redis 查用户。
这一步会在下一篇双拦截器里详细讲。
本篇先记住:
登录接口返回 token,不代表用户信息存在 token 里。token 只是 Redis 登录态的 key 的一部分。
22. Redis token 方案为什么适合集群
现在再回到最开始的问题:
为什么集群下要换成 Redis token?
因为 Redis 是共享的。
多台 Tomcat 都可以访问同一个 Redis。
浏览器
Nginx
Tomcat A
Tomcat B
Tomcat C
Redis
发送验证码请求落到 Tomcat A:
text
Tomcat A 把验证码写入 Redis
登录请求落到 Tomcat B:
text
Tomcat B 从 Redis 读取验证码
后续请求落到 Tomcat C:
text
Tomcat C 根据 token 从 Redis 读取用户信息
只要 Redis 是同一个,哪台 Tomcat 处理请求都能读到登录相关数据。
这就是 Redis 方案解决 Session 共享问题的核心。
23. Redis token 方案是不是没有 Session 了
从业务代码角度看,我们不再依赖 HttpSession 保存验证码和用户信息。
但从思想上看,Redis token 方案仍然在做类似 Session 的事情:
text
客户端保存一个凭证。
服务端根据凭证查用户状态。
区别只是:
text
Session 版:
凭证是 JSESSIONID。
状态存在 Tomcat Session。
Redis token 版:
凭证是 token。
状态存在 Redis。
所以不要把它想得太神秘。
它不是"无状态登录"。
因为用户状态仍然保存在 Redis。
它更准确地说是:
text
基于 Redis 的集中式登录态方案。
24. 本篇最容易混淆的几个点
1. Redis 代替 Session,是不是 token 里保存了用户信息
不是。
这个项目里的 token 只是随机字符串。
用户信息保存在 Redis:
text
login:token:{token} -> UserDTO
2. token 和 JSESSIONID 是不是完全一样
不是。
它们作用相似,都是客户端凭证。
但 JSESSIONID 通常由 Tomcat Session 机制生成并通过 Cookie 携带。
token 是业务代码生成的随机字符串,通常由前端放到请求头里。
3. 验证码为什么用手机号作为 key
因为登录时用户会提交手机号和验证码。
后端可以根据手机号拼出同一个 key,取出之前发送的验证码进行比较。
4. 为什么登录 token 不能用手机号
手机号是用户身份信息,不适合作为登录凭证。
登录凭证应该随机、不可预测、可过期、可删除。
5. Redis 里存的是 User 还是 UserDTO
应该存 UserDTO。
User 是数据库实体,可能包含敏感字段。
UserDTO 是适合登录上下文和前端展示的安全对象。
6. Redis 保存用户信息后,还需要 MySQL 吗
需要。
MySQL 仍然保存用户的长期基础信息。
Redis 保存的是短期登录态。
25. 面试怎么回答
如果面试官问:为什么不用 Session,而要用 Redis 实现登录态?
可以这样回答:
Session 默认保存在单台 Tomcat 内存中。单机部署没有问题,但集群部署时,请求可能被负载均衡到不同 Tomcat,导致另一台 Tomcat 读不到之前保存的 Session。Session 复制会带来内存压力和同步延迟,所以可以把登录态放到 Redis 这种共享存储中,多台服务实例都从 Redis 读取登录状态。
如果面试官问:Redis token 登录流程是什么?
可以这样回答:
用户先请求发送验证码,后端校验手机号后生成验证码,并以
login:code:{phone}为 key 存入 Redis,设置较短过期时间。用户登录时提交手机号和验证码,后端从 Redis 取出验证码校验。校验通过后查询或创建用户,生成随机 token,把用户的安全信息 UserDTO 存入 Redis Hash,key 是login:token:{token},并返回 token 给前端。后续请求前端携带 token,后端根据 token 去 Redis 查询用户信息。
如果面试官问:token 和 SessionId 有什么区别?
可以这样回答:
二者都可以作为客户端凭证,用来在服务端查找登录状态。但 SessionId 通常由 Tomcat 的 Session 机制生成,登录数据默认存在 Tomcat 内存中,并通过 Cookie 自动携带;token 通常由业务代码生成,前端放在请求头中携带,服务端根据 token 去 Redis 等共享存储中查用户状态。Redis token 更适合集群和前后端分离场景。
如果面试官问:为什么用户信息要存 Redis Hash?
可以这样回答:
登录态中的用户信息字段比较少,比如 id、昵称、头像,适合用 Redis Hash 保存成 field-value 结构。这样读取时可以直接得到 Map,再转换成 UserDTO。相比直接存完整 User,UserDTO 能避免敏感字段泄露;相比 JSON 字符串,Hash 结构字段更直观。
如果面试官问:为什么 token 不能直接用手机号?
可以这样回答:
手机号是用户敏感身份信息,不适合作为登录凭证。登录凭证应该是随机、不可预测、可过期、可删除的。使用随机 token 可以降低伪造风险,也方便实现多端登录、退出登录和独立过期控制。
26. 总结
这一篇的主线其实很简单:
text
Session 版登录能跑
↓
但是 Session 默认存在单台 Tomcat 内存
↓
集群下不同 Tomcat 之间 Session 不共享
↓
所以把验证码和登录态放到 Redis
↓
浏览器后续不再靠 JSESSIONID,而是携带 token
↓
后端根据 token 从 Redis 找 UserDTO
最重要的是把这句话记牢:
Redis token 方案不是把用户信息塞进 token,而是让 token 成为 Redis 登录态的查询凭证。
本篇解决了"登录态存到哪里"的问题。
但还有一个问题没有完全展开:
text
用户后续请求携带 token 后,后端在哪里解析 token?
哪些接口需要登录,哪些接口不需要登录?
token 快过期时,为什么需要刷新有效期?
为什么项目最终用了两个拦截器?
这些就是下一篇要讲的内容:双拦截器、ThreadLocal 和登录态刷新。