【Redis实战001】彻底搞懂分布式登录:Redis 缓存 + ThreadLocal 实现登录态共享

608564A16E7D652E882914E830EE4050(1)


📚博客主页:代码探秘者

✨专栏:文章正在持续更新ing…

✅C语言/C++:C++(详细版) 数据结构) 十大排序算法

✅Java基础:JavaSE基础 面向对象大合集 JavaSE进阶 Java版数据结构JDK新特性

✅前端基础: 前端三剑客 必学前端技术栈

✅后端经典框架: 后端基础 SpringBoot Tlias项目(含SSM)

✅数据库:Mysql

✅常用中间件:redis入门+实战 Elasticsearch RabbitMQ

✅Linux: 部署篇

✅微服务:微服务

❤️感谢大家点赞👍🏻收藏⭐评论✍🏻,您的三连就是我持续更新的动力❤️

🙏作者水平有限,欢迎各位大佬指点,相互学习进步!


img

Spring 登录拦截器 + Redis + ThreadLocal 实现用户认证与退出

本文注意:

章节一、二可以只做了解,不详细探讨

章节三开始才是重点

一、基于Session实现共享(了解)

1.登录

发送验证码:

用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号

如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户

短信验证码登录、注册:

用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息

校验登录状态:

用户在请求时候,会从cookie中携带者JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有session信息,则进行拦截,如果有session信息,则将用户信息保存到threadLocal中,并且放行

1653066208144

2.共享

1653068874258

核心思路分析:

具有两个大问题

1、每台服务器中都有完整的一份session数据,服务器压力过大。

2、session拷贝数据时,可能会出现延迟

所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了

1653069893050

在单体应用中,使用 HttpSession 管理用户登录状态非常方便。但一旦系统扩展为分布式架构(多台服务器、多个节点),传统的 session 就暴露了明显的缺陷。


二、❌ 为什么不能直接用 Session?(了解)

1. Session 不共享

  • HttpSession 是服务器内存中的一个对象,只存在于当前服务器节点上。
  • 用户如果第一次请求被路由到 A 服务器,登录状态保存在 A 的内存中;
  • 下次请求被路由到 B 服务器,B 没有这个 session,用户就被认为未登录

结论:在分布式环境中,session 是不共享的。


2. Session 粘性问题(Session Stickiness)不可扩展

  • 可以使用“粘性会话(sticky session)”策略,让某用户的请求固定落到同一台服务器。
  • 但这带来两个问题:
    • 一旦该节点宕机,用户 session 丢失;
    • 负载不均衡,影响性能。

3. 不能横向扩展

  • 使用 session 意味着必须将用户状态保存在单一机器内存中。
  • 当业务量上升、服务器需要扩容时,session 难以迁移或同步,不利于扩展和容灾。

4. 不适用于前后端分离 / 移动端接口

  • 移动端、前端应用(如 React/Vue)通常使用 Token 来进行身份认证,不能依赖 cookie 自动传递 sessionId。
  • HttpSession 是基于 cookie 的机制,不适用于 token 驱动的接口体系。

✅ 分布式登录正确姿势:Token + Redis

为了实现分布式下的统一登录状态管理,通常会采用:

技术作用
Token登录后生成的唯一标识,客户端存储,服务端根据 token 查 Redis 获取用户信息
Redis高性能、集中式缓存服务器,用于保存用户登录信息,可被所有节点共享
ThreadLocal拦截器中将当前用户信息存入 ThreadLocal,在业务代码中随时获取

🧠 总结一句话:

HttpSession 是单机状态,不能跨节点共享,在分布式系统中不可用。需要使用 Redis + Token 实现用户登录状态的统一管理。

三、基于Redis发送短信验证码

✅ 1.页面流程

image-20250605122614185

✅ 2.核心逻辑总结(共5点)

  1. 校验手机号格式(防止非法输入)。
  2. 生成 6 位随机数字验证码(使用 Hutool 工具)。
  3. 将验证码存入 Redis(key = 手机号拼接常量)。
  4. 设置验证码有效期(如2分钟,防止长期有效)。
  5. 记录日志并返回成功结果(便于调试)。

✅ 3.Service 实现类部分

UserServiceImpl里发送验证码

📘 核心代码

    @Override
    public Result sendCode(String phone) {
        //1.校验手机号
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式错误");
        }
        //2.符合,生成验证码,保存到redis
        String code= RandomUtil.randomNumbers(6);    //hutool工具             2L          按分钟计算
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
        log.info("发送验证码成功,验证码为:{}",code);
        //返回ok
        return Result.ok();
    }
  • 测试成功

image-20250605123046521

四、基于Redis实现登录

✅ 1.页面流程

1653319474181

✅ 2.重要逻辑总结(核心9点)

  1. 校验手机号是否合法(用正则工具类 RegexUtils 检查格式)。
  2. 验证验证码是否正确(从 Redis 获取验证码并与用户输入对比)。
  3. 根据手机号查询用户(若不存在则创建新用户)。
  4. 生成登录 token(UUID 唯一字符串)。
  5. 将用户信息封装为 DTO 对象(避免泄露敏感数据)。
  6. 使用 BeanUtil.beanToMap 将 DTO 转为 Map(为存 Redis 哈希结构做准备)。
  7. 将用户信息写入 Redis(以 token 为 key,Map 为 value)。
  8. 设置 Redis 过期时间(表示登录状态有效期)。
  9. 返回 token 给前端(供前端后续请求时携带身份信息)。

