作者 | 李增光
杏仁后端工程师。「 只有变秃,才能变强!」
开始进行 Web 开发时,我们可能会遇到这样的情况,当服务器重启之后,之前的登录状态会失效需要重新登录。又或者你的应用程序部署了不止一台机器,用户在机器A上登陆之后,来到机器B又需要重新登陆,因为机器A的 Session 在机器B 是没有的。
在解决这两个问题之前,我们先来重新了解下 HTTP 协议的相关知识。
HTTP 协议有个特点,是无状态的,意味着请求与请求是没有关系的。早期的 HTTP 协议只是用来简单地浏览网页,没有其他需求,因此请求与请求之间不需要关联。但现代的 Web 应用功能非常丰富,可以网上购物、支付、游戏、听音乐等等。如果请求与请求之间没有关联,就会出现一个很尴尬的问题:Web 应用不知道你是谁。为此 HTTP 协议需要一种技术让请求与请求之间建立起联系来标识用户。于是出现了 Cookie 技术。
Cookie 是 HTTP 报文的一个请求头,Web 应用可以将用户的标识信息或者其他一些信息(用户名等等)存储在 Cookie 中。用户经过验证之后,每次 HTTP 请求报文中都包含 Cookie;当然服务端为了标识用户,即使不经过登录验证,也可以存放一个唯一的字符串用来标识用户。采用 Cookie 就解决了用户标识的问题,同时 Cookie 中包含有用户的其他信息。Cookie 本质上就是一份存储在用户本地的文件,里面包含了需要在每次请求中传递的信息。
Cookie 以明文的方式存储了用户信息,造成了非常大的安全隐患,而 Session 的出现解决这个问题。用户信息可以以 Session 的形式存储在后端。这样当用户请求到来时,请求可以和 Session 对应起来,当后端处理请求时,可以从 Session 中获取用户信息。那么 Session 是怎么和请求对应起来的?答案是通过 Cookie,在 Cookie 中填充一个类似 SessionID 之类的字段用来标识请求。这样用户的信息存在后端,相对安全,也不需要在 Cookie 中存储大量信息浪费流量。但前端想要获取用户信息,例如昵称,头像等信息,依然需要请求后端接口去获取这些信息。
随着用户规模的增长,一个应用有多个实例,部署在不同的 Web 容器中。因此应用不可能再依赖单一的 Web 容器来管理 Session,需要将 Session 管理拆分出来。为此常见的 Session 管理都会采用高性能的存储方式来存储 Session,例如 Redis 和 MemCache,并且通过集群的部署,防止单点故障,提升高可用性。然后采用定时器,或者后台轮询的方式在 Session 过期时将 Session 失效掉。
于是,Spring Session 应运而生
它是一种流行的 Session 管理实现方式,相比上文提到的,Spring Session 做的要更多。Spring Session 并不和特定的协议如 HTTP 绑定,而是实现了一种广义上的 Session,支持 WebSocket 和 WebSession 以及多种存储类型如 Redis、MongoDB 等等。
Spring Session 是与协议无关的,因此想要在 Web 中使用 Spring Session 需要进行集成。一个很常见的问题是:Spring Session 在 Web 中的入口是哪里?答案是 Filter。
Spring Session 与 Web 集成的时候,需要用到以下 4 个核心组件:SessionRepositoryFilter、SessionRepositoryRequestWrapper、SessionRepositoryResponseWrapper 和 MultiHttpSessionStrategy,它们的协作方式如下:
当请求到来的时候,SessionRepositoryFilter 会拦截请求,采用包装器模式,将 HttpServletRequest 进行包装为 SessionRepositoryRequestWrapper。
SessionRepositoryRequestWrapper 会覆盖 HttpServletRequest 原本的 getSession()方法。getSession() 会改变 Session 的获取和存储方式,开发人员可以自己定义采用某种方式,例如 Redis、数据库等来获取 Session。用户获取到 Session 之后,可能会对 Session 做出改变,开发人员不需要手动的对 Session 进行提交和持久化,SpringSession 将自动完成。
SessionRepositoryFilter 将 HttpServletResponse 包装为 SessionRepositoryResponseWrapper,并覆盖 SessionRepositoryResponseWrapper 生命周期函数 onResponseCommitted(当请求处理完毕,该函数会被调用)。
在 onResponseCommitted 函数中,会调用 HttpSessionStrategy 确保 Session 被正确地持久化。这样 Session 在 HTTP 的整个生命周期就完成了。
Spring Session 有两个核心组件: Session 和 SessionRepository。 Spring Session 简单易用,通过 SessionRepository 来操作 Session。 当建立会话时,创建 Session,将一些用户信息(例如用户 ID)存到 Session 中,并通过 SessionRepository 将 Session 持久化。 当会话重新建立的时候,可以获取到 Session 中的信息。 同时后台维护了一个定时任务,将过期的 Session 通过 SessionRepository 删除掉。 下面详细介绍一下这两个核心组件。
Session 即会话,这里的 Session 指的是广义的 Session 并不和特定的协议如 HTTP 绑定,支持 HttpSession、WebSocket Session,以及其他与 Web 无关的 Session。Session 可以存储与用户相关的信息或者其他信息,通过维护一个键值对(Key-Value)来存储这些信息。Session 接口签名如下所示:
public interface Session { String getId(); <T> T getAttribute(String attributeName); Set<String> getAttributeNames(); void setAttribute(String attributeName, Object attributeValue); void removeAttribute(String attributeName); }
以下是相关参数介绍:
getId
:每个 Session 都有一个唯一的字符串用来标识 Session。
getAttribute
:获取 Session 中的数据,需要传递一个 name 获取对应的存储数据,返回类型是泛型,不需要进行强制转换。
getAttributeNames
:获取 Session 中存储信息所有的 name(也就是 Key)。
setAttribute
:填充或修改 Session 中存储的数据。
removeAttribute
:删除 Session 中填充的数据。
Session 因其存储方式的不同,支持以下多种实现方式:
GemFireSession
:采用 GemFire 作为数据源,在金融领域应用非常广泛。
HazelcastSession
:采用 Hazelcast 作为数据源。
JdbcSession
:采用关系型数据库作为数据源,支持 SQL。
MapSession
:采用 Java 中的 Map 作为数据源,一般作为快速启动的 demo 使用。
MongoExpiringSession
:采用 MongoDB 作为数据源。
RedisSession
:采用 Redis 作为数据源。
以上存储方式中,采用 Redis 作为数据源非常流行,因此下文将重点讨论 Spring Session 在 Redis 中实现。
SessionRepository 用来增删改查 Session 在对应数据源中的接口。SessionRepository 的接口签名如下所示:
public interface SessionRepository<S extends Session> { S createSession(); void save(S session); S getSession(String id); void delete(String id); }
以下是相关参数介绍:
createSession
:创建 Session。
save
:更新 Session。
getSession
:根据 ID 来获取 Session。
delete
:根据 ID 来删除 Session。
在 Spring Session 中最常用的数据源为 Redis,本部分将重点介绍 Spring Session 如何在 Redis 中实现。Spring Session 创建 Session 后,使用 SessionRepository 将 Session 持久化到 Redis 中。当 Session 中的数据更新时,Redis 中的数据也会更新;当 Session 被重新访问刷新时,Redis 中的过期时间也会刷新;当 Redis 中的数据失效时,Session 也会失效。
前文提到的 Session 和 SessionRepository 组件,Spring Session 采用 Redis 作为存储方式时,都有对应的实现方式,即下面两个实现类。
Session 在采用 Redis 作为存储方式时,对应的实现类为 RedisSession。RedisSession 并不直接实现 Session, 而是实现了 ExpiringSession。ExpiringSession 增加了一些属性,用来判断 Session 是否失效,ExpiringSession 继承 Session。RedisSession 的接口签名如下所示:
RedisSession 接口:org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession
final class RedisSession implements ExpiringSession { private final MapSession cached; private Long originalLastAccessTime; private Map<String, Object> delta = new HashMap<String, Object>(); private boolean isNew; private String originalPrincipalName; RedisSession() { this(new MapSession()); this.delta.put(CREATION_TIME_ATTR, getCreationTime()); this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds()); this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime()); this.isNew = true; this.flushImmediateIfNecessary(); } // ... }
以下是相关参数介绍:
cached
:采用 MapSession 作为缓存,意味着查找 Session 中的信息先从 MapSession 中查找,然后再从 Redis 中查找。
originalLastAccessTime
:上一次访问时间。
delta
:与 Session 中的更新数据相关。
isNew
:RedisSession 是否是新建的、未被更新过。
originalPrincipalName
:主题名称。通常是用户名
Session 在 Redis 中以 HashMap 的结构方式存储。
SessionRepository 在采用 Redis 作为存储方式时,对应的实现类为 RedisOperationSessionRepository。RedisOperationSessionRepository 并不直接实现 SessionRepository,而是实现了 FindByIndexNameSessionRepository。FindByIndexNameSessionRepository 继承 SessionRepository,并提供了强大的 Session 查找接口。RedisOperationsSessionRepository 接口如下 所示:
RedisOperationsSessionRepository 接口:org.springframework.session.data.redis.RedisOperationsSessionRepository
public class RedisOperationsSessionRepository implements FindByIndexNameSessionRepository<RedisOperationsSessionRepository.RedisSession>, MessageListener { private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; static PrincipalNameResolver PRINCIPAL_NAME_RESOLVER = new PrincipalNameResolver(); static final String DEFAULT_SPRING_SESSION_REDIS_PREFIX = "spring:session:"; static final String CREATION_TIME_ATTR = "creationTime"; static final String MAX_INACTIVE_ATTR = "maxInactiveInterval"; static final String LAST_ACCESSED_ATTR = "lastAccessedTime"; static final String SESSION_ATTR_PREFIX = "sessionAttr:"; private String keyPrefix = DEFAULT_SPRING_SESSION_REDIS_PREFIX; private final RedisOperations<Object, Object> sessionRedisOperations; private final RedisSessionExpirationPolicy expirationPolicy; private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() { public void publishEvent(ApplicationEvent event) { } public void publishEvent(Object event) { } }; private Integer defaultMaxInactiveInterval; private RedisSerializer<Object> defaultSerializer = new JdkSerializationRedisSerializer(); private RedisFlushMode redisFlushMode = RedisFlushMode.ON_SAVE; // ... }
以下是相关参数介绍:
DEFAULT_SPRING_SESSION_REDIS_PREFIX
:Spring Session 在 Redis 中存储 Session 的前缀。
CREATION_TIME_ATTR
:Session 的创建时间。
MAX_INACTIVE_ATTR
:Session 的有效时间。
LAST_ACCESSED_ATTR
:Session 的上次使用时间。
SESSION_ATTR_PREFIX
:例如在 Session 中存储了 name 属性,value 为 小明
,Session 在 Redis 中以 HashMap 的方式,那么 name 的存储方式为 sessionAttr:name
, value 为 小明
。
sessionRedisOperations:
指定一组基本Redis操作的接口,由{@link RedisTemplate}实现。不经常使用
expirationPolicy
:**设置session在Redis中的过期策略(不知道怎么用)**
eventPublisher
:事件订阅,主要是 SessionCreatedEvent,SessionDestoryEvent,SessionDeleteEvent
在实际项目中使用时,我想通过 监听Session 的几个生命周期事件统计在线用户,发现行不通,因为session的创建销毁和用户登录退出不能精确的对应起来。想要知道使用 spring session 后的在线用户数,可以通过观察session 在redis中的key值。
SessionRepository 存储 Session,本质上是在操作 Redis,如下所示:
①.整点分钟的session过期集合,根据 ④ 的失效时间填充
②.登录用户,**可以获取所有登录系统的用户**
③.系统所有session
④.过期session key,session过期时会从此处删除
创建 Session 时会填充一个唯一的字符串用来标识 Session。在 Redis 中会为 Session 设置以下属性 creationTime、maxInactiveInterval 和 lastAccessedTime 与上文中的创建时间、有效时间、上次访问时间相对应。
Session 在 Redis 中创建之后触发 SessionCreatedEvent
,创建 Session 后需要额外的逻辑可以订阅该事件。注意,Session 中的失效时间属性 maxInactiveInterval
的值为 1800
,但在 Redis 中 Session 的失效时间为 2100
,这涉及到 Session 在 Redis 中的失效机制。
Redis 提供了失效机制,可以为键值对设置失效期。试想一下,用 Redis 实现一个最简单的 Session 失效,可以为存储在 Redis 中的 Session 直接设置失效,时间设置为 1800
即可。但 Spring Session 为什么没有这样做呢?
这是 Spring Session 为应用提供的一个扩展点,当 Session 失效时,Spring Session 可以通过消息订阅的方式通知到应用,应用可能会做出一些自己的逻辑处理。因此 Spring Session 新增加了 Expiration Key。
`APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""``EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800`
当 Expiration Key 被删除之后会触发 SessionDestroyEvent (内含 Session 相关信息)。Spring Session 会清除 Expiration Redis 中的 Session。但是存在这样一个问题,Redis 无法保证当 Key 过期无法访问时能够触发 SessionDestroyEvent。
因此,Redis 后台维护了一个任务,去定时地检测 Key 是否失效(不可访问),如果失效会触发 SessionDestroyEvent。但是这个任务的优先级非常低,很有可能 Key 已经失效了,但检测任务没有分配到执行时间片去触发 SessionDestroyEvent。
为了解决这个问题,Spring Session 根据整点分钟数维护了一个集合, 根据 Expiration Key 的失效时间将其填充到 expirations:
整点分钟数的集合中 :
`SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe``EXPIRE spring:session:expirations1439245080000 2100`
expirations 的到期时间是 实际 session 的过期时间增加5分钟,为了在 session 到期时还可以访问 session 的值。session 会在我们执行必要的处理之后才会被清理。
Spring Session 后台会维护一个定时任务去检测符合整点分钟数的 expirations 集合,然后访问其中的 Expiration Key。如果 Expiration Key 已经失效,Redis 会自动删除 Expiration Key 并触发 SessionDestroyEvent,这样 Spring Session 会清理掉已经触发 SessionDestroyEvent 的 Session。Spring Session 维护的定时任务代码在 RedisOperationsSessionRepository 中:
@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}") public void cleanupExpiredSessions() { this.expirationPolicy.cleanExpiredSessions(); }
每当调用 cleanExpiredSessions()时,都会访问前一分钟的会话,以确保它们在过期时被删除。 在某些情况下, cleanExpiredSessions()方法可能不会在特定时间内被调用。例如,重新启动服务器时可能会发生这种情况。为了应对这种情况,还会设置Redis会话的到期时间。
定时任务每分钟的 0 秒开始执行,如觉得这个频率太高,可以通过自定义 spring.session.cleanup.corn.expression
进行更改任务的执行时间。
通过上述分析,我们发现 Spring Session 设计的非常巧妙。Spring Session 并不会根据 expirations 集合中的内容去删除 Expiration Key。而是对可能失效的 Expiration Key 进行请求,让 Redis 自身判断 Key 是否已经失效,如果失效则进行清除,触发删除事件。
本文分析了 Spring Session 的架构,介绍了采用 Redis 存储 Session 的实现细节,涉及事件监听和如何通过定时任务巧妙地失效 Session。此外,通过源码解析梳理了在 Web 中集成 Spring Session 的流程。
参考 Spring Session 官方文档,了解更多内容。
全文完
以下文章您可能也会感兴趣:
MongoDB应用介绍
数据库索引优化
缓存的那些事
Java 并发编程 -- 线程池源码实战
Lombok Builder 构建器做了哪些事情?
WePY 2.0 新特性
SSL证书的自动化管理
了解一下第三方登
分布式 ID 生成策略
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。
长按左侧二维码关注我们,这里有一群热血青年期待着与您相会。