深入剖析Tomcat(九) Session的实现原理

提到Session,相信大家都不陌生,Http协议本身是无状态的,每次请求都是独立的,而当我们想要将多次请求建立某种关系的时候,就会用到Cookie+Session这个组合,也就是常说的“会话”概念,将多次请求当成一次会话来看待。而Tomcat恰恰就是支持这个机制的。

Cookie+Session

Cookie是由客户端来支持的,这个客户端通常是由浏览器来担任,当使用ip或域名访问一个服务时,如果在服务端的响应头中有“Set-Cookie”这个key,证明服务端给了客户端一些Cookie,在接下来的请求中,客户端就要在请求头中带上这些Cookie。在Tomcat的会话机制中,这个Cookie通常叫做“JSESSIONID”。

Session由服务端来管理,准确的说,是由Tomcat中的一个Context容器持有的Session管理器来管理。一个服务端可能与多个客户端建立会话,所以Session管理器要管理多个Session对象,管理的方式其实就是用一个Map来存储所有活跃的Session,key为Session的id,value就是Session对象本身。而“JSESSIONID”其实就是这个Map的key,所以Tomcat通过JSESSIONID就能找到对应的Session对象。

至于为什么是Context容器持有Session管理器,前面文章有讲过,一个Context容器代表一个Web应用,而我们客户端访问的其实就是一个Web应用的接口,与这个Web应用建立会话。Tomcat本身是支持部署多个Web应用的,也就是可能会有多个Context容器,他们分别管理自己的会话;但是基于目前springboot大流行的场景下,一个Tomcat也就只包含了一个Context容器,针对的也就是我们这个springboot项目。

在Tomcat中,Session只存在于内存中吗?关闭Tomcat后,所有Session就会丢失吗?

答案是不会!默认情况下,Tomcat在正常关闭时,会将当前活跃的Session序列化存储到一个本地文件中,这个文件的文件名默认为“SESSIONS.ser”;Tomcat启动时会去找这个文件,如果文件存在的话,就将这个文件中的Session集合反序列化回来,重新放到内存中。当然如果你觉得你的应用中Session非常重要的话,Tomcat还支持你将Session存储到数据库中(这个好像用到的场景不多)。

如果你kill -9杀进程的话,Tomcat没有正常关闭,是来不及将session持久化的。

每个首次请求的Http请求,Tomcat都会为其创建Session吗?

不都会。Tomcat不会为所有请求都创建Session,仅当某个请求需要Session支持时才创建Session。那什么情况下代表它需要Session支持呢?通常情况下是在servlet中显示调用“HttpServletRequest.getSession()”方法后,就代表它需要Session了。如果一个全新的请求打进来后,没有任何逻辑去获取Session,那Tomcat就不会为其创建Session。

Seesion有效期是怎么刷新的呢?如何判断过没过有效期?

每个Session对象都会维护一个“最后一次访问时间”的字段,叫做“lastAccessedTime” ;另外还维护了一个Session有效期的时长字段,叫做“maxInactiveInterval”。

