在现代 Web 应用中,登录态管理是一个绕不开的问题。
传统基于 HttpSession 的登录方式,在单机环境下尚可使用,但在 分布式 / 微服务 / 集群部署 场景下会暴露出大量问题。

本文将基于一个完整可落地的工程实现,详细讲解:

  • 什么是 Session 集群
  • Session 集群的 核心缺点
  • 为什么用 Redis + Token 替代 Session
  • 短信验证码登录的完整流程
  • Redis 中 Key 设计、数据结构
  • 双拦截器设计(Token 刷新 + 登录校验)
  • ThreadLocal 在其中扮演的角色

本文代码全部来自真实项目,而非伪代码。


一、什么是 Session 集群?

1.1 单机 Session 的工作方式

在传统 Java Web 中,登录流程通常如下:

  1. 用户提交账号密码
  2. 服务端校验成功
  3. 将用户信息存入 HttpSession
  4. 浏览器持有 JSESSIONID
  5. 后续请求通过 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 是“强状态”的,天然不适合分布式架构

具体问题包括:

  1. ❌ 与服务器强绑定
  2. ❌ 扩容、缩容复杂
  3. ❌ 跨语言、跨服务困难
  4. ❌ 无法支持 API 化 / 移动端
  5. ❌ 难以精细控制登录态

二、为什么用 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 整体设计思路

登录流程拆分为 两个阶段

  1. 验证码阶段
  2. 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();
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