当前位置: 首页 > 工具软件 > session > 使用案例 >

Spring-Session实现session共享原理及解析

羊柏
2023-12-01

写在前面

Session简介

是什么?

Session在网络中表示“会话控制”,用于存储特定用户所需的属性和其他的配置信息;Session表示一个特定的时间间隔,可以指用户从登陆系统到注销退出系统之家的时间。

为什么出现?

因为http 是一种无状态协议,如果没有Session的话,服务器无法识别请求是否来自同一个用户!在一些业务场景中需要知道前面的操作和后台的操作是不是同一个用户的行为,即业务之间是有关联性的。

怎么用?

使用Session结合浏览器Cookie,将服务器Session保存到浏览器cookie中,这样可以保持http会话状态。

Session服务器创建,如Tomcat,浏览器发起请求到Tomcat服务器,然后Tomcat服务器生成SessionId保存在内存中,并将SessionId返回给浏览器,浏览器通过Cookie保存SessionId信息,用户每次通过浏览器访问服务器都会带上SessionId信息,这样就可以判断每次的请求是不是同一个用户,解决http协议无状态问题。

前言

公司现在业务服务器都采用分布式集群的方式进行部署,一个Web应用,可能部署在几台不同的服务器上,通过Nginx等进行负载均衡(一般使用Nginx+Tomcat实现负载均衡)。此时来自同一用户的Http请求将有可能被分发到不同的Web站点中去(如:第一次分配到A站点,第二次可能分配到B站点)。分布式使用Nginxt实现负载均衡,最常用的均衡算法有IP_Hash、轮训、根据权重、随机等。不管对于哪一种负载均衡算法,由于Nginx对不同的请求分发到某一个Tomcat,Tomcat在运行的时候分别是不同的容器里,因此会出现Session不同步或者丢失的问题。那么问题就来了,如何保证不同的Web站点能够共享同一份Session数据呢?

实际上,实现Session共享的方案很多,最简单的想法就是把session数据保存到内存以外的一个统一的地方,例如MongoDB/Redis等数据库中,那么问题又来了,如何替换掉Servlet容器创建和管理HttpSession的实现呢?

这里我们就可以引入一个新的框架来接管会话的session数据,那么,Spring-Session就应运而生了。

实现原理简单介绍

Spring-Session的实现就是设计一个过滤器Filter,当Web服务器接收到http请求后,当请求进入对应的Filter进行过滤,利用HttpServletRequestWrapper,实现自己的 getSession()方法,接管创建和管理Session数据的工作。将原本需要由web服务器创建会话的过程转交给Spring-Session进行创建,本来创建的会话保存在Web服务器内存中,通过Spring-Session创建的会话信息可以保存第三方的服务中,如:redis,mysql等。Web服务器之间通过连接第三方服务来共享数据,实现Session共享!

当然,我们也可以通过其他方法实现接管创建和管理Session数据的工作,可以利用Servlet容器提供的插件功能,自定义HttpSession的创建和管理策略,并通过配置的方式替换掉默认的策略。不过这种方式有个缺点,就是需要耦合Tomcat/Jetty等Servlet容器的代码。这方面其实早就有开源项目了,例如 memcached-session-manager,以及 tomcat-redis-session-manager。暂时都只支持Tomcat6/Tomcat7。这里我们将学习应用最为广泛的Spring-Session。

Spring-Session官网地址:Spring Session

Spring-Session官方文档:Spring Session

Spring Session特性

下面是来自官网的特性介绍:Spring Session提供了一套创建和管理Servlet HttpSession的方案。Spring Session提供了集群Session(Clustered Sessions)功能,默认采用外置的Redis来存储Session数据,以此来解决Session共享的问题。

Spring Session提供以下特性:

  • API和用于管理用户会话的实现;
  • 允许以应用程序容器(即Tomcat)中性的方式替换HttpSession;
  • Spring Session 让支持集群会话变得不那么繁琐,并且不和应用程序容器金习性绑定到。
  • Spring 会话支持在单个浏览器实例中管理多个用户的会话。
  • Spring Session 允许在headers 中提供会话ID以使用RESTful API。

集成Spring Session的正确姿势

springboot整合redis集成spring-session非常的方便,这也是springboot的宗旨,简化配置。

(1)第一步,添加Maven依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

(2)第二步,配置redis

在application.properties中配置redis

# Redis服务器地址
spring.redis.host=localhost
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=

(3)第三步,编写一个配置类

