Spring Security 6.x 集成Spring Session实现分布式会话共享

2024-08-04 23:12:26 浏览数 (3)

在上一篇详细介绍了Spring Security中基于Session的认证管理机制,其中大部分操作都是建立在Tomcat容器内部面向内存的Session管理能力之上,但在分布式环境中,通常不会将Session维护在Servlet容器的内存中,多个容器之间需要实现Session共享,其解决方案也有不少,例如IP绑定,Session同步等,这些方案在架构层面缺乏灵活性和扩展性,其实从本质上来说,问题的根本在于Session和容器之间的耦合问题,那么自然就会想到将Session从容器中分离出来,存储在诸如数据库,redis,MongoDB等第三方中间件中,不过会带来基本的网络通信成本,为了在一定程度上弥补性能上的损失,大多数情况会选择Redis作为存储Session的中间件,基于这个思路,Spring也提供了一套通用的分布式会话共享框架,即Spring Session,本文主要介绍如何整合和使用Spring Security和Spring Session这两个框架,以及一些背后的基本原理。

一、基本配置

1.1 引入依赖

跟前几篇相似,Spring Boot使用3.3.0版本,然后添加相关依赖,由于已经在spring-boot-dependencies中声明过,这里直接引入spring-session-data-redis和spring-boot-starter-data-redis即可

代码语言:java复制
<properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <spring-boot.version>3.3.0</spring-boot.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>


<dependencies>
    ...
    <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>
</dependencies>

1.2 添加配置

在Spring Session提供两个SessionRepository实现,默认实现为RedisSessionRepository,它的逻辑比较简单,在redis中只维护了一个key,即spring:session:sessions:{sessionId},因此它只能提供findBySessionId等基础功能,另一个实现是RedisIndexedSessionRepository,相比之下功能更加强大,这里我们选择使用RedisIndexedSessionRepository(下一节说明其实现细节)

如果使用Spring Boot框架,则无需手动添加@EnableRedisHttpSession,仅需要在配置文件中添加redis配置即可,并指定repository-type为indexed,这样就会自动注入RedisIndexedSessionRepository的实例。

代码语言:yaml复制
spring:
  session:
    redis:
      repository-type: indexed
    timeout: 3600 # session过期时间(单位为秒),默认是30分钟,这里调整为1个小时
  data:
    redis:
      host: localhost
      port: 6379

1.3 整合Spring Security

Spring Session与Spring Security的整合主要是通过SessionRegistry接口实现的,我们需要使用Spring Session自动注入的RedisIndexedSessionRepository,并用它来创建一个SpringSessionBackedSessionRegistry的Bean对象,用于将SessionRegistry默认实现SessionRegistryImpl替换为SpringSessionBackedSessionRegistry

代码语言:java复制
@Configuration
public class SpringSessionConfiguration {

    @Bean
    public SessionRegistry sessionRegistry(RedisIndexedSessionRepository sessionRepository) {
        return new SpringSessionBackedSessionRegistry<>(sessionRepository);
    }

    @Bean
    public SpringSessionRememberMeServices rememberMeServices() {
        SpringSessionRememberMeServices rememberMeServices = new SpringSessionRememberMeServices();
        rememberMeServices.setAlwaysRemember(true);
        return rememberMeServices;
    }
}

在上一篇介绍过,在Session并发控制时需要依赖Sessionregistry对Session进行维护,因此这里对SessionManagement DSL进行配置,将上述sessionRegistry实例注入进来,另外,Spring Session还提供了RememberMeServices的实现类SpringSessionRememberMeServices,可以用于在RememberMeAuthenticationFilter这个过滤器中替换默认实现TokenBasedRememberMeServices,它实现RememberMe的方式非常简单,即将Redis中存储的Session过期时间调整为默认的30天,其源码也比较简单,这里就不贴了。在实际生产中,可以根据需要确定是否启用Spring Session提供的组件。

至于其他配置,依然使用在《微信公众平台OAuth2授权实战》一文中给出的代码。

代码语言:java复制
@Slf4j
@EnableWebSecurity
@Configuration
public class SpringSecurityConfiguration {

    @Resource
    private ClientRegistrationRepository clientRegistrationRepository;

    @Resource
    private SessionRegistry sessionRegistry;
    
    @Resource
    private SpringSessionRememberMeServices rememberMeServices;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 注入自定义OAuth2AuthorizationRequestResolver对象
        http.oauth2Login(oauth2 -> oauth2
                .authorizationEndpoint(authorization -> authorization.authorizationRequestResolver(authorizationRequestResolver(clientRegistrationRepository)))
                .tokenEndpoint(token -> token.accessTokenResponseClient(accessTokenResponseClient()))
                .userInfoEndpoint(userInfo -> userInfo.userService(userService()))
        );
        // 添加Session管理配置,并注入SessionRegistry
        http.sessionManagement(session -> session.sessionConcurrency(concurrency -> concurrency.maximumSessions(1).sessionRegistry(sessionRegistry)));
        // 添加rememberMe配置,并注入SpringSessionRememberMeServices
        http.rememberMe(rememberMe->rememberMe.rememberMeServices(rememberMeServices));

        DefaultSecurityFilterChain filterChain = http.build();
        filterChain.getFilters().stream().map(Object::toString).forEach(log::info);
        return filterChain;
    }
    ...
}

二、测试验证

为了简单验证一下Spring Session的效果,在本机上直接使用docker拉起一个redis服务

代码语言:shell复制
docker run -p 6379:6379 --name redis redis

启动程序,然后打开微信开发者工具,访问授权端点,在完成微信公众平台OAuth2认证之后,然后查看一下Redis存储的情况。

代码语言:bash复制
127.0.0.1:6379> keys *
1) "spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:xe6x9dxa8xe6xb4x8b.AIxf0x9fx90xb3"
2) "spring:session:expirations:1722094320000"
3) "spring:session:sessions:expires:19460f32-2d12-4339-be42-254b641989b4"
4) "spring:session:sessions:19460f32-2d12-4339-be42-254b641989b4"

127.0.0.1:6379> hgetAll spring:session:sessions:19460f32-2d12-4339-be42-254b641989b4
 1) "creationTime"
 2) "xacxedx00x05srx00x0ejava.lang.Long;x8bxe4x90xccx8f#xdfx02x00x01Jx00x05valuexrx00x10java.lang.Numberx86xacx95x1dx0bx94xe0x8bx02x00x00xpx00x00x01x90ZSx16xa9"
 3) "maxInactiveInterval"
 4) "xacxedx00x05srx00x11java.lang.Integerx12xe2xa0xa4xf7x81x878x02x00x01Ix00x05valuexrx00x10java.lang.Numberx86xacx95x1dx0bx94xe0x8bx02x00x00xpx00'x8dx00"
 5) "sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST"
 6) ""
 7) "sessionAttr:SPRING_SECURITY_CONTEXT"
 8) "xacxedx00x05srx00=org.springframework.security.core.context.SecurityContextImplx00x00x00x00x00x00x02lx02x00x01Lx00x0eauthenticationtx002Lorg/springframework/security/core/Authentication;xpsrx00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationTokenx00x00x00x00x00x00x02lx02x00x02Lx00x1eauthorizedClientRegistrationIdtx00x12Ljava/lang/String;Lx00tprincipaltx00:Lorg/springframework/security/oauth2/core/user/OAuth2User;xrx00Gorg.springframework.security.authentication.AbstractAuthenticationTokenxd3xaa(~nGdx0ex02x00x03Zx00rauthenticatedLx00x0bauthoritiestx00x16Ljava/util/Collection;Lx00adetailstx00x12Ljava/lang/Object;xpx01srx00&java.util.Collections$UnmodifiableListxfcx0f%1xb5xecx8ex10x02x00x01Lx00x04listtx00x10Ljava/util/List;xrx00,java.util.Collections$UnmodifiableCollectionx19Bx00x80xcb^xf7x1ex02x00x01Lx00x01cqx00~x00axpsrx00x13java.util.ArrayListxx81xd2x1dx99xc7ax9dx03x00x01Ix00x04sizexpx00x00x00x02wx04x00x00x00x02srx00Aorg.springframework.security.oauth2.core.user.OAuth2UserAuthorityx00x00x00x00x00x00x02lx02x00x02Lx00nattributestx00x0fLjava/util/Map;Lx00tauthorityqx00~x00x04xpsrx00%java.util.Collections$UnmodifiableMapxf1xa5xa8xfetxf5aBx02x00x01Lx00x01mqx00~x00x11xpsrx00x17java.util.LinkedHashMap4xc0N\x10lxc0xfbx02x00x01Zx00x0baccessOrderxrx00x11java.util.HashMapx05axdaxc1xc3x16`xd1x03x00x02Fx00nloadFactorIx00tthresholdxp?@x00x00x00x00x00x0cwbx00x00x00x10x00x00x00ttx00x06openidtx00x1coS1mP6PYpk_AFGB7sNeKgX4U3Cc4tx00bnicknametx00x0fxe6x9dxa8xe6xb4x8b.AIxedxa0xbdxedxb0xb3tx00x03sexsrx00x11java.lang.Integerx12xe2xa0xa4xf7x81x878x02x00x01Ix00x05valuexrx00x10java.lang.Numberx86xacx95x1dx0bx94xe0x8bx02x00x00xpx00x00x00x00tx00blanguagetx00x00tx00x04cityqx00~x00!tx00bprovinceqx00~x00!tx00acountryqx00~x00!tx00nheadimgurltx00x82https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTJGp0zryiaomEIJC5cRpwBPezkqWBtHOHV2k9pgwuv78ibC7aZlsKZ3P8rgx3aLH5uwc2Fnb3JpTy2A/132tx00tprivilegesqx00~x00x0ex00x00x00x00wx04x00x00x00x00xxx00tx00x0bOAUTH2_USERsrx00Borg.springframework.security.core.authority.SimpleGrantedAuthorityx00x00x00x00x00x00x02lx02x00x01Lx00x04roleqx00~x00x04xptx00x15SCOPE_snsapi_userinfoxqx00~x00x0fsrx00Horg.springframework.security.web.authentication.WebAuthenticationDetailsx00x00x00x00x00x00x02lx02x00x02Lx00rremoteAddressqx00~x00x04Lx00tsessionIdqx00~x00x04xptx00t127.0.0.1tx00$21ec4803-3ff5-413d-84cd-1d5ffa27c388tx00x06wechatsrx00?org.springframework.security.oauth2.core.user.DefaultOAuth2Userx00x00x00x00x00x00x02lx02x00x03Lx00nattributesqx00~x00x11Lx00x0bauthoritiestx00x0fLjava/util/Set;Lx00x10nameAttributeKeyqx00~x00x04xpsqx00~x00x13sqx00~x00x15?@x00x00x00x00x00x0cwbx00x00x00x10x00x00x00tqx00~x00x18qx00~x00x19qx00~x00x1aqx00~x00x1bqx00~x00x1cqx00~x00x1fqx00~x00 qx00~x00!qx00~x00"qx00~x00!qx00~x00#qx00~x00!qx00~x00$qx00~x00!qx00~x00%qx00~x00&qx00~x00'qx00~x00(xx00srx00%java.util.Collections$UnmodifiableSetx80x1dx92xd1x8fx9bx80Ux02x00x00xqx00~x00x0csrx00x17java.util.LinkedHashSetxd8lxd7Zx95xdd*x1ex02x00x00xrx00x11java.util.HashSetxbaDx85x95x96xb8xb74x03x00x00xpwx0cx00x00x00x10?@x00x00x00x00x00x02qx00~x00x12qx00~x00 xtx00bnickname"
 9) "lastAccessedTime"
10) "xacxedx00x05srx00x0ejava.lang.Long;x8bxe4x90xccx8f#xdfx02x00x01Jx00x05valuexrx00x10java.lang.Numberx86xacx95x1dx0bx94xe0x8bx02x00x00xpx00x00x01x90ZS$xd9"
11) "sessionAttr:SPRING_SECURITY_LAST_EXCEPTION"
12) ""

可以看到,此时Redis一共保存了4个相关的Key,其中Key为“spring:session:sessions:19460f32-2d12-4339-be42-254b641989b4”的Hash表就是用来保存Session对象的,其中“sessionAttr:SPRING_SECURITY_CONTEXT”字段就是SecurityContext对象的序列化信息,这表明已认证的SecurityContext被写入到了Session,并成功地完成了持久化。

三、实现原理

3.1 “狸猫换太子”

Spring Session的实现原理并不复杂,本质上只要想要办法实现一个HttpServletRequest接口,将其中涉及session的方法都使用Redis的操作实现,然后在整个请求的执行过程中,替换默认的HttpServletRequest实例,替换为新实例即可,而在Java中本身就提供一种装饰器模式的实现方案,即HttpServletRequestWrapper,开发者可以通过继承该类以扩展HttpServletRequest的各类操作,Spring Session为此提供了一个SessionRepositoryRequestWrapper作为HttpServletRequestWrapper的子类,它重写了changeSessionId,getSession等多个Session操作的相关方法,下面具体看一下getSession方法的源码

代码语言:java复制
private final class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper {
    ...
    public HttpSessionWrapper getSession(boolean create) {
        HttpSessionWrapper currentSession = getCurrentSession(); // HttpSessionWrapper也是私有内部类,这里其实是从HttpServletRequest的Attribute属性中获取HttpSessionWrapper的实例,相当于利用Request属性中作为缓存
        if (currentSession != null) {
           return currentSession;
        }
        S requestedSession = getRequestedSession(); // 该方法从Cookie中获取到SESSION的值,即sessionId,然后通过SessionRepository查询对应的Session对象,并赋值给SessionRepositoryRequestWrapper对象内的requestedSession,作为缓存
        if (requestedSession != null) {
           if (getAttribute(INVALID_SESSION_ID_ATTR) == null) { // 检查合法标识
              requestedSession.setLastAccessedTime(Instant.now()); // 更新最近访问时间
              this.requestedSessionIdValid = true;
              currentSession = new HttpSessionWrapper(requestedSession, getServletContext());
              currentSession.markNotNew();
              setCurrentSession(currentSession); // 创建一个新的HttpSessionWrapper包装对象,写入到HttpServletRequest的attribute中缓存起来
              return currentSession;
           }
        }
        else {
           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;
        }
        ...
        S session = SessionRepositoryFilter.this.sessionRepository.createSession(); // 创建一个新的Session对象
        session.setLastAccessedTime(Instant.now());
        currentSession = new HttpSessionWrapper(session, getServletContext());
        setCurrentSession(currentSession); // 同上
        return currentSession;
    }
    
    private S getRequestedSession() {
        if (!this.requestedSessionCached) {
           List<String> sessionIds = SessionRepositoryFilter.this.httpSessionIdResolver.resolveSessionIds(this);
           for (String sessionId : sessionIds) {
              if (this.requestedSessionId == null) {
                 this.requestedSessionId = sessionId;
              }
              S session = SessionRepositoryFilter.this.sessionRepository.findById(sessionId);
              if (session != null) {
                 this.requestedSession = session;
                 break;
              }
           }
           this.requestedSessionCached = true;
        }
        return this.requestedSession;
    }
    ...
}

可以看到,这里为Session使用了二级缓存,第一级是在HttpServletRequest的attribute属性,第二级是SessionRepositoryRequestWrapper内的requestedSession成员变量,如果在这两个地方都没有的话,则会使用sessionRepository创建一个新的Session,并更新到第一级缓存中。

再看一下commitSession方法,该方法主要负责将本次请求的session进行持久化,如果将当前session已被失效,例如调用了HttpSession#invalidate方法,那么这里会将Cookie中的SESSION值置为空字符串,如果当前session正常,则通过sessionRepository写入Redis中,同时,如果SESSION的cookie为空,或者sessionId已变更,则需要更新cookie值。

代码语言:java复制
private void commitSession() {
    HttpSessionWrapper wrappedSession = getCurrentSession();
    if (wrappedSession == null) {
       if (isInvalidateClientSession()) {
          SessionRepositoryFilter.this.httpSessionIdResolver.expireSession(this, this.response); // 设置SESSION的Cookie值为""
       }
    }
    else {
       S session = wrappedSession.getSession();
       String requestedSessionId = getRequestedSessionId();
       clearRequestedSessionCache();
       SessionRepositoryFilter.this.sessionRepository.save(session);
       String sessionId = session.getId();
       if (!isRequestedSessionIdValid() || !sessionId.equals(requestedSessionId)) { // isRequestedSessionIdValid 该方法判断从Cookie中是否能取到session,如不能取到,则需要设置Cookie值
          SessionRepositoryFilter.this.httpSessionIdResolver.setSessionId(this, this.response, sessionId);
       }
    }
}

那么有了SessionRepositoryRequestWrapper之后,那么如何实现“狸猫换太子”,替换掉原来默认的HttpServletRequest?其实也很简单,在引入Spring Session之后,会自动注册一个SessionRepositoryFilter的过滤器,它是Spring Session框架提供的一个最重要的Filter,在整个Servlet的FilterChain中的优先级排在第二位,仅次于CharacterEncodingFilter,其核心作用就是使用SessionRepositoryRequestWrapper对HttpServletRequest进行包装,使用SessionRepositoryResponseWrapper对HttpServletResponse进行包装,使得后续所有Session的操作都使用包装类提供的方法,另外它还负责在整个FilterChain退出时执行commitSession方法,将Session写入Redis的逻辑,源码如下

代码语言:java复制
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);
    SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest, response);

    try {
       filterChain.doFilter(wrappedRequest, wrappedResponse); // 在filterChain中传递包装类
    }
    finally {
       wrappedRequest.commitSession(); // 写入Session
    }
}

3.2 RedisIndexedSessionRepository的过期策略

上文提到过,在Spring Session框中,SessionRepository主要由两个实现,即RedisSessionRepository和RedisIndexedSessionRepository,其中RedisSessionRepository只提供了通过sessionId查询Session的简单方法,而RedisIndexedSessionRepository实现了按照索引查询Session的方法,例如根据用户名(principal)查询,为此它在Redis中存储了单独的一个Key,即"spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:{principal}" 。

另外在Session过期的实现方案上,RedisSessionRepository简单依赖Redis的key过期时间,而RedisIndexedSessionRepository则为了确保principal索引的Key可以被删除,实现过程也更加复杂,下面详细介绍一下RedisIndexedSessionRepository的过期策略。

为了理解过期策略的设计动机,首先介绍一下Redis的过期Key清理机制:当一个Key过期时,事实上这个Key并不会直接被清理掉,而是只有该Key被访问时,才会检查是否已过期,如果已过期,则移除该Key,这是一种惰性删除的策略,显然这会导致长期不活跃的Key一直不被清理而占用内存,因此Redis也会执行定期扫描任务,将过期的Key移除,但是这种扫描任务优先级是比较低的,为了控制任务执行的时长,Redis会抽取部分Key检查是否已过期,因此依然有一定概率导致过期的Key没有被删除。

针对上述问题,RedisIndexedSessionRepository设计了一套过期策略来确保每个过期的Session都能够被清理掉,首先在持久化Session对象时,通常会发生以下几条命令:

代码语言:bash复制
HMSET spring:session:sessions:{sessionId} <Hash> #value为Hash结构,这是用来存储session对象的,具体结构为:<creationTime, 创建时间>, <maxInactiveInterval, 过期时间>,<lastAccessedTime,最近访问时间>,以及该Session所有的属性名和属性值
SADD spring:session:expirations:{时间戳} <Set> #value为Set结构,其成员为“expires:{sessionId}",表示{时间戳}这个时间点下应该要被删除的{sessionId}
EXPIRE spring:session:expirations:{时间戳} 2100 # 设置Key的过期时间为maxInactiveInterval 5分钟
APPEND spring:session:sessions:expires:{sessionId} "" #value为空字符串,用来标记需要过期的{sessionId}
EXPIRE spring:session:sessions:expires:{sessionId} 1800 #设置Key的过期时间为maxInactiveInterval
EXPIRE spring:session:sessions:{sessionId} 2100  #设置Key的过期时间为maxInactiveInterval 5分钟

其中{时间戳}是经过下列方法计算得到的,其中expiresInMillis方法得到的是该Session最近访问时间加上maxInactiveInterval(最大非活跃间隔),也就是该Session实际应该过期的时间点,而roundUpToNextMinute方法则将时间点对齐到下一分钟的整点

代码语言:java复制
long toExpire = roundUpToNextMinute(expiresInMillis(session)); 

static long expiresInMillis(Session session) {
    int maxInactiveInSeconds = (int) session.getMaxInactiveInterval().getSeconds();
    long lastAccessedTimeInMillis = session.getLastAccessedTime().toEpochMilli();
    return lastAccessedTimeInMillis   TimeUnit.SECONDS.toMillis(maxInactiveInSeconds);
}

static long roundUpToNextMinute(long timeInMs) {
    Calendar date = Calendar.getInstance();
    date.setTimeInMillis(timeInMs);
    date.add(Calendar.MINUTE, 1);
    date.clear(Calendar.SECOND);
    date.clear(Calendar.MILLISECOND);
    return date.getTimeInMillis();
}

之所以要记录额外的Key,并对时间戳进行取整处理,是跟RedisIndexedSessionRepository中维护的清理过期Session的定时任务,以及Redis的keyspace notifications键空间通知机制有关。

先来看一下清理过期Session的定时任务,该任务每分钟执行一次,源码如下:

代码语言:java复制
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);
    Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
    this.redis.delete(expirationKey); // 删除本身
    for (Object session : sessionsToExpire) {
       String sessionKey = getSessionKey((String) session);
       touch(sessionKey); //触发惰性删除
    }
}

任务开始时取当前分钟整数值,然后查询spring:session:expirations:{时间戳}这个key对应集合的所有成员,即expire:{sessionId},如果存在,则调用touch方法执行Redis的EXISTS命令,这个命令就触发上面所介绍Redis惰性删除的操作。以确保所有过期的expire:{sessionId}会被清理掉。

当Key被清理时,Redis的keyspace notifications都会发布一个SessionDeletedEvent或SessionExpiredEvent的事件,此时在RedisIndexedSessionRepository的onMessage方法就会接受到这个事件的消息,并执行相关操作,包括清理掉Principal索引的Key,源码如下

代码语言:java复制
@Override
public void onMessage(Message message, byte[] pattern) {
    byte[] messageChannel = message.getChannel();

    if (ByteUtils.startsWith(messageChannel, this.sessionCreatedChannelPrefixBytes)) { 
        ...
    }

    byte[] messageBody = message.getBody();

    if (!ByteUtils.startsWith(messageBody, this.expiredKeyPrefixBytes)) { // 即expires:{sessionId}
       return;
    }

    boolean isDeleted = Arrays.equals(messageChannel, this.sessionDeletedChannelBytes);
    if (isDeleted || Arrays.equals(messageChannel, this.sessionExpiredChannelBytes)) {
       String body = new String(messageBody);
       int beginIndex = body.lastIndexOf(":")   1;
       int endIndex = body.length();
       String sessionId = body.substring(beginIndex, endIndex); // 截取出sessionId的值

       RedisSession session = getSession(sessionId, true); 
        // true表示允许返回已过期的session对象
        // 由于session的过期时间为maxInactiveInterval
        // 而对应的spring:session:sessions:{sessionId}的过期时间是maxInactiveInterval 5分钟,因此这里得到的session一般就是已过期的
       ...
       cleanupPrincipalIndex(session); // 清理Principal index的Key,确保后续不会被查询到
       ...
    }
}

下面举一个例子,假设当前有一个session对象,其sessionId为1,设置的maxInactiveInterval为30分钟,最近一次访问时间为20点15分10秒,那么时间戳的计算逻辑为20点15分10秒 30分钟=20点45分10秒,此为session实际应该过期的时间点,然后向上取分钟整数,即20点46分00秒,作为时间戳,因此它保存在Redis时,会创建以下几个Key:

spring:session:sessions:1 [session] # TTL为35分钟(maxInactiveInterval 5分钟),即20点50分10秒过期 spring:session:expirations:{20点46分00秒} [expires:1]# 同上,同样也是20点50分10秒过期 spring:session:sessions:expires:1 "" # TTL为30分钟(maxInactiveInterval)

为了下文方便说明,我们这里做一些简单的定义:

  • spring:session:sessions:1命名为session_data,表示这个Key存储的session数据
  • spring:session:expirations:{20点46分00秒}命名为job_index,表示这个Key是服务于RedisIndexedSessionRepository中的定时任务
  • spring:session:sessions:expires:1命名为sessionId_index,表示这个Key是用来索引需要过期的sessionId
  • spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:{principal}"命名为principal_index,表示可以通过principal查询到sessionId

参考下图(限于排版,忽略时间轴的比例问题),其实有2种情况会触发Key的清理,第一种情况是,在晚于20点45分10秒,早于20点46分00秒之间的某一个时刻,Redis后台扫描发现了sessionId_index已经过期了,那么直接进行清理,第二种情况是Redis并没有在后台扫描发现这个过期的Key,那么在20分46分00秒时,RedisIndexedSessionRepository的定时任务开始执行,job_index对应的集合中的成员"expires:1"就会被取出,然后通过它就可以拼接出sessionId_index的Key,并对其执行touch方法触发Redis惰性删除操作(同时也会直接删除job_index),不论哪种情况,sessionId_index,即"spring:session:sessions:expires:1"都会被清理,这样一来通过订阅keyspace notifications的事件,就能够确保principal_index总是能够被删除掉。

而至于RedisIndexedSessionRepository#findById方法,该方法在取出Session便会检查是否过期,因此可以保证不会返回已过期的Session对象

代码语言:java复制
@Override
public RedisSession findById(String id) {
    return getSession(id, false);
}

private RedisSession getSession(String id, boolean allowExpired) { 
    Map<String, Object> entries = getSessionBoundHashOperations(id).entries();
    if ((entries == null) || entries.isEmpty()) {
       return null;
    }
    MapSession loaded = this.redisSessionMapper.apply(id, entries);
    if (loaded == null || (!allowExpired && loaded.isExpired())) { // 当allowExpired=false时,会检查Session对象本身是否过期,即比较当前时间减去maxInactiveInterval是否大于最近访问时间
       return null;
    }
    RedisSession result = new RedisSession(loaded, false);
    result.originalLastAccessTime = loaded.getLastAccessedTime();
    return result;
}

四、总结

本文主要介绍了如何在Spring Boot项目中引入Spring Session框架,并与Spring Security进行整合,整体来看,Spring Session框架还是比较好上手的,仅需要一些简单的配置即可实现分布式Session的共享方案。而第三节重点介绍了Spring Session实现原理,包括SessionRepositoryRequestWrapper和SessionRepositoryFilter的工作机制,首先通过SessionRepositoryRequestWrapper将HttpServletRequest中与session操作相关的方法全部重写,然后向Servlet FilterChain注册一个SessionRepositoryFilter,将HttpServletRequest实例包装起来,并在FilterChain中传递,从而保障后续所有的session操作都由SessionRepositoryRequestWrapper实现。

另外还着重介绍了RedisIndexedRespository的过期策略,为了弥补Redis无法保障Key过期后及时被清理的问题,Spring Session设计了3种不同作用的Key,可以结合上述举例,理解这些Key所起到的作用,这个设计也为我们在处理Key过期清理的方案上提供了很好的参考。

1 人点赞