最近运营同事在管理平台(生产环境)上碰到一个问题:登录之后会莫名其妙地变成未登录状态,被踢回登录页面。
管理平台使用的Spring MVC框架实现的后台接口,React实现的前台页面。之前引入React的时候已经做过前后端分离。但是当时考虑到技术栈的原因,没有对登录体系进行彻底改造,没有引入AccessToken来维护登录状态,依然保留了Java的Session机制。考虑到管理平台属于内部使用,访问量不大,因此直接在Nginx层使用iphash进行了Session粘滞,确保同一个用户的请求总是被同一个的后台Tomcat处理,这样就可以使用传统的session机制保持用户登录状态。
排查过程中,由于踢出登录情况比较随机,所以最初怀疑是超时时间设置有问题,但是一直无法重现错误。一直耽搁了好几天,很幸运的,昨天测试的同学终于找到了重现的步骤:在管理平台执行某个数据导出操作,导出操作耗时比较长,在操作没有结束的时候点击其他链接,就会出现未登录踢回登录页面的情况。
1. SessionContext
经过检查代码,发现出现登录异常问题的时候返回的错误码是:LOGIN_OVERTIME,确实是超时的返回代码。但是测试中发现即使刚刚登录,按照上面操作也会出现问题,显然真正的原因不是超时。
没办法,开始扒代码,先找到返回错误码的地方:
1private BaseResult isLogon() {
2 BaseResult result = new BaseResult();
3 UserVO user = SessionContext.getSessionContext().getCurrentUser();
4 if (user != null) {
5 result.setSuccess(true);
6 } else {
7 result.setCode(ResultCode.LOGIN_OVERTIME.getCode());
8 result.setMsg(" ");
9 //result.setMsg(ResultCode.LOGIN_OVERTIME.getDesc());
10 }
11 return result;
12}
逻辑很简单,获取当前的SessionContext,如果currentUser不为空则认为已经登录,否则没有登录。
继续看SessionContext:
1public class SessionContext {
2 private transient static final ThreadLocal<SessionContext> SESSION_CONTEXT = new ThreadLocal<SessionContext>();
3
4 private HttpServletRequest request;
5 private HttpServletResponse response;
6 ...
7 public UserVO getCurrentUser() {
8 return (UserVO) request.getSession().getAttribute("currUser");
9 }
10
11 public void setCurrentUser(UserVO user) {
12 request.getSession().setAttribute("currUser", user);
13 }
14
15 public void initSession(HttpServletRequest request, HttpServletResponse response) {
16 this.request = request;
17 this.response = response;
18 }
19 ...
20 public static SessionContext getSessionContext() {
21 if (SESSION_CONTEXT.get() == null) {
22 SessionContext sc = new SessionContext();
23 SESSION_CONTEXT.set(sc);
24 }
25 return SESSION_CONTEXT.get();
26 }
27}
上面的代码的目的是使用ThreadLocal保存SessionContext对象。而SessionContext对象是通过initSession函数注入了request、response后创建的。getCurrentUser获取的是实际上是SessionContext对应的request对象中的内容。
2. 问题原因
扒到以上代码,终于发现问题出在哪儿了:因为Tomcat使用的是线程池(ThreadPool),一个线程池内的线程是复用的,并不能够保证每次web请求都使用同样的线程进行处理;也无法保证一个线程只为一个用户服务。所以在Web容器环境中使用ThreadLocal要特别小心,最好是不用,它和本地环境中的ThreadLocal还是有很多差异的。具体到之前的问题:当一个操作花费时间很长的时候,操作还没有结束,线程依然繁忙,进行第二次请求时,Tomcat会启用新的线程接受处理,但是新的线程ThreadLocal中显然没有对应的SessionContext,自然会被判定为未登录。
3. 修复方法
这部分代码是之前同事遗留下来的,限于时间原因,一直没有仔细看,原来隐藏着这种bug。如果要修复了,方法大概有如下几种:
- 就是引入AccessToken机制。用户登录之后分配AccessToken,该AccessToken使用Redis等外部存储进行保存。因为Token每次都跟随请求发送过来,这样就可以摆脱session粘滞的限制;
- 如果保持现有的session粘滞配置的话,可以考虑引入redis,通过
request.getSession().getId()
获得的sessionId作为主键保存currentUser等信息。我们知道Tomcat中SessionID是通过Cookie传递的(JSESSIONID),同时在Tomcat中也开辟了一块内存保存Session相关的信息。因为配置了Session粘滞,所以同一个用户来的请求,总是转发到同一台Tomcat上处理。所以根据请求携带的Cookie可以找到对应的sessionId,也能够找到Tomcat中对应的Session数据,从而找到当前用户的登录状态。
4. 内存泄露
另外,上面的代码中实际上是有内存泄露问题的:request、response对象被赋值到SessionContext对象,然后被注入到了ThreadLocal中。而Tomcat的线程池对象是持久对象,不会很快被释放。因此这两个对象很难被释放掉。当访问量越大,内存消耗会越快。只是目前的管理平台访问量比较小,所以问题不突出,一直没有发现。
总之:在并发状态下使用Tomcat的ThreadLocal是不可靠的。最好的办法是慎用。