用来启用RedisHttpSession功能,并向Spring容器中注册一个RedisConnectionFactory,同时将RedisHttpSessionConfig加入到容器中

@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class RedisHttpSessionConfig {
}

还可以把该注解@@EnableRedisHttpSession注解加在启动类的上面。

一、Spring Session原理

前面集成spring-sesion的最后一步中,编写了一个配置类RedisHttpSessionConfig,它包含注解@EnableRedisHttpSession,@EnableRedisHttpSession注解通过Import引入了RedisHttpSessionConfiguration配置类。该配置类通过@Bean注解,向Spring容器中注册了一个SessionRepositoryFilter(SessionRepositoryFilter的依赖关系:SessionRepositoryFilter --> SessionRepository --> RedisTemplate --> RedisConnectionFactory)。

SessionRepositoryFilter这个过滤器的主要作用是拦所有的请求,接管创建和管理Session数据。具体怎样接管session数据我们后边讲,我们现在只需要了解SessionRepositoryFilter整个过滤器作用就行。

RedisHttpSessionConfiguration继承了SpringHttpSessionConfiguration,SpringHttpSessionConfiguration中进行了SessionRepositoryFilter的注册,代码如下:

@Configuration
public class SpringHttpSessionConfiguration implements ApplicationContextAware {

    //.....
    
	@Bean
	public <S extends Session> SessionRepositoryFilter<? extends Session> springSessionRepositoryFilter(
			SessionRepository<S> sessionRepository) {
		SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<>(sessionRepository);
		sessionRepositoryFilter.setServletContext(this.servletContext);
		sessionRepositoryFilter.setHttpSessionIdResolver(this.httpSessionIdResolver);
		return sessionRepositoryFilter;
	}
}

这里我们可以看到,注册这个filter时需要一个SessionRepository参数,那么,这个参数又是从哪里来的呢?

在SpringHttpSessionConfiguration的继承类RedisHttpSessionConfiguration中,我们找到了SessionRepository被注入的代码

@Configuration
@EnableScheduling
public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
		implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware,
		SchedulingConfigurer {
	@Bean
	public RedisOperationsSessionRepository sessionRepository() {
		RedisTemplate<Object, Object> redisTemplate = createRedisTemplate();
		RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(
				redisTemplate);
		sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
		if (this.defaultRedisSerializer != null) {
			sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
		}
		sessionRepository
				.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
		if (StringUtils.hasText(this.redisNamespace)) {
			sessionRepository.setRedisKeyNamespace(this.redisNamespace);
		}
		sessionRepository.setRedisFlushMode(this.redisFlushMode);
		int database = resolveDatabase();
		sessionRepository.setDatabase(database);
		return sessionRepository;
	}
}

这样一来就不需要开发人员主动配置一个RedisOperationsSessionRepository,但是这个配置需要一个RedisOperations,而这个RedisOperations也是定义在这个类中的。而这个RedisTemplate依赖一个RedisConnectionFactory是需要开发人员配置的。如果我们使用spring-boot,只需要指定application.properties的spring.redis.cluster.nodes即可配置一个redis集群JedisConnectionFactory。具体请参考org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration.RedisConnectionConfiguration

好了,下面我们可以来介绍一下SessionRepositoryFilter如何接管创建和管理Session数据了

SessionRepositoryFilter

SessionRepositoryFilter是一个优先级最高的 javax.servlet. Filter,它使用了一个SessionRepositoryRequestWrapper类接管了Http Session的创建和管理工作。

每当有请求进入时,过滤器会首先将ServletRequest 和ServletResponse 这两个对象转换成Spring内部的包装类SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper对象。

@Order(SessionRepositoryFilter.DEFAULT_ORDER)
public class SessionRepositoryFilter<S extends Session> extends OncePerRequestFilter {
    @Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        
		request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
         //使用HttpServletRequest 、HttpServletResponse和servletContext创建一个SessionRepositoryRequestWrapper
		SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
				request, response, this.servletContext);
		SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
				wrappedRequest, response);
		try {
			filterChain.doFilter(wrappedRequest, wrappedResponse);
		}
		finally {
            //保存session信息
			wrappedRequest.commitSession();
		}
	}
}

SessionRepositoryRequestWrapper类

重写了原生的getSession方法。

