最全分布式Session解決方案
點(diǎn)擊關(guān)注公眾號(hào),Java干貨及時(shí)送達(dá)

Java技術(shù)迷?|?出品
考慮一個(gè)場(chǎng)景,用戶在進(jìn)行下單操作之前后臺(tái)需要校驗(yàn)該用戶是否登錄,若未登錄則不允許提交訂單,這在傳統(tǒng)的單體應(yīng)用中非常容易實(shí)現(xiàn),只需在提交訂單之前判斷Session中的用戶信息是否登錄即可,但在分布式應(yīng)用中,這顯然是一個(gè)待解決的問題。
NO.1

分布式應(yīng)用下Session存在的問題

在分布式架構(gòu)中,一個(gè)應(yīng)用往往被劃分為多個(gè)子模塊,比如:登錄注冊(cè)模塊和訂單模塊,當(dāng)應(yīng)用被拆分后,隨之而來的便是數(shù)據(jù)的共享問題:
一般我們都在登錄注冊(cè)模塊中將用戶的登錄狀態(tài)保存到Session中,然而當(dāng)用戶進(jìn)行下單操作時(shí),由于訂單模塊是獨(dú)立的,它無法獲取到登錄注冊(cè)模塊中保存的Session,所以訂單模塊是無法判斷用戶是否登錄的。而為了保證系統(tǒng)的高可用,一個(gè)模塊往往被部署多份形成集群,這些模塊之間的數(shù)據(jù)共享也是一個(gè)問題:
用戶在一個(gè)模塊中登錄成功后,很可能在下次訪問時(shí)請(qǐng)求被負(fù)載均衡到其它的集群模塊中,這樣會(huì)導(dǎo)致無法讀取到Session,使得用戶又得重新登錄一次系統(tǒng)。
NO.2

Session共享問題的案例演示

下面編寫一個(gè)案例進(jìn)行演示,首先創(chuàng)建一個(gè)SpringBoot應(yīng)用,實(shí)現(xiàn)登錄模塊:
@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("登錄失敗");
}
}
}
再創(chuàng)建一個(gè)SpringBoot應(yīng)用,實(shí)現(xiàn)訂單模塊:
@RestController
public class OrderController {
@GetMapping("/order/test")
public String order(@CookieValue("JSESSIONID") String jSessionId) {
return "success";
}
}
代碼都非常簡(jiǎn)單,我們主要是觀察Session的問題,在登錄模塊中編寫遠(yuǎn)程調(diào)用接口:
@FeignClient("service-order")
public interface ServiceOrderClient {
@GetMapping("/order/test")
String order();
}
將這兩個(gè)應(yīng)用都注冊(cè)到Nacos中,其它代碼我就不貼出來了,都比較簡(jiǎn)單。分別啟動(dòng)這兩個(gè)項(xiàng)目,并訪問 http://localhost:8080/test ,會(huì)發(fā)現(xiàn)訪問是不成功的:
控制臺(tái)輸出的結(jié)果:
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,我們知道,服務(wù)端是通過JSESSIONID來找到該用戶對(duì)應(yīng)的Session信息的,既然JSESSIONID都獲取不到,就更不用說用戶信息了,這就是Session不共享的問題。
NO.3

Redis解決Session共享問題

對(duì)于分布式應(yīng)用中的Session問題,其實(shí)也非常簡(jiǎn)單,無非就是不能共享到Session,所以,我們可以類比緩存的思想,將Session放入緩存中,其它服務(wù)想要獲取Session也從緩存中拿,這樣就實(shí)現(xiàn)了Session的共享。改進(jìn)一下登錄模塊:
@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;
}
當(dāng)我們?cè)L問登錄接口?http://localhost:8080/login?username=admin&password=admin[1]?時(shí),就會(huì)向Redis保存一份Session的值:
此時(shí)若是其它服務(wù)需要Session,只要從Redis中讀取即可,修改一下訂單模塊:
@RestController
public class OrderController {
@GetMapping("/order/test")
public String order() {
return "success";
}
}
在訂單模塊中添加一個(gè)登錄的攔截器:
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 手動(dòng)獲取StringRedisTemplate對(duì)象
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;
}
}
}
將攔截器注冊(cè)一下:
@Configuration
public class MyWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**");
}
}
重啟項(xiàng)目,訪問 http://localhost:8080/test ,輸出結(jié)果:
User(username=admin, password=admin)
用戶已登錄......NO.4

