在分布式系统架构中,用户请求可能被负载均衡器分发到不同的服务器节点。如果用户的第一次请求落在服务器A并创建了Session,而第二次请求被路由到服务器B,服务器B无法识别该用户的Session状态,导致用户需要重新登录,这显然是灾难性的用户体验。
例如在Nginx的负载均衡策略中,通过IP哈希等策略将同一个ip的用户请求固定到同一服务器中,这样session自然也没有失效。
缺点:单点故障风险高(服务器宕机导致Session丢失);扩容时Rehash引发路由混乱。
例如在Tomcat集群中实现Session复制,需通过修改配置文件使不同节点间自动同步会话数据。集群内所有服务器实时同步Session数据。
缺点:同步开销随服务器数量指数级增长,引发网络风暴和内存浪费。
SpringBoot整合Spring Session,通过redis存储方式实现session共享。
通过集中存储Session(如Redis),实现:
在pom.xml中引入关键依赖:
1 2 3 4 5 6 7 8 9 |
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> |
在application.properties中加上Redis的配置:
1 2 3 4 5 |
spring: data: redis: host: localhost port: 6379 |
需要注入一个名为springSessionDefaultRedisSerializer的序列化对象,用于在redis中写入对象时进行序列化,不然session中存入对象会抛出异常。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
package com.morris.redis.demo.session;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
@Configuration public class RedisConfig {
@Bean public GenericJackson2JsonRedisSerializer springSessionDefaultRedisSerializer() { // 需要注入一个名为springSessionDefaultRedisSerializer的序列化对象 // 不然session中存入对象会抛出异常 return new GenericJackson2JsonRedisSerializer(); } } |
不需要显示的通过注解@EnableRedisHttpSession来开启session共享。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
package com.morris.redis.demo.session;
import jakarta.servlet.http.HttpSession; import org.springframework.web.bind.annotation.*;
@RestController public class AuthController {
@PostMapping("/login") public String login(HttpSession session, @RequestBody User user) { // 验证用户凭证... session.setAttribute("currentUser", user); return "登录成功,SessionID:" + session.getId(); }
@GetMapping("/profile") @ResponseBody public User profile(HttpSession session) { // 任意服务节点都能获取到相同Session return (User) session.getAttribute("currentUser"); } } |
调用登录接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
$ curl --location --request POST 'http://172.23.208.1:8080/login' --header 'Content-Type: application/json' --data-raw '{"name": "morris"}' -v Note: Unnecessary use of -X or --request, POST is already inferred. * Trying 172.23.208.1:8080... * TCP_NODELAY set * Connected to 172.23.208.1 (172.23.208.1) port 8080 (#0) > POST /login HTTP/1.1 > Host: 172.23.208.1:8080 > User-Agent: curl/7.68.0 > Accept: */* > Content-Type: application/json > Content-Length: 18 > * upload completely sent off: 18 out of 18 bytes * Mark bundle as not supporting multiuse < HTTP/1.1 200 < Set-Cookie: SESSION=ZTE0Yjc5NjItODFiZS00ZGYwLWE0NDktYTBjNmQ4ZjUxYmYy; Path=/; HttpOnly; SameSite=Lax < Content-Type: text/plain;charset=UTF-8 < Content-Length: 63 < Date: Tue, 24 Jun 2025 03:23:52 GMT < * Connection #0 to host 172.23.208.1 left intact 登录成功,SessionID:e14b7962-81be-4df0-a449-a0c6d8f51bf2 |
可以看到返回的响应头中带有cookie,后续请求需要带上这个cookie去请求接口才能识别出用户。
查询用户信息:
1 2 |
$ curl --location --request GET 'http://172.23.208.1:8080/profile' --cookie 'SESSION=ZTE0Yjc5NjItODFiZS00ZGYwLWE0NDktYTBjNmQ4ZjUxYmYy' {"name":"morris"} |
可以修改端口再启动一个服务,换个服务查询用户信息:
1 2 |
$ curl --location 'http://172.23.208.1:8082/profile' --cookie 'SESSION=ZTE0Yjc5NjItODFiZS00ZGYwLWE0NDktYTBjNmQ4ZjUxYmYy' {"name":"morris"} |
1 2 3 4 5 6 7 8 |
@Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setCookieName("JSESSIONID"); serializer.setDomainNamePattern("example.com"); serializer.setCookiePath("/"); return serializer; } |
这就是为什么不需要使用注解@EnableRedisHttpSession来开启session共享。
SessionAutoConfiguration类中会引入RedisSessionConfiguration。
1 2 3 4 5 6 7 |
@Configuration(proxyBeanMethods = false) @ConditionalOnMissingBean(SessionRepository.class) @Import({ RedisSessionConfiguration.class, JdbcSessionConfiguration.class, HazelcastSessionConfiguration.class, MongoSessionConfiguration.class }) static class ServletSessionRepositoryConfiguration {
} |
RedisSessionConfiguration类中会引入RedisHttpSessionConfiguration:
1 2 3 4 |
@Configuration(proxyBeanMethods = false) @ConditionalOnProperty(prefix = "spring.session.redis", name = "repository-type", havingValue = "default", matchIfMissing = true) @Import(RedisHttpSessionConfiguration.class) static class DefaultRedisSessionConfiguration { |
而注解@EnableRedisHttpSession引入的配置类也是RedisSessionConfiguration:
1 2 3 4 5 6 7 |
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target({ java.lang.annotation.ElementType.TYPE }) @Documented @Import(SpringHttpSessionConfiguration.class) public @interface EnableSpringHttpSession {
} |
自定义过滤器SessionRepositoryFilter拦截所有请求,透明地替换了Servlet容器原生的HttpSession实现。
将请求包装为SessionRepositoryRequestWrapper:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(request, response); SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest, response);
try { filterChain.doFilter(wrappedRequest, wrappedResponse); } finally { wrappedRequest.commitSession(); } } |
HttpServletRequestWrapper中重写getSession()方法实现session会话替换。
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 |
public HttpSessionWrapper getSession(boolean create) { HttpSessionWrapper currentSession = getCurrentSession(); if (currentSession != null) { return currentSession; } S requestedSession = getRequestedSession(); if (requestedSession != null) { if (getAttribute(INVALID_SESSION_ID_ATTR) == null) { requestedSession.setLastAccessedTime(Instant.now()); this.requestedSessionIdValid = true; currentSession = new HttpSessionWrapper(requestedSession, getServletContext()); currentSession.markNotNew(); setCurrentSession(currentSession); return currentSession; } } else { // This is an invalid session id. No need to ask again if // request.getSession is invoked for the duration of this request if (SESSION_LOGGER.isDebugEnabled()) { SESSION_LOGGER.debug( "No session found by id: Caching result for getSession(false) for this HttpServletRequest."); } setAttribute(INVALID_SESSION_ID_ATTR, "true"); } if (!create) { return null; } if (SessionRepositoryFilter.this.httpSessionIdResolver instanceof CookieHttpSessionIdResolver && this.response.isCommitted()) { throw new IllegalStateException("Cannot create a session after the response has been committed"); } if (SESSION_LOGGER.isDebugEnabled()) { SESSION_LOGGER.debug( "A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for " + SESSION_LOGGER_NAME, new RuntimeException("For debugging purposes only (not an error)")); } S session = SessionRepositoryFilter.this.sessionRepository.createSession(); session.setLastAccessedTime(Instant.now()); currentSession = new HttpSessionWrapper(session, getServletContext()); setCurrentSession(currentSession); return currentSession; } |
RedisSessionRepository负责创建RedisSession。
1 2 3 4 5 6 7 |
public RedisSession createSession() { MapSession cached = new MapSession(this.sessionIdGenerator); cached.setMaxInactiveInterval(this.defaultMaxInactiveInterval); RedisSession session = new RedisSession(cached, true); session.flushIfRequired(); return session; } |
session保存时使用的是sessionRedisOperations,其实就是RedisTemplate,这个RedisTemplate是spring session自己创建的,而不是使用的项目中的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
private void save() { saveChangeSessionId(); saveDelta(); if (this.isNew) { this.isNew = false; } }
private void saveDelta() { if (this.delta.isEmpty()) { return; } String key = getSessionKey(getId()); RedisSessionRepository.this.sessionRedisOperations.opsForHash().putAll(key, new HashMap<>(this.delta)); RedisSessionRepository.this.sessionRedisOperations.expireAt(key, Instant.ofEpochMilli(getLastAccessedTime().toEpochMilli()) .plusSeconds(getMaxInactiveInterval().getSeconds())); this.delta.clear(); } |