@Override
		public HttpSessionWrapper getSession(boolean create) {
            //获取当前Request作用域中代表Session的属性,缓存作用避免每次都从sessionRepository获取
			HttpSessionWrapper currentSession = getCurrentSession();
			if (currentSession != null) {
				return currentSession;
			}
            //查找客户端中一个叫SESSION的cookie,拿到sessionId,通过sessionRepository对象根据sessionId去Redis中查找
			S requestedSession = getRequestedSession();
            //如果从redis中查询到了值
			if (requestedSession != null) {
                //客户端存在sessionId 并且未过期
				if (getAttribute(INVALID_SESSION_ID_ATTR) == null) {
					requestedSession.setLastAccessedTime(Instant.now());
					this.requestedSessionIdValid = true;
					currentSession = new HttpSessionWrapper(requestedSession, getServletContext());
					currentSession.setNew(false);
                    //将Session设置到request属性中
					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
				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");
			}
            //不创建Session就直接返回null
			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)"));
			}
            //执行到这了说明需要创建新的Session
            // 通过sessionRepository创建RedisSession这个对象
			S session = SessionRepositoryFilter.this.sessionRepository.createSession();
			session.setLastAccessedTime(Instant.now());
			currentSession = new HttpSessionWrapper(session, getServletContext());
			setCurrentSession(currentSession);
			return currentSession;
		}
	// 通过sessionRepository创建RedisSession这个对象
	@Override
	public RedisSession createSession() {
		Duration maxInactiveInterval = Duration
				.ofSeconds((this.defaultMaxInactiveInterval != null)
						? this.defaultMaxInactiveInterval
						: MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS);
		RedisSession session = new RedisSession(maxInactiveInterval);
		session.flushImmediateIfNecessary();
		return session;
	}

上面有一点需要注意就是将Sesison对象包装成了HttpSessionWrapper,目的是当Session失效时可以从sessionRepository删除。

这里重写了getSession方法,也就是为什么每当执行HttpServletRequest执行.getSession()方法后就会刷新session的过期时间。

private final class HttpSessionWrapper extends HttpSessionAdapter<S> {

			HttpSessionWrapper(S session, ServletContext servletContext) {
				super(session, servletContext);
			}
			@Override
			public void invalidate() {
				super.invalidate();
				SessionRepositoryRequestWrapper.this.requestedSessionInvalidated = true;
				setCurrentSession(null);
				clearRequestedSessionCache();
				SessionRepositoryFilter.this.sessionRepository.deleteById(getId());
			}
		}

到这里,我们了解了SessionRepositoryRequestWrapper类接管Http Session并重写了getSession来实现了session的管理,那么,session数据又是怎么存放到redis中的呢?

SessionRepository保存session数据

上边,我们看到SessionRepositoryFilter的doFilterInternal方法最后有一个finally中执行了wrappedRequest.commitSession();方法,这里就是保存session数据到redis。

	@Override
	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);

		try {
			filterChain.doFilter(wrappedRequest, wrappedResponse);
		}
		finally {
            //将Session同步到Redis,同时这个方法还会将当前的SESSIONID写到cookie中去,同时还会发布SESSION创建事件到队列里面去
			wrappedRequest.commitSession();
		}
	}


    //使用httpessionidresolver将会话id写入响应并持久化会话。
    private void commitSession() {
                HttpSessionWrapper wrappedSession = getCurrentSession();
                if (wrappedSession == null) {
                    //session已过期,更新过期时间
                    if (isInvalidateClientSession()) {
                        SessionRepositoryFilter.this.httpSessionIdResolver.expireSession(this,
                                this.response);
                    }
                }
                else {
                    S session = wrappedSession.getSession();
                    clearRequestedSessionCache();
                    //持久化session数据到redis
                    SessionRepositoryFilter.this.sessionRepository.save(session);
                    String sessionId = session.getId();
                    if (!isRequestedSessionIdValid()
                            || !sessionId.equals(getRequestedSessionId())) {
                        SessionRepositoryFilter.this.httpSessionIdResolver.setSessionId(this,
                                this.response, sessionId);
                    }
                }
    }

commitSession这个方法的作用就是当前Session存在则使用sessionRepository保存(可能是新Session)或更新(老Session则更新一下避免过期)Session。如果Session不存在并且isInvalidateClientSession()为true说明Session已过期调用httpSessionStrategy .onInvalidateSession(this, this.response);更新Cookie。

commitSession()方法还会在过滤器结束后调用,用来更新Session。

二、Spring Session机制实现

1、Spring Session 数据结构解读

使用 Spring Session 管理服务器的session信息,在Redis 中看到如下的session信息数据结构:

A) "spring:session:sessions:39feb101-87d4-42c7-ab53-ac6fe0d91925"

B) "spring:session:expirations:1523934840000"

C) "spring:session:sessions:expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"

为了统一叙述,在此将他们进行编号,后续简称为 A 类型键,B 类型键,C 类型键。先简单分析下他们的特点

  • 他们公用的前缀是 spring:session
  • A 类型键的组成是前缀 +”sessions”+sessionId,对应的值是一个 hash 数据结构。
{
    "lastAccessedTime": 1523933008926,/*2018/4/17 10:43:28*/
    "creationTime": 1523933008926, /*2018/4/17 10:43:28*/
    "maxInactiveInterval": 1800,
    "sessionAttr:name": "xu"
}

其中 creationTime(创建时间),lastAccessedTime(最后访问时间),maxInactiveInterval(session 失效的间隔时长) 等字段是系统字段,sessionAttr:xx 是HttpServletRequest.setAttribute("xxx","xxx")存入的,它可能会存在多个键值对,用户存放在 session 中的数据如数存放于此。A 类型键对应的默认 TTL 是 35 分钟。

  • B 类型键的组成是前缀 +”expirations”+ 时间戳。其对应的值是一个 set 数据结构,这个 set 数据结构中存储着一系列的 C 类型键。B 类型键对应的默认 TTL 是 30 分钟
[
    "expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"
    "expires:836d11aa-11e2-44e0-a0b2-92b54dec2324"
]
  • C 类型键的组成是前缀 +”sessions:expires”+sessionId,对应一个空值,它仅仅是 sessionId 在 redis 中的一个引用,具体作用后边介绍。C 类型键对应的默认 TTL 是 30 分钟。

2、设计 A类型键记录session信息

使用 redis 存储 session 数据,session 需要有过期机制,redis 的键可以自动过期,肯定很方便,但是,从 Spring Session 的数据结构我们可以看到, Spring Session 管理session数据使用了三种数据进行存储,为什么要如此设计呢?每个类型的数据都有什么作用呢?我们接下来就会逐一解释这三种数据的作用及用法。

我们可以想到,对 A 类型的键设置 ttl A 30 分钟,这样 30 分钟之后 session 过期,0-30 分钟期间如果用户持续操作,那就根据 sessionId 找到 A 类型的 key,刷新 lastAccessedTime 的值,并重新设置 ttl,这样就完成了「续签」的特性。

显然 Spring Session 没有采用如此简练的设计,为什么呢?我们通过阅读 Spring Session 的文档,得知,redis 的键过期机制不“保险”,这和 redis 的过期删除策略和内存淘汰策略有关,大致意思可以理解为:

  1. redis 在键实际过期之后不一定会被删除,可能会继续存留,但具体存留的时间我没有做过研究,可能是 1~2 分钟,可能会更久。
  2. 具有过期时间的 key 有两种方式来保证过期,一是这个键在过期的时候被访问了,二是后台运行一个定时任务自己删除过期的 key。划重点:这启发我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键。
  1. 如果没有指令持续关注 key,并且 redis 中存在许多与 TTL 关联的 key,则 key 真正被删除的时间将会有显著的延迟!显著的延迟!显著的延迟!

这里插一个题外话,简单介绍一下Redis的过期删除策略和内存淘汰策略

Redis的过期删除策略和内存淘汰策略

1、Redis关于过期时间的判定依据

在Redis内部,每当我们设置一个键的过期时间时,Redis就会将该键带上过期时间存放到一个过期字典中。当我们查询一个键时,Redis便首先检查该键是否存在过期字典中,如果存在,那就获取其过期时间。然后将过期时间和当前系统时间进行比对,比系统时间大,那就没有过期;反之判定该键过期。

2、过期删除策略

通常删除某个key,我们有如下三种方式进行处理。

①、定时删除

在设置某个key 的过期时间同时,我们创建一个定时器,让定时器在该过期时间到来时,立即执行对其进行删除的操作。

优点:定时删除对内存是最友好的,能够保存内存的key一旦过期就能立即从内存中删除。

缺点:对CPU最不友好,在过期键比较多的时候,删除过期键会占用一部分 CPU 时间,对服务器的响应时间和吞吐量造成影响。

②、惰性删除

设置该key 过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。

优点:对 CPU友好,我们只会在使用该键时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查。

缺点:对内存不友好,如果一个键已经过期,但是一直没有使用,那么该键就会一直存在内存中,如果数据库中有很多这种使用不到的过期键,这些键便永远不会被删除,内存永远不会释放。从而造成内存泄漏。

