最近公司承接的某项目里,随着用户量的递增,单服务渐渐不能满足客户的需求了,客户方希望我们对原有的服务进行扩展,采用分布式集群部署的方式实现对高并发和负载均衡的支持。这本一是个被广泛应用的架构,按理说实现起来并不困难。但是,我们在进行单服务到多服务的转换时,遇到了一个大难题:Session处理。
因早期架构设计存在一定的局限性,我们在功能研发时,很多程序里都直接使用Session来进行信息的存储和查询,如用户信息、部分关键字典等。然而在做分布式部署时,怎么处理这些用到Session的程序成了我们的大难题。
举个例子,用户第一次登陆时,通过网关链接到服务器A,服务器A处理了用户的登陆信息,并将用户权鉴信息存到了Session里,等用户使用系统中的某个功能时,网关改用服务器B进行处理。而B首先会从Session里查询用户的权鉴信息,如果查不到,就让用户重新登陆。这时候用户看到系统上的登陆界面就茫然了:怎么我刚登完,又让我登一次?你们还讲不讲道理了?
这个缺陷严重影响了功能的使用。因此,要想实现分布式部署,就必须得做到权鉴统一,一次登陆,多服务有效。摆在我们面前的路其实有很多,比如:
1、上CAS单点登陆或者AUTH2这样的认证服务,专门对用户的登陆,权限信息进行管理。这种解决方案比较成熟,如果在架构设计之初就实施,确实是一个很好的办法。但是眼下执行起来却比较困难。首先是要考虑认证服务的可用性、集群部署,而后需要对现有的代码做大量的改动,废掉每个服务里的登陆功能,再把以前用到Session的地方全改成从认证服务获取信息,改动太大,影响了系统的稳定性。
2、通过redis来存储和获取用户信息,所有用Session的地方,全换成用redis存取数据。相对方法1来说,改动略小,但是还是涉及较多的程序变动。
3、通过Spring Session,在不对原有代码做太多改动的情况下,用redis对Session信息进行存储。
经过多次对比和论证,我们决定采用第3种方法来解决这个问题。本文就是在实践Spring Session时,我的一些心得和体会。
在使用Spring Session之前,必须了解一些基本概念,如与Spring Session紧密相关的两个概念:Cookie和Session。
Cookie
HTTP 协议是一种无状态,无连接的协议,当客户端发出一个请求时,它们之间就会建立一个连接,等服务器响应了这个请求,这个连接就会被断开,服务器和客户端都不会记住它们曾经的亲密接触,客户端不会记得它临幸过哪个服务器,服务器也不会知道它为谁服务过,典型的渣男渣女行为,这是令人十分不齿的!
因为HTTP无状态的特性无法满足交互式Web的需求, Cookie诞生了。它可以把用户的信息储存起来,用于做会话状态管理(如用户登录状态、购物车或其它需要记录的信息);个性化设置(如用户自定义设置、主题等);浏览器行为跟踪(如跟踪分析用户行为等)。服务器把用户登录的信息保存到客户端的 Cookie 中,这样用户感觉这个网站已经记住了自己。
但是 Cookie 也有它的缺点:
1、不宜存储过长的数据。
2、Cookie 是以文件的形式保存在客户端的磁盘上,所以一 些重要数据很容易被修改(比如用户购买一些东西之后,修改自己的余额,然后提交给服务器),因此Cookie信息也不够安全。
Session
Session是服务器端使用的一种记录客户端状态的机制,在使用上比Cookie简单一些,相应的也增加了服务器的存储压力。相比于Cookie,Session 就能保证数据的安全,因为它是保存在服务器上的。Session具有以下特点:
1、Session中的数据保存在服务器端;
2、Session中可以保存任意类型的数据;
3、Session默认的生命周期是20分钟,可以手动设置更长或更短的时间 (这里不建议将Session的超时时间设置过长,因为默认情况下Session在内存中保存,设置时间过长,保存的数据过大会导致内存不足)。
Session的生命周期
Session中的数据保存在服务器端,在客户端需要的时候创建Session,在客户端不需要的时候销毁Session,使它不再占用服务器内存。前面说了服务器并不管客户端是否依然存在,因而它也无法确定客户端什么时间不再使用它,但是如果在客户端不用的时候不及时销毁Session,服务器很快就会内存不足。为了解决这个问题,给Session加了一个生命周期,当服务器发现Session超过了它的生命周期,就会释放该Session所占用的内存空间。
Session的销毁有两种方式:
1、session调用了session.invalidate()方法.
2、前后两次请求超出了Session指定的生命周期时间(Session生成后,只要用户继续访问,服务器就会更新Session的最后访问时间,并维护该Session。用户每访问服务器一次,无论是否读写Session,服务器都认为该用户的Session"活跃(active)"了一次。而长时间不读写Session,过了Session指定的生命周期时间,Session就会被服务器销毁。)
使session失效的方法有:
1、关闭tomcat
2、reload web应用
3、超出了Session生命周期时间
4、invalidate session
Cookie与Session使用场景
Cookie技术可以将信息存储在不同的浏览器中,并且可以实现多次请求下的数据共享,分为临时Cookie和长久Cookie。如果一个Cookie没有设置有效期,那么浏览器在关闭时就会删除这个Cookie,这种Cookie叫做临时Cookie;如果Cookie设置了有效期,那么浏览器会一直保存这个Cookie,直到有效期为止,这种Cookie叫做长久Cookie。
Session是一种建立在Cookie之上的通信状态保留机制,可以实现在服务端存储某个用户的一些信息。服务器创建Session后,将Session的ID以Cookie的形式返回给浏览器,只要浏览器不关,再去访问服务器时,就会携带着Session的ID,服务器发现浏览器带Session的ID过来,就会使用内存中与之对应的Session为之服务。如果说Cookie机制是通过检查客户身上的“通行证”来确定客户身份的话,那么Session机制就是通过检查服务器上的“客户明细表”来确认客户身份。Session相当于程序在服务器上建立的一份客户档案,客户来访的时候只需要查询客户档案表即可。
注意:新开的浏览器窗口会生成新的Session,但子窗口除外。子窗口会共用父窗口的Session。例如,在链接上右击,在弹出的快捷菜单中选择"在新窗口中打开"时,子窗口便可以访问父窗口的Session。
熟悉了上述的相关概念后,接下来,咱们郑重的介绍一下Spring Session。
Spring Session是Spring项目下的一个子项目,主要用于在分布式系统环境下实现Session共享。使用Spring Session能简单有效地实现微服务环境下的Session共享。轻松解决分布式系统中的session管理难题。它提供了多种Session存储方案,主要有两种:
1、Servlet容器Session存储:默认方案,使用Servlet容器(如Tomcat)自带的Session存储机制。优点是简单易用,但无法实现Session共享。
2、Spring Sessionstore存储:提供多种Session Store实现,支持Session共享。主要有:
- RedisStore:将Session存储在Redis中,多台服务器共用一个Redis实现Session共享。这是默认推荐的方案。
- HazelcastStore:将Session存储在Hazelcast缓存中,多台服务器使用同一个Hazelcast集群实现Session共享。
- MongoDBStore:将Session存储在MongoDB文档数据库中,多台服务器使用同一个MongoDB实现Session共享。
- JDBCStore:将Session存储在关系型数据库中,多台服务器使用同一个数据库实现Session共享。
Spring Session生命周期
回到之前说的Session的生命周期问题,在这里补充一下,当使用Spring Session时,Session的生命周期发生了一定的变化:
1、创建Session:当用户第一次访问网站时,一个全新的Session会被创建,同时也会在配置的后端存储(比如Redis)中创建一个key,value就是新创建的Session对象。
2、会话活动:每当用户发送一个请求,如果还在会话超时时间内,会话就会变为活动状态。这将更新在Redis中的键的过期时间戳,确保它不会过期。
3、会话非活动:如果用户在一定时间内没有发送任何请求,会话变为非活动状态。这不会更新Redis key的过期时间戳。如果key在过期时间内没有再次变为活动状态,它最终会过期,session会被销毁。
4、显式销毁:当调用HttpServletRequest#logout() 或 HttpSession#invalidate()时,Session会被销毁。这也会导致Redis中的key被删除。
5、key过期:如果在会话超时时间内Session没有变为活动状态,Redis key会过期并被删除。这也会销毁与之对应的Session。
所以,与常规的Session生命周期不同的是:Spring Session引入了“非活动”状态,并且真正的Session销毁时间也推迟到了Redis key的过期时间。这带来的好处是可以配置一个较长的全局session过期时间,而通过续期使活动Session保持有效。非活动的Session会在较短的时间内过期,这有利于节省服务器资源。
另外,使用Spring Session还需要注意的地方是:必须配置Session超时时间与Redis key过期时间保持一致,否则会出现Session被错误销毁或未及时销毁的问题。
在application.properties中配置这两个过期时间的方法如下:
#设置session的过期时间为30分钟
spring.session.timeout=30m
# 设置redis的过期时间为30分钟
spring.session.redis.timeout=1800
通过以上的介绍,相信大家对Spring Session有了一定的了解,接下来我们通过一次简单的实践,来让大家们领略它的魅力。
1、导入依赖
org.springframework.session
spring-session-data-redis
redis.clients
jedis
2、在application.yml中配置redis连接
spring:
redis:
host: 127.0.0.1
port: 6379
session:
store-type: redis # Session存储类型为REDIS
3、创建config类,实现Spring Session的支持
java
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400*30)
public class RedisSessionConfig {
}
4、创建一个简单的用户对象及服务调用
// 实体类
public class User {
private String username;
private String password;
}
// controller类
@RestController
public class UserController {
@GetMapping("/user/{username}")
public User getUser(@PathVariable("username") String username) {
User user = new User(username, "123456");
//从session中获取用户访问次数,并累加1
Integer count = (Integer) session.getAttribute(username);
count = (count == null ? 1 : count + 1);
session.setAttribute(username, count);
//从session中获取用户信息,证明session已与redis共享
User sessionUser = (User) session.getAttribute("user");
return user;
}
}
有了以上简单的示例, 接下来咱们玩点有难度的,直接上SpringCloud大礼包,通过Provider提供Session,Consumer消费Session,Zuul实现Provider,Consumer的调用,Eureka来监控各服务的状态,安排!
1、首先,在Session Provider中添加以下方法:
@GetMapping("/user/{username}")
public User getUser(@PathVariable("username") String username) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
HttpSession session = request.getSession();
User user = (User) session.getAttribute(username);
if (user == null) {
user = new User(username, "123456");
}
user.setCount((user.getCount() == null ? 1 : user.getCount() + 1));
session.setAttribute(username, user);
return user;
}
2、在Session Consumer中添加以下方法:
@GetMapping("/getUserInfo/{username}")
public User getUser(@PathVariable("username") String username) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
HttpSession session = request.getSession();
//获取用户信息
User sessionUser = (User) session.getAttribute(username);
if (sessionUser == null ) {
return new User("无法从session中获取到用户信息", Strings.EMPTY);
}
sessionUser.setCount((sessionUser.getCount() == null ? 1 : sessionUser.getCount() + 1));
session.setAttribute(username, sessionUser);
return sessionUser;
}
3、Eureka和Zuul就不写了,按上面的代码逻辑,如果可以实现Session共享,则通过Provider访问了一次请求后,在Consumer中就可以根据用户名获得用户信息,还可以得到递增的查询用户的次数(count)。而测试结果也与预期一致。如下:
-访问Eureka,得到各服务状态:
-直接访问Session Provider
-直接访问Session Consumer
-通过Zuul 访问Session Provider
-通过Zuul 访问Session Consumer
(文末附上本人在调试Spring Session时编辑的源码,CV不易,请大佬们不要吝惜你们的点赞哈,雷袭在这里谢谢大家了!)