序言
大家好,我是老马。
前面我们共同学习了 spring session 的入门使用:
大家对使用 spring session 一定不在话下。
但是每次夜深人静时,你是否会思考 spring session 背后的实现原理?
我们只是使用了一个注解,背后的一切又是怎样运转的呢?
接下来,就让我们从源码入手,重新认识一下 spring session。
使用 @EnableRedisHttpSession
在 Web 环境中,创建新 RedisIndexedSessionRepository 的最简单方法是使用 @EnableRedisHttpSession
。
这个注解看起来非常简单,实际上却默默地为我们做了很多事情:
(1)将 session 的信息持久化到 redis
(2)使用 spring 自己实现的 HTTP session 实现,替代原生的实现,对于开发者完全透明。
(3)针对 session 的过期的处理
(4)针对 session 续签的处理
(5)时间通知机制,确保资源的正确关闭和释放
注解定义
注解定义如下:
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
@Target({ java.lang.annotation.ElementType.TYPE })
@Documented
@Import(RedisHttpSessionConfiguration.class)
@Configuration
public @interface EnableRedisHttpSession {
// 默认是 30min
int maxInactiveIntervalInSeconds() default 1800;
// 命名空间默认为空
String redisNamespace() default "";
// 只有当前 SessionRepository#save() 调用时,才刷新 redis
RedisFlushMode redisFlushMode() default RedisFlushMode.ON_SAVE;
}
您可以使用以下属性来自定义配置:
maxInactiveIntervalInSeconds:会话过期前的时间,以秒为单位。
redisNamespace:允许为会话配置应用程序特定的名称空间。 Redis键和通道ID以 <redisNamespace>
前缀开头。
flushMode:允许指定何时将数据写入Redis。 默认设置仅在SessionRepository上调用save时。 FlushMode.IMMEDIATE的值会尽快写入Redis。
RedisHttpSessionConfiguration.java
还有一个核心的配置,就是 @Import(RedisHttpSessionConfiguration.class)
。
核心目的
完成核心目标(1)将 session 的信息持久化到 redis。
类定义
@Configuration
@EnableScheduling
public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
implements EmbeddedValueResolverAware, ImportAware {}
实现了两个接口,并且继承自 SpringHttpSessionConfiguration
RedisTemplate 初始化
我们使用 spring session 的 redis 实现时,信息会被写入 redis。
所以肯定需要 redis 相关的操作 bean 定义。RedisTemplate
的 bean 定义如下:
@Bean
public RedisTemplate<Object, Object> sessionRedisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<Object, Object>();
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
if (this.defaultRedisSerializer != null) {
template.setDefaultSerializer(this.defaultRedisSerializer);
}
template.setConnectionFactory(connectionFactory);
return template;
}
这里的 RedisConnectionFactory 是怎么来的呢?
实际上是通过 spring-data-redis 自动配置得到的,因为我们只是在 springboot 应用中的 application.properties 中配置了 redis 的连接信息。
RedisConnectionFactory
这个初始化在 RedisAutoConfiguration 类中:
@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public JedisConnectionFactory redisConnectionFactory()
throws UnknownHostException {
return applyProperties(createJedisConnectionFactory());
}
当没有定义 RedisConnectionFactory 类时,会根据配置文件初始化对应的 JedisConnectionFactory。
RedisOperationsSessionRepository 定义
@Bean
public RedisOperationsSessionRepository sessionRepository(
@Qualifier("sessionRedisTemplate") RedisOperations<Object, Object> sessionRedisTemplate,
ApplicationEventPublisher applicationEventPublisher) {
RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(
sessionRedisTemplate);
sessionRepository.setApplicationEventPublisher(applicationEventPublisher);
sessionRepository
.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
if (this.defaultRedisSerializer != null) {
sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
}
String redisNamespace = getRedisNamespace();
if (StringUtils.hasText(redisNamespace)) {
sessionRepository.setRedisKeyNamespace(redisNamespace);
}
sessionRepository.setRedisFlushMode(this.redisFlushMode);
return sessionRepository;
}
这里根据配置初始化了对应的 RedisOperationsSessionRepository。
SessionRepositoryFilter 拦截器
@EnableRedisHttpSession
做的最核心的一件事情实际上是重新实现了 HttpServletRequest 和 HttpServletResponse。
让我们完全可以像以前使用单机的 session 一样,来操作 spring session。
而想要实现这个,就需要看一下 SessionRepositoryFilter 拦截器实现。
完成核心目标(2):使用 spring 自己实现的 Http 实现,替代原生的实现。对于开发者完全透明。
SpringHttpSessionConfiguration
RedisHttpSessionConfiguration 继承自 SpringHttpSessionConfiguration,其中定义了 SessionRepositoryFilter
对象。
@Bean
public <S extends ExpiringSession> SessionRepositoryFilter<? extends ExpiringSession> springSessionRepositoryFilter(
SessionRepository<S> sessionRepository) {
SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<S>(
sessionRepository);
sessionRepositoryFilter.setServletContext(this.servletContext);
if (this.httpSessionStrategy instanceof MultiHttpSessionStrategy) {
sessionRepositoryFilter.setHttpSessionStrategy(
(MultiHttpSessionStrategy) this.httpSessionStrategy);
}
else {
sessionRepositoryFilter.setHttpSessionStrategy(this.httpSessionStrategy);
}
return sessionRepositoryFilter;
}
这里根据 session 的类型,分别设置了 2 种不同的 httpSessionStrategy 策略。
SessionRepositoryFilter 实现
其中 SessionRepositoryFilter 的实现如下:
@Order(SessionRepositoryFilter.DEFAULT_ORDER)
public class SessionRepositoryFilter<S extends ExpiringSession>
extends OncePerRequestFilter {}
这是 Filter 继承自 OncePerRequestFilter
,保证每一个请求只执行一次。
构造器
这里就是上面可以指定 sessionRepository,初始化对应的 sessionRepository。
public SessionRepositoryFilter(SessionRepository<S> sessionRepository) {
if (sessionRepository == null) {
throw new IllegalArgumentException("sessionRepository cannot be null");
}
this.sessionRepository = sessionRepository;
}
核心方法
这个类最核心的方法如下:
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 设置 request 中的属性
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
// 构建 request/response 以及对应的策略
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 {
// 提交 session
wrappedRequest.commitSession();
}
}
可以看的出来,这里的 request/response 实际上已经被 spring session 偷梁换柱了。
我们来看一下这两个是实现类。
SessionRepositoryRequestWrapper
类定义
这个类继承自 HttpServletRequestWrapper 类,实现了 HttpServletRequest 接口。
private final class SessionRepositoryRequestWrapper
extends HttpServletRequestWrapper {
private Boolean requestedSessionIdValid;
private boolean requestedSessionInvalidated;
private final HttpServletResponse response;
private final ServletContext servletContext;
private SessionRepositoryRequestWrapper(HttpServletRequest request,
HttpServletResponse response, ServletContext servletContext) {
super(request);
this.response = response;
this.servletContext = servletContext;
}
}
核心方法
getSession(boolean)
@Override
public HttpSessionWrapper getSession(boolean create) {
// 获取当前 session,如果已经存在,则直接返回。
HttpSessionWrapper currentSession = getCurrentSession();
if (currentSession != null) {
return currentSession;
}
// 获取当前的 sessionId
String requestedSessionId = getRequestedSessionId();
if (requestedSessionId != null
&& getAttribute(INVALID_SESSION_ID_ATTR) == null) {
S session = getSession(requestedSessionId);
if (session != null) {
this.requestedSessionIdValid = true;
currentSession = new HttpSessionWrapper(session, getServletContext());
currentSession.setNew(false);
setCurrentSession(currentSession);
return currentSession;
}
else {
// This is an invalid session id. No need to ask again if
// request.getSession is invoked for the duration of this request
setAttribute(INVALID_SESSION_ID_ATTR, "true");
}
}
// 如果不需要新建,直接返回 null
if (!create) {
return null;
}
// 创建 session
S session = SessionRepositoryFilter.this.sessionRepository.createSession();
// 更新最后的访问时间
session.setLastAccessedTime(System.currentTimeMillis());
// 创建 session,并且设置到当前的 session 中
currentSession = new HttpSessionWrapper(session, getServletContext());
setCurrentSession(currentSession);
return currentSession;
}
getSession(requestedSessionId) 获取 session
对于传统的 HTTP Session,是根据 JSESSIONID 直接获取对应的 session。
这里是根据 requestedSessionId 获取的,实现如下:
private S getSession(String sessionId) {
S session = SessionRepositoryFilter.this.sessionRepository
.getSession(sessionId);
if (session == null) {
return null;
}
session.setLastAccessedTime(System.currentTimeMillis());
return session;
}
sessionRepository 如果是基于 redis 的话,就是从 redis 去获取对应的 session 信息。
commitSession 对于 session 信息的持久化
对于 session 信息的持久化。
private void commitSession() {
// 获取当前的 session
HttpSessionWrapper wrappedSession = getCurrentSession();
if (wrappedSession == null) {
// session 已经失效
if (isInvalidateClientSession()) {
SessionRepositoryFilter.this.httpSessionStrategy
.onInvalidateSession(this, this.response);
}
}
else {
S session = wrappedSession.getSession();
// 通过 session 进行持久化
SessionRepositoryFilter.this.sessionRepository.save(session);
if (!isRequestedSessionIdValid()
|| !session.getId().equals(getRequestedSessionId())) {
SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session,
this, this.response);
}
}
}
Redis sessionRepository 对应的保存方法:
public void save(RedisSession session) {
session.saveDelta();
if (session.isNew()) {
String sessionCreatedKey = getSessionCreatedChannel(session.getId());
this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta);
session.setNew(false);
}
}
过期 session 的清空问题
针对需要过期 session 的清空,也针对 redis 做了相应的优化。
测试
实际生成的信息如下:
127.0.0.1:6379> keys *
A) spring:session:sessions:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4
B) spring:session:sessions:expires:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4
C) spring:session:expirations:1610022960000
A 类的数据分析
对于数据 1)
127.0.0.1:6379> get spring:session:sessions:expires:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4
""
其中 spring:session:sessions:expires:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4
的类型最简单,就是一个 string,对应的值是 “”
B 类的数据分析
对于 key spring:session:expirations:1610022960000
这里实际上方的是某一分钟需要清空的数据 set
127.0.0.1:6379> SMEMBERS spring:session:expirations:1610022960000
1) "\xac\xed\x00\x05t\x00,expires:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4"
通过 spring session 的定时任务执行,通过访问 redis,保证需要过期的数据被 redis 清空。
直接通过 expires:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4
加上对应的前缀 spring:session:sessions:
就可以得到需要清空的 key。
对应代码:
@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
}
这个任务调度,是 spring 的 @EnableScheduling
启用的任务调度触发的,此处不做展开。
C 类的数据分析
对于 key spring:session:sessions:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4
的信息最丰富,存储了所有 session 的相关信息。
127.0.0.1:6379> hkeys spring:session:sessions:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4
sessionAttr:SESSION-KEY-00370850-f656-40cf-ac3b-59bedf31f94d
creationTime
maxInactiveInterval
lastAccessedTime
对应的值:
127.0.0.1:6379> hget spring:session:sessions:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4 sessionAttr:SESSION-KEY-4a03c06c-f4dd-4b48-9314-d4899b5a0ba6
"\xac\xed\x00\x05t\x00\x05guest"
127.0.0.1:6379> hget spring:session:sessions:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4 creationTime
"\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01v\xdc\x95\xc4\xd8"
127.0.0.1:6379> hget spring:session:sessions:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4 maxInactiveInterval
"\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\a\b"
127.0.0.1:6379> hget spring:session:sessions:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4 lastAccessedTime
"\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01v\xdc\x95\xc4\xd8"
针对 session 的续签处理
session 中非常核心的是 lastAccessedTime,也就是我们常说的续签问题。
每次 session 的续签,需要将旧桶中的数据移除,放到新桶中。
源码
每次访问,都会更新对应的 lastAccessedTime
Long originalExpiration = this.originalLastAccessTime == null ? null
: this.originalLastAccessTime + TimeUnit.SECONDS
.toMillis(getMaxInactiveIntervalInSeconds());
RedisOperationsSessionRepository.this.expirationPolicy
.onExpirationUpdated(originalExpiration, this);
场景
假设存在一个 sessionId=1 的会话,初始时间戳为 1420656360000
spring:session:expirations:1420656360000 -> [1]
spring:session:session:1 -> <session>
接下来迎来了并发访问,(用户可能在浏览器中多次点击):
-
线程 1 在第 2 分钟请求,产生了续签,session:1 应当从 1420656360000 这个桶移动到 142065642000 这个桶
-
线程 2 在第 3 分钟请求,也产生了续签,session:1 本应当从 1420656360000 这个桶移动到 142065648000 这个桶
如果上两步按照次序执行,自然不会有问题。但第 3 分钟的请求可能已经执行完毕了,第 2 分钟才刚开始执行。
并发分析
像下面这样:
线程 2 从第一分钟的桶中移除 session:1,并移动到第三分钟的桶中
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]
线程 1 完成相同的操作,它也是基于第一分钟来做的,但会移动到第二分钟的桶中
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656420000 -> [1]
最后 redis 中键的情况变成了这样:
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]
spring:session:expirations:1420656420000 -> [1]
后台定时任务会在第 32 分钟扫描到 spring:session:expirations:1420656420000 桶中存在的 session,这意味着,本应该在第 33 分钟才会过期的 key,在第 32 分钟就会被删除!
解决方案
分布式锁
一种简单的方法是用户的每次 session 续期加上分布式锁,这显然不能被接受。来看看 Spring Session 是怎么巧妙地应对这个并发问题的。
spring session 的解决方案
public void cleanExpiredSessions() {
long now = System.currentTimeMillis();
long prevMin = roundDownMinute(now);
// 获取到定时清空的型键
String expirationKey = getExpirationKey(prevMin);
// 取出当前这一分钟应当过期的 session
Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
// 注意:这里删除的是定时清空型键,不是删除 session 本身!
this.redis.delete(expirationKey);
for (Object session : sessionsToExpire) {
String sessionKey = getSessionKey((String) session);
// 遍历一下对应的 expireKey 类型的键
touch(sessionKey);
}
}
/**
* By trying to access the session we only trigger a deletion if it the TTL is
* expired. This is done to handle
* https://github.com/spring-projects/spring-session/issues/93
*
* @param key the key
*/
private void touch(String key) {
// 并不是删除 key,而只是访问 key
// 通过访问,让 redis 自己去删除。
this.redis.hasKey(key);
}
spring session 解决方案分析
这里面逻辑主要是拿到过期键的集合(实际上是 expireKey 类型的 key,但这里可以理解为 sessionId,expireKey 类型我下面会介绍),此时这个集合里面存在三种类型的 sessionId。
(1)已经被 redis 删除的过期键。redis 很靠谱的及时清理了过期的键。
(2)已经过期,但是还没来得及被 redis 清除的 key。
我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键,所以 redis.hasKey(key); 该操作就是为了触发 redis 的自己删除。
(3)并发问题导致的多余数据,实际上并未过期。
如上所述,第 32 分钟的桶里面存在的 session:1 实际上并不应该被删除,使用 touch 的好处便是我只负责检测,删不删交给 redis 判断。
session:1 在第 32 分钟被 touch 了一次,并未被删除,在第 33 分钟时应当被 redis 删除,但可能存在延时,这个时候 touch 一次,确保删除。
过期通知事件
有了 redis 的清空 + 定时任务清空,可能觉得还是不够保险。
于是还有一个过期时间通知机制,这个可以让 redis 在清空 key 的时候,通知到监听者,便于进行相关的处理。
SessionDeletedEvent 和 SessionExpiredEvent
SessionDeletedEvent和SessionExpiredEvent都是SessionDestroyedEvent的两种类型。
RedisIndexedSessionRepository支持在删除会话时触发SessionDeletedEvent或在会话过期时触发SessionExpiredEvent。
这是确保正确清理与会话相关的资源所必需的。
例子
RedisHttpSessionConfiguration 中有一个配置可以定义,默认的实现是 ConfigureNotifyKeyspaceEventsAction
。
例如,当与WebSockets集成时,SessionDestroyedEvent负责关闭任何活动的WebSocket连接。
可以通过SessionMessageListener来触发SessionDeletedEvent或SessionExpiredEvent,该监听器监听Redis Keyspace事件。
为了使其正常工作,需要启用通用命令的Redis键空间事件和过期事件。
以下示例显示了如何执行此操作:
redis-cli config set notify-keyspace-events Egx
如果使用 @EnableRedisHttpSession
,则将自动完成SessionMessageListener的管理并启用必要的Redis Keyspace事件。
ps: 这里实际上可能存在坑,如果大批量的删除 redis,触发了 Redis keyspace 通知时间,可能瞬间把网络大打爆。
但是,在安全的Redis环境中,禁用config命令。 这意味着Spring Session无法为您配置Redis Keyspace事件。
如何禁用
要禁用自动配置,请将ConfigureRedisAction.NO_OP添加为Bean。
例如,通过Java配置,可以使用以下命令:
@Bean
ConfigureRedisAction configureRedisAction() {
return ConfigureRedisAction.NO_OP;
}
为什么要这样设计?
A) "spring:session:sessions:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4"
B) "spring:session:expirations:1523934840000"
C) "spring:session:sessions:expires:f925b626-1b6e-4f19-9cc6-dcbb18a8eec4"
存储 session 实际内容的 A 类型键和用于定时器确保删除的桶 B 类型键过期时间都是 30 分钟 (key 的 TTL 是 30 分钟),注意一个细节,spring-session 中 A 类型键的过期时间是 35 分钟,比实际的 30 分钟多了 5 分钟,这意味着即便 session 已经过期,我们还是可以在 redis 中有 5 分钟间隔来操作过期的 session。
于此同时,spring-session 引入了 C 类型键来作为 session 的引用。
C 类型键的组成为 spring:session:
+ sessions:expires
+ sessionId,对应一个空值。
同时也是 B 类型键桶中存放的 session 引用,ttl 为 30 分钟。
具体作用便是在自身过期后触发 redis 的 keyspace notifications (http://redis.io/topics/notifications).
- 为什么引入 C 类型键?
keyspace notifications 只会告诉我们哪个键过期了,不会告诉我们内容是什么。
关键就在于如果 session 过期后监听器可能想要访问 session 的具体内容,然而自身都过期了,还怎么获取内容。
所以,C 类型键存在的意义便是解耦 session 的存储和 session 的过期,并且使得 server 获取到过期通知后可以访问到 session 真实的值。
对于用户来说,C 类型键过期后,意味着登录失效,而对于服务端而言,真正的过期其实是 A 类型键过期,这中间会有 5 分钟的误差。
小结
如果是你实现一个 session 框架,你会如何设计?
存在哪些问题?
如何方便的整合?拓展?
spring session 的优点
spring session 重写了 http session,使用起来可以保证和原生的 http session 一样,这一点非常的优秀。
而且持久化层可以非常方便的替换,可以换成 redis/monogodb/msyql,或者单机内存。这一点也非常值得学习。
对于 redis 的过期考虑还是非常细致的,这需要对于 redis 有一定理解,并且在性能和安全之间找到平衡。
一点建议
个人感觉 redis 的事件通知大部分场景都是不需要的,虽然有些场景会用到。
这让设计变得有些复杂,而且有时候使用不慎,会直接把网络打爆。
后续我们将参考 spring session,设计并且实现一个属于自己的 session。
希望本文对你有所帮助,如果喜欢,欢迎点赞收藏转发一波。
我是老马,期待与你的下次相遇。
参考资料
从 Spring-Session 源码看 Session 机制的实现细节