③、定期删除

每隔一段时间,我们就对一些key进行检查,删除里面过期的key。

优点:可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。另外定期删除,也能有效释放过期键占用的内存。

缺点:难以确定删除操作执行的时长和频率。

如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好。

如果执行的太少,那又和惰性删除一样了,过期键占用的内存不会及时得到释放。

另外最重要的是,在获取某个键时,如果某个键的过期时间已经到了,但是还没执行定期删除,那么就会返回这个键的值,这是业务不能忍受的错误。

Redis采用的过期删除策略

前面讨论了删除过期键的三种策略,发现单一使用某一策略都不能满足实际需求,聪明的你可能想到了,既然单一策略不能满足,那就组合来使用吧。

没错,Redis的过期删除策略就是:惰性删除和定期删除两种策略配合使用

惰性删除:Redis的惰性删除策略由 db.c/expireIfNeeded 函数实现,所有键读写命令执行之前都会调用 expireIfNeeded 函数对其进行检查,如果过期,则删除该键,然后执行键不存在的操作;未过期则不作操作,继续执行原有的命令。

定期删除:由redis.c/activeExpireCycle 函数实现,函数以一定的频率运行,每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。

注意:并不是一次运行就检查所有的库,所有的键,而是随机检查一定数量的键。

定期删除函数的运行频率,在Redis2.6版本中,规定每秒运行10次,大概100ms运行一次。在Redis2.8版本后,可以通过修改配置文件redis.conf 的 hz 选项来调整这个次数。

3、内存淘汰策略

①、设置Redis最大内存

在配置文件redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大内存。不设定该参数默认是无限制的,但是通常会设定其为物理内存的四分之三

②、设置内存淘汰方式

当现有内存大于 maxmemory 时,便会触发redis主动淘汰内存方式,通过设置 maxmemory-policy, 在redis.conf 配置文件中,可以设置淘汰方式,默认方式为:noeviction 不移除任何key,只是返回一个写错误

小结:Redis过期删除策略是采用惰性删除和定期删除这两种方式组合进行的,惰性删除能够保证过期的数据我们在获取时一定获取不到,而定期删除设置合适的频率,则可以保证无效的数据及时得到释放,而不会一直占用内存数据。但是我们说Redis是部署在物理机上的,内存不可能无限扩充的,当内存达到我们设定的界限后,便自动触发Redis内存淘汰策略,而具体的策略方式要根据实际业务情况进行选取。

我们可以看出,单纯依赖于 redis 的过期时间是不可靠的,所以Spring Session又进行了第二步的设计。

3、引入 B 类型键确保session的过期机制

如果Redis的过期删除策略不能确保过期的key立刻就被删除,那么为什么不再设计一个后台定时任务,定时去删除那些过期的键,配合上 redis 的自动过期,这样可以双重保险?但是,第一个问题来了,我们将这些过期键存在哪儿呢?不找个合适的地方存起来,定时任务到哪儿去删除这些应该过期的键呢?总不能扫描全库吧!所以,Spring Session引入了 B 类型键。

spring:session:expirations:1523934840000

这里的1523934840000 这明显是个 Unix 时间戳,它的含义是存放着这一分钟内应该过期的键,所以它是一个 set 数据结构。还记得 lastAccessedTime=1523933008926,maxInactiveInterval=1800 吧,lastAccessedTime 转换成北京时间是: 2018/4/17 10:43:28,向上取整是 2018/4/17 10:44:00,再次转换为 Unix 时间戳得到 1523932980000,单位是 ms,1800 是过期时间的间隔,单位是 s,二者相加 1523932980000+1800*1000=1523934840000。这样 B 类型键便作为了一个「桶」,存放着这一分钟应当过期的 session 的 key。

后台定时任务

org.springframework.session.data.redis.RedisSessionExpirationPolicy#cleanupExpiredSessions

@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
   this.expirationPolicy.cleanExpiredSessions();
}

后台提供了定时任务去“删除”过期的 key,来补偿 redis 到期未删除的 key。即:取得当前时间的时间戳作为 key,去 redis 中定位到 spring:session:expirations:{当前时间戳} ,这个 set 里面存放的便是所有过期的 key 了。

续签的影响

每次 session 的续签,需要将旧桶中的数据移除,放到新桶中。验证这一点很容易。

