在Tomcat中使用ThreadLocal以及Session

最近运营同事在管理平台(生产环境)上碰到一个问题:登录之后会莫名其妙地变成未登录状态,被踢回登录页面。

管理平台使用的Spring MVC框架实现的后台接口,React实现的前台页面。之前引入React的时候已经做过前后端分离。但是当时考虑到技术栈的原因,没有对登录体系进行彻底改造,没有引入AccessToken来维护登录状态,依然保留了Java的Session机制。考虑到管理平台属于内部使用,访问量不大,因此直接在Nginx层使用iphash进行了Session粘滞,确保同一个用户的请求总是被同一个的后台Tomcat处理,这样就可以使用传统的session机制保持用户登录状态。

排查过程中,由于踢出登录情况比较随机,所以最初怀疑是超时时间设置有问题,但是一直无法重现错误。一直耽搁了好几天,很幸运的,昨天测试的同学终于找到了重现的步骤:在管理平台执行某个数据导出操作,导出操作耗时比较长,在操作没有结束的时候点击其他链接,就会出现未登录踢回登录页面的情况。

1. SessionContext

经过检查代码,发现出现登录异常问题的时候返回的错误码是:LOGIN_OVERTIME,确实是超时的返回代码。但是测试中发现即使刚刚登录,按照上面操作也会出现问题,显然真正的原因不是超时。

没办法,开始扒代码,先找到返回错误码的地方:

1
2
3
4
5
6
7
8
9
10
11
12
private BaseResult isLogon() {
BaseResult result = new BaseResult();
UserVO user = SessionContext.getSessionContext().getCurrentUser();
if (user != null) {
result.setSuccess(true);
} else {
result.setCode(ResultCode.LOGIN_OVERTIME.getCode());
result.setMsg(" ");
//result.setMsg(ResultCode.LOGIN_OVERTIME.getDesc());
}
return result;
}

逻辑很简单,获取当前的SessionContext,如果currentUser不为空则认为已经登录,否则没有登录。

继续看SessionContext:

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
27
public class SessionContext {
private transient static final ThreadLocal<SessionContext> SESSION_CONTEXT = new ThreadLocal<SessionContext>();

private HttpServletRequest request;
private HttpServletResponse response;
...
public UserVO getCurrentUser() {
return (UserVO) request.getSession().getAttribute("currUser");
}

public void setCurrentUser(UserVO user) {
request.getSession().setAttribute("currUser", user);
}

public void initSession(HttpServletRequest request, HttpServletResponse response) {
this.request = request;
this.response = response;
}
...
public static SessionContext getSessionContext() {
if (SESSION_CONTEXT.get() == null) {
SessionContext sc = new SessionContext();
SESSION_CONTEXT.set(sc);
}
return SESSION_CONTEXT.get();
}
}

上面的代码的目的是使用ThreadLocal保存SessionContext对象。而SessionContext对象是通过initSession函数注入了request、response后创建的。getCurrentUser获取的是实际上是SessionContext对应的request对象中的内容。

2. 问题原因

扒到以上代码,终于发现问题出在哪儿了:因为Tomcat使用的是线程池(ThreadPool),一个线程池内的线程是复用的,并不能够保证每次web请求都使用同样的线程进行处理;也无法保证一个线程只为一个用户服务。所以在Web容器环境中使用ThreadLocal要特别小心,最好是不用,它和本地环境中的ThreadLocal还是有很多差异的。具体到之前的问题:当一个操作花费时间很长的时候,操作还没有结束,线程依然繁忙,进行第二次请求时,Tomcat会启用新的线程接受处理,但是新的线程ThreadLocal中显然没有对应的SessionContext,自然会被判定为未登录。

3. 修复方法

这部分代码是之前同事遗留下来的,限于时间原因,一直没有仔细看,原来隐藏着这种bug。如果要修复了,方法大概有如下几种:

  1. 就是引入AccessToken机制。用户登录之后分配AccessToken,该AccessToken使用Redis等外部存储进行保存。因为Token每次都跟随请求发送过来,这样就可以摆脱session粘滞的限制;
  2. 如果保持现有的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是不可靠的。最好的办法是慎用

热评文章