分布式session共享解决方案

  |   0 评论   |   0 浏览

分布式session共享解决方案

1.分布式 Session 问题

  • 示意图

image-20230219173607725

  • 解读上图,假如我们去购买商品
  1. 当 Nginx 对请求进行负载均衡后, 可能对应到不同的 Tomcat
  2. 比如第 1 次请求, 均衡到 TomcatA, 这时 Session 就记录在 TomcatA, 第 2 次请求,
    均衡到 TomcatB, 这时就出现问题了,因为 TomcatB 会认为该用户是第 1 次来,就会
    允许购买请求
  3. 这样就会造成重复购买

2.解决方案

2.1Session 绑定/粘滞

什么是 session 绑定/粘滞/黏滞

image-20230219173702703

  • 解读上图

概述: 服务器会把某个用户的请求, 交给 tomcat 集群中的一个节点,以后此节点就负责该保存该用户的session

  1. Session 绑定可以利用负载均衡的源地址 Hash(ip_hash)算法实现
  2. 负载均衡服务器总是将来源于同一个 IP 的请求分发到同一台服务器上,也可以根据 Cookie 信息将同一个用户的请求总是分发到同一台服务器上
  3. 这样整个会话期间,该用户所有的请求都在同一台服务器上处理,即 Session 绑定
    在某台特定服务器上,保证 Session 总能在这台服务器上获取。这种方法又被称为
    session 黏滞/粘滞

ps:nginx配置ip_hash示例

upstream llpservers{
	ip_hash;
	server 192.168.79.111:8081;
	server 192.168.79.111:8080;
}

优点: 不占用服务端内存

缺点:

  1. 增加新机器,会重新 Hash,导致重新登录
  2. 应用重启, 需要重新登录
  3. 某台服务器宕机,该机器上的 Session 也就不存在了,用户请求切换到其他机器后因为没有 Session 而无法完成业务处理, 这种方案不符合系统高可用需求, 使用较少

2.2Session 复制

image-20230219174427596

ps:可以通过配置tomcat实现session配置

  • Session 复制是小型架构使用较多的一种服务器集群 Session 管理机制
  • 应用服务器开启 Web 容器的 Session 复制功能,在集群中的几台服务器之间同步
    Session 对象,使每台服务器上都保存了所有用户的 Session 信息
  • 这样任何一台机器宕机都不会导致 Session 数据的丢失,而服务器使用 Session 时,
    也只需要在本机获取即可

优点: 不占用服务端内存

缺点:

  1. 增加新机器,会重新 Hash,导致重新登录
  2. 应用重启, 需要重新登录
  3. 某台服务器宕机,该机器上的 Session 也就不存在了,用户请求切换到其他机器后因为没有 Session 而无法完成业务处理, 这种方案不符合系统高可用需求, 使用较少

2.3前端存储

优点: 不占用服务端内存

缺点:

  1. 存在安全风险
  2. 数据大小受 cookie 限制
  3. 占用外网带宽

2.4 后端集中存储

优点:安全,容易水平扩展

缺点:增加复杂度,需要修改代码

3.代码实现

现在主流的解决方案还是将用户登录信息在后端集中存储,这里列举两种存储方式

3.1 SpringSession 实现分布式 Session

基本说明

将用户 Session 不再存放到各自登录的 Tomcat 服务器,而是统一存在 Redis,从而解决Session 分布式问题

  1. 如图, 将用户的 Session 信息统一保存到 Redis 进行管理
  2. 说明: SpringSession在默认情况下是以原生形式保存的

image-20230219200625731

引入依赖

<!--spring data redis 依赖, 即 spring 整合 redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.4.5</version>
</dependency>
<!--pool2 对象池依赖-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.9.0</version>
</dependency>
<!--实现分布式 session, 即将 Session 保存到指定的 Redis-->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

image-20230219200828662

3.2直接将用户信息统一放入 Redis

基本说明

