• 分布式Session解决方案


    考虑一个场景,用户在进行下单操作之前后台需要校验该用户是否登录,若未登录则不允许提交订单,这在传统的单体应用中非常容易实现,只需在提交订单之前判断Session中的用户信息是否登录即可,但在分布式应用中,这显然是一个待解决的问题。

    分布式应用下Session存在的问题

    在分布式架构中,一个应用往往被划分为多个子模块,比如:登录注册模块和订单模块,当应用被拆分后,随之而来的便是数据的共享问题:

    一般我们都在登录注册模块中将用户的登录状态保存到Session中,然而当用户进行下单操作时,由于订单模块是独立的,它无法获取到登录注册模块中保存的Session,所以订单模块是无法判断用户是否登录的。

    而为了保证系统的高可用,一个模块往往被部署多份形成集群,这些模块之间的数据共享也是一个问题:

    用户在一个模块中登录成功后,很可能在下次访问时请求被负载均衡到其它的集群模块中,这样会导致无法读取到Session,使得用户又得重新登录一次系统。

    Session共享问题的案例演示

    下面编写一个案例进行演示,首先创建一个SpringBoot应用,实现登录模块:

    @RestController
    public class LoginController {
    
        @Autowired
        private ServiceOrderClient serviceOrderClient;
    
        @GetMapping("/login")
        public Result login(User user, HttpSession session) {
            String username = user.getUsername();
            String password = user.getPassword();
            Result result = new Result();
            if ("admin".equals(username) && "admin".equals(password)) {
                result.setCode(200);
                result.setMessage("登录成功");
                session.setAttribute("user", user);
            } else {
                result.setCode(-1);
                result.setMessage("登录失败");
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    再创建一个SpringBoot应用,实现订单模块:

    @RestController
    public class OrderController {
    
        @GetMapping("/order/test")
        public String order(@CookieValue("JSESSIONID") String jSessionId) {
            return "success";
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    代码都非常简单,我们主要是观察Session的问题,在登录模块中编写远程调用接口:

    @FeignClient("service-order")
    public interface ServiceOrderClient {
    
        @GetMapping("/order/test")
        String order();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    将这两个应用都注册到Nacos中,其它代码我就不贴出来了,都比较简单。

    分别启动这两个项目,并访问 http://localhost:8080/test ,会发现访问是不成功的:

    控制台输出的结果:

    2021-09-21 16:51:43.155  WARN 20908 --- [nio-9000-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingRequestCookieException: Missing cookie 'JSESSIONID' for method parameter of type String]
    
    • 1

    找不到名为 JSESSIONID 的Cookie,我们知道,服务端是通过JSESSIONID来找到该用户对应的Session信息的,既然JSESSIONID都获取不到,就更不用说用户信息了,这就是Session不共享的问题。

    Redis解决Session共享问题

    对于分布式应用中的Session问题,其实也非常简单,无非就是不能共享到Session,所以,我们可以类比缓存的思想,将Session放入缓存中,其它服务想要获取Session也从缓存中拿,这样就实现了Session的共享。

    改进一下登录模块:

    @GetMapping("/login")
    public Result login(User user, HttpSession session) {
        String username = user.getUsername();
        String password = user.getPassword();
        Result result = new Result();
        if ("admin".equals(username) && "admin".equals(password)) {
            result.setCode(200);
            result.setMessage("登录成功");
            String json = JSONObject.toJSONString(user);
            redisTemplate.opsForValue().set("session", json);
        } else {
            result.setCode(-1);
            result.setMessage("登录失败");
        }
        return result;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    当我们访问登录接口 http://localhost:8080/login?username=admin&password=admin 时,就会向Redis保存一份Session的值:

    此时若是其它服务需要Session,只要从Redis中读取即可,修改一下订单模块:

    @RestController
    public class OrderController {
    
        @GetMapping("/order/test")
        public String order() {
            return "success";
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在订单模块中添加一个登录的拦截器:

    public class LoginInterceptor implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 手动获取StringRedisTemplate对象
            StringRedisTemplate redisTemplate = SpringBeanOperator.getBean(StringRedisTemplate.class);
            String json = redisTemplate.opsForValue().get("session");
            User user = JSONObject.parseObject(json, User.class);
            System.out.println(user);
            if (user == null) {
                System.out.println("用户未登录......");
                return false;
            } else {
                System.out.println("用户已登录......");
                return true;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    将拦截器注册一下:

    @Configuration
    public class MyWebConfig implements WebMvcConfigurer {
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(new LoginInterceptor())
                    .addPathPatterns("/**");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    重启项目,访问 http://localhost:8080/test ,输出结果:

    User(username=admin, password=admin)
    用户已登录......
    
    • 1
    • 2

    SpringSession解决Session共享问题

    刚才我们自己使用Redis尝试着解决了一下Session的共享问题,然而这种方式是有很多缺陷的,首先,我们保存的只是一个User对象,并不是Session,所以我们无法标识该用户,这样会导致用户访问到了其它用户的信息,使得系统混乱。我们当然可以使用JSESSIONID来标识不同的用户,但其实,Spring已经为我们提供了一个组件来解决这一问题,那就是SpringSession。

    \

    在两个模块中都引入SpringSession的依赖:

    <dependency>
      <groupId>org.springframework.session</groupId>
      <artifactId>spring-session-data-redis</artifactId>
    </dependency>
    
    • 1
    • 2
    • 3
    • 4

    在application.yml中配置一下Session的保存方式为Redis:

    spring:  session:
        store-type: redis
    
    • 1
    • 2

    最后在启动类上添加 @EnableRedisHttpSession 注解,这样SpringSession的整合就完成了。

    我们修改登录模块的代码:

    @GetMapping("/login")
    public Result login(User user, HttpSession session) {
        String username = user.getUsername();
        String password = user.getPassword();
        Result result = new Result();
        if ("admin".equals(username) && "admin".equals(password)) {
            result.setCode(200);
            result.setMessage("登录成功");
            session.setAttribute("user",user);
        } else {
            result.setCode(-1);
            result.setMessage("登录失败");
        }
        return result;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    按照正常流程将User对象存入Session,重启项目并访问登录接口,来看看Redis中有什么变化:

    此时Redis中已经保存了用户信息,并且还有创建时间、存活时间等配置,其它模块要想获取到Session中的用户信息,也只需要按正常流程编写代码即可:

    public class LoginInterceptor implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            HttpSession session = request.getSession();
            User user = (User) session.getAttribute("user");
            System.out.println(user);
            if (user == null) {
                System.out.println("用户未登录......");
                return false;
            } else {
                System.out.println("用户已登录......");
                return true;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    需要注意的是登录模块存入的User对象需要和其它模块读出的User对象包名一致,所以最好将User类抽取到公共模块中,提供给所有模块使用。

    到这里SpringSession就解决了Session共享的问题,你可以运行项目测试一下,访问 http://localhost:8080/test

    结果出乎意料,控制台的结果是:

    null
    用户未登录......
    
    • 1
    • 2

    这就奇怪了,难道是SpringSession没起作用?我们写一个测试方法测试一下:

    @GetMapping("/test")
    public String test(HttpSession session) {
        User user = (User) session.getAttribute("user");
        System.out.println(user);
        return "test";
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    访问 http://localhost:9000/test ,得到结果:

    User(username=admin, password=admin)
    
    • 1

    显然SpringSession是没有任何问题的,那么问题出在哪里了呢?

    OpenFeign远程调用的坑

    刚才我们进行了测试,发现在订单模块中直接访问Session可以获取User对象,然而通过远程调用,User就获取不到了,我们可以猜测这是OpenFeign出现了问题,Debug调试一下项目,这是远程调用的代码:

    我们跟进去看看:

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if ("equals".equals(method.getName())) {
            try {
                Object otherHandler =
                    args.length > 0 && args[0] != null ? Proxy.getInvocationHandler(args[0]) : null;
                return equals(otherHandler);
            } catch (IllegalArgumentException e) {
                return false;
            }
        } else if ("hashCode".equals(method.getName())) {
            return hashCode();
        } else if ("toString".equals(method.getName())) {
            return toString();
        }
    
        return dispatch.get(method).invoke(args);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    该方法中进行了一些判断,最终会调用dispatch.get()方法:

    @Override
    public Object invoke(Object[] argv) throws Throwable {
        RequestTemplate template = buildTemplateFromArgs.create(argv);
        Options options = findOptions(argv);
        Retryer retryer = this.retryer.clone();
        while (true) {
            try {
                return executeAndDecode(template, options);
            } catch (RetryableException e) {
                try {
                    retryer.continueOrPropagate(e);
                } catch (RetryableException th) {
                    Throwable cause = th.getCause();
                    if (propagationPolicy == UNWRAP && cause != null) {
                        throw cause;
                    } else {
                        throw th;
                    }
                }
                if (logLevel != Logger.Level.NONE) {
                    logger.logRetry(metadata.configKey(), logLevel);
                }
                continue;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    该方法又会调用executeAndDecode():

    该方法会封装一个请求模板作为目标请求进行远程调用,然而我们观察到该请求模板中并没有任何的参数和请求头,而我们知道,Session是依靠JSESSIONID进行识别的,在SpringSession中,Session是依靠SESSIONID识别的:

    由此我们得到结论,因为OpenFeign远程调用丢失了请求头,导致SESSIONID丢失,最终导致订单模块无法获取到User对象。得知了问题后,解决就非常简单了,我们可以创建一个请求过滤器,它将在请求模板生成前对请求进行处理:

    @Configuration
    public class MyFeignConfig {
    
        @Bean
        public RequestInterceptor requestInterceptor() {
            return requestTemplate -> {
                System.out.println("远程调用前调用该方法-->requestInterceptor......");
                ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                HttpServletRequest request = requestAttributes.getRequest();
                String cookie = request.getHeader("Cookie");
                requestTemplate.header("Cookie", cookie);
            };
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    将原Request对象中的Cookie请求头信息设置给请求模板,这样OpenFeign创建的请求就具有了Cookie内容,重新启动项目测试,问题迎刃而解。

  • 相关阅读:
    Elasticsearch 聚合检索 (分组统计)
    Docker与K8S
    PerfView专题 (第一篇): 如何寻找热点函数
    Ubuntu 手动安装 gdal 指定版本
    如何配置Outlook的SMTP信息?
    重新认识面向对象
    (二十)数据结构-查找的基本概念
    在SOLIDWORKS搭建一个简易的履带式机器人
    贯穿设计模式第二话--开闭原则
    C++/C关于#define的那些奇奇怪怪的用法
  • 原文地址:https://blog.csdn.net/qq_42453117/article/details/125016516