文档章节

分布式共享Session之SpringSession源码细节

alexqdjay
 alexqdjay
发布于 03/17 13:58
字数 2488
阅读 444
收藏 17

1. 概要

本文介绍SpringSession的主要功能的实现原理。在看源码的同时参照SpringSession开了一个“简化”版的Session框架--SimpleSession,简单好用,功能刚好够用,由于删除了很多SpringSession种用不到的功能,源码上可读性更好和自定义开发更容易。

2. 替代本地原生Session的秘密

几乎所有的方案都类似,使用 Filter 把请求拦截掉然后包装 Request Response 使得 Request.getSession 返回的 Session 也是包装过的,改变了原有 Session 的行为,譬如存储属性值是把属性值存储在 **Redis** 中,这样就实现了`分布式Session`了。

SpringSession 使用 SessionRepositoryFilter 这个过滤器来实现上面所说的。  
SimpleSession 使用 SimpleSessionFilter 来实现。

2.1 SessionRepositoryFilter

//包装
HttpServletRequest strategyRequest = this.httpSessionStrategy
		.wrapRequest(wrappedRequest, wrappedResponse);
//再包装
HttpServletResponse strategyResponse = this.httpSessionStrategy
		.wrapResponse(wrappedRequest, wrappedResponse);

try {
	filterChain.doFilter(strategyRequest, strategyResponse);
}
finally {
   // 提交
	wrappedRequest.commitSession();
}

包装的类是 SessionRepositoryResponseWrapperSessionRepositoryRequestWrapper 对应 ResponseRequest

2.2 SessionRepositoryResponseWrapper

继承自 OnCommittedResponseWrapper 主要目标就是一个,当 Response 输出完毕后调用 commit

@Override
protected void onResponseCommitted() {
	this.request.commitSession();
}

2.3 SessionRepositoryRequestWrapper

这个类功能比较多,因为要改变原有很多跟 Session 的接口,譬如 getSessionisRequestedSessionIdValid等。

当然最重要的是 getSession 方法,返回的 Session 是经包装的。

2.3.1 getSession

@Override
public HttpSessionWrapper getSession(boolean create) {
	// currentSession 是存在 request 的 attribute 中
	HttpSessionWrapper currentSession = getCurrentSession();
	// 存在即返回
	if (currentSession != null) {
		return currentSession;
	}
	// 获取请求的 sessionId, Cookie策略的话从cookie里拿, header策略的话在 Http Head 中获取
	String requestedSessionId = getRequestedSessionId();
	// 如果获取到,并且没有‘sessionId失效’标识
	if (requestedSessionId != null
			&& getAttribute(INVALID_SESSION_ID_ATTR) == null) {
		// 这里是从 repository 中读取,如 RedisRepository
		S session = getSession(requestedSessionId);
		// 读取到了就恢复出session
		if (session != null) {
			this.requestedSessionIdValid = true;
			currentSession = new HttpSessionWrapper(session, getServletContext());
			currentSession.setNew(false);
			setCurrentSession(currentSession);
			return currentSession;
		}
		// 没有读取到(过期了), 设置‘失效’标识, 下次不用再去 repository 中读取
		else {
			// This is an invalid session id. No need to ask again if
			// request.getSession is invoked for the duration of this request
			if (SESSION_LOGGER.isDebugEnabled()) {
				SESSION_LOGGER.debug(
						"No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
			}
			setAttribute(INVALID_SESSION_ID_ATTR, "true");
		}
	}
	if (!create) {
		return null;
	}
	if (SESSION_LOGGER.isDebugEnabled()) {
		SESSION_LOGGER.debug(
				"A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for "
						+ SESSION_LOGGER_NAME,
				new RuntimeException(
						"For debugging purposes only (not an error)"));
	}
	// 都没有那么就创建一个新的
	S session = SessionRepositoryFilter.this.sessionRepository.createSession();
	session.setLastAccessedTime(Instant.now());
	currentSession = new HttpSessionWrapper(session, getServletContext());
	setCurrentSession(currentSession);
	return currentSession;
}

这里涉及到 SessionRepository 下面介绍。

2.3.2 commitSession

由于现在的 Session 跟之前的已经完全不同,存储属性值更新属性值都是远程操作,使用"懒操作"模式可以使得频繁的操作更加有效率。

private void commitSession() {
	HttpSessionWrapper wrappedSession = getCurrentSession();
	// 如果没有session,并且已经被标记为失效时,调用 onInvalidateSession 进行通知处理
	if (wrappedSession == null) {
		if (isInvalidateClientSession()) {
			SessionRepositoryFilter.this.httpSessionStrategy
					.onInvalidateSession(this, this.response);
		}
	}
	// 如果存在就更新属性值
	else {
		S session = wrappedSession.getSession();
		SessionRepositoryFilter.this.sessionRepository.save(session);
		// 如果请求的sessionId跟当前的session的id不同,或者请求的sessionId无效,
		// 则调用 onNewSession 进行通知处理
		if (!isRequestedSessionIdValid()
				|| !session.getId().equals(getRequestedSessionId())) {
			SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session,
					this, this.response);
		}
	}
}

