黑马点评短信登录02_redis_token_login

黑马点评短信登录二:为什么单机 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 和登录态刷新。

相关推荐
j7~3 小时前
【MYSQL】 mysql库和表的操作--详解
数据库·c++·mysql·数据库表的操作·数据库库的操作
ECT-OS-JiuHuaShan3 小时前
什么是认知,认知的本质是什么?
数据库·人工智能·算法·机器学习·数学建模
2301_7815714211 小时前
Golang格式化输出占位符都有什么_Golang fmt占位符教程【通俗】
jvm·数据库·python
养肥胖虎11 小时前
RAG学习笔记(3):区分数据库检索与RAG的使用场景
数据库·ai·rag
_ku_ku_12 小时前
数据库系统原理 · 数据库应用开发 · 自学总结
数据库
No8g攻城狮12 小时前
【人大金仓】wsl2+ubuntu22.04安装人大金仓数据库V9
java·数据库·spring boot·非关系型数据库
山峰哥13 小时前
SQL慢查询调优实战:从全表扫描到索引覆盖的完整复盘
前端·数据库·sql·性能优化
代码中介商13 小时前
Redis入门:5大数据类型全解析
数据库·redis·缓存
渣渣盟13 小时前
数据库设计范式详解(纯小白版)
数据库·oracle·软考·数据库工程师