前面将 Session 统一存放到指定 Redis, 是以原生的形式存放, 在操作时, 还需要反序列化,不方便,我们可以直接将登录用户信息统一存放到 Redis, 利于操作

需求分析/图解

直接将登录用户信息统一存放到 Redis, 利于操作

image-20230219202714967

image-20230219203300481

image-20230219203241485

代码+配置实现

引入依赖

<!--spring data redis 依赖, 即 spring 整合 redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>2.4.5</version>
        </dependency>
        <!--pool2 对象池依赖-->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
            <version>2.9.0</version>
        </dependency>
        <!--实现分布式 session, 即将 Session 保存到指定的 Redis-->
        <!--<dependency>-->
        <!--    <groupId>org.springframework.session</groupId>-->
        <!--    <artifactId>spring-session-data-redis</artifactId>-->
        <!--</dependency>-->

redis配置

public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory redisConnectionFactory) {

        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

        //设置连接池工厂
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        //首先解决key的序列化方式
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer);

        //解决value的序列化方式
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        //将当前对象的数据类型也存入序列化的结果字符串中
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);

        // 解决jackson2无法反序列化LocalDateTime的问题
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.registerModule(new JavaTimeModule());
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);

        return redisTemplate;
    }
}
#配置redis
  redis:
    host: 192.168.79.202
    port: 6379
    database: 0
    timeout: 10000ms
    lettuce:
      pool:
        #最大连接数
        max-active: 12
        #最大链接阻塞等待时间,默认是-1
        max-wait: 10000ms
        #最大空闲链接,默认是8
        max-idle: 200
        #最小空闲数,默认是0
        min-idle: 5

改造登录接口

@Override
public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
    //接收到mobile和password[midPass]
    String mobile = loginVo.getMobile();
    String password = loginVo.getPassword();
    //判断手机号和密码是否为空
    // if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) {
    //     return RespBean.error(RespBeanEnum.LOGIN_ERROR);
    // }
    //判断手机号码是否合格
    // if (!ValidatorUtil.isMobile(mobile)) {
    //     return RespBean.error(RespBeanEnum.MOBILE_ERROR);
    // }
    //查询DB,看看用户是否存在
    User user = userMapper.selectById(mobile);
    if (user == null) {
        // return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        throw new BusinessException(RespBeanEnum.LOGIN_ERROR);
    }
    //将中间密码(客户端|前端经过了一次加密加盐)转换为最终存储到数据库得密码并进行比对
    if (!MD5Util.midPassToDBPass(password, user.getSlat()).equals(user.getPassword())) {
        // return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        throw new BusinessException(RespBeanEnum.LOGIN_ERROR);
    }
    //登录成功
    //给每个用户生成一个ticket-唯一
    String ticket = UUIDUtil.uuid();
    //将登录成功的用户保存到session中
    //实现分布式session,将登录信息存放到redis中
    redisTemplate.opsForValue().set("user:" + ticket, user,30, TimeUnit.MINUTES);
    // request.getSession().setAttribute(ticket, user);
    CookieUtil.setCookie(request, response, "userTicket", ticket);
    return RespBean.success();
}
@Override
public User getUserByTicket(HttpServletRequest request, HttpServletResponse response, String userTicket) {
    if (!StringUtils.hasText(userTicket)) {
        return null;
    }
    User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
    //获取用户登录信息,更新cookie,刷新过期时间
    if (user != null) {
        CookieUtil.setCookie(request, response, "userTicket", userTicket);
        return user;
    }
    return null;
}
@RequestMapping("/toList")
public String toList(Model model, @CookieValue("userTicket") String userTicket, HttpServletRequest request, HttpServletResponse response) {
    //如果cookie没有生成,则表示没有登录
    if (!StringUtils.hasText(userTicket)) {
        return "login";
    }
    User user = userService.getUserByTicket(request, response, userTicket);
    //用户没有成功登录
    if (null == user) {
        return "login";
    }
    //将user放入到model中
    model.addAttribute("user", user);
    return "goodsList";
}