SpringSession解決Session共享問題
剛才我們自己使用Redis嘗試著解決了一下Session的共享問題,然而這種方式是有很多缺陷的,首先,我們保存的只是一個(gè)User對(duì)象,并不是Session,所以我們無法標(biāo)識(shí)該用戶,這樣會(huì)導(dǎo)致用戶訪問到了其它用戶的信息,使得系統(tǒng)混亂。我們當(dāng)然可以使用JSESSIONID來標(biāo)識(shí)不同的用戶,但其實(shí),Spring已經(jīng)為我們提供了一個(gè)組件來解決這一問題,那就是SpringSession。
在兩個(gè)模塊中都引入SpringSession的依賴:
<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>
在application.yml中配置一下Session的保存方式為Redis:
spring: session:
store-type: redis
最后在啟動(dòng)類上添加 @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對(duì)象存入Session,重啟項(xiàng)目并訪問登錄接口,來看看Redis中有什么變化:
此時(shí)Redis中已經(jīng)保存了用戶信息,并且還有創(chuàng)建時(shí)間、存活時(shí)間等配置,其它模塊要想獲取到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對(duì)象需要和其它模塊讀出的User對(duì)象包名一致,所以最好將User類抽取到公共模塊中,提供給所有模塊使用。
到這里SpringSession就解決了Session共享的問題,你可以運(yùn)行項(xiàng)目測(cè)試一下,訪問 http://localhost:8080/test :
結(jié)果出乎意料,控制臺(tái)的結(jié)果是:
null
用戶未登錄......
這就奇怪了,難道是SpringSession沒起作用?我們寫一個(gè)測(cè)試方法測(cè)試一下:
@GetMapping("/test")
public String test(HttpSession session) {
User user = (User) session.getAttribute("user");
System.out.println(user);
return "test";
}
訪問 http://localhost:9000/test ,得到結(jié)果:
User(username=admin, password=admin)
顯然SpringSession是沒有任何問題的,那么問題出在哪里了呢?
NO.5
OpenFeign遠(yuǎn)程調(diào)用的坑

剛才我們進(jìn)行了測(cè)試,發(fā)現(xiàn)在訂單模塊中直接訪問Session可以獲取User對(duì)象,然而通過遠(yuǎn)程調(diào)用,User就獲取不到了,我們可以猜測(cè)這是OpenFeign出現(xiàn)了問題,Debug調(diào)試一下項(xiàng)目,這是遠(yuǎn)程調(diào)用的代碼:
我們跟進(jìn)去看看:
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);
}
該方法中進(jìn)行了一些判斷,最終會(huì)調(diào)用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;
}
}
}
該方法又會(huì)調(diào)用executeAndDecode():
該方法會(huì)封裝一個(gè)請(qǐng)求模板作為目標(biāo)請(qǐng)求進(jìn)行遠(yuǎn)程調(diào)用,然而我們觀察到該請(qǐng)求模板中并沒有任何的參數(shù)和請(qǐng)求頭,而我們知道,Session是依靠JSESSIONID進(jìn)行識(shí)別的,在SpringSession中,Session是依靠SESSION識(shí)別的:
由此我們得到結(jié)論,因?yàn)镺penFeign遠(yuǎn)程調(diào)用丟失了請(qǐng)求頭,導(dǎo)致SESSIONID丟失,最終導(dǎo)致訂單模塊無法獲取到User對(duì)象。得知了問題后,解決就非常簡(jiǎn)單了,我們可以創(chuàng)建一個(gè)請(qǐng)求過濾器,它將在請(qǐng)求模板生成前對(duì)請(qǐng)求進(jìn)行處理:
@Configuration
public class MyFeignConfig {
@Bean
public RequestInterceptor requestInterceptor() {
return requestTemplate -> {
System.out.println("遠(yuǎn)程調(diào)用前調(diào)用該方法-->requestInterceptor......");
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
String cookie = request.getHeader("Cookie");
requestTemplate.header("Cookie", cookie);
};
}
}
將原Request對(duì)象中的Cookie請(qǐng)求頭信息設(shè)置給請(qǐng)求模板,這樣OpenFeign創(chuàng)建的請(qǐng)求就具有了Cookie內(nèi)容,重新啟動(dòng)項(xiàng)目測(cè)試,問題迎刃而解。
References
[1]?http://localhost:8080/login?username=admin&password=admin:?http://localhost:8080/login?username=admin&password=admin
本文作者:汪偉俊?為Java技術(shù)迷專欄作者?投稿,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載
往 期 推 薦
3、在 IntelliJ IDEA 中這樣使用 Git,賊方便了!
4、計(jì)算機(jī)時(shí)間到底是怎么來的?程序員必看的時(shí)間知識(shí)!
點(diǎn)分享
點(diǎn)收藏
點(diǎn)點(diǎn)贊
點(diǎn)在看





