Session粘滞性问题->Redis实现session共享

文章目录


一、Session

当用户首次访问时,服务器为其创建一个唯一的 Session 对象,并生成一个 SessionId 写回客户端(通常通过 Cookie)。此后,客户端每次请求都会自动带上这个 SessionId,服务器便可根据它找到对应用户的会话,从而实现"记住"用户状态。

1.Session 的存储位置

在传统的 Java Web 应用中,Tomcat 默认把 Session 对象存放在 JVM 堆内存中。内部实际上是一个 ConcurrentHashMap,Key 为 SessionId,Value 为 HttpSession 对象,其中存储了用户相关的属性。

2.Session的生命周期

Session 在以下情况会被销毁:

  • 超过设定的过期时间(默认 30 分钟,可通过 web.xml 或 server.xml 中的 session-timeout 配置)。
  • 调用 session.invalidate() 方法手动销毁。
  • Tomcat 重启或宕机------由于数据只存在内存,重启后全部丢失。

3.Session的粘滞性问题

因为每个 Tomcat 实例都在自己的内存中维护 Session,一旦引入负载均衡和多个实例,就会出现经典的 Session 共享问题。

假设你有多台 Tomcat,前端使用 Nginx 轮询分发请求

  • 用户第一次请求 → Nginx → Tomcat 1 → 登录成功,Session 存在 Tomcat 1 的内存中
  • 用户第二次请求被分到 Tomcat 2,Tomcat 2 的内存里没有这个 Session,就会认为用户未登录。

这就是 Session 粘滞性问题。

二、解决方案:tomcat+redis+nginx 实现session共享

业界主流的方案是使用 Redis 作为集中式 Session 存储器。所有 Tomcat 实例在需要读写 Session 时都去访问同一个 Redis,从而达到会话共享。

1.登录校验

对于下面的验证码登录实践案例中,原本 session.setAttribute("code", code) 被替换为 Redis 写入。这样无论请求被 Nginx 分发到哪台机器,校验验证码时都从同一个 Redis 读取。

此时Redis中存储的数据如下,code对应验证码校验,token存储对应的用户 UserDTO

java 复制代码
@Override
public Result sendCode(String phone, HttpSession session) {
    //校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        //手机号不符合
        return Result.fail("手机号格式错误");
    }
    //手机号符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    /*//保存验证码到session
    session.setAttribute("code", code);*/
    //保存验证码到redis
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    //发送验证码
    log.debug("发送验证码成功,验证码:{}", code);
    //返回ok
    return Result.ok();
}
java 复制代码
public Result login(LoginFormDTO loginForm, HttpSession session) {
    //校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        //手机号不符合
        return Result.fail("手机号格式错误");
    }
    //从redis中获取验证码 校验验证码
    /*  Object cacheCode = session.getAttribute("code");*/
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        //不一致 报错
        return Result.fail("验证码错误");
    }
    //一致 根据手机号查询用户
    User user = baseMapper
            .selectOne(new LambdaQueryWrapper<User>()
                    .eq(User::getPhone, phone));
    //判断用户是否存在
    if (user == null) {
        //不存在 创建新用户
        user = createUserWithPhone(phone);
    }
    /*//保存用户信息到session
    session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));*/
    //生成token
    String token = UUID.randomUUID().toString(true);
    //userDTO转map
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>()
            , CopyOptions.create().setIgnoreNullValue(true)
                    .setFieldValueEditor(
                            (name, value) -> value.toString()
                    ));
    //保存用户信息到redis
    stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, map);
    //设置过期时间
    stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
    return Result.ok(token);
}

2.拦截器设置

  • 第一个拦截器RefreshTokenInterceptor的作用:从请求头取 Token → 查 Redis → 得到用户数据 → 存入 ThreadLocal,刷新 Redis 中该 Token 的过期时间,请求结束后,清理 ThreadLocal。
  • 第二个拦截器LoginInterceptor的作用:检查 ThreadLocal 里有没有用户数据。有就放行,没有就返回 401

ThreadLocal相当于备份了Redis中的UserDTO数据,对于该次请求(该次线程)ThreadLocal中的数据存在,方便进行显示,不用再去Redis中查看了
两个拦截器为了解决Redis中token的过期时间问题,如果只设置一个拦截器,用户每次访问需要UserDTO登录校验的界面,会刷新token过期时间,但是如果访问商家页面这种不需要登录校验,此时token的过期时间就没有刷新

java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //登陆拦截器
        registry
                .addInterceptor(new LoginInterceptor())
                .excludePathPatterns("/user/code"
                        , "/user/login"
                        , "/blog/hot"
                        , "/shop/**"
                        , "/shop-type/**"
                        , "/upload/**"
                        , "/voucher/**"
                )
                .order(1);
        //Token续命拦截器
        registry
                .addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**")
                .order(0);
    }
}
java 复制代码
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //获取用户
        if (UserHolder.getUser() == null) {
            //不存在用户 拦截
            response.setStatus(401);
            return false;
        }
        //存在用户放行
        return true;
    }
}
java 复制代码
public class RefreshTokenInterceptor implements HandlerInterceptor {
    private final StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //从请求头中获取token
        String token = request.getHeader("authorization");
        if (StringUtils.isEmpty(token)) {
            //不存在token
            return true;
        }
        //从redis中获取用户
        Map<Object, Object> userMap =
                stringRedisTemplate.opsForHash()
                        .entries(RedisConstants.LOGIN_USER_KEY + token);
        //用户不存在
        if (userMap.isEmpty()) {
            return true;
        }
        //hash转UserDTO存入ThreadLocal
        UserHolder.saveUser(BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false));
        //token续命
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}
相关推荐
珠海西格电力1 小时前
零碳园区管理系统“云-边-端”架构协同的价值及具体案例
大数据·数据库·人工智能·架构·能源
sinat_383437361 小时前
如何在 Laravel 中筛选并格式化匹配预定义列表的产品数据
jvm·数据库·python
2401_846339561 小时前
mysql如何用执行流程思维写好SQL_SQL优化方法总结
jvm·数据库·python
鸽芷咕1 小时前
KingbaseES数据库设计规范与SQL开发最佳实践
数据库·sql·设计规范
forEverPlume1 小时前
SQL如何统计分组内不重复值的数量_COUNT与DISTINCT结合应用
jvm·数据库·python
极创信息1 小时前
信创领域五种主流CPU架构(X86 / ARM / RISC-V / MIPS / LoongArch)
java·arm开发·数据库·spring boot·mysql·软件工程·risc-v
chaofan9802 小时前
突破大模型落地瓶颈:Claude 4.7 与 GPT-5.5 长上下文工程实测
数据库·人工智能·python·gpt·自动化·php·api
2501_901200532 小时前
PHP源码部署需要多大硬盘空间_PHP项目存储空间估算方法【方法】
jvm·数据库·python
小肝一下2 小时前
3. 数据类型
android·数据库·mysql·adb