黑马点评【基于redis实现共享session登录】

目录

一、基于Session实现登录流程

1.发送验证码:

2.短信验证码登录、注册:

3.校验登录状态:

4.session共享问题

[4.1为什么会出现 Session 集群共享问题?](#4.1为什么会出现 Session 集群共享问题?)

4.2常见解决方案

[1. 基于 Cookie 的 Session(客户端存储)](#1. 基于 Cookie 的 Session(客户端存储))

[2. Session 复制(服务器间同步)](#2. Session 复制(服务器间同步))

[3. 集中式 Session 存储(推荐方案)](#3. 集中式 Session 存储(推荐方案))

二、基于redis实现共享session登录

1.选择合适的存储结构

2.修改发送短信的逻辑

3.解决状态登录刷新问题


一、基于Session实现登录流程

1.发送验证码:

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

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

java 复制代码
    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3.符合,生成验证码
        String code = RandomUtil.randomNumbers(6);

        // 4.保存验证码到 session
        session.setAttribute("code",code);
        // 5.发送验证码
        log.debug("发送短信验证码成功,验证码:{}", code);
        // 返回ok
        return Result.ok();
    }

2.短信验证码登录、注册:

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

java 复制代码
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3.校验验证码
        Object cacheCode = session.getAttribute("code");
        String code = loginForm.getCode();
        if(cacheCode == null || !cacheCode.toString().equals(code)){
             //3.不一致,报错
            return Result.fail("验证码错误");
        }
        //一致,根据手机号查询用户
        User user = query().eq("phone", phone).one();

        //5.判断用户是否存在
        if(user == null){
            //不存在,则创建
            user =  createUserWithPhone(phone);
        }
        //7.保存用户信息到session中
        session.setAttribute("user",user);

        return Result.ok();
    }

3.校验登录状态:

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

拦截器代码:

java 复制代码
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
       //1.获取session
        HttpSession session = request.getSession();
        //2.获取session中的用户
        Object user = session.getAttribute("user");
        //3.判断用户是否存在
        if(user == null){
              //4.不存在,拦截,返回401状态码
              response.setStatus(401);
              return false;
        }
        //5.存在,保存用户信息到Threadlocal
        UserHolder.saveUser((User)user);
        //6.放行
        return true;
    }
}

让拦截器生效

java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

4.session共享问题

在分布式系统或集群环境中,Session 共享问题是指多个服务器节点之间如何共享用户会话(Session)数据,以确保用户在不同节点间切换时会话不丢失、状态一致。

4.1为什么会出现 Session 集群共享问题?

  1. 无状态协议特性

    HTTP 是无状态协议,服务器通过 Session 机制维持用户状态(如登录信息、购物车数据等)。在单体应用中,Session 通常存储在服务器内存中。
    但在集群 / 分布式架构中,用户请求可能被负载均衡器分配到不同节点,若节点间无法共享 Session,会导致:

    • 用户重复登录
    • 会话数据丢失(如购物车内容丢失)
    • 业务逻辑异常(如分布式事务状态不一致)
  2. 集群节点的独立性

    每个节点的内存独立,无法直接访问其他节点的 Session 数据。

4.2常见解决方案

  • 原理:将 Session 数据序列化后存储在客户端 Cookie 中,每次请求携带 Cookie,服务器从 Cookie 中读取 Session。
  • 优点
    • 无需服务器间共享数据,完全分布式
    • 减轻服务器存储压力
  • 缺点
    • 安全性差(敏感数据需加密,否则易被篡改)
    • Cookie 大小受限(通常不超过 4KB)
    • 不适用于大量 Session 数据

适用场景:轻量级应用、非敏感数据场景。

2. Session 复制(服务器间同步)
  • 原理:多个节点之间通过网络实时复制 Session 数据,确保每个节点拥有全量 Session 副本。
  • 优点
    • 透明性高,应用无需修改代码
    • 适用于中小型集群
  • 缺点
    • 性能开销大(Session 变更时需全量同步,节点越多延迟越高)
    • 内存占用高(每个节点存储所有 Session)
    • 扩展性差,不适合大规模集群

适用场景:节点较少(≤5 个)、Session 更新不频繁的场景。

3. 集中式 Session 存储(推荐方案)

将 Session 数据存储在独立的第三方存储系统中(如 Redis、Memcached、数据库),所有节点通过访问统一存储获取 Session。

  • 优点
    • 高性能(内存存储,Redis 支持集群和持久化)
    • 可扩展性强(支持动态扩缩容)
    • 数据安全(可配置密码、加密传输)
  • 缺点
    • 需要额外维护缓存集群
    • 存在缓存穿透、缓存雪崩风险(需通过布隆过滤器、限流等机制防范)

适用场景:大型分布式系统、高并发场景。

这里我们使用redis

二、基于redis实现共享session登录

1.选择合适的存储结构

首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如上图,如果使用String,注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。

