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:
代码语言:javascript复制$ 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 进程命令如下:
代码语言:javascript复制$ src/redis-server
打开 redis client 命令
代码语言:javascript复制$ 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 服务上。 使用 * 号获取所有配置项命令:
代码语言:javascript复制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
- "kotlin"
- "kotlin"
- "springboot"
- "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
- "kotlin"
- "redis"
- "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
- "redis"
- "1"
- "mongodb"
- "2"
- "mysql"
- "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类的构造函数代码可以看出
代码语言:javascript复制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 信息如下
代码语言:javascript复制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:
代码语言:javascript复制################# 使用 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,可以看到控制台输出的日志为:
代码语言:javascript复制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()方法如下:
代码语言:javascript复制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,代码相同,如下:
代码语言:javascript复制@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 包,然后在命令行分别执行:
代码语言:javascript复制$ 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:
代码语言:javascript复制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 命令如下:
代码语言:javascript复制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 存储的数据类型,例如:
代码语言:javascript复制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 等操作方法。否则会报错:
代码语言:javascript复制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"的所有字段和值的命令如下:
代码语言:javascript复制127.0.0.1:6379> hgetall "spring:session:sessions:c4a6db26-d86d-47db-b53c-a10d3b997e40"
1) "maxInactiveInterval"
…
3) "lastAccessedTime"
…
单独获取maxInactiveInterval、creationTime的值的命令如下:
代码语言:javascript复制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进行负载均衡。限于篇幅,本书暂不作详细介绍。