✅ 3.隐藏用户敏感信息

我们通过浏览器观察到此时用户的全部信息都在,这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了

@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

在登录方法处修改

//5.2 保存token到HashMap存储(但是不要存私密信息)
UserDTO userDTO= BeanUtil.copyProperties(user,UserDTO.class);
//6.添加用户
UserHolder.saveUser(userDTO);
//      难理解    userDTO转换为HashMap
Map<String,Object> userMap=BeanUtil.beanToMap(userDTO,new HashMap<>(),  // 创建一个空map
                                              CopyOptions.create().setIgnoreNullValue(true)           //忽略空值
                                              .setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));//key(属性) :value(属性值,转为字符串)
//7.将token和userMap存储到redis中
String tokenKey= LOGIN_USER_KEY+token;
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);

在拦截器处:

//存在,保存用户信息到Threadlocal
UserHolder.saveUser(userDTO);

✅ 4.ThreadLocal实现用户上下文共享

在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离。

在UserHolder处:将user对象换成UserDTO

public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
        tl.set(user);
    }

    public static UserDTO getUser(){
        return tl.get();
    }

    public static void removeUser(){
        tl.remove();
    }
}

✅ 5.key的结构设计

1653319261433

redis:

image-20250605124039459

✅ 6.代码

UserServiceImpl里

    @Override
    public Result login(LoginFormDTO loginFormDTO, HttpSession session) {
        //1.先判断手机号
        String phone = loginFormDTO.getPhone();
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式错误");
        }
        //2.校验验证码(不为空/需要与redis的正确)
        //获取校验码
        String code = loginFormDTO.getCode();
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY +phone);
        if(code == null || !cacheCode.equals(code)){
            return Result.fail("验证码错误");
        }
        //3.验证码正确,查询用户
        User user = query().eq("phone", phone).one();
        //4.用户不存在,创建新用户
        if(user == null){
        //  user =createUserWithPhone(phone);//使用手机号创建一个用户
            user = new User();
            user.setPhone(phone);
            user.setNickName(USER_SIGN_KEY+RandomUtil.randomString(10));
            //保存用户
            save(user);
        }
        //5.保存用户信息到redis中
        //5.1 随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString();
        //5.2 保存token到HashMap存储(但是不要存私密信息)
        UserDTO userDTO= BeanUtil.copyProperties(user,UserDTO.class);
        //6.添加用户
        UserHolder.saveUser(userDTO);
        //      难理解    userDTO转换为HashMap
        Map<String,Object> userMap=BeanUtil.beanToMap(userDTO,new HashMap<>(),  // 创建一个空map
                CopyOptions.create().setIgnoreNullValue(true)           //忽略空值
                        .setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));//key(属性) :value(属性值,转为字符串)
        //7.将token和userMap存储到redis中
        String tokenKey= LOGIN_USER_KEY+token;
        stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
        //8.设置token有效期                      //设置的分钟数       按分钟计算
        stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);//注意:这里的key是tokenKey,不是userMap
        //9.返回token
        return Result.ok(token);
    }

五、登录拦截器以及优化

✅1.初始方案思路

在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的。

1653320822964

✅2.优化方案

既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

1653320764547

✅ 3.登录状态刷新拦截器

刷新token拦截器-RefreshTokenInterceptor(优先级最高)

public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.基于TOKEN获取redis中的用户
        String key  = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 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;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}
	

✅ 4.登录拦截器

登录拦截器-LoginInterceptor优先级第二

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

注册拦截器-MvcConfig

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //登录的拦截器
       registry.addInterceptor(new LoginInterceptor())      //.addPathPatterns("/**")
               .excludePathPatterns(
                       "/shop/**",
                       "/voucher/**",
                       "/shop-type/**",
                       "/upload/**",
                       "/blog/hot",
                       "/user/code",
                       "/user/login"
               ).order(1);  //越小的数字,优先级越高
        //token刷新(以及保存登录用户)
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**").order(0);
    }
}

六、获取用户信息和退出登录

在这里插入图片描述

✅ 1.获取用户信息

@GetMapping("/me")
public Result me(){
  // TODO 获取当前登录的用户并返回
  //我的获取用户信息
  // 获取当前登录的用户并返回
  UserDTO user = UserHolder.getUser();
  return Result.ok(user);
}

✅2.退出登录

    @PostMapping("/logout")
    public Result logout(HttpServletRequest request){
        // TODO 实现登出功能
      
      	//获取token
        String token = request.getHeader("authorization");
        if (token == null || token.isEmpty()) {
            return Result.fail("缺少有效的登录凭证");
        }
        
        String key = LOGIN_USER_KEY + token;
        // 删除Redis中对应的用户信息
        stringRedisTemplate.delete(key);
        
        // 清除线程本地存储的用户信息
        UserHolder.removeUser();

        return Result.ok("退出登录成功");
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

代码探秘者

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值