Session 会话
通常我们所说的会话是两个或更多个通信设备之间或计算机和用户之间的半永久性交互式信息交换, 会话在某个时间点建立,然后在稍后的时间点拆除。
建立的通信会话可以在每个方向上涉及多于一个消息, 这些消息只存在这个会话中, 而与其他会话隔离.
会话通常是有状态的,这意味着至少一个通信部分需要保存关于会话历史的信息以便能够进行通信,这与无状态通信相反,其中通信由具有响应的独立请求组成。
而状态保存在什么地方, 有很多选择, 内存中, 磁盘上, 共享缓存中, 数据库里, 总有一款适合你.
常见的会话就有 TCP Session, SIP Session , RTP Session , HTTP Session 等等, 分别工作在传输层, 会话层和应用层
TCP Session
这个自不必说, 用三次握手建立会话, 四次挥手终止会话
-
三次握手
四次挥手
这样在连接的两端就建立了一个 TCP Session, 并且维护着会话状态
SIP/RTP Session
SIP是一种应用层控制协议,可以建立,修改和终止多媒体会话(会议),例如互联网电话呼叫,多媒体分发播放和多媒体会议。它在TCP 或 UDP 之上通过 INVITE 消息来搭建用户代理之间的信令(控制 - SIP Session) 和媒体会话 (RTP Session)
这里不做赘述, 请见微服务协议之 SIP
更多细节见 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
这里重点讲讲 HTTP Session, 传统 Web 应用里都有一个 session 的概念,相比用 Cookie 在客户端记录信息确定用户身份, Session 一般是在服务器端记录信息确定用户身份和状态, 这里的状态不仅指用户登录和在线的状态, 也包括应用层中的一些业务相关的信息, 比如很多网站的购物车就是放在 Http Session 里的.
在分布式系统中, 通常不建议将会话状态放在一台服务器的内存或磁盘中, 因为这样的话, 系统会有单点失败而导致的服务不可用, 如下图所示:
如果客户端与服务器的 session 只存在于 server 1 中 负载均衡器做流量派发时, 必须要把流量派发到server 1, 这叫 session sticky , 一旦 server 1 挂掉了, 这个对话状态就丢失了, 如果你在网站购物, 突然购物车里选好的宝贝都没了, 这多让人恼火
对于session 状态的管理我们一般有三种策略
session sticky 会话粘滞
如上所述, 会话在一台服务器上持续, 直到会话终止, 问题在于单点失败session replicate 会话复制
将会话信息复制到各台服务器上, 例如利用多播技术及组通信技术把状态同步到组中的每一台server, 我曾经用过 Jgroups, 在服务器数量不多的情况下工作得不错, 可是如果服务器距离较远并不在一个网段, 服务器数量较多, 这种方案就不适合了, 同步消息过多且有性能问题.session Repository 会话仓库
会话状态存储在共享的数据仓库中, 这样每台server 都可以轻松存取, 会话仓库可以是传统关系型数据库或NOSQL产品, 不过单点失败转移到了会话仓库, 如果访问量比较大且存取频繁, 对会话仓库的要求也比较高, 鉴于会话并不需要存储很长时间, 相比 Oracle/MySQL, Cassandra 或 Redis 更加合适
就以现在比较流行的 Spring Session 的 Redis 方案为例
购物车示例 Spring Session + Redis
Redis 的安装和配置不说了, 非常简单, 参见Redis 入门,
我是在自己的 macbook 中启了一个 redis docker image , 侦听端口是 6379
建立一个 Spring Boot 项目, 在 https://start.spring.io 上选择
- Session
- Lombok
- Web
- Redis
将生成的压缩包解开, 这是一个 spring boot 项目的框架
让我们先看看所需要的依赖库
- 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 {
}
新建购物车类和控制器
- 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;
}
}
例子代码参见 https://github.com/walterfan/helloworld/tree/master/hellosession
用 postman 尝试一下, 先保存购物车
PUT http://localhost:8000/api/v1/carts/100
# request:
{
"cartId": "1001",
"userId": "200",
"shoppingList": [
"iphone",
"ipad"
]
}
# response
{
"cartId": "1001",
"userId": "200",
"shoppingList": [
"iphone",
"ipad"
]
}
再读取购物车
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"
}
打开 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 工具, 好用简单, 主页是 https://www.npmjs.com/package/redis-commander, 安装启动超简单:
npm install -g redis-commander
redis-commander -p 9090
如果这时你用 curl 再来试一下
我们用 curl 也来试一下, 这是不同的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
# 响应输出
{"cartId":"101","userId":"200","shoppingList":["pc","ipad"]}
curl -L -b cookies.txt http://localhost:8000/api/v1/carts/100
# 响应输出
{"cartId":"101","userId":"200","shoppingList":["pc","ipad"]}
试试把 cookies.txt 中的 session 改成之前的sessonID, 就可以取回之前存储的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 是不能重复的, 在生成 sessionID 时于算法上就要保证唯一性,tomcat的算法参见https://tomcat.apache.org/tomcat-8.0-doc/config/sessionidgenerator.html, 其实我觉得就用uuid 好了
把 Redis 实例改成 Redis cluster 的地址, 这个 sesssion 就会复制到其他 redis 实例中, 从而保证了高可用性, Redis 的高并发量也保证了性能
参考资料
- 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