MiddleWare-Redis共享Session
在微服务架构中,往往由多个微服务共同支撑前端请求,如果涉及到用户状态就需要考虑分布式 Session 管理理问题,比如用户登录请求分发在服务器 A,用户购买请求分发到了服务器 B, 那么服务器就必须可以获取到用户的登录信息,否则就会影响正常交易。因此,在分布式架构或微服务架构下,必须保证一个应用服务器上保存 Session 后,其他应用服务器可以同步或共享这个 Session。
目前主流的分布式 Session 管理有两种方案。
Session 复制
部分 Web 服务器能够支持 Session 复制功能,如 Tomcat。用户可以通过修改 Web 服务器的配置文件,让Web 服务器进行 Session 复制,保持每一个服务器节点的 Session 数据都能达到一致。 这种方案的实现依赖于 Web 服务器,需要 Web 服务器有 Session 复制功能。当 Web 应用中 Session 数量较多的时候,每个服务器节点都需要有一部分内存用来存放 Session,将会占用大量内存资源。同时大量的Session 对象通过网络传输进行复制,不但占用了了网络资源,还会因为复制同步出现延迟,导致程序运行错误。
在微服务架构中,往往需要 N 个服务端来共同支持服务,不建议采用这种⽅方案。
Session 集中存储
在单独的服务器或服务器集群上使用缓存技术,如 Redis 存储 Session 数据,集中管理所有的 Session,所有的 Web 服务器都从这个存储介质中存取对应的 Session,实现 Session 共享。将 Session 信息从应用中 剥离出来后,其实就达到了了服务的无状态化,这样就方便在业务极速发展时水平扩充。
在微服务架构下,推荐采用此方案,接下来详细介绍。
Session 共享
什么是 Session
由于 HTTP 协议是无状态的协议,因而服务端需要记录用户的状态时,就需要用某种机制来识具体的用户。Session 是另⼀一种记录客户状态的机制,不同的是 Cookie 保存在客户端浏览器中,而 Session 保存在服务器上。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上,这就是 Session。客户端浏览器再次访问时只需要从该 Session 中查找该客户的状态就可以了了。
为什么需要 Session 共享
在互联网⾏行行业中用户量访问巨大,往往需要多个节点共同对外提供某一种服务,如下图:
用户的请求首先会到达前置网关,前置网关根据路由策略将请求分发到后端的服务器,这就会出现第一次的请求会交给服务器 A 处理,下次的请求可能会是服务 B 处理,如果不做 Session 共享的话,就有可能出现用户在服务 A 登录了了,下次请求的时候到达服务 B ⼜又要求用户重新登录。
前置网关我们⼀一般使用 lvs、Nginx 或者 F5 等软硬件,有些软件可以指定策略让用户每次请求都分发到同⼀台服务器中,这也有个弊端,如果当其中一台服务 Down 掉之后,就会出现一批用户交易失效。在实际工作中我们建议使用外部的缓存设备来共享 Session,避免单个节点挂掉而影响服务,使用外部缓存 Session后,我们的共享数据都会放到外部缓存容器中,服务本身就会变成无状态的服务,可以随意的根据流量的⼤小增加或者减少负载的设备。
Spring 官⽅方针对 Session 管理这个问题,提供了专门的组件 Spring Session,使用 Spring Session 在项目中集成分布式Session非常方便。
Spring Session
Spring Session 提供了一套创建和管理 Servlet HttpSession 的⽅方案。Spring Session 提供了了集群 Session(Clustered Sessions)功能,默认采⽤用外置的 Redis 来存储 Session 数据,以此来解决 Session 共 享的问题。
Spring Session 为企业级 Java 应用的 Session 管理带来了了革新,使得以下的功能更加容易实现:
-
API 和用于管理用户会话的实现;
- HttpSession,允许以应⽤用程序容器(即 Tomcat)中性的方式替换 HttpSession;
- 将 Session 所保存的状态卸载到特定的外部 Session 存储中,如 Redis 或 Apache Geode 中,它们能 够以独立于应用服务器的方式提供高质量的集群;
- 支持每个浏览器上使用多个 Session,从而能够很容易地构建更加丰富的终端用户体验;
- 控制 Session ID 如何在客户端和服务器之间进行行交换,这样的话就能很容易地编写 Restful API,因为 它可以从 HTTP 头信息中获取 Session ID,而不必再依赖于 cookie;
- 当⽤用户使用 WebSocket 发送请求的时候,能够保持 HttpSession 处于活跃状态。
需要说明的很重要的一点就是,Spring Session 的核⼼心项目并不依赖于 Spring 框架,因此,我们甚至能够将其应用于不使用 Spring 框架的项目中。
Spring 为 Spring Session 和 Redis 的集成提供了了组件:spring-session-data-redis,接下来演示如何使用。
快速集成
引入依赖包
1
2
3
4
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
添加配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 数据库配置
spring:
datasource:
url: jdbc:mysql://xxxxxxx
driver-class-name: com.mysql.cj.jdbc.Driver
username: ********
password: ********
# JPA 配置
jpa:
properties:
hibernate:
hbm2ddl:
auto: update
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
show-sql: true
# Redis 配置
redis:
# Redis数据库索引(默认为0)
database: 3
# Redis服务器地址
host: x.x.x.x
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
password:
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
shutdown-timeout: 100
整体配置分为三块: 数据库配置、JPA配置、Redis配置
在项目中创建SessionConfig类,使用注解配置器过期时间
SessionConfig.java
1
2
3
4
5
6
7
8
9
10
11
package top.withlevi.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400 * 30)
public class SessionConfig {
}
maxInactiveIntervalInSeconds: 配置Session失效时间,使用Redis Session之后,原Spring Boot中的server.session.timeout 属性
需要这两步Spring Boot 分布式 Session 就配置完成了。
测试验证
首先我们要编写RedisConfig, 为了防止在存写的时候key-value的时候乱码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package top.withlevi.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@EnableCaching
public class RedisConfig {
/**
* 配置自定义redisTemplate
* @return
*/
@Bean
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
// 设置值(value)的序列化采用Jackson2JsonRedisSerializer。
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
// 设置键(key)的序列化采用StringRedisSerializer。
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
然后再编写Web层写两个方法进行验证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package top.withlevi.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.withlevi.model.User;
import top.withlevi.repository.UserRepository;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
/**
* Created by Levi Zhao on 7/18/2023 9:39 AM
*
* @Author Levi
*/
@RestController
public class SessionController {
@Resource
private UserRepository userRepository;
@RequestMapping(value = "/setSession")
public Map<String, Object> setSession(HttpServletRequest request) {
Map<String, Object> map = new HashMap<>();
request.getSession().setAttribute("message", request.getRequestURL());
map.put("request Url ", request.getRequestURL());
return map;
}
@RequestMapping(value = "/getSession")
public Object getSession(HttpServletRequest request) {
Map<String, Object> map = new HashMap<>();
map.put("sessionId", request.getSession().getId());
map.put("message", request.getSession().getAttribute("message"));
return map;
}
}
在请求setSession的地址的时候,并把请求地址存放到Key为message的Session中,同事结果返回页面。
在请求getSession的地址的时候,获取Session中的Session Id 和Key为message的信息,将获取到的信息封装到Map中并在页面展示。
在进行测试之前我们需要需要上述的步骤在创建另外一个spring-boot-redis-session项目,并在yaml配置文件中把启动端口改为:9090
1
2
server:
port: 9090
修改完成后依次启动这两个项目。
首先访问8080端口的服务,浏览器输入网址
1
http://localhost:8080/setSession
页面返回的信息如下:
1
{"request Url ":"http://localhost:8080/setSession"}
然后在浏览器中输入以下网址
1
http://localhost:8080/getSession
页面返回的信息如下:
1
{"sessionId":"5ab6402b-bd34-4358-8533-379f4be65e96","message":"http://localhost:8080/setSession"}
说明Url地址信息已经存放到Session中。
然后访问9090端口的服务,浏览器输入网址:
1
http://localhost:9090/getSession
页面返回的信息如下:
1
{"sessionId":"5ab6402b-bd34-4358-8533-379f4be65e96","message":"http://localhost:8080/setSession"}
通过对比发现,8080端口的服务和9090端口的服务返回的Session信息完全一致,说明已经实现了Session共享
模拟登录
在实际中作中常使用共享 Session 的方式去保存用户的登录状态,避免用户在不同的页面多次登录。我们来简单模拟一下这个场景,假设有一个 index 页面,必须是登录的用户才可以访问,如果用户没有登录给出请登录的提示。在一台实例上登录后,再次访问另外一台的 index 看它是否需要再次登录,来验证统一登录是否成功。
添加登录方法,登录成功后将用户信息存放到 Session 中
先创建User
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package top.withlevi.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.io.Serializable;
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "user")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue
private Long id;
@Column(nullable = false, unique = true)
private String userName;
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String nickname;
@Column(nullable = false)
private String regTime;
}
然后创建repository
1
2
3
4
5
6
7
8
9
10
11
12
13
package top.withlevi.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import top.withlevi.model.User;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByUserName(String userName);
}
最后在Web层进行完善
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package top.withlevi.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.withlevi.model.User;
import top.withlevi.repository.UserRepository;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
/**
* Created by Levi Zhao on 7/18/2023 9:39 AM
*
* @Author Levi
*/
@RestController
public class SessionController {
@Resource
private UserRepository userRepository;
@RequestMapping(value = "/setSession")
public Map<String, Object> setSession(HttpServletRequest request) {
Map<String, Object> map = new HashMap<>();
request.getSession().setAttribute("message", request.getRequestURL());
map.put("request Url ", request.getRequestURL());
return map;
}
@RequestMapping(value = "/getSession")
public Object getSession(HttpServletRequest request) {
Map<String, Object> map = new HashMap<>();
map.put("sessionId", request.getSession().getId());
map.put("message", request.getSession().getAttribute("message"));
return map;
}
/**
* 验证是否登录的页面
* @param request
* @return
*/
@RequestMapping("/index")
public String index(HttpServletRequest request) {
String msg = "index content";
Object user = request.getSession().getAttribute("user");
System.out.println(user);
if (user == null) {
msg = "Please login first.";
}
return msg;
}
/**
* 登录方法
* @param request
* @param userName
* @param password
* @return
*/
@RequestMapping("/login")
public String login(HttpServletRequest request, String userName, String password) {
String msg = "login failure!";
User user = userRepository.findByUserName(userName);
if (user != null && user.getPassword().equals(password)) {
request.getSession().setAttribute("user", user);
msg = "Login successful.";
}
return msg;
}
/**
* 登出方法
* @param request
* @return
*/
@RequestMapping("/logout")
public String logout(HttpServletRequest request) {
request.getSession().removeAttribute("user");
return "logout successful.";
}
}
通过 JPA 的方式查询数据库中的用户名和密码,通过对比判断是否登录成功,成功后将用户信息存储到 Session 中。
我们依次启动8080端口和9090端口的项目,然后在数据库的user表中添加一个user,例如:
1
INSERT INTO `user` VALUES ('1','test@gmail.com','xiaozhao','zhao@2023','2023','Levi');
注意启动之前需要把yaml中关于JPA中 改为ddl-auto:update
首先测试 8080 端⼝口的服务,直接访问网址
1
http://localhost:8080/index
页面返回:
1
please login first!
提示请先登录。我们将验证用户名为 Levi,密码为 zhao@2023 用户登录。
访问地址:
1
http://localhost:8080/login?userName=Levi&password=zhao@2023
模拟⽤用户登录,返回:
1
login successful!
提示登录成功。我们再次访问地址
1
http://localhost:8080/index
返回
1
index content
说明已经可以查看受限的资源。
再来测试 9090 端口的服务,直接访问网址
1
http://localhost:9090/index
页面返回
1
index content
并没有提示请先进行登录,这说明 9090 服务已经同步了了用户的登录状态,达到了了统一登录的目的。 我们在 8080 服务上测试用户退出系统,再来验证 9090 的用户登录状态是否同步失效。首先访问地址
1
http://localhost:8080/loginout
模拟用户在 8080 服务上退出,访问网址
1
http://localhost:8080/index
返回
1
please login first!
说明用户在 8080 服务上已经退出。再次访问地址
1
http://localhost:9090/index
页面返回:
1
please login first!
说明 9090 服务上的退出状态也进行了了同步。 注意,本次实验只是简单模拟统一登录,实际⽣生产中我们会以 Filter 的方式对登录状态进行校验. 我们最后来看一下,使用 Redis 作为 Session 共享之后的示意图:
从上图可以看出,所有的服务都将 Session 的信息存储到 Redis 集群中,无论是对 Session 的注销、更新都 会同步到集群中,达到了 Session 共享的目的。
总结
在微服务架构下,系统被分割成大量的小而相互关联的微服务,因此需要考虑分布式 Session 管理,方便平台架构升级时水平扩充。通过向架构中引入高性能的缓存服务器,将整个微服务架构下的 Session 进行统⼀管理。
Spring Session 是 Spring 官方提供的 Session 管理组件,集成到 Spring Boot 项目中轻松解决分布式Session 管理的问题。