这里 onInvalidateSession onNewSession 都是 Strategy 的方法,根据不一样的策略采取的处理也不一样。  
Strategy 有:  

  1. CookieHttpSessionStrategy
  2. HeaderHttpSessionStrategy

2.4 Session 

SpringSessionsession 有两部分组成:  

  1. Session: 接口, 默认实现类 MapSession , 它是 session 的本地对象,存储着属性及一些特征(如:lastAccessedTime), 最终会被同步到远端, 以及从远端获取下来后存储在本地的实体。   
  2. HttpSessionAdapter: 为了能让 SessionHttpSession 接洽起来而设立的适配器。

2.5 Repository 

public interface SessionRepository<S extends Session> {
	// 创建session
	S createSession();
	
	// 保存session
	void save(S session);
	
	// 根据sessionId 获取
	S findById(String id);

	// 删除特定id的session值
	void deleteById(String id);
}

各种实现,最典型用得最多的就是 RedisOperationsSessionRepository, 下面整个第3章(Spring Session Redis存储结构)就是讲整个类存储的策略和设计。

3. Spring Session Redis存储结构

session在存储时分为:  

  1. session本身的一些属性存储  
  2. 专门负责用于过期的key存储  
  3. 以时间为key存储在该时间点需要过期的sessionId列表 

3.1 为什么需要三个存储结构?  

先说明第二存储是用来干嘛的,第二存储一般设置成session的过期时间如30分钟或者15分钟,同时session的客户端会注册一个redis的key过期事件的监听,一旦有key过期客户端有会事件响应和处理。
  
在处理事件时可能会需要该session的信息,这时候第一个存储就有用了,因此第一个存储的过期时间会比第二存储过期时间多1-3min,这就是为什么需要把属性存储和过期分开的原因。    

那第三个session的用处呢?对`Redis`比较熟悉的同学一定会知道其中的奥秘,因为`Redis`的key过期方式是定期随机测试是否过期和获取时测试是否过期(也称懒删除),由于定期随机测试Task的优先级是比较低的,所以即便这个key已经过期但是没有测试到所以不会触发key过期的事件。所以,第三个存储的意义在于,存储了什么时间点会过期的session,这样可以去主动请求来触发懒删除,以此触发过期事件。

3.2 Redis 三个key和存储结构

  1. Session主内容存储,key:spring:session:sessions:{SID},内容:Map,key : value
  2. 过期存储,key:spring:session:sessions:expires:{UUID},内容为空
  3. 过期sessionId列表存储,key:spring:session:expirations:{ExpiryTime},内容Set

3.3 运行方式

因为第二种 key 的存在,所以会自动失效并且发出事件,但是有延迟,所以有个定时任务在不停地扫描当前分钟过期的 key ,即扫描第三种 key ,一旦扫描到就进行删除。

相应事件的程序会把第一种 key 删除。

3.4 代码细节

3.4.1 更新失效时间

// 更新失效时间
public void onExpirationUpdated(Long originalExpirationTimeInMilli, Session session) {
	// NO.2 key
	String keyToExpire = "expires:" + session.getId();
	// 往后推迟 lastAccessTime + MaxInactiveInterval
	long toExpire = roundUpToNextMinute(expiresInMillis(session));
	// 原来的NO.3 key 清除掉
	if (originalExpirationTimeInMilli != null) {
		long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
		if (toExpire != originalRoundedUp) {
			String expireKey = getExpirationKey(originalRoundedUp);
			this.redis.boundSetOps(expireKey).remove(keyToExpire);
		}
	}

	long sessionExpireInSeconds = session.getMaxInactiveInterval().getSeconds();
	String sessionKey = getSessionKey(keyToExpire);

	// MaxInactiveInterval < 0 Session永久有效
	if (sessionExpireInSeconds < 0) {
		this.redis.boundValueOps(sessionKey).append("");
		this.redis.boundValueOps(sessionKey).persist();
		this.redis.boundHashOps(getSessionKey(session.getId())).persist();
		return;
	}

	// 拼装NO.3 key
	String expireKey = getExpirationKey(toExpire);
	BoundSetOperations<Object, Object> expireOperations = this.redis
			.boundSetOps(expireKey);
	expireOperations.add(keyToExpire);

	long fiveMinutesAfterExpires = sessionExpireInSeconds
			+ TimeUnit.MINUTES.toSeconds(5);
	// NO.3 key 过期时间是自身时间+5分钟
	expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
	// sessionKey -> NO.2 key
	if (sessionExpireInSeconds == 0) {
		this.redis.delete(sessionKey);
	}
	else {
		this.redis.boundValueOps(sessionKey).append("");
		this.redis.boundValueOps(sessionKey)
			.expire(sessionExpireInSeconds, TimeUnit.SECONDS);
	}
	// NO.1 也是过期时间推迟5min
	this.redis.boundHashOps(getSessionKey(session.getId()))
			.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
}

