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
- "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類的構造函數代碼可以看出
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/downloadhttp://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
…