Session 會(huì)話
通常我們所說(shuō)的會(huì)話是兩個(gè)或更多個(gè)通信設(shè)備之間或計(jì)算機(jī)和用戶之間的半永久性交互式信息交換, 會(huì)話在某個(gè)時(shí)間點(diǎn)建立,然后在稍后的時(shí)間點(diǎn)拆除。
建立的通信會(huì)話可以在每個(gè)方向上涉及多于一個(gè)消息, 這些消息只存在這個(gè)會(huì)話中, 而與其他會(huì)話隔離.
會(huì)話通常是有狀態(tài)的,這意味著至少一個(gè)通信部分需要保存關(guān)于會(huì)話歷史的信息以便能夠進(jìn)行通信泛源,這與無(wú)狀態(tài)通信相反,其中通信由具有響應(yīng)的獨(dú)立請(qǐng)求組成。
而狀態(tài)保存在什么地方, 有很多選擇, 內(nèi)存中, 磁盤(pán)上, 共享緩存中, 數(shù)據(jù)庫(kù)里, 總有一款適合你.
常見(jiàn)的會(huì)話就有 TCP Session, SIP Session , RTP Session , HTTP Session 等等, 分別工作在傳輸層, 會(huì)話層和應(yīng)用層
TCP Session
這個(gè)自不必說(shuō), 用三次握手建立會(huì)話, 四次揮手終止會(huì)話
-
三次握手
四次揮手
這樣在連接的兩端就建立了一個(gè) TCP Session, 并且維護(hù)著會(huì)話狀態(tài)
SIP/RTP Session
SIP是一種應(yīng)用層控制協(xié)議斯棒,可以建立,修改和終止多媒體會(huì)話(會(huì)議)主经,例如互聯(lián)網(wǎng)電話呼叫,多媒體分發(fā)播放和多媒體會(huì)議荣暮。它在TCP 或 UDP 之上通過(guò) INVITE 消息來(lái)搭建用戶代理之間的信令(控制 - SIP Session) 和媒體會(huì)話 (RTP Session)
這里不做贅述, 請(qǐng)見(jiàn)微服務(wù)協(xié)議之 SIP
更多細(xì)節(jié)見(jiàn) RFC
- RFC3261(https://tools.ietf.org/html/rfc3261): SIP: Session Initiation Protocol
- RFC3550(https://tools.ietf.org/html/rfc3550): RTP: A Transport Protocol for Real-Time Applications
- RFC7329(https://tools.ietf.org/html/rfc7329): A Session Identifier for the Session Initiation Protocol (SIP)
Http Session
這里重點(diǎn)講講 HTTP Session, 傳統(tǒng) Web 應(yīng)用里都有一個(gè) session 的概念,相比用 Cookie 在客戶端記錄信息確定用戶身份, Session 一般是在服務(wù)器端記錄信息確定用戶身份和狀態(tài), 這里的狀態(tài)不僅指用戶登錄和在線的狀態(tài), 也包括應(yīng)用層中的一些業(yè)務(wù)相關(guān)的信息, 比如很多網(wǎng)站的購(gòu)物車(chē)就是放在 Http Session 里的.
在分布式系統(tǒng)中, 通常不建議將會(huì)話狀態(tài)放在一臺(tái)服務(wù)器的內(nèi)存或磁盤(pán)中, 因?yàn)檫@樣的話, 系統(tǒng)會(huì)有單點(diǎn)失敗而導(dǎo)致的服務(wù)不可用, 如下圖所示:
如果客戶端與服務(wù)器的 session 只存在于 server 1 中 負(fù)載均衡器做流量派發(fā)時(shí), 必須要把流量派發(fā)到server 1, 這叫 session sticky , 一旦 server 1 掛掉了, 這個(gè)對(duì)話狀態(tài)就丟失了, 如果你在網(wǎng)站購(gòu)物, 突然購(gòu)物車(chē)?yán)镞x好的寶貝都沒(méi)了, 這多讓人惱火
對(duì)于session 狀態(tài)的管理我們一般有三種策略
session sticky 會(huì)話粘滯
如上所述, 會(huì)話在一臺(tái)服務(wù)器上持續(xù), 直到會(huì)話終止, 問(wèn)題在于單點(diǎn)失敗session replicate 會(huì)話復(fù)制
將會(huì)話信息復(fù)制到各臺(tái)服務(wù)器上, 例如利用多播技術(shù)及組通信技術(shù)把狀態(tài)同步到組中的每一臺(tái)server, 我曾經(jīng)用過(guò) Jgroups, 在服務(wù)器數(shù)量不多的情況下工作得不錯(cuò), 可是如果服務(wù)器距離較遠(yuǎn)并不在一個(gè)網(wǎng)段, 服務(wù)器數(shù)量較多, 這種方案就不適合了, 同步消息過(guò)多且有性能問(wèn)題.session Repository 會(huì)話倉(cāng)庫(kù)
會(huì)話狀態(tài)存儲(chǔ)在共享的數(shù)據(jù)倉(cāng)庫(kù)中, 這樣每臺(tái)server 都可以輕松存取, 會(huì)話倉(cāng)庫(kù)可以是傳統(tǒng)關(guān)系型數(shù)據(jù)庫(kù)或NOSQL產(chǎn)品, 不過(guò)單點(diǎn)失敗轉(zhuǎn)移到了會(huì)話倉(cāng)庫(kù), 如果訪問(wèn)量比較大且存取頻繁, 對(duì)會(huì)話倉(cāng)庫(kù)的要求也比較高, 鑒于會(huì)話并不需要存儲(chǔ)很長(zhǎng)時(shí)間, 相比 Oracle/MySQL, Cassandra 或 Redis 更加合適
就以現(xiàn)在比較流行的 Spring Session 的 Redis 方案為例
購(gòu)物車(chē)示例 Spring Session + Redis
Redis 的安裝和配置不說(shuō)了, 非常簡(jiǎn)單, 參見(jiàn)Redis 入門(mén),
我是在自己的 macbook 中啟了一個(gè) redis docker image , 偵聽(tīng)端口是 6379
建立一個(gè) Spring Boot 項(xiàng)目, 在 https://start.spring.io 上選擇
- Session
- Lombok
- Web
- Redis
將生成的壓縮包解開(kāi), 這是一個(gè) spring boot 項(xiàng)目的框架
讓我們先看看所需要的依賴庫(kù)
- pom.xml
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
搞定配置
- application.yml
# refer to https://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference/html/common-application-properties.html
spring:
profiles:
#use dev environment by default
active: dev
jackson:
date-format: yyyy-MM-dd HH:mm:ss
---
# dev environment
spring:
profiles: dev
redis:
host: localhost
port: 6379
server:
port: 8000
---
# production environment
spring:
profiles: pro
redis:
host: 127.0.0.1
port: 6379
server:
port: 8080
- RedisSessionConfig
package com.github.walterfan.hellosession;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@EnableRedisHttpSession
public class RedisSessionConfig {
}
新建購(gòu)物車(chē)類和控制器
- ShoppingCart
package com.github.walterfan.hellosession;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
public class ShoppingCart implements Serializable {
private String cartId;
private String userId;
private List<String> shoppingList;
}
- ShoppingCartController
package com.github.walterfan.hellosession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping(value = "/api/v1")
public class ShoppingCartController {
@RequestMapping(value = "/carts/{cartId}", method = RequestMethod.GET)
public ShoppingCart getShoppingCart (HttpServletRequest request, @PathVariable String cartId){
HttpSession httpSession = request.getSession();
ShoppingCart cart = (ShoppingCart) httpSession.getAttribute(cartId);
log.info("getShoppingCart sessionId={}, cartId={}", httpSession.getId(), cartId);
if(null != cart)
log.info("cart={}", cart);
return cart;
}
@RequestMapping(value = "/carts/{cartId}" , method = RequestMethod.PUT)
public ShoppingCart setShoppingCart (HttpServletRequest request, @PathVariable String cartId, @RequestBody ShoppingCart cart){
HttpSession httpSession = request.getSession();
httpSession.setAttribute(cartId, cart);
log.info("setShoppingCart sessionId={}, cart={}", httpSession.getId(), cart);
return cart;
}
@RequestMapping(value = "/session" , method = RequestMethod.GET)
public Map<String, String> getVersionInfo (HttpServletRequest request){
HttpSession httpSession = request.getSession();
Enumeration<String> names = httpSession.getAttributeNames();
Map<String, String> map = new HashMap<>();
map.put("sessionId", httpSession.getId());
while (names.hasMoreElements()) {
String key = names.nextElement();
String value = String.valueOf(httpSession.getAttribute(key));
map.put(key, value);
}
return map;
}
}
例子代碼參見(jiàn) https://github.com/walterfan/helloworld/tree/master/hellosession
用 postman 嘗試一下, 先保存購(gòu)物車(chē)
PUT http://localhost:8000/api/v1/carts/100
# request:
{
"cartId": "1001",
"userId": "200",
"shoppingList": [
"iphone",
"ipad"
]
}
# response
{
"cartId": "1001",
"userId": "200",
"shoppingList": [
"iphone",
"ipad"
]
}
再讀取購(gòu)物車(chē)
GET http://localhost:8000/api/v1/carts/100
# response
{
"cartId": "1001",
"userId": "200",
"shoppingList": [
"iphone",
"ipad"
]
}
GET http://localhost:8000/api/v1/session
# response
{
"100": "ShoppingCart(cartId=1001, userId=200, shoppingList=[iphone, ipad])",
"sessionId": "e1c33d09-1e7c-47d4-83d3-9932a836ce18"
}
打開(kāi) redis 命令行工具
redis-cli> keys spring:session:*
127.0.0.1:6379> flushall
OK
127.0.0.1:6379> keys spring:session:*
(empty list or set)
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379> keys *
1) "spring:session:sessions:e1c33d09-1e7c-47d4-83d3-9932a836ce18"
2) "spring:session:expirations:1533389160000"
3) "spring:session:sessions:expires:e1c33d09-1e7c-47d4-83d3-9932a836ce18"
127.0.0.1:6379> hgetall "spring:session:sessions:e1c33d09-1e7c-47d4-83d3-9932a836ce18"
1) "maxInactiveInterval"
2) "\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\a\b"
3) "lastAccessedTime"
4) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01e\x05\x02\a~"
5) "sessionAttr:100"
6) "\xac\xed\x00\x05sr\x00.com.github.walterfan.hellosession.ShoppingCart(\bQ\xcd\xb2\x05O\xdb\x02\x00\x03L\x00\x06cartIdt\x00\x12Ljava/lang/String;L\x00\x0cshoppingListt\x00\x10Ljava/util/List;L\x00\x06userIdq\x00~\x00\x01xpt\x00\x02a1sr\x00\x13java.util.ArrayListx\x81\xd2\x1d\x99\xc7a\x9d\x03\x00\x01I\x00\x04sizexp\x00\x00\x00\x02w\x04\x00\x00\x00\x02t\x00\x02pct\x00\x04ipadxt\x00\x02a2"
7) "creationTime"
8) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01e\x05\x02\a~"
推薦一款 Redis 的 Web GUI 工具, 好用簡(jiǎn)單, 主頁(yè)是 https://www.npmjs.com/package/redis-commander, 安裝啟動(dòng)超簡(jiǎn)單:
npm install -g redis-commander
redis-commander -p 9090
打開(kāi) http://localhost:9090
如果這時(shí)你用 curl 再來(lái)試一下
我們用 curl 也來(lái)試一下, 這是不同的session 了
curl -c cookies.txt -X PUT -H "Content-Type: application/json" -d '{"cartId":"101","userId":"200", "shoppingList":["pc", "ipad"]}' http://localhost:8000/api/v1/carts/100
# 響應(yīng)輸出
{"cartId":"101","userId":"200","shoppingList":["pc","ipad"]}
curl -L -b cookies.txt http://localhost:8000/api/v1/carts/100
# 響應(yīng)輸出
{"cartId":"101","userId":"200","shoppingList":["pc","ipad"]}
試試把 cookies.txt 中的 session 改成之前的sessonID, 就可以取回之前存儲(chǔ)的sessionID 了
注意這里的sessionID要base64 編碼
YAFAN-M-N0CV:hellosession yafan$ more cookies.txt
# Netscape HTTP Cookie File
# https://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 SESSION ZjY3Y2MxMDEtYWNkNC00MGE4LThmNDAtOWZlZDljMjRiY2My
所以 session ID 是不能重復(fù)的, 在生成 sessionID 時(shí)于算法上就要保證唯一性,tomcat的算法參見(jiàn)https://tomcat.apache.org/tomcat-8.0-doc/config/sessionidgenerator.html, 其實(shí)我覺(jué)得就用uuid 好了
把 Redis 實(shí)例改成 Redis cluster 的地址, 這個(gè) sesssion 就會(huì)復(fù)制到其他 redis 實(shí)例中, 從而保證了高可用性, Redis 的高并發(fā)量也保證了性能
參考資料
- https://docs.spring.io/spring-session/docs/2.0.4.RELEASE/reference/html5/
- https://www.npmjs.com/package/redis-commander
- http://telescript.denayer.wenk.be/~hcr/cn/idoceo/tcp_connection.html
- https://en.wikipedia.org/wiki/Transmission_Control_Protocol
- https://en.wikipedia.org/wiki/Session_Initiation_Protocol