3.4.2 Redis事件监听

当 Redis key 过期会往两个频道发布事件,一个是 expired 频道的 key 事件,一个是 key 频道的 expired 事件。(不过需要开启这个功能)

PUBLISH __keyspace@0__:key expired  
PUBLISH __keyspace@0__:expired key

下面是 Spring Session 中 Redis 的事件监听。

container.addMessageListener(messageListener,
				Arrays.asList(new PatternTopic("__keyevent@*:del"),
						new PatternTopic("__keyevent@*:expired")));
container.addMessageListener(messageListener, Arrays.asList(new PatternTopic(messageListener.getSessionCreatedChannelPrefix() + "*")));

事件处理:

public void onMessage(Message message, byte[] pattern) {
	byte[] messageChannel = message.getChannel();
	byte[] messageBody = message.getBody();
	if (messageChannel == null || messageBody == null) {
		return;
	}

	String channel = new String(messageChannel);

	// 新建Session
	if (channel.startsWith(getSessionCreatedChannelPrefix())) {
		// TODO: is this thread safe?
		Map<Object, Object> loaded = (Map<Object, Object>) this.defaultSerializer
				.deserialize(message.getBody());
		handleCreated(loaded, channel);
		return;
	}

	String body = new String(messageBody);
	if (!body.startsWith(getExpiredKeyPrefix())) {
		return;
	}

	// 删除及过期Session
	boolean isDeleted = channel.endsWith(":del");
	if (isDeleted || channel.endsWith(":expired")) {
		int beginIndex = body.lastIndexOf(":") + 1;
		int endIndex = body.length();
		String sessionId = body.substring(beginIndex, endIndex);

		RedisSession session = getSession(sessionId, true);

		if (logger.isDebugEnabled()) {
			logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
		}
		// 清楚登录用户关联的sessionId,如: userId -> set(sessionId)
		cleanupPrincipalIndex(session);

		if (isDeleted) {
			handleDeleted(sessionId, session);
		}
		else {
			handleExpired(sessionId, session);
		}

		return;
	}
}

其中,handleCreatehandleDeletedhandleExpired 都是用于发布 Spring Context 的本地事件的。

4. Simple Session 简化和优化

4.1 Session存储使用 Map 

Session主要内容在Redis中存储采用 Map 结构可以优化读写性能,因为绝大多数属性属于写少读多,如果采用整体做序列化的方式,每次都是整存整取,对于session多个属性操作性能会略快,如果操作属性比较少(如一个)那么性能上会略慢,但整体上讲不会对应用构成瓶颈。

4.2 修改更新

Spring Session 将对session的修改,如创建、销毁以及put属性都做成了在请求最后(Response Commit)再一起保存到 Redis,期间随便操作多少次都不会更新到 Redis 中,这样确实减少了对 Redis 的操作,只要是多于一次的都是优化。(也可以设置成每次操作都进行更新)

但是有个问题,如果 response 已经 commit 了,这时候再修改session,值将不会更新到Redis,这个也算不足。

Simple Session 对值的修改也采取懒更新或者立即更新,可以通过配置进行切换。懒更新则使用比 Spring 更简单的方式进行,当 SimpleSessionFilter 执行完毕以后进行提交,而不像 SpringSession 还要考虑 response 输出完再 commit ,所以比较简单,但如果顺序排在前面的 Filter(执行 after 应该在 SimpleSessionFilter 后面)在 chain.doFilter 之后就不能再进行 session 的操作。

4.3 简化存储结构

3 key 式的存储确实是设计巧妙,但是由于 Simple Session 没有去实现 Session 变更(create, delete & expired)事件,所以也就没必要去使用 3 key 存储。因此,使用了最简洁的设计: SessionId -> map(key:value),存储 Session 相关属性及 attributes。

