前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >分布式Session解决方案

分布式Session解决方案

作者头像
wangweijun
发布2022-05-29 10:53:09
5900
发布2022-05-29 10:53:09
举报
文章被收录于专栏:wangweijunwangweijun

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

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

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

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

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

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

Session共享问题的案例演示

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

代码语言:javascript
复制
@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("登录失败");
        }
    }
}

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

代码语言:javascript
复制
@RestController
public class OrderController {

    @GetMapping("/order/test")
    public String order(@CookieValue("JSESSIONID") String jSessionId) {
        return "success";
    }
}

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

代码语言:javascript
复制
@FeignClient("service-order")
public interface ServiceOrderClient {

    @GetMapping("/order/test")
    String order();
}

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

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

控制台输出的结果:

代码语言:javascript
复制
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]

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

Redis解决Session共享问题

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

改进一下登录模块:

代码语言:javascript
复制
@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;
}

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

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

代码语言:javascript
复制
@RestController
public class OrderController {

    @GetMapping("/order/test")
    public String order() {
        return "success";
    }
}

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

代码语言:javascript
复制
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;
        }
    }
}

将拦截器注册一下:

代码语言:javascript
复制
@Configuration
public class MyWebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**");
    }
}

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

代码语言:javascript
复制
User(username=admin, password=admin)
用户已登录......

SpringSession解决Session共享问题

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

\

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

代码语言:javascript
复制
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>

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

代码语言:javascript
复制
spring:  session:
    store-type: redis

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

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

代码语言:javascript
复制
@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;
}

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

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

代码语言:javascript
复制
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;
        }
    }
}

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

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

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

代码语言:javascript
复制
null
用户未登录......

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

代码语言:javascript
复制
@GetMapping("/test")
public String test(HttpSession session) {
    User user = (User) session.getAttribute("user");
    System.out.println(user);
    return "test";
}

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

代码语言:javascript
复制
User(username=admin, password=admin)

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

OpenFeign远程调用的坑

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

我们跟进去看看:

代码语言:javascript
复制
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);
}

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

代码语言:javascript
复制
@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;
        }
    }
}

该方法又会调用executeAndDecode():

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

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

代码语言:javascript
复制
@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);
        };
    }
}

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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-05-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 分布式应用下Session存在的问题
  • Session共享问题的案例演示
  • Redis解决Session共享问题
  • SpringSession解决Session共享问题
  • OpenFeign远程调用的坑
相关产品与服务
云数据库 Redis
腾讯云数据库 Redis(TencentDB for Redis)是腾讯云打造的兼容 Redis 协议的缓存和存储服务。丰富的数据结构能帮助您完成不同类型的业务场景开发。支持主从热备,提供自动容灾切换、数据备份、故障迁移、实例监控、在线扩容、数据回档等全套的数据库服务。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档