1.1 得益于HttpSession和HttpServletRequest都是接口,这意味着我们可以提供自己的实现类来重写其中的方法,Spring session正是利用这个特性实现的。
首先是实现了HttpServletRequest的SessionRepositoryRequestWrapper类,伪代码如下
public class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper { public SessionRepositoryRequestWrapper(HttpServletRequest original) { super(original); } public HttpSessionWrapper getSession(String sessionId) { // 调用sessionRepository获取存储具体介质中存储的session,并封装成自定义类的实现了HttpSession的类 sessionRepository.getSession(sessionId); } public HttpSessionWrapper getSession(boolean createNew) { // create an HttpSession implementation from Spring Session } void commitSession() { // 调用sessionRepository将session信息存储到具体的介质中 sessionRepository.save(session) } // ... other methods delegate to the original HttpServletRequest ... }
实现HttpSession的类HttpSessionWrapper,此类内部包含一个具体的Spring session实现类,并将调用HttpSession的方法都委托给这个具体的Spring session实现
伪代码如下
class HttpSessionWrapper extends ExpiringSessionHttpSession{ }
class ExpiringSessionHttpSessionimplements HttpSession { ExpiringSessionHttpSession(S session, ServletContext servletContext) { this.session = session; this.servletContext = servletContext; } // 获取id委托给spring session的具体实现 public String getId() { return this.session.getId(); } // 获取最后访问时间委托给spring session的具体实现 public long getLastAccessedTime() { checkState(); return this.session.getLastAccessedTime(); } // 其他需要实现的方法 }
1.2 通过SessionRepositoryFilter将HttpServletRequest替换成我们自己的SessionRepositoryRequestWrapper类
伪代码如下
public class SessionRepositoryFilter implements Filter { public doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest httpRequest = (HttpServletRequest) request; // 替换成我们自己的实现,后续的filter、及业务逻辑再调用HttpServletRequest.getSession就会调用我们自己的实现 SessionRepositoryRequestWrapper customRequest = new SessionRepositoryRequestWrapper(httpRequest); try{ chain.doFilter(customRequest, response, chain); } finally { // 将变更后的session更新到redis中 } } // ... }
因为HttpServletRequest在这个filter中被替换,所以SessionRepositoryFilter必须要在所有需要获取session的filter前面执行。
2.UML图
3.Session在redis中的存储结构
在介绍Spring session在redis的存储结构之前,先简单介绍spring session中利用的几个redis特性
- 在redis中可以给每个key设定过期时间(TTL)
- 通过配置,可以让redis在发生key的删除、过期、新增等事件时通知订阅者(具体可参考 Notifications
spring session 一个sessionId默认情况下会在redis形成三个键,如下:
key | 数据结构 | 存储数据 | 默认过期时间 |
spring:session:sessions:{sessionId} | hash | 具体的session信息包括creationTime、maxInactiveInterval、lastAccessedTime以及sessionAttr信息 | 2100 |
spring:session:sessions:expires:{sessionId} | String | sessionId的一个引用,对应的value是空值 | 1800 |
spring:session:expirations:{时间戳} | Set | 存储对应时间戳过期的sessionId引用 | 2100 |
现在来说明下问什么spring要这样设计对应的存储。
3.1 一般来讲,我们利用session,主要逻辑就是第一次访问时产生一个session,一段时间内不访问(默认30分钟),session失效。 这样利用redis提供的key实效功能,我们只用spring:session:sessions:{sessionId}貌似就可以实现这个功能,为什么spring不这样做呢,按照spring官方的说法
引用
One problem with relying on Redis expiration exclusively is that Redis makes no guarantee of when the expired event will be fired if the key has not been accessed. Specifically the background task that Redis uses to clean up expired keys is a low priority task and may not trigger the key expiration. For additional details see Timing of expired events section in the Redis documentation.
大概意思就是在redis中虽然一个key过期了,但是redis并不能保证这个key的过期事件会被触发,除非我们明确的访问下这个key。因为在redis中清理过期key是一个低优先级的任务。
实际上redis清理key的任务不仅优先级低,并且每次清理时也不会查看所有的key来删除过期数据。
所以,我们不能单纯依赖redis,只设计一个key就完成session的存储和过期。那spring怎么做的呢,下面来看下spring从redis中获取session的代码段
private RedisSession getSession(String id, boolean allowExpired) { Map
就是获取session,如果能获取到,还要再执行一次判断,如果是过期session也不返回。
3.2 前面说过,spring:session:sessions:{sessionId}key对应的ttl时间是2100,但是spring session的默认过期时间是1800(30分钟),为什么ttl的时间要比过期时间多5分钟呢,这个主要是考虑到如果业务需要在session过期后做一些清理操作,就必须在session过期后还能获取到session信息,所以session的时间存储时间多了5分钟。那spring是怎么获取到那些session过期了呢,这里用到了redis提供的另一个功能Notifications,具体配置在ConfigureNotifyKeyspaceEventsAction这个类中。具体的实现则引入了第二个key【spring:session:sessions:expires:{sessionId}】,这个key只是真正的session的一个饮用,在spring创建session时,同步的创建,这个key的ttl时间正是真正的spring session过期时间1800,这个key过期时会给订阅者发送如下一个事件消息
spring 在获取这个消息后会发布SessionExpiredEvent事件,具体代码如下
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); if (channel.startsWith(getSessionCreatedChannelPrefix())) { // TODO: is this thread safe? Map
这样如果我们业务如果需要做些清理工作,在收到SessionExpiredEvent(SessionDestroyedEvent)事件后可以根据相应的session信息做对应的清理工作。
3.3 类似于session的过期不能完全依赖redis一样,spring:session:sessions:expires:{sessionId}的过期也不能依赖redis,那spring是怎么做的呢,这个就是第三个key spring:session:expirations:{时间戳}的功能了。spring生成session后、或者更新session的最后访问时间后,都会根据session的存活时间算出一个时间戳,就是这个session在那一分钟过期,然后把【spring:session:sessions:expires:{sessionId}】这个key 存到对应的spring:session:expirations:{时间戳}中,如果更新了一个session,会同步的把这个key从旧的spring:session:expirations:{时间戳}中移除,然后追加到新计算出来的spring:session:expirations:{时间戳}中。具体代码如下
public void onExpirationUpdated(Long originalExpirationTimeInMilli, ExpiringSession session) { String keyToExpire = "expires:" + session.getId(); long toExpire = roundUpToNextMinute(expiresInMillis(session)); if (originalExpirationTimeInMilli != null) { long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli); if (toExpire != originalRoundedUp) { // 从旧的里面移除 String expireKey = getExpirationKey(originalRoundedUp); this.redis.boundSetOps(expireKey).remove(keyToExpire); } } long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds(); String sessionKey = getSessionKey(keyToExpire); if (sessionExpireInSeconds < 0) { this.redis.boundValueOps(sessionKey).append(""); this.redis.boundValueOps(sessionKey).persist(); this.redis.boundHashOps(getSessionKey(session.getId())).persist(); return; } String expireKey = getExpirationKey(toExpire); BoundSetOperationsexpireOperations = this.redis .boundSetOps(expireKey); // 加入到新的里面 expireOperations.add(keyToExpire); long fiveMinutesAfterExpires = sessionExpireInSeconds + TimeUnit.MINUTES.toSeconds(5); expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS); if (sessionExpireInSeconds == 0) { this.redis.delete(sessionKey); } else { this.redis.boundValueOps(sessionKey).append(""); // 更新对应key的过期时间 this.redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds, TimeUnit.SECONDS); } this.redis.boundHashOps(getSessionKey(session.getId())) .expire(fiveMinutesAfterExpires, TimeUnit.SECONDS); }
然后spring又启动了一个定时任务,每分钟执行一次,将这一分钟过期的key【spring:session:expirations:{时间戳}】中所有的key都删除
// 启动定时任务,每分钟执行一次 @Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}") public void cleanupExpiredSessions() { this.expirationPolicy.cleanExpiredSessions(); }
public void cleanExpiredSessions() { long now = System.currentTimeMillis(); long prevMin = roundDownMinute(now); if (logger.isDebugEnabled()) { logger.debug("Cleaning up sessions expiring at " + new Date(prevMin)); } String expirationKey = getExpirationKey(prevMin); SetsessionsToExpire = this.redis.boundSetOps(expirationKey).members(); // 清除过期的key this.redis.delete(expirationKey); for (Object session : sessionsToExpire) { // 将过期的session清除掉 String sessionKey = getSessionKey((String) session); touch(sessionKey); } }
此处还有一个小窍门,因为在如下场景下会出现对session的并发更新
请求1开始访问,设定的session最后访问时间是100
请求2开始访问,设定session的最后访问时间是200
正常情况下 这个session的会被放到spring:session:expirations:200这个set中,但是现在因为请求1响应太慢,在请求2结束后才真正结束,此时会导致 这个session对应的espire信息在两个set中都有。然后时间到达100对应的set开始清除,我们就删除了这个session信息,而实际上这个session还没有到达严格意义上的过期,所以spring 在清除这个key时,不是直接删除,而只是访问了一下这个key,我们之前也说过,一个key如果过期后,只要以任何形式访问一下,redis就会自己把这个过期key删除。具体代码
/** * 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) { this.redis.hasKey(key); }