点评--day02--2.1 短信登录--基于Redis实现短信登录流程

4 阅读4分钟

1. 为什么要使用 Redis 代替 Session?

在单体 Tomcat 架构下,Session 登录运行良好。但是一旦引入 Tomcat 集群,就会面临 Session 共享问题:多台 Tomcat 并不共享 Session 存储空间,当请求被负载均衡切换到不同服务器时,会导致数据丢失。 虽然早期的方案有 Session 拷贝,但这会导致服务器内存压力过大且存在数据延迟。因此,我们采用 Redis 来代替 Session,因为 Redis 是独立部署且数据共享的,能完美避免 Session 共享问题。

2. Redis 存储设计分析

在使用 Redis 存储登录用户信息前,我们需要设计合适的数据结构和 Key:

  • 数据结构选择(Hash) :如果使用 String 结构存储 JSON,会占用较多内存;而使用 Hash 结构可以将对象中的每个字段独立存储,不仅可以针对单个字段做 CRUD,而且内存占用更少,所以我们推荐使用 Hash。
  • Key 的设计(Token) :Session 是每个用户独立的,但 Redis 的 Key 是共享的。我们不能直接把手机号(Phone)作为 Key 并返回给前端,因为这样极不安全。正确的做法是:在后台生成一个随机串(Token,如 UUID),以这个 Token 作为 Redis 的 Key,然后将 Token 返回给前端。前端后续请求时携带这个 Token 即可。

3. 核心业务流程与代码实现

3.1 短信验证码登录、注册逻辑

当用户提交手机号和验证码后:

  1. 校验验证码是否一致。
  2. 根据手机号查询用户,不存在则创建。
  3. 随机生成 Token 作为登录令牌
  4. 将 User 对象转为 HashMap,并存入 Redis 中
  5. 设置 Token 的有效期(TTL) ,并将 Token 返回给前端。

核心代码实现:

// 7.保存用户信息到 redis中
// 7.1.随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 7.2.将User对象转为HashMap存储
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
    CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存储到 Redis 的 Hash 结构中
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);

(以上逻辑参考自源文档,,,)

4. 核心优化:双拦截器解决“状态登录刷新”问题

暴露问题: 如果只使用一个拦截器来拦截“需要登录的路径”(如个人中心),并在其中刷新 Redis 的 Token 有效期,会产生一个致命问题:如果用户一直在访问不需要登录的路径(如首页浏览文章),拦截器就不会生效,Token 的有效期就不会被刷新,导致用户在活跃状态下突然掉线。

优化方案(双拦截器模型) : 为了解决这个问题,我们需要设计两个拦截器:

  1. 第一个拦截器(RefreshTokenInterceptor拦截一切路径。它负责从请求头获取 Token,去 Redis 查询用户。如果查到了,就把用户保存到 ThreadLocal 中,并且刷新 Token 的有效期;如果没查到,直接放行(不拦截),,。
  2. 第二个拦截器(LoginInterceptor只拦截需要登录的路径。它不需要再去查 Redis,只需要判断 ThreadLocal 中有没有用户即可。如果没有,说明未登录,直接拦截(返回401);如果有,则放行,。

核心代码演示(拦截器分离):

  • 全局刷新拦截器(RefreshTokenInterceptor):
// 1.获取请求头中的token
String token = request.getHeader("authorization");
// ...略过为空判断
// 2.基于TOKEN获取redis中的用户
String key  = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// ...略过为空判断
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期 (核心逻辑)
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;

(以上逻辑参考自源文档,)

  • 登录校验拦截器(LoginInterceptor):
// 1.判断是否需要拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null) {
    // 没有,需要拦截,设置状态码
    response.setStatus(401);
    return false;
}
// 有用户,直接放行
return true;

(以上逻辑参考自源文档)

通过引入 Redis 以及双拦截器设计,我们不仅完美解决了分布式环境下的状态共享问题,还优雅地保证了用户处于活跃状态时登录凭证的自动续期。