3.3 实现 WebMvcConfigurer ,优化登录

需求分析/图解

  1. 获取浏览器传递的 cookie 值,进行参数解析,直接转成 User 对象,继续传递
@RequestMapping("/toList")
//通过自定义参数解析器,封装user信息供controller层方法使用
public String toList(Model model, User user) {
    //用户没有成功登录
    if (null == user) {
        return "login";
    }
    //将user放入到model中
    model.addAttribute("user", user);
    return "goodsList";
}

代码+配置实现

自定义参数解析器

/**
 * 自定义参数解析器
 */
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

    @Resource
    private UserService userService;

    /**
     * 如果这个方法返回 true 才会执行下面的 resolveArgument 方法
     * 返回 false 不执行下面的方法
     *
     * @param parameter
     * @return
     */
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> parameterType = parameter.getParameterType();
        //如果controller层方法中含有User类型的参数,则执行下面的resolveArgument方法
        return parameterType == User.class;
    }


    /**
     * 这个方法,类似拦截器,将传入的参数,取出 cookie 值,然后获取对应的 User 对象
     * 并把这个 User 对象作为参数继续传递
     *
     * @param parameter
     * @param mavContainer
     * @param webRequest
     * @param binderFactory
     * @return
     * @throws Exception
     */
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request =
                webRequest.getNativeRequest(HttpServletRequest.class);
        HttpServletResponse response =
                webRequest.getNativeResponse(HttpServletResponse.class);
        String userTicket = CookieUtil.getCookieValue(request, "userTicket");
        if (!StringUtils.hasText(userTicket)) {
            return null;
        }
        return userService.getUserByTicket(request, response, userTicket);

    }
}

添加自定义参数解析器到解析器列表中

@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Resource
    private UserArgumentResolver userArgumentResolver;

    /**
     * 静态资源加载
     * @param registry
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
    }

    /**
     * 将自定义参数解析器添加到解析器列表中
     * @param resolvers
     */
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(userArgumentResolver);
    }

}

改造controller层代码

//登录功能
@RequestMapping("/doLogin")
@ResponseBody
public RespBean doLogin
(@Validated LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
    log.info("{}", loginVo);
    return userService.doLogin(loginVo, request, response);
}

3.4使用拦截器进行登录校验

自定义登录认证注解

/**
 * 登录认证注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorization {
}

登录认证拦截器

public class AuthorizationInterceptor implements HandlerInterceptor {



    @Resource
    private RedisTemplate redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        Authorization annotation = method.getAnnotation(Authorization.class);
        String userTicket = CookieUtil.getCookieValue(request, "userTicket");
        if (annotation != null) {
            User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
            if (user != null) {
                //TODO 还可以进一步封装,比如将用户信息封装到ThreadLocal中便于后续接口获取
                return true;
            }
        }
        return false;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }
}
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Resource
    private UserArgumentResolver userArgumentResolver;

    /**
     * 静态资源加载
     * @param registry
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //表示拦截所有请求
        registry.addInterceptor(authorizationInterceptor()).addPathPatterns("/**")
                .excludePathPatterns("classpath:/static/")
                .excludePathPatterns("/toLogin")
                .excludePathPatterns("classpath:/templates/");
    }

    @Bean
    AuthorizationInterceptor authorizationInterceptor(){
        return new AuthorizationInterceptor();
    }

}

AuthorizationInterceptor对添加了@Authorization注解的controller层方法统一进行登录认证,无需再每个方法都去做用户是登录的校验操作

@Authorization   
@RequestMapping("/toList")
public String toList(Model model) {
    //将user放入到model中
    model.addAttribute("user", user);
    return "goodsList";
}

标题:分布式session共享解决方案
作者:llp
地址:https://llinp.cn/articles/2023/02/19/1676813063757.html