判断Session有没有过期的算法为“ 当前时间- lastAccessedTime > maxInactiveInterval”,为true就是过期了;基于当前会话,如果后续请求打进来后,Session会自动将lastAccessedTime更新为当前时间,也就是刷新了有效期(对应StandardSession#access()方法,该方法是被一个叫AuthenticatorBase的阀调用的)。

Session过期后,Session管理器会将该对象回收利用,下次需要创建Session对象时,如果回收池中有可用对象的话,就直接用了,避免了重复创建对象。什么时候触发这个Session过期的判断呢?有两个触发点:

  1. 每次请求打进来后,如果携带了sessionId,就判断一次   
  2. Tomcat后台维护一个线程,每隔一段时间就检查一遍所有Session,判断有没有过期。

上面大白话说了一堆,下面来看看Tomcat是怎么设计这套Session管理机制的。

Session设计

Session对象

Session在Tomcat中以对象的形式存在。

这里有个概念要提前弄明白,Tomcat是基于servlet规范来设计的,servlet规范中对于容器、Session等有一套定义好的接口,比如Session类就是实现了javax.servlet.http.HttpSession 接口的类,这些servlet规范的接口都在servlet.jar中。再说Catalina,它是servlet容器的一个实现,可以理解为是以一个插件的形式放在Tomcat中,是存在被替换掉的可能性的。Catalina内部为了实现特有的功能,会自己定义一些接口,比如针对Session就设计了org.apache.catalina.Session接口。所以在Catalina中,一个Session的具体实现类要继承两个接口:来自servlet的HttpSession接口与自己yy的Session接口。但是,当Catalina要把做好的Session对象提供给具体的servlet进行使用时,就不能暴露自己在org.apache.catalina.Session中定义的方法了,因为这个接口是它自己yy的,没在servlet规范中,如果某天Tomcat心血来潮替换掉了Catalina这个组件,那这个yy的接口就根本不存在了,所以只能让servlet使用javax.servlet.http.HttpSession这个接口中定义的方法。 但是catalina中的Session实现类是实现了两个接口的,怎么办呢?Tomcat针对这种事件的通用做法就是使用门面类,类名通常就叫做 xxxxFacade。以静态代理的方式,使用门面类只暴露HttpSession接口中的方法即可,将这个门面类给servlet使用就OK了。

在Tomcat中,每种组件的规范,其实都是通过接口来定义的,比如本次讲的Session就是HttpSession接口与Session接口,接口代码就不往这放了,随便找个Web项目你都能看到,这里我仅以Catalina中对Session的默认实现类StandardSession,来简单讲一下Session对象都有啥

下面是删减后的StandardSession类的代码

public class StandardSession implements HttpSession, Session, Serializable {

    // 该session中的属性集合,通常我们往session中放东西就是放到了这个属性里
    protected ConcurrentMap<String, Object> attributes = new ConcurrentHashMap<>();

    // session id
    protected String id = null;

    // 当前请求的访问时间
    protected volatile long thisAccessedTime = creationTime;

    // 最后一次访问时间
    protected volatile long lastAccessedTime = creationTime;

    // 该session关联的session管理器
    protected transient Manager manager = null;

    // session会话的超时时间,单位为秒,负值代表永不超时
    protected volatile int maxInactiveInterval = -1;

    // 该sesssion会话请求次数计数器
    protected transient AtomicInteger accessCount = null;

    // Session的门面类,提供给servlet使用
    protected transient StandardSessionFacade facade = null;



    /**
     * 返回一个实现了HttpSession接口的门面类的对象,供servlet使用
     */
    @Override
    public HttpSession getSession() {
        if (facade == null) {
            if (SecurityUtil.isPackageProtectionEnabled()) {
                facade = AccessController.doPrivileged(new PrivilegedNewSessionFacade(this));
            } else {
                facade = new StandardSessionFacade(this);
            }
        }
        return facade;
    }

    /**
     * 当该Session相关的请求进来时,更新访问时间。这个方法由Context容器自动调用,无需用户手动在servlet中调用
     */
    @Override
    public void access() {

        this.thisAccessedTime = System.currentTimeMillis();

        if (ACTIVITY_CHECK) {
            accessCount.incrementAndGet();
        }
    }

    /**
     * 当请求访问结束时(已经执行了相关servlet的方法了),调用此方法,更新lastAccessedTime的值
     */
    @Override
    public void endAccess() {

        isNew = false;

        /**
         * The servlet spec mandates to ignore request handling time
         * in lastAccessedTime.
         */
        if (LAST_ACCESS_AT_START) {
            this.lastAccessedTime = this.thisAccessedTime;
            this.thisAccessedTime = System.currentTimeMillis();
        } else {
            this.thisAccessedTime = System.currentTimeMillis();
            this.lastAccessedTime = this.thisAccessedTime;
        }

        if (ACTIVITY_CHECK) {
            accessCount.decrementAndGet();
        }
    }

    /**
     * 当判断出session过期后,调用此方法使session过期
     */
    @Override
    public void expire() {
        expire(true);
    }

    /**
     * 使session失效
     *
     * @param notify 是否通知监听器
     */
    public void expire(boolean notify) {

        if (notify) {
            // 通知监听器
        }

        // session管理器移除该session

        // 该将session置为无效

        // 解绑此session关联的属性对象,即attributes中的值。无用的对象就可以被GC掉了
    }

    /**
     * 回收该session。
     * 释放所有的对象引用,初始化实例变量的值,准备被再次使用
     */
    @Override
    public void recycle() {

        // Reset the instance variables associated with this Session
        attributes.clear();
        setAuthType(null);
        creationTime = 0L;
        expiring = false;
        id = null;
        lastAccessedTime = 0L;
        maxInactiveInterval = -1;
        notes.clear();
        setPrincipal(null);
        isNew = false;
        isValid = false;
        manager = null;
    }

}

我们最常用到的应该就是attributes这个属性了,通常我们会在会话中放入一些信息,如验证码、用户信息等,就是放到了这个属性中。类似于下面这种用法

    @GetMapping("/login")
    public String login(HttpServletRequest request) {
        //  登录验证的逻辑……

        HttpSession session = request.getSession();
        session.setAttribute("userInfo",user);
        
        // 其他逻辑……
    }

其他各项属性都是为了控制Session而存在的,平时用时感知不大。如session有效期的控制,提供门面装饰对象StandardSessionFacade等,它还提供了recycle方法来重置自己,方便被回收利用。

装饰类StandardSessionFacade就比较简单了,它只实现了HttpSession接口,内部代理一个HttpSession对象(通常来说就是StandardSession对象),接口的实现逻辑都是直接调用的代理对象的同名方法。

public class StandardSessionFacade implements HttpSession {

    public StandardSessionFacade(HttpSession session) {
        this.session = session;
    }

    private final HttpSession session;

    @Override
    public long getCreationTime() {
        return session.getCreationTime();
    }

    @Override
    public String getId() {
        return session.getId();
    }

    // 其他方法省略 …………
}

上面提到的相关类的类图如下

Session管理器

上面展示了Session对象,它代表一个会话,这个对象里存放了该会话的一些属性。Session管理器就是管理多个Session对象的一个存在,catalina制定了Session管理器的规范,封装成了org.apache.catalina.Manager接口,并提供了ManagerBase类(实现了Manager接口)来提供Session管理器的一些统一行为。StandardManager是ManagerBase的子类,是Tomcat默认的Session管理器。下面我综合ManagerBase与StandardManager中的一些属性和方法,来展示下Session管理器的主要作用

首先看这两个属性

// 当前活跃的session,key为session的id
protected Map<String, Session> sessions = new ConcurrentHashMap<>();

// 已经被回收的Session集合,可以被再次使用(这个回收利用的机制好似高版本已经不用了,读者注意版本区别,这里是Tomcat4的逻辑)
protected List<Session> recycled = new ArrayList<>();

Session管理器负责创建Session,对应的方法为createSession,HttpServletRequest.getSession()最终也会调到这个createSession方法。createSession中会尝试利用回收池中的Session,减少对象的创建,节省资源。

public Session createSession() {

    // Recycle or create a Session instance
    Session session = null;
    synchronized (recycled) {
        int size = recycled.size();
        if (size > 0) {
            session = recycled.get(size - 1);
            recycled.remove(size - 1);
        }
    }
    if (session != null)
        session.setManager(this);
    else
        session = new StandardSession(this);

    // 为新的Session初始化属性值
    session.setNew(true);
    session.setValid(true);
    session.setCreationTime(System.currentTimeMillis());
    session.setMaxInactiveInterval(this.maxInactiveInterval);
    String sessionId = generateSessionId();
    String jvmRoute = getJvmRoute();
    if (jvmRoute != null) {
        sessionId += '.' + jvmRoute;
        session.setId(sessionId);
    }
    // setId这个方法中会将这个session加入到sessions这个Map中
    session.setId(sessionId);

    return session;
}

Session管理器支持通过sessionId来查找session,其实就是对sessions这个Map做检索, 方法太简单,不再放代码了。

Session管理器支持序列化sessions到“SESSIONS.ser”文件中,也支持从“SESSIONS.ser”文件中反序列化出Session对象集合,放到sessions属性中。对应的方法分别为load()和unload(),这两个方法是在对应的生命周期方法执行时被调用的,比如 Session管理器组件初始化时执行的start()方法中会调用load()方法,组件销毁时调用的stop()方法中会调用unload()方法。代码不在展示了,感兴趣的同学去看下源码吧。

Session管理器还负责销毁那些已经失效的Session对象,Tomcat中会有一个独立线程周期性的调用StandardManager的processExpires方法来巡检所有Session对象,销毁那些已过期的。

private void processExpires() {

    long timeNow = System.currentTimeMillis();
    Session sessions[] = findSessions();

    for (int i = 0; i < sessions.length; i++) {
        StandardSession session = (StandardSession) sessions[i];
        if (!session.isValid()) {
            continue;
        }
        int maxInactiveInterval = session.getMaxInactiveInterval();
        if (maxInactiveInterval < 0) {
            continue;
        }
        // 计算出session已经空闲的时间,与最大空闲时间做对比,如果超时了,就将session进行过期处理(重置并放入回收池)
        int timeIdle = (int) ((timeNow - session.getLastAccessedTime()) / 1000L);
        if (timeIdle >= maxInactiveInterval) {
            try {
                session.expire();
            } catch (Throwable t) {
                log(sm.getString("standardManager.expireException"), t);
            }
        }
    }

}

除了StandardManager,Tomcat还提供了PersistentManager来支持将Session持久化到不同的储存库中,可以是文件(FileStore)也可以是数据库(JDBCStore),用的比较少,这里就不详细介绍了。

上面提到的类的类图如下

下面画个流程图来串一下 Session建立、刷新、回收的过程

集群共享Session

线上系统为了高可用,一般都会采用集群部署,并使用负载均衡工具(如Nginx)做负载。这会带来一个问题:Tomcat中的Session管理都是针对单机服务而言的,集群服务如何管理Session呢?通常这就需要一个外部介质来存Session了,而且这个外部介质需要是集群中每个节点都能访问到的。通常来说我们会选择redis来充当这个介质,而spring也提供了spring-session-data-redis包来支持这项工作。

spring-session-data-redis的用法

首先引入maven依赖

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
    <version>2.7.4</version>
</dependency>

然后在springboot的启动类上加上@EnableRedisHttpSession注解,就完事了,用起来非常简单。

@EnableRedisHttpSession注解中有几个属性,方便你做一些个性化设置,如果没有特殊需求,用它的默认值即可。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(RedisHttpSessionConfiguration.class)
public @interface EnableRedisHttpSession {

    /**
     * Session的有效期,单位为秒,默认是30分钟
     */
    int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;

    /**
     * 为key定义唯一的命名空间。默认为“spring: session:”,用于隔离会话。
     * 例如,如果你有一个名为“应用A”的应用程序需要与“应用B”保持会话隔离,你可以为应用程序设置两个不同的值,它们可以在同一个Redis实例中运行。
     */
    String redisNamespace() default RedisSessionRepository.DEFAULT_KEY_NAMESPACE;

    /**
     * Redis会话的刷新模式。默认是ON_SAVE,在HTTP响应提交之前将Session刷新到redis。
     * 将该值设置为IMMEDIATE时,对Session有任何更新时,都会立即写入到Redis。
     */
    FlushMode flushMode() default FlushMode.ON_SAVE;

    /**
     * 保存session的模式,默认为ON_SET_ATTRIBUTE,只向redis中刷新发生改变的属性。
     * 设为ON_GET_ATTRIBUTE时,除了session中属性变化时会保存,在从session中读取属性时也会向redis刷新数据。
     * 设为ALWAYS,代表对session的任何操作,都会向redis中刷新数据
     */
    SaveMode saveMode() default SaveMode.ON_SET_ATTRIBUTE;

}

最常使用的属性是maxInactiveIntervalInSeconds,自定义一个session有效期。

其次是redisNamespace,在多个不同的服务都需要往同一个redis实例中放session时,会通过这个属性来做服务区分。

flushMode和saveMode 几乎没用过。甚至我对saveMode这个属性的应用场景都没看懂😂。

spring-session-data-redis的原理

用法很简单,原理才重要。

工作原理
  1. 会话存储替换:Spring Session Data Redis 替换了标准的 HTTP 会话存储机制,通过 Redis 进行会话数据的存储和管理。

  2. 拦截器和过滤器:Spring Session 提供了一个 SessionRepositoryFilter 过滤器,拦截所有进入的 HTTP 请求,替换原有的 HttpSession 实现。

  3. RedisIndexedSessionRepository:该类持有RedisTemplate引用,将会话数据存储在 Redis 中,并负责会话的创建、更新、删除等操作。

主要流程

1.请求拦截SessionRepositoryFilter是一个servlet过滤器,它会拦截所有HTTP 请求,使用自己的requestWrapper与responseWrapper对象将原本的request与response对象包装了一下,然后将包装后的对象扔回了过滤器链中,也就是说当请求到达servlet后,servlet拿到的是这个requestWrapper与responseWrapper对象。 

这个FIlter是设计的精髓,拦截到请求并将request与response偷梁换柱后,自己想特殊实现的逻辑就在xxxWrapper类中重写相关方法,还想使用原逻辑的就还调用原来对象的方法。

2.会话操作与持久化

再看下SessionRepositoryFilter的类结构,在SessionRepositoryRequestWrapper这个类中还有一个HttpSessionWrapper类,这个类就是用来替掉Tomcat原本的HttpSession的。

RedisIndexedSessionRepository类持有一个RedisTemplate对象,负责与redis进行交互。HttpSessionWrapper对session的操作会调用RedisIndexedSessionRepository类中对应方法去redis中存取数据。

redis中存储的session数据

spring-session-data-redis将一个session放入redis时,会创建三个key。Hash结构的key存储的是session的数据,我们编码用到的也是这个数据;另外两个key是与session过期处理相关的,平常我们也不用关心。

前端收到的cookie

使用spring-session-data-redis后,这个框架会将真实的sessionId进行base64编码后再返给前端,如上图的session信息,前端收到的cookie就为  

SESSION=Zjc0MWNmMzYtYTZiMy00YmY2LTgyNDQtM2U3MGYzNmE4NmI0

注意,这时候已经没有“JSESSIONID”了,前后端辨认会话使用“SESSION”。

好,spring-session-data-redis就聊到这里。

下篇文章聊一聊Tomcat的安全性,敬请期待!

源码分享

https://gitee.com/huo-ming-lu/HowTomcatWorks

本篇文章并没有讲原书中的示例,感兴趣的同学可以自己运行观察一下。另外,我在gitee上提供的源码为Tomcat4的代码,写这篇文章时我也参考了Tomcat9的代码,新版和老版的整体思路没变,在一些细节上有些优化,可以先看老版代码,再对比着新版代码去看。

相关推荐

  1. 深入剖析:MySQL Innodb事务实现原理

    2024-06-10 18:38:02       33 阅读
  2. QT5.14.2深入剖析Qt反射机制实现原理

    2024-06-10 18:38:02       19 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-06-10 18:38:02       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-06-10 18:38:02       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-06-10 18:38:02       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-06-10 18:38:02       18 阅读

热门阅读

  1. NG32单片机GPIO口配置方式

    2024-06-10 18:38:02       11 阅读
  2. 2024-06-05 拷贝、函数、装饰器、迭代生成器

    2024-06-10 18:38:02       10 阅读
  3. 使用 AWK 为特定行添加编号并打印

    2024-06-10 18:38:02       10 阅读
  4. 每日一题(LeetCode·704)二分查找

    2024-06-10 18:38:02       7 阅读
  5. 将单条十六进制字符串转换为单个 pcap 文件

    2024-06-10 18:38:02       10 阅读
  6. 10_1 Linunx Web服务管理

    2024-06-10 18:38:02       11 阅读
  7. 反射学习记

    2024-06-10 18:38:02       9 阅读
  8. web前端常识:深入理解与实战应用

    2024-06-10 18:38:02       8 阅读
  9. vue项目中不同接口使用不同的域名

    2024-06-10 18:38:02       6 阅读
  10. Flutter商城类APP常用的第三方库总汇

    2024-06-10 18:38:02       8 阅读
  11. Spark MLlib机器学习

    2024-06-10 18:38:02       8 阅读
  12. 【教程】在Linux系统上安装Docker和Docker Compose

    2024-06-10 18:38:02       9 阅读
  13. C语言中的break和continue语句的作用和区别

    2024-06-10 18:38:02       9 阅读