写在前面
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 依赖
(2) 第二步,配置 redis
在 application.properties 中配置 redis
(3) 第三步,编写一个配置类
用来启用 RedisHttpSession 功能,并向 Spring 容器中注册一个 RedisConnectionFactory,同时将 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 的注册,代码如下:
这里我们可以看到,注册这个 filter 时需要一个 SessionRepository 参数,那么,这个参数又是从哪里来的呢?
在 SpringHttpSessionConfiguration 的继承类 RedisHttpSessionConfiguration 中,我们找到了 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 对象。
SessionRepositoryRequestWrapper 类
重写了原生的 getSession 方法。
上面有一点需要注意就是将 Sesison 对象包装成了 HttpSessionWrapper,目的是当 Session 失效时可以从 sessionRepository 删除。
这里重写了 getSession 方法,也就是为什么每当执行 HttpServletRequest 执行. getSession() 方法后就会刷新 session 的过期时间。
到这里,我们了解了 SessionRepositoryRequestWrapper 类接管 Http Session 并重写了 getSession 来实现了 session 的管理,那么,session 数据又是怎么存放到 redis 中的呢?
SessionRepository 保存 session 数据
上边,我们看到 SessionRepositoryFilter 的 doFilterInternal 方法最后有一个 finally 中执行了 wrappedRequest.commitSession(); 方法,这里就是保存 session 数据到 redis。
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 类型键,B 类型键,C 类型键。先简单分析下他们的特点
- 他们公用的前缀是 spring:session
- A 类型键的组成是前缀 +”sessions”+sessionId,对应的值是一个 hash 数据结构。
其中 creationTime(创建时间),lastAccessedTime(最后访问时间),maxInactiveInterval(session 失效的间隔时长) 等字段是系统字段,sessionAttr:xx 是 HttpServletRequest.setAttribute(“xxx”,“xxx”) 存入的,它可能会存在多个键值对,用户存放在 session 中的数据如数存放于此。A 类型键对应的默认 TTL 是 35 分钟。
- B 类型键的组成是前缀 +”expirations”+ 时间戳。其对应的值是一个 set 数据结构,这个 set 数据结构中存储着一系列的 C 类型键。B 类型键对应的默认 TTL 是 30 分钟
- 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 的过期删除策略和内存淘汰策略有关,大致意思可以理解为:
-
redis 在键实际过期之后不一定会被删除,可能会继续存留,但具体存留的时间我没有做过研究,可能是 1~2 分钟,可能会更久。
-
具有过期时间的 key 有两种方式来保证过期,一是这个键在过期的时候被访问了,二是后台运行一个定时任务自己删除过期的 key。划重点:这启发我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键。
-
如果没有指令持续关注 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 类型键。
这里的 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
后台提供了定时任务去 “删除” 过期的 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 是怎么巧妙地应对这个并发问题的。
这里面逻辑主要是拿到过期键的集合(实际上是 C 类型的 key,但这里可以理解为 sessionId,C 类型我下面会介绍),此时这个集合里面存在三种类型的 sessionId。
-
已经被 redis 删除的过期键。万事大吉,redis 很靠谱的及时清理了过期的键。
-
已经过期,但是还没来得及被 redis 清除的 key。还记得前面 redis 文档里面提到的一个技巧吗?我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键,所以 redis.hasKey(key); 该操作就是为了触发 redis 的自己删除。
-
并发问题导致的多余数据,实际上并未过期。如上所述,第 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 分钟的误差。