详解 Spring Session 架构与设计
前言开始进行 Web 开发时,我们可能会遇到这样的情况,Web 容器(例如 Tomcat、Jetty)包含 Session 的实现,当服务器重启之后,之前的登录状态会失效需要重新登录。又或者你的应用程序部署了不止一台机器,用户在机器A上登陆之后,来到机器B又需要重新登陆,因为机器A的 Session 在机器B 是没有的。在解决这两个问题之前,我们先来重新了解下 HTTP 协议的相关知识。HT...
前言
开始进行 Web 开发时,我们可能会遇到这样的情况,Web 容器(例如 Tomcat、Jetty)包含 Session 的实现,当服务器重启之后,之前的登录状态会失效需要重新登录。又或者你的应用程序部署了不止一台机器,用户在机器A上登陆之后,来到机器B又需要重新登陆,因为机器A的 Session 在机器B 是没有的。
在解决这两个问题之前,我们先来重新了解下 HTTP 协议的相关知识。
HTTP 协议
HTTP 协议有个特点,是无状态的,意味着请求与请求是没有关系的。早期的 HTTP 协议只是用来简单地浏览网页,没有其他需求,因此请求与请求之间不需要关联。但现代的 Web 应用功能非常丰富,可以网上购物、支付、游戏、听音乐等等。如果请求与请求之间没有关联,就会出现一个很尴尬的问题:Web 应用不知道你是谁。为此 HTTP 协议需要一种技术让请求与请求之间建立起联系来标识用户。于是出现了 Cookie 技术。
Cookie 技术
Cookie 是 HTTP 报文的一个请求头,Web 应用可以将用户的标识信息或者其他一些信息(用户名等等)存储在 Cookie 中。用户经过验证之后,每次 HTTP 请求报文中都包含 Cookie;当然服务端为了标识用户,即使不经过登录验证,也可以存放一个唯一的字符串用来标识用户。采用 Cookie 就解决了用户标识的问题,同时 Cookie 中包含有用户的其他信息。Cookie 本质上就是一份存储在用户本地的文件,里面包含了需要在每次请求中传递的信息。
Session 技术
Cookie 以明文的方式存储了用户信息,造成了非常大的安全隐患,而 Session 的出现解决这个问题。用户信息可以以 Session 的形式存储在后端。这样当用户请求到来时,请求可以和 Session 对应起来,当后端处理请求时,可以从 Session 中获取用户信息。那么 Session 是怎么和请求对应起来的?答案是通过 Cookie,在 Cookie 中填充一个类似 SessionID 之类的字段用来标识请求。这样用户的信息存在后端,相对安全,也不需要在 Cookie 中存储大量信息浪费流量。但前端想要获取用户信息,例如昵称,头像等信息,依然需要请求后端接口去获取这些信息。
Session 管理
随着用户规模的增长,一个应用有多个实例,部署在不同的 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 架构设计
Spring Session 有两个核心组件:Session 和 SessionRepository。Spring Session 简单易用,通过 SessionRepository 来操作 Session。当建立会话时,创建 Session,将一些用户信息(例如用户 ID)存到 Session 中,并通过 SessionRepository 将 Session 持久化。当会话重新建立的时候,可以获取到 Session 中的信息。同时后台维护了一个定时任务,将过期的 Session 通过 SessionRepository 删除掉。下面详细介绍一下这两个核心组件。
Session
Session 即会话,这里的 Session 指的是广义的 Session 并不和特定的协议如 HTTP 绑定,支持 HttpSession、WebSocket Session,以及其他与 Web 无关的 Session。Session 可以存储与用户相关的信息或者其他信息,通过维护一个键值对(Key-Value)来存储这些信息。Session 接口签名如下所示:
Session 接口:org.springframework.session.Session
/**
* Provides a way to identify a user in an agnostic way. This allows the session to be
* used by an HttpSession, WebSocket Session, or even non web related sessions.
*
* @author Rob Winch
* @since 1.0
*/
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
SessionRepository 用来增删改查 Session 在对应数据源中的接口。SessionRepository 的接口签名如下所示:
SessionRepository 接口:org.springframework.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 如何在 Redis 中实现。Spring Session 创建 Session 后,使用 SessionRepository 将 Session 持久化到 Redis 中。当 Session 中的数据更新时,Redis 中的数据也会更新;当 Session 被重新访问刷新时,Redis 中的过期时间也会刷新;当 Redis 中的数据失效时,Session 也会失效。
采用 Redis 作为存储对应的实现类
前文提到的 Session 和 SessionRepository 组件,Spring Session 采用 Redis 作为存储方式时,都有对应的实现方式,即下面两个实现类。
RedisSession
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 的结构方式存储。
RedisOperationsSessionRepository
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 在 Redis 中的存储结构
SessionRepository 存储 Session,本质上是在操作 Redis,如下所示:
- ①.整点分钟的session过期集合,根据 ④ 的失效时间填充
- ②.登录用户,可以获取所有登录系统的用户
- ③.系统所有session
- ④.过期session key,session过期时会从此处删除
Session 在 Redis 中的存储
1. HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:name lee sessionAttr:mobile 18381111111
2. EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
3. APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
4. EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
5. SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
6. EXPIRE spring:session:expirations1439245080000 2100
在 Redis 中所有 Key 的前缀都是 spring:session
(与上文中的DEFAULT_SPRING_SESSION_REDIS_PREFIX
)相对应。假设多个项目共用一个 Redis,这时需要改变前缀。
在 Redis 中创建 Session
创建 Session 时会填充一个唯一的字符串用来标识 Session。在 Redis 中会为 Session 设置以下属性 creationTime、maxInactiveInterval 和 lastAccessedTime 与上文中的创建时间、有效时间、上次访问时间相对应。Session 中填充了两个属性 name 和 mobile。Session 的创建如下 所示:
Session 创建
HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:name li sessionAttr:mobile 18381111111
EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
Session 在 Redis 中创建之后触发 SessionCreatedEvent
,创建 Session 后需要额外的逻辑可以订阅该事件。注意,Session 中的失效时间属性 maxInactiveInterval
的值为 1800
,但在 Redis 中 Session 的失效时间为 2100
,这涉及到 Session 在 Redis 中的失效机制,下文会详细解答。
在 Redis 中实现 Session 失效
Redis 提供了失效机制,可以为键值对设置失效期。试想一下,用 Redis 实现一个最简单的 Session 失效,可以为存储在 Redis 中的 Session 直接设置失效,时间设置为 1800
即可。但 Spring Session 为什么没有这样做呢?
这是 Spring Session 为应用提供的一个扩展点,当 Session 失效时,Spring Session 可以通过消息订阅的方式通知到应用,应用可能会做出一些自己的逻辑处理。因此 Spring Session 新增加了 Expiration Key,为 Expiration Key 设置失效时间为 1800
,如下所示:
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。更多关于 Redis 中 Key 失效的细节参考 Timing of expired events。
为了解决这个问题,Spring Session 根据整点分钟数维护了一个集合,根据 Expiration Key 的失效时间将其填充到 expirations:
整点分钟数的集合中:
expirations 集合
SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
EXPIRE spring:session:expirations1439245080000 2100
Spring Session 后台会维护一个定时任务去检测符合整点分钟数的 expirations 集合,然后访问其中的 Expiration Key。如果 Expiration Key 已经失效,Redis 会自动删除 Expiration Key 并触发 SessionDestroyEvent,这样 Spring Session 会清理掉已经触发 SessionDestroyEvent 的 Session。Spring Session 维护的定时任务代码在 RedisOperationsSessionRepository 中:
Spring Session 定时任务
@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 是否已经失效,如果失效则进行清除,触发删除事件。此外,在 Redis 集群中,如果不采用分布式锁(会极大的降低性能),Redis 可能会错误的把一个 Key 标记为失效,如果冒然的删除 Key 会导致出错。采用请求 Expiration Key 的方式,Redis 自身会做出正确的判断。
Spring Session 与 Web 的集成
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 如何与 Web 集成。
SessionRepositoryFilter
SessionRepositoryFilter 拦截所有请求,对 HttpServletRequest 进行包装处理生成 SessionRepositoryRequestWrapper,对 HttpServletResponse 进行包装处理生成 SessionRepositoryResponseWrapper。SessionRepositoryFilter 的核心代码如下
doFilterInternal 方法
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
request, response, this.servletContext);
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
wrappedRequest, response);
HttpServletRequest strategyRequest = this.httpSessionStrategy
.wrapRequest(wrappedRequest, wrappedResponse);
HttpServletResponse strategyResponse = this.httpSessionStrategy
.wrapResponse(wrappedRequest, wrappedResponse);
try {
filterChain.doFilter(strategyRequest, strategyResponse);
}
finally {
wrappedRequest.commitSession();
}
}
注意 SessionRepositoryFilter 必须放置在任何访问或者进行 commit 操作之前,因为只有这样才能保证 J2EE 的 Session 被 Spring Session 提供的 Session 进行复写并进行正确的持久化。
SessionRepositoryRequestWrapper
SessionRepositoryRequestWrapper 是 HttpServletRequest 包装类,并覆盖 getSession 方法。getSession 方法会做如下操作:
- 调用 MultiHttpSessionStrategy 生成和获取 Session 的唯一标识符 ID。
- 调用 SessionRepository 生成和获取 Session。
getRequestedSessionId 方法用来获取 Session 的 ID,本质上就是调用 MultiHttpSessionStrategy 来获取
getSession(String id)方法用来获取 Session,本质上是调用 SessionRepository 来查找 Session
SessionRepositoryResponseWrapper
SessionRepositoryResponseWrapper 是 HttpServletResponse 的包装类,覆盖了 onResponseCommitted 方法。主要职责是检测 Session 是否失效,如果失效进行相应处理;确保新创建的 Session 被正确的持久化。
onResponseCommitted 方法本质上调用 SessionRepositoryRequestWrapper 的 commitSession 方法
commitSession 方法会判断 Session 的状态,进行失效、更新等处理。
MultiHttpSessionStrategy
MultiHttpSessionStrategy 继承 RequestResponsePostProcessor 和 HttpSessionStrategy 接口。RequestResponsePostProcessor 接口,允许开发人员对 HttpServletRequest 和 HttpServletResponse 进行一些定制化的操作,例如读取自定义的请求头,进行个性化处理。
HttpSessionStrategy 即 Session 实现策略,上文提到 Session 的失效策略是采用 Cookie 的方式,因此 HttpSessionStrategy 的默认失效方式是 CookieHttpSessionStrategy。
以下是相关参数介绍:
getRequestedSessionId
:获取 Session 的 ID,默认从 Cookie 中获取 Session 字段的值。onNewSession
:当用后台为请求建立了 Session 时,需要通知浏览器等客户端,接收 Session 的 ID。默认通过 Cookie 实现,将 Session 字段填充 Session 的 ID,并放置在Set-cookie
响应头中。onInvalidateSession
:当 Session 失效时调用,默认通过 Cookie 的方式,将 Session 字段删除。
结束语
本文分析了 Spring Session 的架构,介绍了采用 Redis 存储 Session 的实现细节,涉及时间监听和如何通过定时任务巧妙地失效 Session。此外,通过源码解析梳理了在 Web 中集成 Spring Session 的流程。
参考资源
参考 Spring Session 官方文档,了解更多内容。
更多推荐
所有评论(0)