最全分布式Session解决方案

Java技术迷

共 8880字,需浏览 18分钟

 · 2021-10-12

点击关注公众号,Java干货及时送达

Java | 

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


NO.1


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


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

一般我们都在登录注册模块中将用户的登录状态保存到Session中,然而当用户进行下单操作时,由于订单模块是独立的,它无法获取到登录注册模块中保存的Session,所以订单模块是无法判断用户是否登录的。而为了保证系统的高可用,一个模块往往被部署多份形成集群,这些模块之间的数据共享也是一个问题:

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


NO.2


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("登录失败");
}
}
}

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

@RestController
public class OrderController {

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

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

@FeignClient("service-order")
public interface ServiceOrderClient {

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

将这两个应用都注册到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]

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


NO.3


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;
}

当我们访问登录接口 http://localhost:8080/login?username=admin&password=admin[1] 时,就会向Redis保存一份Session的值:此时若是其它服务需要Session,只要从Redis中读取即可,修改一下订单模块:

@RestController
public class OrderController {

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

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

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;
}
}
}

将拦截器注册一下:

@Configuration
public class MyWebConfig implements WebMvcConfigurer {

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

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

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


NO.4


SpringSession解决Session共享问题

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

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


<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>

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

spring:  session:
store-type: redis

最后在启动类上添加 @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;
}

按照正常流程将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;
}
}
}

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

到这里SpringSession就解决了Session共享的问题,你可以运行项目测试一下,访问 http://localhost:8080/test :结果出乎意料,控制台的结果是:

null
用户未登录......

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

@GetMapping("/test")
public String test(HttpSession session) {
User user = (User) session.getAttribute("user");
System.out.println(user);
return "test";
}

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

User(username=admin, password=admin)

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

NO.5

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);
}

该方法中进行了一些判断,最终会调用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;
}
}
}

该方法又会调用executeAndDecode():该方法会封装一个请求模板作为目标请求进行远程调用,然而我们观察到该请求模板中并没有任何的参数和请求头,而我们知道,Session是依靠JSESSIONID进行识别的,在SpringSession中,Session是依靠SESSION识别的:由此我们得到结论,因为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);
};
}
}

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

References

[1] http://localhost:8080/login?username=admin&password=admin: http://localhost:8080/login?username=admin&password=admin

本文作者:汪伟俊 为Java技术迷专栏作者 投稿,未经允许请勿转载

1、灵魂一问:你的登录接口真的安全吗?

2、HashMap 中这些设计,绝了~

3、在 IntelliJ IDEA 中这样使用 Git,贼方便了!

4、计算机时间到底是怎么来的?程序员必看的时间知识!

5、这些IDEA的优化设置赶紧安排起来,效率提升杠杠的!

6、21 款 yyds 的 IDEA插件

7、真香!用 IDEA 神器看源码,效率真高!

点分享

点收藏

点点赞

点在看

浏览 4
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报