基于 Redis 的短信验证码登录实现详解(替代 Session 集群方案)
·
在现代 Web 应用中,登录态管理是一个绕不开的问题。
传统基于 HttpSession 的登录方式,在单机环境下尚可使用,但在 分布式 / 微服务 / 集群部署 场景下会暴露出大量问题。
本文将基于一个完整可落地的工程实现,详细讲解:
- 什么是 Session 集群
- Session 集群的 核心缺点
- 为什么用 Redis + Token 替代 Session
- 短信验证码登录的完整流程
- Redis 中 Key 设计、数据结构
- 双拦截器设计(Token 刷新 + 登录校验)
- ThreadLocal 在其中扮演的角色
本文代码全部来自真实项目,而非伪代码。
一、什么是 Session 集群?
1.1 单机 Session 的工作方式
在传统 Java Web 中,登录流程通常如下:
- 用户提交账号密码
- 服务端校验成功
- 将用户信息存入
HttpSession - 浏览器持有
JSESSIONID - 后续请求通过
JSESSIONID找回 Session
关键点:
Session 默认是 存储在单台服务器内存中 的。
1.2 Session 集群是什么?
当系统部署成多台服务器时:
用户请求 → Nginx → Server A / Server B / Server C
如果用户第一次登录请求被分发到 Server A:
- Session 存在 A 的内存中
第二次请求被转发到 Server B:
- B 本地 没有 Session
- 用户被认为 未登录
为了解决这个问题,常见的 Session 集群方案有三种:
方案一:Session Sticky(会话粘滞)
- Nginx 固定把同一用户请求转发到同一台服务器
问题:
- 一台机器宕机 → 用户全部掉线
- 负载不均衡
- 无法弹性扩容
方案二:Session 同步复制
- Tomcat 之间同步 Session 数据
问题:
- Session 数据网络广播
- 节点越多,性能越差
- Session 体积大时严重拖慢系统
方案三:集中式 Session(Redis / DB)
- 所有 Session 存到 Redis
问题:
- 本质仍是 Session 思维
- 与微服务、无状态架构不匹配
- Session 生命周期受容器影响
1.3 Session 集群的本质缺陷
总结一句话:
Session 是“强状态”的,天然不适合分布式架构
具体问题包括:
- ❌ 与服务器强绑定
- ❌ 扩容、缩容复杂
- ❌ 跨语言、跨服务困难
- ❌ 无法支持 API 化 / 移动端
- ❌ 难以精细控制登录态
二、为什么用 Redis + Token 替代 Session?
2.1 Token 登录的核心思想
不再依赖服务器内存状态,而是:
- 登录成功 → 服务端生成 Token
- Token 作为 唯一登录凭证
- 用户信息存入 Redis
- 客户端每次请求携带 Token
客户端 → Authorization: Bearer token
服务端 → Redis 查询用户
2.2 Redis + Token 的优势
1️⃣ 服务端无状态
- 任意请求可以落到任意服务器
- 天然支持集群、微服务
2️⃣ 高性能
- Redis 内存存储
- O(1) 访问复杂度
- 比 Session 复制快几个数量级
3️⃣ 生命周期可控
- 精确设置 Token 过期时间
- 支持滑动过期
- 可主动踢人下线
4️⃣ 天然支持多端登录
- Web / App / 小程序统一模型
- 每个端一个 Token
三、基于 Redis 的短信验证码登录整体流程
3.1 整体设计思路
登录流程拆分为 两个阶段:
- 验证码阶段
- Token 登录态阶段
3.2 Redis Key 设计
// 短信验证码
LOGIN_CODE_KEY + phone
// login:code:138xxxx
// 登录用户信息
LOGIN_USER_KEY + token
// login:token:uuid
四、短信验证码发送流程详解
4.1 发送验证码核心逻辑
@Override
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2. 生成 6 位验证码
String code = RandomUtil.randomNumbers(6);
// 3. 存入 Redis,设置过期时间
stringRedisTemplate.opsForValue()
.set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 4. 模拟发送短信
log.debug("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
4.2 设计说明
- 验证码只存在 Redis,不落库
- 设置 TTL,自动失效
- 防止验证码复用
- Redis 天然适合短期数据存储
五、登录流程详解(验证码 + Token)
5.1 校验验证码
String redisCode = stringRedisTemplate.opsForValue()
.get(LOGIN_CODE_KEY + phone);
if (code == null || !code.equals(redisCode)) {
return Result.fail("验证码错误!");
}
此外在上面代码还可以加入校验手机号的代码:
//1.校验手机号
String phone = loginForm.getPhone();//从登录表中获取用户信息
String code = loginForm.getCode();
if(RegexUtils.isPhoneInvalid(phone)){
//1.1如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
要点:
- Redis 是唯一可信来源
- 不使用 Session
5.2 用户不存在自动注册
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
private User createUserWithPhone(String phone) {
//创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
save(user);
return user;
}
5.3 生成 Token 并存 Redis
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(user, userDTO);
Map<String, Object> userMap = BeanUtil.beanToMap(
userDTO,
new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((k, v) -> v.toString())
);
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
5.4 为什么用 Hash 存用户?
- Redis Hash 适合结构化对象
- 可以单字段更新
- 减少序列化成本
- 避免 Java 序列化问题
5.5 返回 Token
return Result.ok(token);
前端保存 Token(LocalStorage / Header)
六、拦截器链-Token 刷新拦截器(RefreshTokenInterceptor)

6.1 作用
- 所有请求都会经过
- 只负责一件事:
解析 Token → 查询 Redis → 刷新 TTL → 保存 ThreadLocal
6.2 核心逻辑
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}
Map<Object, Object> userMap =
stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
if (userMap.isEmpty()) {
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
// 刷新 Token 有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
6.3 为什么要刷新 Token?
实现 滑动过期机制:
- 用户活跃 → 不掉线
- 长期不访问 → 自动过期
- 比 Session 更灵活
七、拦截器链-登录校验拦截器(LoginInterceptor)
7.1 作用
- 只拦截 需要登录的接口
- 判断 ThreadLocal 中是否有用户
if (UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
7.2 拦截器顺序设计
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**")
.order(0);
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(...)
.order(1);
顺序非常关键:
1️⃣ 先解析 Token
2️⃣ 再判断是否登录
八、ThreadLocal 的作用是什么?
ThreadLocal 用于在一次请求对应的线程生命周期内保存登录用户等上下文信息,避免层层传参,并减少重复查询 Redis 或数据库的开销。
8.1 为什么不用每层都传 User?
- Controller
- Service
- Mapper
层层传递会导致:
- 方法参数污染
- 强耦合
8.2 ThreadLocal 的优势
- 每个请求一个线程
- 用户数据线程隔离
- 使用方便
UserHolder.saveUser(userDTO);
UserHolder.getUser();
UserHolder.removeUser();
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)