4.4 功能删减

Spime Session 实现了主要功能,包括只实现了 Redis 存储方案,至于其他方案如:db,使用者根据需要自己按接口实现。至于如:Spring Security的支持,socket场景的支持,都没有纳入主要功能去实现。

5. 代码库

码云:https://gitee.com/alexqdjay/simple-session

github: https://github.com/alexqdjay/simple-session

 

© 著作权归作者所有

共有 人打赏支持
alexqdjay
粉丝 35
博文 26
码字总数 31560
作品 0
浦东
高级程序员
私信 提问
加载中

评论(3)

alexqdjay
alexqdjay

引用来自“bboss”的评论

分布式Session的实现有很多,从简单到复杂各种各样,但是要做到分布式Session跟原生本地Session一致的API,对开发人员几乎是0门槛是不容易的
bboss session也是一个不错的选择:
https://www.oschina.net/p/bboss-session
这位老师广告打到这了都。。。哈哈
bboss
bboss
分布式Session的实现有很多,从简单到复杂各种各样,但是要做到分布式Session跟原生本地Session一致的API,对开发人员几乎是0门槛是不容易的
bboss session也是一个不错的选择:
https://www.oschina.net/p/bboss-session
tiandee
tiandee
666,希望博主以后多发这类理论实践并重的好文章
Springboot和Spring Session实现session共享

HttpSession是通过Servlet容器创建和管理的,像Tomcat/Jetty都是保存在内存中的。而如果我们把web服务器搭建成分布式的集群,然后利用LVS或Nginx做负载均衡,那么来自同一用户的Http请求将有...

ben4
2017/10/19
0
0
SpringBoot整合Redis、ApacheSolr和SpringSession

一、简介 SpringBoot自从问世以来,以其方便的配置受到了广大开发者的青睐。它提供了各种starter简化很多繁琐的配置。SpringBoot整合Druid、Mybatis已经司空见惯,在这里就不详细介绍了。今天...

小忽悠
07/19
0
0
SpringSession:集成SpringBoot

是 旗下的一个项目,把 容器实现的 替换为,专注于解决管理问题。可简单快速且无缝的集成到我们的应用中。本文通过一个案例,使用来集成 ,并且使用作为存储来实践下 的使用。 环境准备 因为...

glmapper
11/03
0
0
Spring Session关键类源码分析

要想使用spring session,还需要创建名为springSessionRepositoryFilter的SessionRepositoryFilter类。该类实现了Sevlet Filter接口,当请求穿越sevlet filter链时应该首先经过springSession...

芥末无疆
02/17
0
0
Spring Boot 使用 Spring Session 集成 Redis 实现Session共享

Spring Boot 使用 Spring Session 集成 Redis 实现Session共享 《Spring Boot 2.0极简教程》—— 基于 Gradle + Kotlin的企业级应用开发最佳实践 通常在web开发中,Session 会话管理是很重要...

程序员诗人
04/17
0
0

没有更多内容

加载失败,请刷新页面

加载更多

ConcurrentHashMap 高并发性的实现机制

ConcurrentHashMap 的结构分析 为了更好的理解 ConcurrentHashMap 高并发的具体实现,让我们先探索它的结构模型。 ConcurrentHashMap 类中包含两个静态内部类 HashEntry 和 Segment。HashEnt...

TonyStarkSir
今天
3
0
大数据教程(7.4)HDFS的java客户端API(流处理方式)

博主上一篇博客分享了namenode和datanode的工作原理,本章节将继前面的HDFS的java客户端简单API后深度讲述HDFS流处理API。 场景:博主前面的文章介绍过HDFS上存的大文件会成不同的块存储在不...

em_aaron
昨天
2
0
聊聊storm的window trigger

序 本文主要研究一下storm的window trigger WindowTridentProcessor.prepare storm-core-1.2.2-sources.jar!/org/apache/storm/trident/windowing/WindowTridentProcessor.java public v......

go4it
昨天
6
0
CentOS 生产环境配置

初始配置 对于一般配置来说,不需要安装 epel-release 仓库,本文主要在于希望跟随 RHEL 的配置流程,紧跟红帽公司对于服务器的配置说明。 # yum update 安装 centos-release-scl # yum ins...

clin003
昨天
9
0
GPON网络故障处理手册

导读 为了方便广大网络工作者工作需要,特搜集以下GPON网络处理流程供大家学习参考。开始—初步定为故障—检查光纤状况—检查ONU状态--检查设备运行状态—检查设备数据配置—检查上层设备状态...

问题终结者
昨天
10
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部