从零开始设计Session存储方案 - Spring Session + Redis

4 篇文章 0 订阅
1 篇文章 0 订阅

原创出处: http://www.iocoder.cn/Spring-Session/laoxu/spring-session-4/?
先直接上代码吧,然后再来一一拓展
1、首先引入相关的依赖

  <!-- 实现对 Spring Session 使用 Redis 作为数据源的自动化配置 -->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

        <!-- 实现对 Spring Data Redis 的自动化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <!-- 去掉对 Lettuce 的依赖,因为 Spring Boot 优先使用 Lettuce 作为 Redis 客户端 -->
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>


        <!-- 引入 Jedis 的依赖,这样 Spring Boot 实现对 Jedis 的自动化配置 -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

2、添加相关的配置
由于我本地redis没有设置密码,所以没有添加password

redis:
    host: 127.0.0.1
    port: 6379
    jedis:
      pool:
        # 连接池中的最大空闲连接,默认值也是8。
        max-idle: 100
        # 连接池中的最小空闲连接,默认值也是0。
        min-idle: 1
        # # 连接池最大连接数,默认为 8 。使用负数表示没有限制。如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)
        max-active: 100
        # 连接池最大阻塞等待时间,单位:毫秒。默认值为-1,表示永不超时。如果超过等待时间,则直接抛出JedisConnectionException
        max-wait: -1
    #选择数据库
    database: 5
    #超时时间
    timeout: 0

3、SessionConfiguration

@Configuration
@EnableRedisHttpSession // 自动化配置 Spring Session 使用 Redis 作为数据源
public class SessionConfiguration {

    /**
     * 创建 {@link RedisOperationsSessionRepository} 使用的 RedisSerializer Bean 。
     *
     * 具体可以看看 {@link RedisHttpSessionConfiguration#setDefaultRedisSerializer(RedisSerializer)} 方法,
     * 它会引入名字为 "springSessionDefaultRedisSerializer" 的 Bean 。
     *
     * @return RedisSerializer Bean、
     *
     *
     * 在类上,添加 @EnableRedisHttpSession 注解,开启自动化配置 Spring Session 使用 Redis 作为数据源。该注解有如下属性:
     * 1、maxInactiveIntervalInSeconds 属性,Session 不活跃后的过期时间,默认为 1800 秒。
     * 2、redisNamespace 属性,在 Redis 的 key 的统一前缀,默认为 "spring:session" 。
     * 3、redisFlushMode 属性,Redis 会话刷新模式(RedisFlushMode)。目前有两种,默认为 RedisFlushMode.ON_SAVE 。
     * 4、RedisFlushMode.ON_SAVE ,在请求执行完成时,统一写入 Redis 存储。
     * 5、RedisFlushMode.IMMEDIATE ,在每次修改 Session 时,立即写入 Redis 存储。
     * 6、cleanupCron 属性,清理 Redis Session 会话过期的任务执行 CRON 表达式,默认为 "0 * * * * *" 每分钟执行一次。虽然说,Redis 自带了 key 的过期,但是惰性删除策略,
     * 7、实际过期的 Session 还在 Redis 中占用内存。所以,Spring Session 通过定时任务,删除 Redis 中过期的 Session ,尽快释放 Redis 的内存。
     * @return
     */
    @Bean(name = "springSessionDefaultRedisSerializer")
    //在 #springSessionDefaultRedisSerializer() 方法,定义了一个 Bean 名字为 "springSessionDefaultRedisSerializer" 的 RedisSerializer Bean ,
    // 采用 JSON 序列化方式。因为默认情况下,采用 Java 自带的序列化方式 ,可读性很差,所以进行替换。
    public RedisSerializer springSessionDefaultRedisSerializer() {
        return RedisSerializer.json();
    }

}

想象一个场景,现在一到面试题呈现在你面前,让你从零开始设计一个 Session 存储方案,你会怎么回答?

说白了就是让你设计一套数据结构存储 Session,并且我相信提出这个问题时,大多数读者脑海中会浮现出 redis,设计一个 map,使用 ttl 等等,但没想到的细节可能会更多。先来预览一下 Spring Session 的实际数据结构是什么样的(使用 spring-session-redis 实现),当我们访问一次集成了 Spring Session 的 web 应用时

我们在数据结构中随意存储一个数据时,redis中的数据结构如下:

在这里插入图片描述
这三种键职责的分析将会贯彻全文,为了统一叙述,在此将他们进行编号,后续简称为 A 类型键,B 类型键,C 类型键。先简单分析下他们的特点

他们公用的前缀是 spring:session
A 类型键的组成是前缀 +”sessions”+sessionId,对应的值是一个 hash 数据结构。在我的 demo 中,其值如下

{
    "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 可能会存在多个键值对,用户存放在 session 中的数据如数存放于此。

A 类型键对应的默认 TTL 是 35 分钟。

B 类型键的组成是前缀 +”expirations”+ 时间戳,无需纠结这个时间戳的含义,先卖个关子。其对应的值是一个 set 数据结构,这个 set 数据结构中存储着一系列的 C 类型键。在我的 demo 中,其值如下

[
    "expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"
]

B 类型键对应的默认 TTL 是 30 分钟

C 类型键的组成是前缀 +”sessions:expires”+sessionId,对应一个空值,它仅仅是 sessionId 在 redis 中的一个引用,具体作用继续卖关子。
C 类型键对应的默认 TTL 是 30 分钟。

kirito-session 的天使轮方案
介绍完 Spring Session 的数据结构,我们先放到一边,来看看如果我们自己设计一个 Session 方案,拟定为 kirito-session 吧,该如何设计。

kirito 的心路历程是这样的:“使用 redis 存 session 数据,对,session 需要有过期机制,redis 的键可以自动过期,肯定很方便。”

于是 kirito 设计出了 spring-session 中的 A 类型键,复用它的数据结构:

{
    "lastAccessedTime": 1523933008926,
    "creationTime": 1523933008926,
    "maxInactiveInterval": 1800,
    key/value...
}

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

显然 Spring Session 没有采用如此简练的设计,为什么呢?翻看 Spring Session 的文档


> 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 的键过期机制不“保险”,这和 redis 的设计有关,不在此拓展开,研究这个的时候翻了不少资料,得出了如下的总结:

redis 在键实际过期之后不一定会被删除,可能会继续存留,但具体存留的时间我没有做过研究,可能是 1~2 分钟,可能会更久。
具有过期时间的 key 有两种方式来保证过期,一是这个键在过期的时候被访问了,二是后台运行一个定时任务自己删除过期的 key。划重点: 这启发我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键
如果没有指令持续关注 key,并且 redis 中存在许多与 TTL 关联的 key,则 key 真正被删除的时间将会有显著的延迟!显著的延迟!显著的延迟!
天使轮计划惨遭破产,看来单纯依赖于 redis 的过期时间是不可靠的,秉持着力求严谨的态度,迎来了 A 轮改造。

A 轮改造—引入 B 类型键确保 session 的过期机制
redis 的官方文档启发我们,可以启用一个后台定时任务,定时去删除那些过期的键,配合上 redis 的自动过期,这样可以双重保险。第一个问题来了,我们将这些过期键存在哪儿呢?不找个合适的地方存起来,定时任务到哪儿去删除这些应该过期的键呢?总不能扫描全库吧!来解释我前面卖的第一个关子,看看 B 类型键的特点:

spring:session:expirations:1523934840000

时间戳的含义
1523934840000 这明显是个 Unix 时间戳,它的含义是存放着这一分钟内应该过期的键,所以它是一个 set 数据结构。解释下这个时间戳是怎么计算出来的

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

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();
	}

还记得 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 时间也会被更新。

kirito-session 方案貌似比之前严谨了,目前为止使用了 A 类型键和 B 类型键解决了 session 存储和 redis 键到期不删除的两个问题,但还是存在问题的。

B 轮改造—优雅地解决 B 类型键的并发问题
引入 B 类型键看似解决了问题,却也引入了一个新的问题:并发问题。

来看看一个场景:

假设存在一个 sessionId=1 的会话,初始时间戳为 1420656360000

spring:session:expirations:1420656360000 -> [1]
spring:session:session:1 -> <session>

接下来迎来了并发访问,(用户可能在浏览器中多次点击):

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

线程 2 从第一分钟的桶中移除 session:1,并移动到第三分钟的桶中

spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]

线程 1 完成相同的操作,它也是基于第一分钟来做的,但会移动到第二分钟的桶中

spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656420000 -> [1]

最后 redis 中键的情况变成了这样:

spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]
spring:session:expirations:1420656420000 -> [1]

后台定时任务会在第 32 分钟扫描到 spring:session:expirations:1420656420000 桶中存在的 session,这意味着,本应该在第 33 分钟才会过期的 key,在第 32 分钟就会被删除!

一种简单的方法是用户的每次 session 续期加上分布式锁,这显然不能被接受。来看看 Spring Session 是怎么巧妙地应对这个并发问题的。

org.springframework.session.data.redis.RedisSessionExpirationPolicy#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));
   }

   // 获取到 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。

已经被 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

**

C 轮改造—增加 C 类型键完善过期通知事件

**
虽然引入了 B 类型键,并且在后台加了定时器去确保 session 的过期,但似乎…emmmmm… 还是不够完善。在此之前,kirito-session 的设计方案中,存储 session 实际内容的 A 类型键和用于定时器确保删除的桶 B 类型键过期时间都是 30 分钟 (key 的 TTL 是 30 分钟),注意一个细节,spring-session 中 A 类型键的过期时间是 35 分钟,比实际的 30 分钟多了 5 分钟,这意味着即便 session 已经过期,我们还是可以在 redis 中有 5 分钟间隔来操作过期的 session。于此同时,spring-session 引入了 C 类型键来作为 session 的引用。

解释下之前卖的第二个关子,C 类型键的组成为前缀 +”sessions:expires”+sessionId,对应一个空值,同时也是 B 类型键桶中存放的 session 引用,ttl 为 30 分钟,具体作用便是在自身过期后触发 redis 的 keyspace notifications (http://redis.io/topics/notifications),具体如何监听 redis 的过期事件简单介绍下:org.springframework.session.data.redis.config.ConfigureNotifyKeyspaceEventsAction 该类配置了相关的过期监听,并使用 SessionExpiredEvent 事件发放 session 的过期事件。为什么引入 C 类型键?keyspace notifications 只会告诉我们哪个键过期了,不会告诉我们内容是什么。 关键就在于如果 session 过期后监听器可能想要访问 session 的具体内容,然而自身都过期了,还怎么获取内容 。所以,C 类型键存在的意义便是解耦 session 的存储和 session 的过期,并且使得 server 获取到过期通知后可以访问到 session 真实的值。对于用户来说,C 类型键过期后,意味着登录失效,而对于服务端而言,真正的过期其实是 A 类型键过期,这中间会有 5 分钟的误差。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值