Tomcat-Session管理
本文从源码出发,剖析 Tomcat 中 Session 的懒加载、保活与生命周期控制机制,并解析了 StandardManager 如何借助序列化完成热部署场景下的 Session 持久化与自动恢复
在 StandardContext#start()
阶段,Tomcat 会默认创建一个 StandardManager
实例作为 Session 管理器。它是一个基于本地 JVM 内存的实现,负责:
- 实例化和缓存Session
- 定期检查并清理过期的 Session
- 在 Context 热加载触发时,将 Session 序列化持久化到磁盘。并在启动后,尝试从磁盘恢复上一次的 Session 数据
那么这些功能具体是怎么实现的呢?我们接下来结合源码逐一分析,也顺便看看有没有什么值得优化的地方。
Session创建、缓存和刷新
获取和创建
Tomcat 中的 Session 采用 懒加载策略,即只有在显式调用 HttpServletRequest#getSession()
时,才会触发 Session 的获取或创建、绑定到当前Request等操作。该逻辑核心实现位于 org.apache.catalina.connector.Request#doGetSession
方法,其处理流程如下:
- 已绑定有效 Session:若当前 Request 已绑定有效 Session,直接返回;
- 尝试查找已有 Session:从全局缓存中查找session,如果存在则刷新访问时间,在绑定到当前Request并返回
- 按需创建新 Session:create=true才创建session。先进行sessionId的校验(避免伪造攻击),再创建默认的StandardSession并全局缓存,记录访问时间后返回
protected Session doGetSession(boolean create) {
Context context = getContext();
if (context == null) {
return null;
}
// 当前request中已获取过session切session有效,则直接返回
if ((session != null) && !session.isValid()) {
session = null;
}
if (session != null) {
return session;
}
Manager manager = context.getManager();
if (manager == null) {
return null;
}
if (requestedSessionId != null) {
try {
// 从缓存中找session
session = manager.findSession(requestedSessionId);
} catch (IOException e) {
session = null;
}
if ((session != null) && !session.isValid()) {
session = null;
}
// session找到了且有效,刷新访问时间后返回
if (session != null) {
session.access();
return session;
}
}
// 不许要创建则直接返回
if (!create) {
return null;
}
boolean trackModesIncludesCookie = context.getServletContext().getEffectiveSessionTrackingModes()
.contains(SessionTrackingMode.COOKIE);
if (trackModesIncludesCookie && response.getResponse().isCommitted()) {
throw new IllegalStateException(sm.getString("coyoteRequest.sessionCreateCommitted"));
}
// ====================== 到这里,表示需要创建session ================
// 对客户端提供的sessionId进行校验,一般来说是不允许使用客户端提供的sessionId创建session的
String sessionId = getRequestedSessionId();
if (requestedSessionSSL) { // 一般不会走这
} else if (("/".equals(context.getSessionCookiePath())
&& isRequestedSessionIdFromCookie())) {
// 支持跨 Context 共享 sessionId(必须配置 cookiePath="/" 且启用校验,默认已启用)
if (context.getValidateClientProvidedNewSessionId()) {
boolean found = false;
for (Container container : getHost().findChildren()) {
Manager m = ((Context) container).getManager();
if (m != null) {
try {
if (m.findSession(sessionId) != null) {
found = true;
break;
}
} catch (IOException e) {
}
}
}
if (!found) {
// 未找到则清空,避免伪造攻击
sessionId = null;
}
}
} else {
sessionId = null;
}
// 新建session(默认StandardSession)
session = manager.createSession(sessionId);
// 设置cookie到response中
if (session != null && trackModesIncludesCookie) {
Cookie cookie = ApplicationSessionCookieConfig.createSessionCookie(
context, session.getIdInternal(), isSecure());
response.addSessionCookieInternal(cookie);
}
if (session == null) {
return null;
}
session.access();
return session;
}
/**
* org.apache.catalina.session.ManagerBase#createSession中真正创建session
*/
public Session createSession(String sessionId) {
// session数量较校验(默认-1无上限)
if ((maxActiveSessions >= 0) &&
(getActiveSessions() >= maxActiveSessions)) {
rejectedSessions++;
throw new TooManyActiveSessionsException(
sm.getString("managerBase.createSession.ise"),
maxActiveSessions);
}
Session session = createEmptySession();
session.setNew(true);
session.setValid(true);
session.setCreationTime(System.currentTimeMillis());
// session最大不活跃的时间(默认30分钟)
session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
String id = sessionId;
if (id == null) {
id = generateSessionId();
}
// 设置id并将这个session缓存到ManagerBase#sessions里
session.setId(id);
sessionCounter++;
SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
synchronized (sessionCreationTiming) {
sessionCreationTiming.add(timing);
sessionCreationTiming.poll();
}
return session;
}
刷新
只有在一次 HTTP 请求中真正访问了 Session,Tomcat 才会刷新它的访问时间,具体如下:
- 当请求中第一次调用
request.getSession()
或相关方法时,会触发org.apache.catalina.Session#access()
,记录这次访问的时间- 而在请求结束的时候,会调用
Session#endAccess()
来记录会话访问的结束时间。调用链为:
CoyoteAdapter#service(方法最后部分) → Request#recycle → Request#recycleSessionInfo → Session#endAccess()
通过这套机制,Tomcat 仅在真正使用了Session的请求中才刷新它的访问时间,从而实现只在用到时才保活
Session过期清理
入口
被StandardEngine的backgroundThread定期触发,调用路径为:ContainerBackgroundProcessor#run -> ContainerBackgroundProcessor#processChildren -> StandardContext.backgroundProcess -> Manager#backgroundProcess -> ManagerBase#processExpires
public void processExpires() {
long timeNow = System.currentTimeMillis();
// 所有session
Session sessions[] = findSessions();
int expireHere = 0;
for (Session session : sessions) {
// 对session校验是否式或并清理
if (session != null && !session.isValid()) {
expireHere++;
}
}
long timeEnd = System.currentTimeMillis();
processingTime += (timeEnd - timeNow);
}
校验清理
StandardSession#isValid为校验session是否有效,如果无效则进行清理。整体逻辑比较简单,稍微关注下会被触发的如下三个EventListener,如果有session和其attribute的监听需求就可以用到:
- HttpSessionListener#sessionDestroyed:Session 被销毁时触发。适合用于监听整体 Session 生命周期的结束
- HttpSessionBindingListener#valueUnbound:当某个属性从 Session 中移除,且该属性实现了该接口时触发。偏个体
- HttpSessionAttributeListener#attributeRemoved:Session 中的属性被移除时触发,属性可以不实现这个Listener。偏集中
public boolean isValid() {
if (!this.isValid) {
return false;
}
if (this.expiring) {
return true;
}
if (ACTIVITY_CHECK && accessCount.get() > 0) {
return true;
}
if (maxInactiveInterval > 0) {
// 判断空闲时间是否超过最大不活跃时间,并进行清理
int timeIdle = (int) (getIdleTimeInternal() / 1000L);
if (timeIdle >= maxInactiveInterval) {
expire(true);
}
}
return this.isValid;
}
/*
* 使session过期(从缓存中移除),并根据是否notify决定是否通知相关EventListener
*/
public void expire(boolean notify) {
if (!isValid) {
return;
}
synchronized (this) {
if (expiring || !isValid) {
return;
}
if (manager == null) {
return;
}
expiring = true;
Context context = manager.getContext();
// 是否通知相关Listener
if (notify) {
ClassLoader oldContextClassLoader = null;
try {
oldContextClassLoader = context.bind(Globals.IS_SECURITY_ENABLED, null);
Object listeners[] = context.getApplicationLifecycleListeners();
if (listeners != null && listeners.length > 0) {
HttpSessionEvent event = new HttpSessionEvent(getSession());
for (int i = 0; i < listeners.length; i++) {
// 触发HttpSessionListener的sessionDestroyed事件
int j = (listeners.length - 1) - i;
if (!(listeners[j] instanceof HttpSessionListener)) {
continue;
}
HttpSessionListener listener = (HttpSessionListener) listeners[j];
try {
context.fireContainerEvent("beforeSessionDestroyed",
listener);
listener.sessionDestroyed(event);
context.fireContainerEvent("afterSessionDestroyed",
listener);
} catch (Throwable t) {
manager.getContext().getLogger().error(sm.getString("standardSession.sessionEvent"), t);
}
}
}
} finally {
context.unbind(Globals.IS_SECURITY_ENABLED, oldContextClassLoader);
}
}
if (ACTIVITY_CHECK) {
accessCount.set(0);
}
// 从缓存中移除
manager.remove(this, true);
if (notify) {
fireSessionEvent(Session.SESSION_DESTROYED_EVENT, null);
}
setValid(false);
expiring = false;
String keys[] = keys();
ClassLoader oldContextClassLoader = null;
try {
oldContextClassLoader = context.bind(Globals.IS_SECURITY_ENABLED, null);
// 触发session key相关Listener
for (String key : keys) {
removeAttributeInternal(key, notify);
}
} finally {
context.unbind(Globals.IS_SECURITY_ENABLED, oldContextClassLoader);
}
}
}
Session持久化
众所周知,Tomcat 中Web 应用支持热部署,在热加载过程中,原来的Context会被卸载,它所持有的所有对象(包括HttpSession)都会被销毁。
但从业务逻辑角度来看,session 的生命周期不应该被应用自身的重部署打断。客户端用户都没登出,如果仅因为服务端代码热更新就丢失session数据,用户会难以接受
所以为了解决这个问题,Tomcat 的StandardManager实现了 session 的持久化机制。核心思想是:
- 在
Context
停止前(如热部署时),将内存中的所有有效session
序列化写入磁盘 - 在
Context
启动时,尝试从磁盘加载这些 session,并恢复到内存中
两个核心方法如下:
- StandardManager#doUnload:context#stop时触发,将session序列化到磁盘
- StandardManager#doLoad:context#start时触发,将磁盘的session文件反序列化到内存
protected void doUnload() throws IOException {
if (sessions.isEmpty()) {
return; // nothing to do
}
// 默认为当前context的 ${javax.servlet.context.tempdir}/SESSIONS.ser 文件
File file = file();
if (file == null) {
return;
}
// 即将被卸载的 session 实例
List<StandardSession> list = new ArrayList<>();
try (FileOutputStream fos = new FileOutputStream(file.getAbsolutePath());
BufferedOutputStream bos = new BufferedOutputStream(fos);
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
synchronized (sessions) {
oos.writeObject(Integer.valueOf(sessions.size()));
// 将session序列化到磁盘
for (Session s : sessions.values()) {
StandardSession session = (StandardSession) s;
list.add(session);
session.passivate();
session.writeObjectData(oos);
}
}
}
// 清楚session,但不触发通知(毕竟未真正失活)
for (StandardSession session : list) {
try {
session.expire(false);
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
} finally {
session.recycle();
}
}
}
protected void doLoad() throws ClassNotFoundException, IOException {
sessions.clear();
File file = file();
// 没有就返回
if (file == null) {
return;
}
Loader loader = null;
ClassLoader classLoader = null;
Log logger = null;
try (FileInputStream fis = new FileInputStream(file.getAbsolutePath());
BufferedInputStream bis = new BufferedInputStream(fis)) {
Context c = getContext();
loader = c.getLoader();
logger = c.getLogger();
// 使用当前contxet的WebappClassLoader,加载session
if (loader != null) {
classLoader = loader.getClassLoader();
}
if (classLoader == null) {
classLoader = getClass().getClassLoader();
}
synchronized (sessions) {
try (ObjectInputStream ois = new CustomObjectInputStream(bis, classLoader, logger,
getSessionAttributeValueClassNamePattern(),
getWarnOnSessionAttributeFilterFailure())) {
Integer count = (Integer) ois.readObject();
int n = count.intValue();
for (int i = 0; i < n; i++) {
// 反序列化到内存
StandardSession session = getNewSession();
session.readObjectData(ois);
session.setManager(this);
sessions.put(session.getIdInternal(), session);
// 通知 session 已激活
session.activate();
if (!session.isValidInternal()) {
session.setValid(true);
session.expire();
}
sessionCounter++;
}
} finally {
if (file.exists()) {
// 删除持久化文件,避免重复加载
if (!file.delete()) {
log.warn(sm.getString("standardManager.deletePersistedFileFail", file));
}
}
}
}
} catch (FileNotFoundException e) {
return;
}
}