Spring Session工作原理
本文首发于 vivo互联网技术 微信公众号 https://mp.weixin.qq.com/s/KCOFv0nRuymkX79-RZi9eg
作者:张正林
HTTP协议本身是无状态的,为了保存会话信息,浏览器Cookie通过SessionID标识会话请求,服务器以SessionID为key来存储会话信息。在单实例应用中,可以考虑应用进程自身存储,随着应用体量的增长,需要横向扩容,多实例session共享问题随之而来。
Spring Session就是为了解决多进程session共享的问题,本文将介绍怎么使用Spring Session,以及Spring Session工作原理。
1、引入背景
应用部署在Tomcat时,session是由Tomcat内存维护,如果应用部署多个实例,session就不能共享。Spring Session就是解决为了解决分布式场景中的session共享问题。
2、使用方法
Spring Session支持存储在Hazelcast 、Redis、MongoDB、关系型数据库,本文主要讨论session存储在Redis。
web.xml配置:
<!-- spring session -->
<filter>
<filter-name>springSessionRepositoryFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
<filter-name>springSessionRepositoryFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
Spring 主要配置:
<!--创建了一个RedisConnectionFactory,它将Spring会话连接到Redis服务器-->
<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
<!--配置Redis连接池 ,可以不配置,使用默认就行!-->
p:poolConfig-ref="jedisPoolConfig"
</bean>
<!--创建一个Spring Bean的名称springSessionRepositoryFilter实现过滤器-->
<bean class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
<!--默认session时效30分钟-->
<property name="maxInactiveIntervalInSeconds" value="60" />
</bean>
3、工作流程
Tomcat web.xml解析步骤:
contextInitialized(ServletContextEvent arg0); // Listener
init(FilterConfig filterConfig); // Filter
init(ServletConfig config); // Servlet
初始化顺序:Listener > Filter > Servlet。
1) 通过 Tomcat 的 listener 把SessionRepositoryFilter加载到Spring容器中。
上一小节Spring配置文件里面声明了RedisHttpSessionConfiguration,正是在其父类SpringHttpSessionConfiguration中生成了SessionRepositoryFilter:
@Bean
public <S extends ExpiringSession> SessionRepositoryFilter<? extends ExpiringSession> springSessionRepositoryFilter(
SessionRepository<S> sessionRepository) {
......
return sessionRepositoryFilter;
}
RedisHttpSessionConfiguration类继承关系
2) filter初始化
web.xml里面配置的filter是DelegatingFilterProxy。
DelegatingFilterProxy类继承关系
DelegatingFilterProxy初始化入口在其父类GenericFilterBean中:
public final void init(FilterConfig filterConfig) throws ServletException {
......
// Let subclasses do whatever initialization they like.
initFilterBean();
......
}
DelegatingFilterProxy去Spring容器取第1步初始化好的springSessionRepositoryFilter:
protected void initFilterBean() throws ServletException {
synchronized (this.delegateMonitor) {
if (this.delegate == null) {
// If no target bean name specified, use filter name.
if (this.targetBeanName == null) {
//targetBeanName 为springSessionRepositoryFilter
this.targetBeanName = getFilterName();
}
WebApplicationContext wac = findWebApplicationContext();
if (wac != null) {
this.delegate = initDelegate(wac);
}
}
}
}
至此 sessionRepositoryFilter 初始化完成,DelegatingFilterProxy 实际代理了SessionRepositoryFilter。
SessionRepositoryFilter 工作核心流程:
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
//包装了HttpServletRequest,覆写了HttpServletRequest中 getSession(boolean create)方法
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
request, response, this.servletContext);
......
try {
filterChain.doFilter(strategyRequest, strategyResponse);
}
finally {
//保证session持久化
wrappedRequest.commitSession();
}
}
4、缓存机制
每一个session,Redis实际缓存的数据如下:
spring:session:sessions:1b8b2340-da25-4ca6-864c-4af28f033327
spring:session:sessions:expires:1b8b2340-da25-4ca6-864c-4af28f033327
spring:session:expirations:1557389100000
spring:session:sessions为hash结构,存储Spring Session的主要内容:
hgetall spring:session:sessions:1b8b2340-da25-4ca6-864c-4af28f033327
1) "creationTime"
2) "\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\x01j\x9b\x83\x9d\xfd"
3) "maxInactiveInterval"
4) "\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"
5) "lastAccessedTime"
6) "\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\x01j\x9b\x83\x9d\xfd"
spring:session:sessions:expires 为 string 结构,存储一个空值。
spring:session:expirations为set结构,存储1557389100000 时间点过期的spring:session:sessions:expires键值:
smembers spring:session:expirations:1557389100000
1) "\xac\xed\x00\x05t\x00,expires:1b8b2340-da25-4ca6-864c-4af28f033327"
RedisSessionExpirationPolicy,三个键值生成流程:
public void onExpirationUpdated(Long originalExpirationTimeInMilli,
ExpiringSession session) {
String keyToExpire = "expires:" + session.getId();
long toExpire = roundUpToNextMinute(expiresInMillis(session));
......
//把spring:session:sessions:expires加入到spring:session:expirations开头的key里面
String expireKey = getExpirationKey(toExpire);
BoundSetOperations<Object, Object> expireOperations = this.redis
.boundSetOps(expireKey);
expireOperations.add(keyToExpire);
long fiveMinutesAfterExpires = sessionExpireInSeconds
+ TimeUnit.MINUTES.toSeconds(5);
//spring:session:expirations开头的key过期时间为xml配置的时间后五分钟
expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
if (sessionExpireInSeconds == 0) {
this.redis.delete(sessionKey);
}
else {
//spring:session:sessions:expires开头的key过期时间为xml配置的时间
this.redis.boundValueOps(sessionKey).append("");
this.redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds,
TimeUnit.SECONDS);
}
//spring:session:sessions开头的key过期时间为xml配置的时间后五分钟
this.redis.boundHashOps(getSessionKey(session.getId()))
.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
}
Redis过期键有三种删除策略,分别是定时删除,惰性删除,定期删除。
- 定时删除:通过维护一个定时器,过期马上删除,是最有效的,但是也是最浪费cpu时间的。
- 惰性删除:程序在取出键时才判断它是否过期,过期才删除,这个方法对cpu时间友好,对内存不友好。
- 定期删除:每隔一定时间执行一次删除过期键的操作,并限制每次删除操作的执行时长和频率,是一种折中。
Redis采用了惰性删除和定期删除的策略。由此可见依赖 Redis 的过期策略实时删除过期key是不可靠的。
另外一方面,业务可能会在Spring Session过期后做业务逻辑处理,同时需要session里面的信息,如果只有一个 spring:session:sessions键值,那么Redis删除就删除了,业务没法获取session信息。
spring:session:expirations键中存储了spring:session:sessions:expires键,而spring:session:sessions:expires键过期五分钟早于spring:session:expirations键和spring:session:sessions键(实际Spring Session对于过期事件处理订阅的spring:session:sessions:expires键,下一节会具体讲),这样在订阅到过期事件时还能获取spring:session:sessions键值。
如果通过Redis本身清理机制未及时清除spring:session:sessions:expires,可以通过Spring Session提供的定时任务兜底,保证spring:session:sessions:expires清除。
RedisSessionExpirationPolicy,session清理定时任务
public void cleanExpiredSessions() {
long now = System.currentTimeMillis();
long prevMin = roundDownMinute(now);
......
//获取到spring:session:expirations键
String expirationKey = getExpirationKey(prevMin);
// 取出当前这一分钟应当过期的 session
Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
// 注意:这里删除的是spring:session:expirations键,不是删除 session 本身!
this.redis.delete(expirationKey);
for (Object session : sessionsToExpire) {
String sessionKey = getSessionKey((String) session);
//遍历一下spring:session:sessions:expires键
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,通过惰性删除确保spring:session:sessions:expires键实时删除,
// 同时也保证多线程并发续签的场景下,key移动到不同spring:session:expirations键里面时,
//以spring:session:sessions:expires键实际ttl时间为准
this.redis.hasKey(key);
}
5、事件订阅
默认至少订阅键空间通知gxE事件(http://redisdoc.com/topic/notification.html)。
ConfigureNotifyKeyspaceEventsAction,开启键空间通知:
public void configure(RedisConnection connection) {
String notifyOptions = getNotifyOptions(connection);
String customizedNotifyOptions = notifyOptions;
if (!customizedNotifyOptions.contains("E")) {
customizedNotifyOptions += "E";
}
boolean A = customizedNotifyOptions.contains("A");
if (!(A || customizedNotifyOptions.contains("g"))) {
customizedNotifyOptions += "g";
}
if (!(A || customizedNotifyOptions.contains("x"))) {
customizedNotifyOptions += "x";
}
if (!notifyOptions.equals(customizedNotifyOptions)) {
connection.setConfig(CONFIG_NOTIFY_KEYSPACE_EVENTS, customizedNotifyOptions);
}
}
RedisHttpSessionConfiguration,注册监听事件:
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
RedisOperationsSessionRepository messageListener) {
......
//psubscribe del和expired事件
container.addMessageListener(messageListener,
Arrays.asList(new PatternTopic("__keyevent@*:del"),
new PatternTopic("__keyevent@*:expired")));
//psubscribe created事件
container.addMessageListener(messageListener, Arrays.asList(new PatternTopic(
messageListener.getSessionCreatedChannelPrefix() + "*")));
return container;
}
RedisOperationsSessionRepository,事件处理:
public void onMessage(Message message, byte[] pattern) {
......
if (channel.startsWith(getSessionCreatedChannelPrefix())) {
...
//处理spring:session created事件
handleCreated(loaded, channel);
return;
}
//非spring:session:sessions:expires事件不做处理
String body = new String(messageBody);
if (!body.startsWith(getExpiredKeyPrefix())) {
return;
}
boolean isDeleted = channel.endsWith(":del");
if (isDeleted || channel.endsWith(":expired")) {
......
if (isDeleted) {
//处理spring:session:sessions:expires del事件
handleDeleted(sessionId, session);
}
else {
//处理spring:session:sessions:expires expired事件
handleExpired(sessionId, session);
}
......
return;
}
}
事件订阅样例:
@Component
public class SessionExpiredListener implements ApplicationListener<SessionExpiredEvent> {
@Override
public void onApplicationEvent(SessionExpiredEvent event) {
......
}
}
6、总结
Spring Session给我们提供了很好的分布式环境下资源共享问题解决思路,其基于Servlet 规范实现,业务使用时只需要简单配置就可以实现session共享,做到与业务低耦合,这都是以后我们项目开发中可以借签的设计理念。
更多内容敬请关注 vivo 互联网技术 微信公众号

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
-
上一篇
JAVA并发容器-ConcurrentHashMap 1.7和1.8 源码解析
HashMap是一个线程不安全的类,在并发情况下会产生很多问题,详情可以参考HashMap 源码解析;HashTable是线程安全的类,但是它使用的是synchronized来保证线程安全,线程竞争激烈的情况下效率非常低下。在jdk1.5的时候引入了ConcurrentHashMap,这也是一个线程安全的类,它使用了分段锁的技术来提升并发访问效率。 HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的 线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么 当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并 发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。 在jdk1.7及以前ConcurrentHashMap采用Segment数组结构和HashEntry数组结构组成,之后采用的是和HashMap一样的结构。 ConcurrentHashMap jdk1.7 结构图 采用Segment数组结构和HashEntry数组结构组成,Segment数组的大小就是Concu...
-
下一篇
TiKV 源码解析系列文章(十一)Storage - 事务控制层
作者:张金鹏 背景知识 TiKV 是一个强一致的支持事务的分布式 KV 存储。TiKV 通过 raft 来保证多副本之间的强一致,事务这块 TiKV 参考了 Google 的 Percolator 事务模型,并进行了一些优化。 当 TiKV 的 Service 层收到请求之后,会根据请求的类型把这些请求转发到不同的模块进行处理。对于从 TiDB 下推的读请求,比如 sum,avg 操作,会转发到 Coprocessor 模块进行处理,对于 KV 请求会直接转发到 Storage 进行处理。 KV 操作根据功能可以被划分为 Raw KV 操作以及 Txn KV 操作两大类。Raw KV 操作包括 raw put、raw get、raw delete、raw batch get、raw batch put、raw batch delete、raw scan 等普通 KV 操作。 Txn KV 操作是为了实现事务机制而设计的一系列操作,如 prewrite 和 commit 分别对应于 2PC 中的 prepare 和 commit 阶段的操作。 本文将为大家介绍 TiKV 源码中的 Sto...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- MySQL数据库在高并发下的优化方案
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- SpringBoot2整合MyBatis,连接MySql数据库做增删改查操作
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- Dcoker安装(在线仓库),最新的服务器搭配容器使用
- CentOS7,8上快速安装Gitea,搭建Git服务器
- CentOS8编译安装MySQL8.0.19
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果
- SpringBoot2整合Thymeleaf,官方推荐html解决方案