在第一分钟访问一次 http://localhost:8080/helloworld 端点,得到的 B 类型键为:spring:session:expirations:1523934840000;第二分钟再访问一次 http://localhost:8080/helloworld 端点,A 类型键的 lastAccessedTime 得到更新,并且 spring:session:expirations:1523934840000 这个桶被删除了,新增了 spring:session:expirations:1523934900000 这个桶。当众多用户活跃时,桶的增删和以及 set 中数据的增删都是很频繁的。对了,没提到的一点,对应 key 的 ttl 时间也会被更新。

B 类型键的并发问题

目前为止使用了 A 类型键和 B 类型键解决了 session 存储和 redis 键到期不删除的两个问题,但还是存在问题的。引入 B 类型键看似解决了问题,却也引入了一个新的问题:并发问题。

想象这样一个场景:用户在浏览器连续点击多次,形成多个线程,线程1和线程2,

  • 线程 1 在第 2 分钟请求,产生了续签,session:1 应当从 1420656360000 这个桶移动到 142065642000 这个桶
  • 线程 2 在第 3 分钟请求,也产生了续签,session:1 本应当从 1420656360000 这个桶移动到 142065648000 这个桶
  • 如果上两步按照次序执行,自然不会有问题。但第 3 分钟的请求可能已经执行完毕了,第 2 分钟才刚开始执行。

后台定时任务会在第 32 分钟扫描到 spring:session:expirations:1420656420000 桶中存在的 session,这意味着,本应该在第 33 分钟才会过期的 key,在第 32 分钟就会被删除!一种简单的方法是用户的每次 session 续期加上分布式锁,这显然不能被接受。来看看 Spring Session 是怎么巧妙地应对这个并发问题的。

public void cleanExpiredSessions() {
   long now = System.currentTimeMillis();
   long prevMin = roundDownMinute(now);

   if (logger.isDebugEnabled()) {
      logger.debug("Cleaning up sessions expiring at" + new Date(prevMin));
   }
   // 获取到 B 类型键
   String expirationKey = getExpirationKey(prevMin);
   // 取出当前这一分钟应当过期的 session
   Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
   // 注意:这里删除的是 B 类型键,不是删除 session 本身!
   this.redis.delete(expirationKey);
   for (Object session : sessionsToExpire) {
      String sessionKey = getSessionKey((String) session);
      // 遍历一下 C 类型的键
      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
   this.redis.hasKey(key);
}

这里面逻辑主要是拿到过期键的集合(实际上是 C 类型的 key,但这里可以理解为 sessionId,C 类型我下面会介绍),此时这个集合里面存在三种类型的 sessionId。

  1. 已经被 redis 删除的过期键。万事大吉,redis 很靠谱的及时清理了过期的键。
  2. 已经过期,但是还没来得及被 redis 清除的 key。还记得前面 redis 文档里面提到的一个技巧吗?我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键,所以 redis.hasKey(key); 该操作就是为了触发 redis 的自己删除。
  1. 并发问题导致的多余数据,实际上并未过期。如上所述,第 32 分钟的桶里面存在的 session:1 实际上并不应该被删除,使用 touch 的好处便是我只负责检测,删不删交给 redis 判断。session:1 在第 32 分钟被 touch 了一次,并未被删除,在第 33 分钟时应当被 redis 删除,但可能存在延时,这个时候 touch 一次,确保删除。所以,源码里面特别强调了一下:要用 touch 去触发 key 的删除,而不能直接 del key。

参考 https://github.com/spring-projects/spring-session/issues/93

4、增加 C 类型键完善过期通知事件

虽然引入了 B 类型键,并且在后台加了定时器去确保 session 的过期,但似乎还是不够完善。注意一个细节,spring-session 中 A 类型键的过期时间是 35 分钟,比实际的 30 分钟多了 5 分钟,这意味着即便 session 已经过期,我们还是可以在 redis 中有 5 分钟间隔来操作过期的 session。于此同时,spring-session 引入了 C 类型键来作为 session 的引用。

为什么引入 C 类型键?redis只会告诉我们哪个键过期了,不会告诉我们内容是什么。关键就在于如果 session 过期后监听器可能想要访问 session 的具体内容,然而自身都过期了,还怎么获取内容 。所以,C 类型键存在的意义便是解耦 session 的存储和 session 的过期,并且使得 server 获取到过期通知后可以访问到 session 真实的值。对于用户来说,C 类型键过期后,意味着登录失效,而对于服务端而言,真正的过期其实是 A 类型键过期,这中间会有 5 分钟的误差。

 类似资料: