Spring Boot 使用 Spring Session 集成 Redis 实现Session共享

Spring Boot 使用 Spring Session 集成 Redis 实现Session共享

《Spring Boot 2.0极简教程》—— 基于 Gradle + Kotlin的企业级应用开发最佳实践

通常在web开发中,Session 会话管理是很重要的一部分,用于存储与用户相关的一些数据。在Java Web 系统中的 Session一般由 Tomcat 容器来管理。不过,使用特定的容器虽然可以很好地实现会话管理,但是基于Tomcat的会话插件实现tomcat-redis-session-manager 和tomcat-memcache-session-manager,会话统一由 NoSql 管理。对于项目本身来说,无须改动代码,只需要简单的配置Tomcat的server.xml就可以解决问题。但是插件太依赖于容器,并且对于Tomcat各个版本的支持不是特别的好。重写Tomcat的session管理,代码耦合度高,不利于维护。而使用开源的Spring Session 框架,既不需要修改Tomcat配置,又无须重写代码,只需要配置相应的参数即可完成分布式系统中的 Session 共享管理。

本章我们来介绍在 Spring Boot 应用中如何使用Spring Session 集成 Redis 实现分布式系统中的Session共享,从而实现 Spring Boot 应用的水平扩展。

1.1 集中式共享 Session 架构

我们通常优先采用水平扩展架构来提升系统的可用性和系统性能。但是更多的应用导致管理更加复杂。对于Spring Boot 应用,会话管理是一个难点。 Spring Boot 应用水平扩展通常有如下两个问题需要解决:

1.负载均衡。将用户请求平均派发到水平部署的任意一台Spring Boot 应用服务器上。可以用一个反向代理服务器来实现,例如使用Nginx作为反向代理服务器。在Spring Cloud 中,我们使用 Zuul(智能路由) 集成Eureka(服务发现)、 Hystrix(断路器) 和 Ribbon(客户端负载均衡)来实现。
2.共享 Session。 单个Spring Boot应用的Session由Tomcat来管理。如果部署多个Spring Boot应用,对于同一个用户请求,实现在这些应用之间共享 Session 通常有如下两种方式:
  a.Session 复制:Web服务器通常都支持Session复制,一台应用的 Session 信息改变将立刻复制到其他集群的Web服务器上。
  b.集中式 Session 共享 :所有 Web 服务器都共享同一个Session,Session 通常存放在 Redis 数据库服务器上。

Session 复制的缺点是效率较低,性能差。 所以Spring Boot 应用采用集中式 Session 共享。架构图如下:

上图是一个通用的分布式系统架构,包含了三个独立运行的微服务应用。微服务1部署在一台Tomcat服务器上(IP1:9000),微服务2部署在两台Tomcat服务器(IP2:9001、IP3:9002)上采用水平扩展。架构采用Nginx作为反向代理,Nginx提供统一的入口。Spring Boot应用微服务1和微服务2,都采用 Spring Session实现各个子系统共享同一个 Session,该 Session 统一存放在 Redis中。微服务1和微服务2独立部署的,支持水平扩展,最终整合成一个大的分布式系统。

1.2 Spring Session 介绍

Session 一直都是我们做分布式系统架构时需要解决的一个难题,过去我们可以从 Serlvet容器上解决,比如开源servlet容器-tomcat提供的tomcat-redis-session-manager、memcached-session-manager。 或者通过nginx之类的负载均衡做ip_hash,路由到特定的服务器上。而使用 Spring Session 来管理分布式session,则完全实现了与具体的容器无关。Spring Session 是Spring的项目之一,GitHub地址:https://github.com/spring-projects/spring-session

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

使用Spring Session 可以非常简易地把 Session 存储到第三方存储容器,框架提供了redis、jvm 的 map、mongo、gemfire、hazelcast、jdbc等多种存储 Session 的容器的方式。

1.3 Redis 简介
本节介绍 Redis。Redis是目前使用的非常广泛的内存数据库,相比memcached,它支持更加丰富的数据类型。
1.3.1 Redis 是什么
Redis 是完全开源免费的,遵守BSD协议,是一个高性能的key-value数据库。
Redis支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储。Redis支持数据的备份,即master-slave模式的数据备份。
Redis 优势
 性能极高。 Redis能读的速度是110000次/s,写的速度是81000次/s 。
 丰富的数据类型 。 Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。
 原子性。 Redis的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过MULTI和EXEC指令包起来。
 丰富的特性。 Redis还支持 publish/subscribe, 通知, key 过期等特性。
Redis运行在内存中但是可以持久化到磁盘,所以在对不同数据集进行高速读写时需要权衡内存,因为数据不能大于硬件内存。在内存数据库方面的另一个优点是,相比在磁盘上相同的复杂的数据结构,在内存中操作起来非常简单,这样Redis可以做很多内部复杂性很强的事情。同时,在磁盘格式方面他们是紧凑的以追加的方式产生的,因为他们并不需要进行随机访问。
1.3.2 安装Redis
使用下面的命令下载安装 redis:

$ wget http://download.redis.io/releases/redis-4.0.9.tar.gz
$ tar xzf redis-4.0.9.tar.gz
$ cd redis-4.0.9
$ make

启动 redis server 进程命令如下:

$ src/redis-server

打开 redis client 命令

$ src/redis-cli
redis> set foo bar
OK
redis> get foo
"bar"

这样我们就简单完成了 redis 的环境配置。
如果需要在远程 redis 服务上执行命令,同样我们使用的也是 redis-cli 命令。语法格式如下
$ redis-cli -h host -p port -a password

代码实例:

$redis-cli -h 127.0.0.1 -p 6379 -a "123456"

连接到主机为 127.0.0.1,端口为 6379 ,密码为 123456 的 redis 服务上。
使用 * 号获取所有配置项命令:

redis 127.0.0.1:6379> config get *
  1) "dbfilename"
  2) "dump.rdb"
  3) "requirepass"
  4) "123456"
  5) "masterauth"
  ...

1.3.3 设置Redis密码
通常我们会设置 redis 密码,命令如下:

127.0.0.1:6379> config set requirepass 123456
OK

测试密码:
127.0.0.1:6379> info
NOAUTH Authentication required.
127.0.0.1:6379> set x 0
(error) NOAUTH Authentication required.

提示无权限。使用密码授权登陆:
127.0.0.1:6379> auth 123456
OK
127.0.0.1:6379> set x 0
OK
127.0.0.1:6379> get x
"0"

1.3.4 Redis 数据类型
Redis支持五种数据类型:string(字符串),hash(哈希),list(列表),set(集合)及zset(sorted set:有序集合)。

1.字符串string
string是redis最基本的类型,你可以理解成与Memcached一样的类型,一个key对应一个value。string类型是二进制安全的。意思是redis的string可以包含任何数据。比如jpg图片或者序列化的对象 。
string类型是Redis最基本的数据类型,一个键最大能存储512MB。
代码实例
redis 127.0.0.1:6379> set name "Spring Boot Plus Kotlin"
OK
redis 127.0.0.1:6379> get name
"Spring Boot Plus Kotlin"

在以上实例中我们使用了 Redis 的 SET 和 GET 命令。键为 name,对应的值为 "Spring Boot Plus Kotlin"。

2.哈希Hash
Redis hash 是一个键值(key => value)对集合。Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 适用于存储对象。
代码实例
redis> HMSET myhash field1 "Hello" field2 "World"
"OK"
redis> HGET myhash field1
"Hello"
redis> HGET myhash field2
"World"
以上实例中 hash 数据类型存储了包含用户脚本信息的用户对象。 实例中我们使用了 Redis HMSET, HGETALL 命令,user:1 为键值。每个 hash 可以存储 2^32 -1 键值对(4294967295)。

3.列表 List
Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。
代码实例
127.0.0.1:6379> lpush mylist redis
(integer) 1
127.0.0.1:6379> lpush mylist springboot
(integer) 2
127.0.0.1:6379> lpush mylist kotlin
(integer) 3
127.0.0.1:6379> lpush mylist kotlin
(integer) 4
127.0.0.1:6379> lrange mylist 0 10

  1. "kotlin"
  2. "kotlin"
  3. "springboot"
  4. "redis"
    列表最多可存储 2^32 - 1 元素 (4294967295)。
    4.集合 Set
    Redis的Set是string类型的无序集合。集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。
    使用sadd 命令添加一个 string 元素到 key 对应的 set 集合中,成功返回1,如果元素已经在集合中返回 0,如果 key 对应的 set 不存在则返回错误。
    向集合添加一个或多个成员命令:
    SADD key member1 [member2]
    代码示例:
    127.0.0.1:6379> sadd myset redis
    (integer) 1
    127.0.0.1:6379> sadd myset springboot
    (integer) 1
    127.0.0.1:6379> sadd myset kotlin
    (integer) 1
    127.0.0.1:6379> sadd myset kotlin
    (integer) 0
    获取集合的成员数:
    SCARD key
    代码示例:
    127.0.0.1:6379> scard myset
    (integer) 3

返回集合中的所有成员:
SMEMBERS key
代码示例:
127.0.0.1:6379> smembers myset

  1. "kotlin"
  2. "redis"
  3. "springboot"

注意:以上实例中 kotlin 添加了两次,但根据集合内元素的唯一性,第二次插入的元素将被忽略。集合中最大的成员数为 2^32 - 1 (4294967295)。
5.有序集合(sorted set)
Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个double类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数(score)却可以重复。集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。 集合中最大的成员数为 2^32 - 1 (4294967295)。
代码实例
127.0.0.1:6379> ZADD mysortedset 1 redis
(integer) 1
127.0.0.1:6379> ZADD mysortedset 2 mongodb
(integer) 1
127.0.0.1:6379> ZADD mysortedset 3 mysql
(integer) 1
127.0.0.1:6379> ZADD mysortedset 3 mysql
(integer) 0
127.0.0.1:6379> ZADD mysortedset 4 mysql
(integer) 0
127.0.0.1:6379> ZRANGE mysortedset 0 10 WITHSCORES

  1. "redis"
  2. "1"
  3. "mongodb"
  4. "2"
  5. "mysql"
  6. "4"
    在以上实例中我们通过命令 ZADD 向 redis 的有序集合中添加了三个值并关联上分数。我们重复添加了 mysql,分数以最后添加的元素为准。
    1.3.5 Spring Boot 集成 Redis
    在项目中添加 spring-boot-starter-data-redis 依赖,然后在 application.properties 中配置 spring.redis.* 属性即可使用 StringRedisTemplate模板类来操作 Redis 了。 Spring Data Redis 是对访问redis客户端的一个包装适配,支持Jedis,JRedis,SRP,Lettuce四中开源的redis客户端。RedisTemplate是对redis的CRUD的高级封装,而RedisConnection提供了简单封装。
    一个简单的代码示例如下:
@RestController
class RedisTemplateController {
    @Autowired lateinit var stringRedisTemplate: StringRedisTemplate

    @RequestMapping(value = ["/redis/{key}/{value}"], method = [RequestMethod.GET])
    fun redisSave(@PathVariable key: String, @PathVariable value: String): String {

        val redisValue = stringRedisTemplate.opsForValue().get(key)

        if (StringUtils.isEmpty(redisValue)) {
            stringRedisTemplate.opsForValue().set(key, value)
            return String.format("设置[key=%s,value=%s]成功!", key, value)
        }

        if (redisValue != value) {
            stringRedisTemplate.opsForValue().set(key, value)
            return String.format("更新[key=%s,value=%s]成功!", key, value)
        }

        return String.format("redis中已存在[key=%s,value=%s]的数据!", key, value)
    }

    @RequestMapping(value = ["/redis/{key}"], method = [RequestMethod.GET])
    fun redisGet(@PathVariable key: String): String? {
        return stringRedisTemplate.opsForValue().get(key) // String 类型的 value
    }

    @RequestMapping(value = ["/redisHash/{key}/{field}"], method = [RequestMethod.GET])
    fun redisHashGet(@PathVariable key: String, @PathVariable field: String): String? {
        return stringRedisTemplate.opsForHash<String, String>().get(key, field) // Hash 类型的 value
    }
}



StringRedisTemplate 继承了 RedisTemplate 。RedisTemplate是一个泛型类,而StringRedisTemplate则不是。StringRedisTemplate只能对key=String,value=String的键值对进行操作,RedisTemplate可以对任何类型的key-value键值对操作。
StringRedisTemplate 封装了对Redis的一些常用的操作。StringRedisTemplate 使用的是 StringRedisSerializer。RedisTemplate使用的序列类在在操作数据的时候,比如说存入数据会将数据先序列化成字节数组,然后在存入Redis数据库,这个时候打开Redis查看的时候,你会看到你的数据不是以可读的形式展现的。在使用StringRedisSerializer 操作redis数据类型的时候必须要set相对应的序列化。从StringRedisTemplate类的构造函数代码可以看出

public class StringRedisTemplate extends RedisTemplate<String, String> {
    public StringRedisTemplate() {
        RedisSerializer<String> stringSerializer = new StringRedisSerializer();
        setKeySerializer(stringSerializer);
        setValueSerializer(stringSerializer);
        setHashKeySerializer(stringSerializer);
        setHashValueSerializer(stringSerializer);
    }
    ...
}

StringRedisTemplate 和 RedisTemplate 各自序列化的方式不同,但最终都是得到了一个字节数组,殊途同归,StringRedisTemplate使用的是StringRedisSerializer类;RedisTemplate使用的是JdkSerializationRedisSerializer类。反序列化,则是一个得到String,一个得到Object。
测试 redis 操作
1.请求 http://127.0.0.1:9000/redis/x/1,输出:"更新[key=x,value=1]成功!"。
2.再次请求 http://127.0.0.1:9000/redis/x/1,输出:“redis中已存在[key=x,value=1]的数据!”。
3.请求 http://127.0.0.1:9000/redis/x , 输出:1。
4.请求 http://127.0.0.1:9000/redisHash/spring:session:sessions:06830c1b-8157-46fc-b84a-a086aa8c8d45/lastAccessedTime,输出一段不可读的对象数据:“...java.lang.Long;...java.lang.Number...”。

提示:更多关于 Redis 的介绍参考
https://redis.io/download
http://try.redis.io/

1.4 项目实战
本节通过完整的项目实例来介绍在 Spring Boot 应用中如何使用 Redis 来实现共享 Session。在分布式系统中,Sessiong 共享有很多的解决方案,其中使用 Redis 缓存是最常用的方案之一。
1.创建项目
创建两个 Spring Boot 应用 demo_microservice_api_book、demo_microservice_api_user,它们的 Session 都使用同一个 Redis 数据库存储。
2.添加依赖
在build.gradle中添加 spring-session-data-redis 就可以使用 Redis来存储 Session。
3.配置Redis
为了简单起见,我们这里就使用的单点 Redis 模式。在实际生产中,为了保障高可用性,通常是一个 Redis 集群。在 application.properties中配置 Redis 信息如下

spring.application.name=demo_microservice_api_user
server.port=9001
################# Redis 基础配置 #################
spring.redis.host=127.0.0.1
spring.redis.password=123456
spring.redis.port=6379
#连接超时时间 单位 ms(毫秒)
spring.redis.timeout=3000ms
################# Redis 线程池设置 #################
#连接池中的最大空闲连接,默认值是8。
spring.redis.jedis.pool.max-idle=10
#连接池中的最小空闲连接,默认值是0。
spring.redis.jedis.pool.min-idle=20
#连接池最大活跃数。默认值8。如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)。
spring.redis.jedis.pool.max-active=10
# 等待可用连接的最大时间,单位毫秒,默认值为-1ms,表示永不超时。如果超过等待时间,则直接抛出JedisConnectionException
spring.redis.jedis.pool.max-wait=3000ms

4.配置 Session 存储类型
在 application.properties中配置存储 Session的类型为 Redis:

################# 使用 Redis 存储 Session 设置 #################
# Redis|JDBC|Hazelcast|none
spring.session.store-type=Redis

spring-boot-autoconfigure 的源代码中使用RedisAutoConfiguration来加载Redis的配置类RedisProperties。 其中RedisAutoConfiguration会加载 application.properties 文件的前缀为“spring.redis”的属性。其中“spring.redis.sentinel”是哨兵模式的配置,“spring.redis.cluster”是集群模式的配置。

当我们添加spring.session.store-type=Redis这行配置,指定 Session 的存储方式为 Redis,可以看到控制台输出的日志为:

c.e.s.d.SessionController : 
org.springframework.session.web.http.SessionRepositoryFilter.SessionRepositoryRequestWrapper.HttpSessionWrapper

我们可以看到,Session 已经使用了 HttpSessionWrapper 这个包装类实现,HttpSessionWrapper 背后真正负责 Session 管理的适配器类是 HttpSessionAdapter。RedisOperationsSessionRepository 是采用Redis存储 Session 的核心业务逻辑实现。其中的变量DEFAULT_NAMESPACE = "spring:session"定义了Spring Session 存储在 Redis 中的默认命名空间。其中的getSessionKey()方法如下:

String getSessionKey(String sessionId) {
    return this.namespace + "sessions:" + sessionId;
}

通过方法源码,我们可以知道 session id 存储的 Key 是 spring:session:sessions:{sessionId}。这个我们稍后去 Redis 中查看验证。
按照上面的步骤在另一个项目中再次配置一次,启动后,该项目也会自动进行了session共享。

5.测试 Session 数据
分别在两个 Spring Boot 应用中编写获取 Session 数据的 Controller 类 SessionController,代码相同,如下:

@RestController
class SessionController {
    val log = LoggerFactory.getLogger(SessionController::class.java)
    @RequestMapping(value = "/session")
    fun getSession(request: HttpServletRequest): SessionInfo {
        val session = request.session
        log.info(session.javaClass.canonicalName)
        log.info(session.id)

        val SessionInfo = SessionInfo()
        SessionInfo.id = session.id
        SessionInfo.creationTime = session.creationTime
        SessionInfo.lastAccessedTime = session.lastAccessedTime
        SessionInfo.maxInactiveInterval = session.maxInactiveInterval
        SessionInfo.isNew = session.isNew
        return SessionInfo
    }

    class SessionInfo {
        var id = ""
        var creationTime = 0L
        var lastAccessedTime = 0L
        var maxInactiveInterval = 0
        var isNew = false
    }
}

在本机部署 demo_microservice_api_book,端口号为 9000。部署 demo_microservice_api_user 两个运行实例,端口号分别为9001、9002。即使用 gradle bootJar 打可执行 jar 包,然后在命令行分别执行:

$ java -jar demo_microservice_api_user-0.0.1-SNAPSHOT.jar  --server.port=9001
$ java -jar demo_microservice_api_user-0.0.1-SNAPSHOT.jar  --server.port=9002


访问 http://127.0.0.1:9000/session,得到输出
{
"id": "06830c1b-8157-46fc-b84a-a086aa8c8d45",
"creationTime": 1523693635249,
"lastAccessedTime": 1523697391616,
"maxInactiveInterval": 1800,
"new": false
}
访问 http://127.0.0.1:9001/session,得到输出
{
"id": "06830c1b-8157-46fc-b84a-a086aa8c8d45",
"creationTime": 1523693635249,
"lastAccessedTime": 1523697427153,
"maxInactiveInterval": 1800,
"new": false
}
访问 http://127.0.0.1:9002/session,得到输出
{
"id": "06830c1b-8157-46fc-b84a-a086aa8c8d45",
"creationTime": 1523693635249,
"lastAccessedTime": 1523697440377,
"maxInactiveInterval": 1800,
"new": false
}
我们可以看到,这3个独立运行的应用,都共享了同一个 Session Id。通过 Redis 客户端命令行 redis-cli 输入如下命令,查看所有“spring:session:”开头的 keys:

127.0.0.1:6379> keys spring:session:*
...
15) "spring:session:sessions:expires:06830c1b-8157-46fc-b84a-a086aa8c8d45"
16) "spring:session:sessions:06830c1b-8157-46fc-b84a-a086aa8c8d45"
17) "spring:session:sessions:expires:d2193501-1d0b-4f1a-9b50-cd01949ce998"

我们可以看到,spring:session:sessions的值跟我们在浏览器中得到得到结果一样。正如我们看到的一样,session id 在 Redis 中存储的 Key 是 spring:session:sessions:{sessionId}。
通过 redis-cli 查看 Redis 存储的所有 key 命令如下:

127.0.0.1:6379> keys *
…
4) "spring:session:sessions:c3304842-d3a1-42f5-936c-fb73606beda7"
5) "mylist"
6) "spring:session:sessions:expires:c3304842-d3a1-42f5-936c-fb73606beda7"
7) "spring:session:expirations:1523691300000"
…

执行type命令可以获取一个 key 存储的数据类型,例如:

127.0.0.1:6379> type "spring:session:sessions:c3304842-d3a1-42f5-936c-fb73606beda7"
hash

其中"spring:session:sessions:c3304842-d3a1-42f5-936c-fb73606beda7" 为其中的一个key值。表明出该key存储在现在redis服务器中的类型为 hash。此时操作这个数据就必须使用 hset、hget 等操作方法。否则会报错:

127.0.0.1:6379> get "spring:session:sessions:c3304842-d3a1-42f5-936c-fb73606beda7"
(error) WRONGTYPE Operation against a key holding the wrong kind of value

例如,获取在哈希表中指定 key 为"spring:session:sessions:c4a6db26-d86d-47db-b53c-a10d3b997e40"的所有字段和值的命令如下:

127.0.0.1:6379> hgetall "spring:session:sessions:c4a6db26-d86d-47db-b53c-a10d3b997e40"
1) "maxInactiveInterval"
…
3) "lastAccessedTime"
…

单独获取maxInactiveInterval、creationTime的值的命令如下:

127.0.0.1:6379> hget "spring:session:sessions:c4a6db26-d86d-47db-b53c-a10d3b997e40" maxInactiveInterval
…
127.0.0.1:6379> hget "spring:session:sessions:c4a6db26-d86d-47db-b53c-a10d3b997e40" creationTime
…

提示:
demo_microservice_api_book 工程源代码:
https://github.com/EasySpringBoot/demo_microservice_api_book/tree/demo_session
demo_microservice_api_user 工程源代码:
https://github.com/EasySpringBoot/demo_microservice_api_user/tree/demo_session

1.5 本章小结
本章我们在Spring Session的基础上完成了Spring Boot应用的水平扩展。通过 Spring Boot + Redis来实现 Session 的共享非常简单,而且用处也极大,配合nginx进行负载均衡,便能实现分布式的应用了。
此处,我们没有对 Redis 进行主从、读写分离等配置。而且,nginx的单点故障也是我们应用的障碍,比如使用zookeeper进行负载均衡。限于篇幅,本书暂不作详细介绍。

上一篇:Comparator帮你补救比较缺陷 | 带你学《Java语言高级特性》之三十七


下一篇:CentOS7 Apache/Nginx/MySQL/php-fpm启动重启停止命令大全