String 结构(以 JSON 字符串存用户信息)

优点

  • 存储直观易懂 :直接将整个用户对象序列化为 JSON 字符串存到单个 key - value 中,业务侧读取时反序列化就能拿到完整用户信息,开发调试时查看数据很方便,比如快速通过 get heima:user:1 命令看到 {name:"Jack", age:21} 全貌 。
  • 整体读写高效 :存和取都是针对整个用户对象操作,Redis 处理单 key 的读写命令(如 SET/GET )本身就很高效,对于频繁需要完整用户信息的场景,一次操作就能满足需求 。

缺点

  • 局部更新成本高 :若只想修改用户的 age 字段,得先 GET 整个 JSON 字符串,反序列化成对象改字段,再序列化成 JSON 存回 Redis,多了序列化 / 反序列化以及全量写的开销,操作不灵活 。
  • 内存占用相对高:每个用户对象要存完整的 JSON 字符串,包含字段名、分隔符等冗余信息,大量用户数据存储时,内存浪费会更明显 。

Hash 结构(字段独立存储)

优点

  • 操作灵活性强 :支持对单个字段 CRUD,比如只想改 age,直接执行 HSET heima:user:1 age 22 即可,无需处理整个对象的序列化 / 反序列化,更新局部字段效率高 。
  • 内存更节省 :只存字段名和对应值,没有额外的 JSON 格式冗余(如分隔符、字段名重复存储),相同用户数据量下,Hash 结构内存占用通常更少,适合大规模用户数据存储 。

缺点

  • 数据查看稍复杂 :想看完整用户信息,得用 HGETALL 命令获取所有字段再拼接,不像 String 结构 GET 命令直接拿到直观的 JSON 结果,对调试不太友好 。
  • 批量操作有局限 :若业务经常需要一次性获取 / 设置整个用户对象的多个字段,Hash 得多次调用 HSET/HGET ,相比 String 结构一次 SET/GET ,在这类场景下效率可能稍低 。

2.修改发送短信的逻辑

现在验证码要存入redis,用于之后的登录注册

发送验证码:

java 复制代码
@Override
    public Result sendCode(String phone) {
        // 1 校验手机号
        if(RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号格式不合法");
        }
        // 2 符合,生成验证码,不符合返回
        String code = RandomUtil.randomNumbers(6);
        // 3 保存验证码到session
        stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone,code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
        // 4 发送验证码
        log.info("发送验证码成功:{}",code);
        return Result.ok();
    }

登录注册:

java 复制代码
@Override
    public Result login(LoginFormDTO loginForm) {
        String phone = loginForm.getPhone();
        // 1 校验手机号
        if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
            return Result.fail("手机号格式不合法");
        }
        // 2 校验验证码
        String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();
        if(cacheCode == null || !cacheCode.equals(code)){
            return Result.fail("验证码错误");
        }
        // 3 一致,根据用户手机号查询用户
        User user = query().eq("phone", phone).one();
        if(user == null){
           user = createUserwithPhone(phone);
        }
        UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
        Map<String, Object> userDtomap = BeanUtil.beanToMap(userDTO,new HashMap<>() ,
                CopyOptions.create().setIgnoreNullValue(true)
                                    .setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
        // 4 生成token
        String token = UUID.randomUUID().toString();
        stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token,userDtomap);
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
        return Result.ok(token);
    }

当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,**会去携带着token进行访问,从redis中取出token对应的value,**判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

3.解决状态登录刷新问题

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

优化方案

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

RefreshTokenInterceptor:

java 复制代码
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();
    }
}

LoginInterceptor:

java 复制代码
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;
    }
}

看到这如果有用的话记得点赞关注哦,后续会更新更多内容的!!

相关推荐
啾啾Fun35 分钟前
Java反射操作百倍性能优化
java·性能优化·反射·缓存思想
20岁30年经验的码农42 分钟前
若依微服务Openfeign接口调用超时问题
java·微服务·架构
曲莫终1 小时前
SpEl表达式之强大的集合选择(Collection Selection)和集合投影(Collection Projection)
java·spring boot·spring
ajassi20001 小时前
开源 java android app 开发(十二)封库.aar
android·java·linux·开源
q567315231 小时前
Java使用Selenium反爬虫优化方案
java·开发语言·分布式·爬虫·selenium
kaikaile19951 小时前
解密Spring Boot:深入理解条件装配与条件注解
java·spring boot·spring
守护者1702 小时前
JAVA学习-练习试用Java实现“一个词频统计工具 :读取文本文件,统计并输出每个单词的频率”
java·学习
bing_1582 小时前
Spring Boot 中ConditionalOnClass、ConditionalOnMissingBean 注解详解
java·spring boot·后端
ergdfhgerty2 小时前
斐讯N1部署Armbian与CasaOS实现远程存储管理
java·docker
勤奋的知更鸟2 小时前
Java性能测试工具列举
java·开发语言·测试工具