现在我直接开写这篇博文。这标题里的内容其实是个老话题了,Session 从单机走向分布式,几乎是每一位后端开发者成长路上绕不开的坎。我拆成了 Session 基础、分布式困局、Redis 落地、实操验证、问题排查这五大部分,全程按真实项目里会踩的坑来写。
1. 写在前面:Session、Redis、分布式这三件事,为什么总绑在一起聊
先说个现实:只要你的系统是单机部署,Session 根本不会成为问题。用户登录之后,服务端把用户状态往本地内存一放,再把对应的 sessionId 种到浏览器 Cookie 里,用户下次请求带着这个 id 过来,服务端查一下内存,身份就确认了。这套机制在 Servlet 时代被玩得滚瓜烂熟,写 CRUD 写久了的人,甚至感觉不到 Session 的存在。
但事情一旦从单机变成集群——比如你搞了两台 Tomcat 放在 Nginx 后面做负载均衡,问题立刻浮出水面。用户在 A 机器上登录,下一次请求被负载均衡转发到了 B 机器,B 机器内存里根本没有这条 Session,于是用户被当成一个陌生人,被迫重新登录。要是一天多登几次,要么用户骂娘,要么产品来骂你。
解决思路其实也不玄乎:把 Session 从每台机器的内存里挪出来,集中放到一个所有机器都能访问的地方,这就是“分布式共享 Session”。而 Redis 因为高性能、支持过期时间、数据结构灵活,顺理成章地成为绝大多数项目的首选。
这篇文章我不打算只给你堆配置,我要把三件事讲透:Session 底层到底是怎么回事,分布式场景下 Session 为什么失效,以及如何用 Redis 让集群环境下的登录状态做到“一处登录,处处可用”。最后我会用一个 Spring Boot 项目,从搭建 Redis 环境到写登录接口、再到验证 Session 是否真正共享,全程走一遍。如果你正在做项目重构,或者刚接触微服务和分布式,被多台机器的登录态问题困扰过,这篇内容应该能帮你省掉不少排查时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Session 核心机制拆解:从一次登录请求到底发生了什么开始讲
2.1 HTTP 的无状态与 Session 的“记忆”原理
HTTP 协议本身是无状态的,这句话你可能听烂了,但它的含义值得再咀嚼一遍。每个 HTTP 请求都是独立的,服务端处理完一个请求后,不会主动记住“你刚刚来过”。也就是说,服务端默认对每个请求一视同仁,它不知道请求来自同一个用户还是不同用户。
为了打破这个“失忆症”,我们需要一种机制,让服务端能认出“这个请求是张三发的,而且张三十秒前刚刚登录过”。Session 就是为此诞生的。
我拿现实生活做个类比:Session 像是健身房给你办的一张会员卡。第一次去(首次访问),前台登记了你的信息(创建 Session),发你一张卡(返回 sessionId),你每次进门掏出卡(浏览器带上 Cookie),前台扫一眼卡号,就能从系统里调出你的全部资料(服务端 session.getAttribute)。没带卡?不认人,重新登记。
在这个类比里,健身房的前台系统就是服务端内存里的 Session 存储区,那张卡就是 sessionId。Session 之所以能“记住”你,不是因为 HTTP 变聪明了,而是因为浏览器替你把身份证件(sessionId)每次自动带上了。
2.2 Cookie 里到底装了什么:sessionId 的完整生命周期
这里有一个关键点,很多人会混淆:Session 数据真的存在 Cookie 里吗?答案是不在。Cookie 里只存了一个会话标识符 sessionId,真正的用户数据(用户名、角色、购物车之类的)都在服务端存着。
sessionId 的完整生命周期是这样的:
- 浏览器首次请求应用,服务端调用 request.getSession(),容器(比如 Tomcat)生成一个唯一字符串作为 sessionId,这个 id 通常是随机生成的,不是简单的自增数字,防止被猜测。
- 服务端在内存中创建 HttpSession 对象,往里塞数据,同时把 sessionId 写进响应头
Set-Cookie: JSESSIONID=xxxx; Path=/; HttpOnly。 - 浏览器收到响应后,把这个 Cookie 存起来。
- 之后浏览器对同一域名发起任何请求,都会自动在请求头里带上
Cookie: JSESSIONID=xxxx。 - 服务端拿到 sessionId 后,在内存里找到对应的 HttpSession 对象,把它放到当前线程的上下文中,这样你的代码里拿到的 request.getSession() 就是同一个对象。
当用户关闭浏览器时,这个会话 Cookie 会被浏览器清掉(因为没有设置过期时间,它属于会话级 Cookie)。注意:服务端内存里的 Session 对象不会立刻消失,它要等超时——默认是 30 分钟没有活动才会被回收。
这段机制里最容易被忽视的一个细节是:sessionId 是明文放在 Cookie 里的。如果网络被监听,或者前端脚本能读取这个值,那别人就可以冒充你。所以服务端种 Session Cookie 时一定会加 HttpOnly 标志,这个标志的意思是“JS 代码无法通过 document.cookie 读取这个值”,能有效防 XSS 攻击窃取身份。后面我会再展开讲安全细节。
2.3 服务端 Session 的存储与过期策略
理解了 sessionId 的流动,再看服务端的存储。以 Tomcat 为例,Session 默认存在 ConcurrentHashMap 里,ConcurrentHashMap 的 key 是 sessionId,value 是 StandardSession 对象。每个 StandardSession 内部又是一个 HashMap,用来装 setAttribute(name, value) 进去的业务数据。
Tomcat 对 Session 的管理里有几个极其重要的参数,很多线上事故都和它们有关:
sessionTimeout:Session 空闲超时时间,默认 30 分钟。用户在这段时间内没有新的请求,Session 会被 Tomcat 后台线程回收。注意是“空闲超时”,不是“绝对超时”——只要用户持续在操作,Session 就一直活着。maxInactiveInterval:作用于单个 Session 的秒数,它和 sessionTimeout 是同一个东西的两个视图。- Session 的创建时机:很多人以为 session 是登录时才创建的,其实不是。只要你的代码里调用了
request.getSession(),Tomcat 就会立刻创建一个新的 Session。哪怕你什么都没往里面放,只是拿了一下对象,Session 就已经存在了。
我见过不少性能排查案例,发现内存里堆满了 Session,最后定位到原因:有些请求接口根本没有登录需求,但代码里习惯性地调了 request.getSession(),于是一万个匿名用户就产生了一万个空 Session,白占内存。怎么避免?判断逻辑是:只有真正需要登录态的接口才操作 Session;用 request.getSession(false) 来拿已有 Session,不要用默认的 getSession()。
3. 分布式场景下,Session 失效的三条技术路线与取舍
3.1 为什么负载均衡一开,登录态就“丢”
假设你的应用部署在两台 Tomcat 上,前面挂一个 Nginx 做负载均衡。用户第一次请求被分到 Tomcat A,登录后 Session 存在 A 的内存里。下一次他的请求可能被分到 Tomcat B,B 的内存里没有这个 Session,于是 B 判定用户未登录,要求重新登录。
更尴尬的是,那个 sessionId 的 Cookie 还在浏览器里,但服务端查无此人。这就是分布式环境下 Session 最基本的失效场景,根因是:Session 数据被绑定在了单台机器的内存里。
再往深处想,还有几个问题会被同时引出来:
- 用户反复登录:负载均衡轮询或权重策略下,登录状态时有时无,体验极不稳定。
- 服务重启即“全员下线”:一台机器重启,所有在这台机器上登录的 Session 全部丢失,用户被迫重登。
- 无法水平扩展:机器多了,Session 分布在各台机器上,运维既没法统计在线用户,也没法定向清除某个人的 Session。
这些问题的本质,是存储位置与访问方式之间的错位。解决方案大致可以分成三类。
3.2 第一类:粘性会话(Sticky Session)——把用户“焊死”在一台机器上
粘性会话的做法很直观:Nginx 通过 IP Hash 或者按某个 Cookie 值的哈希,把同一用户的请求永远转发到同一台机器。这样,用户始终落在同一台 Tomcat 上,Session 永远不会落空。
这个方案的优点在于改动极小,服务器代码一行不用改。但代价也很明显:某一台机器挂了,这台机器上的所有在线用户直接掉线,其他机器又不认识他们。而且这种做法等于放弃了负载均衡的弹性,某台机器的负载会天然不均,只要某个 IP 段的用户特别活跃,那台机器就特别吃力。
所以粘性会话只适合不太在乎可用性的内部系统,或者集群机器特别少、挂了影响可控的场景。生产环境的用户端系统,我基本不建议用。
3.3 第二类:Session 复制(Session Replication)——让每台机器都拥有一份
Session 复制的思路是,各台机器之间实时同步 Session 数据。用户在任何一台机器上登录,这台机器立刻把 Session 数据广播给集群里其他所有机器。这样无论请求落在哪台机器上,本地都能找到完整的 Session。
听上去很美好,但工程实现上是灾难。首当其冲的问题是网络开销:每次 Session 数据变更都要广播给所有机器,节点一多,同步消息量呈二次方增长。Tomcat 自带的 DeltaManager 集群方案在节点超过五六个时,性能会明显劣化。另一个问题是数据一致性滞后:广播是异步的,极端情况下用户刚登录完,请求立刻被转发到还没有同步完成的机器,照样失败。
我自己的经验是,集群规模小、对实时性要求不高的旧系统,可以勉强用用;新项目就别趟这浑水了。
3.4 第三类:集中式存储(Redis 方案)——把 Session 交给专业的“仓库”管理
这个方案一句话说就是:把 Session 数据从各台机器的内存中抽出来,统一存放到一个 Redis 集群里,所有应用服务器共享同一个存储源。同时用统一的 Session 过滤器拦截所有请求,把 sessionId 解析后去 Redis 里取数据。
这样做的好处是显而易见的:应用服务器无状态了,重启不影响登录态;添加新机器不需要额外同步数据;对负载均衡策略完全无感,轮询、权重、随机都无所谓。
代价则是引入了一个新组件,Redis 本身需要保证高可用,否则一旦 Redis 挂了,全站用户集体掉线。这就要靠 Redis 哨兵模式或者 Redis Cluster 来解决,属于运维层面的方案选择。
三张表格可以很直观地做对比:
| 方案 | 改动成本 | 一致性保证 | 扩展性 | 故障影响 | 适用场景 |
|---|---|---|---|---|---|
| 粘性会话 | 极低 | 天然一致 | 差 | 单机宕机丢全部用户 | 内部系统、集群极小 |
| Session 复制 | 低 | 异步,可能不一致 | 差 | 同步风暴 | 老系统、节点少的局域网部署 |
| Redis 集中存储 | 中 | 强一致(单点) | 好 | Redis 宕机全站掉线(需高可用保障) | 大多数生产环境 |
从我这些年的实践来看,第三种方案是唯一值得在新项目里采用的。接下来,就是这篇博文的重头戏:怎么把 Redis 方案真正落地。
4. Redis 分布式共享 Session 落地方案:设计思路与核心原理
4.1 Redis 解决 Session 共享的核心设计要点
用 Redis 存 Session,不只是把 Map<String, Object> 塞进 Redis 里那么简单。你至少要想清楚几个问题:Key 怎么设计?Value 用什么结构?过期时间设多久?并发访问怎么保证安全?下面一个一个来。
Key 的设计
我习惯用这样的格式:session:<sessionId>。加前缀 session: 有两个用处:一是防止和其他业务 Key 混淆,二是在 Redis Desktop Manager 之类可视化工具里可以按前缀快速筛选,运维时排查问题非常方便。如果你的系统是多租户架构,还可以加租户维度,比如 session:tenantId:sessionId,避免租户之间的 sessionId 撞车。
Value 的类型选择
这里有两个主流方向,各自优缺点我在表格里列一下:
| Redis 数据结构 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| String(JSON 序列化) | 整个 Session 序列化成一个 JSON 字符串 | 直观、跨语言、可读性好 | 修改任意字段需要整体读写,并发改写可能丢数据 |
| Hash | 每个 attribute 存成一个 field | 支持修改单个字段,粒度细 | 实现稍复杂,需要自研序列化策略 |
大多数业务场景,Session 里放的数据并不会特别频繁地变动——登录用户的基本信息,也许加上权限标识,最多一两个字段。这种低频读写用 String + JSON 就够了,直观且排查方便。如果 Session 里经常要 setAttribute 修改局部信息,Hash 会更合理一些,减少不必要的反序列化开销。
过期时间的设置
Redis 里用 SETEX 或者 EXPIRE 设置过期时间,但要考虑一个关键点:Session 的语义是“空闲超时”,用户每操作一次,有效期就要刷新一次。Redis 不会自动帮你续期,需要应用层在每次读写 Session 时判断是否临近过期,然后重新设置过期时间。
简单的做法是:每次请求进来,如果 Session 存在,就直接 EXPIRE 刷新。代价是每次访问多一次 Redis 写操作,但对于大多数业务量来说完全扛得住。如果要压榨性能,可以只在剩余生存时间小于某个阈值(比如剩余不足 5 分钟)时才刷新,能省掉不少无谓的写请求。
4.2 为什么重造轮子通常不如直接引入成熟组件
讲到具体实现,很多教程会教你自己写一个 Filter 拦截请求,自己序列化 Session,自己管理过期时间。我不想否定这种方案的学习价值——为了搞懂原理,手写一遍确实是很好的训练。但如果你是在做正式项目,我强烈建议直接用 Spring Session 这个官方组件。
为什么?因为这件事的复杂度远比你看着的多。自己写,你需要处理 Cookie 的读写、Session 的存取、序列化异常、并发冲突、Filter 优先级、容器特定 API 的差异……这些坑都踩完一遍,少说一两个星期。而 Spring Session 把这些问题中的绝大多数已经解决掉了,并且是经过了海量生产环境验证的,没必要重造。
Spring Session 做的事情,本质上就是替你实现了刚才说的那套“集中式存储”方案:它提供了一套 Session 过滤器,接管了 HttpSession 的创建和获取逻辑,同时把 Session 数据的落点从 Tomcat 内存切换成了 Redis,业务代码里 request.getSession() 的用法完全不变。
对开发者来说,这个“无缝替换”是最大价值:你不需要改动业务代码,只需要加依赖、改配置,Session 就悄悄地从 Tomcat 内存迁到了 Redis。这也是业界最主流的做法。
4.3 序列化方案选型:JDK 序列化与 JSON 序列化的对比
Spring Session 默认使用 JDK 自带的序列化方式(JdkSerializationRedisSerializer),对象需要实现 java.io.Serializable 接口。这种方式有两个让我不太舒服的点:一是存储到 Redis 里是一堆二进制乱码,人眼完全不可读,排查问题时很痛苦;二是跨语言支持差,如果以后有 Node.js 或 Go 项目要共享这套 Session 数据,解析起来相当麻烦。
所以我通常在配置里会换成 JSON 序列化器(GenericJackson2JsonRedisSerializer),让 Session 数据以 JSON 形式存储。这样在 Redis Desktop Manager 里一眼就能看到用户名、角色这些字段,排查问题效率极高,而且理论上未来可以跨语言读取。
JSON 也不是没有代价:它要求 Session 里的对象类型信息能被明确推断。如果 Session 里存的是具体类型(比如 UserInfo 对象),JSON 序列化器需要在序列化时保留类型信息(通过 @class 字段),否则反序列化时不知道要还原成什么类型。这一点在实际使用中要注意:尽量让 Session 里只存简单的 POJO,避免复杂的泛型嵌套。
5. 从零到一:Spring Boot + Redis 实现分布式共享 Session 登录
5.1 环境准备:Redis 安装与常见坑
开始写代码之前,先把 Redis 准备好。如果你在 Windows 上做开发,会有一些历史遗留的坑值得说道说道。
Windows 上的安装
早年间 Redis 官方不提供 Windows 版本,大家用的是微软维护的移植版,或者使用 Docker 跑 Linux 容器。现在的情况是:官方依然没有原生 Windows 版本,但 GitHub 上有社区维护的 Windows 移植版,用于本地调试足够。如果你的开发机装了 WSL 或者 Docker Desktop,在 Linux 子系统里跑官方 Redis 会更省心,功能也更完整。
以 Docker 方式最省事:
bash复制docker run --name local-redis -p 6379:6379 -d redis:7.0 redis-server --appendonly yes
这条命令干了三件事:拉取 Redis 7.0 镜像,把宿主机的 6379 端口映射到容器内,开启 AOF 持久化(appendonly yes),防止容器重启丢数据。
Linux/macOS 上的安装
bash复制# Ubuntu / Debian
sudo apt update && sudo apt install redis-server
# macOS
brew install redis
redis-server
装完以后,用 redis-cli ping 看看能不能返回 PONG,通了就说明服务起来了。
这里有一个本地验证时的常犯错误:Redis 默认绑定 127.0.0.1,只允许本机访问。如果我们的 Spring Boot 服务和 Redis 在同一台机器上,这没问题。但如果你把服务跑到另一台机器上调试,Redis 又没改 bind 配置,你会发现连接超时,怎么配都连不上。排查的时候先 redis-cli -h <ip> ping 测一下,别上来就改代码。
可视化工具选型
Redis 官方有 RedisInsight(虽然近年新版需要注册账号,稍显烦人),社区里 Another Redis Desktop Manager(简称 ARDM)是很顺手的替代品。功能基本够用:浏览 Key、查看过期时间、执行命令行、分析大 Key。这些工具的核心价值在于,做 Session 共享调试时,可以实时看到 session 开头的 Key 有没有生成、过期时间还剩多少。
5.2 项目依赖与核心配置:3 个必须配对的地方
使用 Maven 或 Gradle 创建 Spring Boot 项目,引入两个关键依赖。
xml复制<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>
Spring Boot 的自动配置会自动检测到 classpath 里的 Spring Session,创建一个名为 springSessionRepositoryFilter 的过滤器,包装原始的请求处理链路。这个过滤器在每次请求到来时,会从 Cookie 中读取 sessionId,尝试从 Redis 中加载对应的 Session;如果没有,就创建一个新的空 Session。
然后配置 Redis 连接信息和 Session 相关参数:
properties复制# Redis 连接配置
spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379
spring.data.redis.password=
spring.data.redis.database=0
# Session 配置
server.servlet.session.timeout=30m
spring.session.timeout=30m
spring.session.redis.namespace=myapp:session
有三个地方要特别看清楚:
server.servlet.session.timeout和spring.session.timeout:前者是 Servlet 容器层面的默认配置,但在 Spring Session 接管后,后者才真正生效。建议两者都设成一致的 30 分钟。spring.session.redis.namespace:配置 Redis Key 的前缀。默认是spring:session,生产环境强烈建议改成自己应用的名称,避免多个应用共用一套 Redis 时 Key 冲突。spring.data.redis.database:选择 Redis 的哪一个逻辑数据库(默认情况下 Redis 有 16 个)。如果不想和其他业务数据混在一起,可以单独指定一个库,清理的时候也方便,直接FLUSHDB只影响当前库,不影响其他库的数据。
5.3 序列化器配置:让 Redis 里看到的是人话而不是二进制
正如前面提到的,默认的 JDK 序列化会把数据变成二进制乱码。下面这段配置的作用,是把 Session 的默认序列化方式从 JDK 换成 JSON。
java复制@Configuration
public class RedisSessionConfig {
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
这段配置让 Spring Session 在往 Redis 写 Session 时,使用 JSON 格式。设置完以后,在 Redis 可视化工具里看到的就不再是乱码,而是类似这样:
json复制{
"@class": "org.springframework.session.MapSession",
"creationTime": 1712736000000,
"lastAccessedTime": 1712736060000,
"maxInactiveInterval": 1800,
"attributeNames": ["loginUser"],
"attribute:loginUser": {
"@class": "com.example.demo.entity.UserInfo",
"userId": 1001,
"username": "zhangsan"
}
}
一眼就能看出这个用户是谁、什么时候登录的、还剩多久过期,排查问题的心情完全不一样。
我提一个 JSON 序列化场景下的细节:如果 UserInfo 里有 LocalDateTime 这类 Java 8 时间类型,GenericJackson2JsonRedisSerializer 默认的序列化能力会不足,反序列化会报错。我在早期项目里就踩过这个坑。解决方案是给 ObjectMapper 注册 JavaTimeModule,或者干脆在 UserInfo 里用 Long 类型的时间戳代替 LocalDateTime,反而更省事。
5.4 登录接口与 Session 操作:一个可直接抄作业的例子
配置完成以后,业务代码的写法和单机 Session 完全一致。这里我给一套最朴素的登录流程示例。
java复制@Service
public class UserService {
public UserInfo login(String username, String password, HttpSession session) {
// 实际项目中这里会查数据库,比对加密后的密码
if (!"admin".equals(username) || !"123456".equals(password)) {
throw new RuntimeException("用户名或密码错误");
}
UserInfo user = new UserInfo();
user.setUserId(1001L);
user.setUsername(username);
session.setAttribute("loginUser", user);
return user;
}
}
对应的 Controller 层:
java复制@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/current")
public Object currentUser(HttpSession session) {
Object loginUser = session.getAttribute("loginUser");
if (loginUser == null) {
return "未登录";
}
return loginUser;
}
@PostMapping("/logout")
public String logout(HttpSession session) {
session.invalidate();
return "已退出登录";
}
}
这里的核心变化是:虽然代码写的还是 HttpSession,但背后已经不是 Tomcat 的内存对象了,而是被 Spring Session 代理成了一个从 Redis 加载数据的对象。session.setAttribute("loginUser", user) 这行代码执行完,用户数据就被写进了 Redis,其他任何一台服务器只要拿到这个 sessionId,都能从 Redis 读出来。
尤其要注意 session.invalidate() 的作用。这个方法在两个层面的行为完全不同:
- 单机 Tomcat 下,
invalidate()只是把内存中的 Session 对象标记为失效。 - Spring Session 下,
invalidate()会立即删除 Redis 里对应的 Key,确保登出后即使有人拿着旧的 sessionId 回来,也查不到数据。
还有一个点:登录成功以后,如果你想改变 sessionId 的取值(防止 Session 固定攻击),可以调用 request.changeSessionId()。在 Spring Session 的机制下,这会生成一个新的 sessionId,同时把 Redis 里的旧 Key 复制到新 Key,然后删掉旧 Key。这个措施在高安全等级的系统里,属于必须做的防御。
5.5 设置 Cookie 属性:安全细节别省
Spring Session 默认会创建一个名为 SESSION 的 Cookie(注意不是 JSESSIONID),用于传递 sessionId。和 Cookie 相关的安全属性,要通过配置来加强。
properties复制server.servlet.session.cookie.name=APP_SESSION
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax
这些配置分别解决了什么问题:
name:把 Cookie 名改成自己应用的标识,方便多应用隔离,也避免和安全扫描工具产生误报。http-only=true:阻断 JavaScript 读取 Cookie,这是对抗 XSS 攻击的基础防线。没有这个,黑客一旦注入脚本,直接偷走 sessionId,任何加密都白搭。secure=true:Cookie 只允许通过 HTTPS 传输。如果还没上 HTTPS,这个选项先别开,否则用户会发现登录后立刻失效。same-site=lax:限制第三方站点发起的跨站请求携带 Cookie,能有效防御 CSRF 攻击。这是现代浏览器都会针对的安全机制,建议保持开启。
6. 实测验证:模拟多实例部署,验证 Session 是否真正共享
6.1 如何用最简单的方式模拟多台服务器
要验证 Session 共享是否成功,你不需要真的去云上开两台服务器。有一个非常轻量的做法:同一个 Spring Boot 项目,在本地启动两个实例,一个端口 8080,一个端口 8081。
bash复制# 实例一
mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8080
# 实例二
mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8081
两个进程连同一个 Redis,这就是一个最简分布式集群的雏形。然后再用 Nginx 或者是直接手动指定端口来模拟负载均衡。
比如第一步用 8080 端口的实例登录:
bash复制curl -X POST http://localhost:8080/api/user/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}' \
-c cookies.txt
-c cookies.txt 会把响应中的 Set-Cookie 保存到文件里。
第二步,带着这个 Cookie 去访问 8081 端口的实例:
bash复制curl http://localhost:8081/api/user/current -b cookies.txt
如果返回了用户信息,说明 Session 共享成功——因为 8081 实例如果没有去 Redis 查数据,它根本不可能认识这台“从没接待过的用户”。
这个验证方式很直观,它把分布式的问题压缩成了一台电脑上两个进程的交互,逻辑完全不变。
6.2 在 Redis 里观察到 Session 的真实形态
登录成功之后,打开 Redis 可视化工具,或者直接用命令行:
bash复制redis-cli
# 查看所有 session 相关的 Key
KEYS myapp:session*
你会看到类似这样的结果:
code复制myapp:session:1b8e2f4c-9a3d-4e5f-8a2b-6c7d8e9f0a1b
接着查看它的剩余生存时间:
bash复制TTL myapp:session:1b8e2f4c-9a3d-4e5f-8a2b-6c7d8e9f0a1b
如果返回 1799 或类似的值,说明过期时间正确地设置为了 30 分钟。再访问一次接口,你会发现 TTL 又被重置回 1800 了——这正是“空闲超时续期”机制在工作。
这里的实测结果是检验配置正确性的最直接证据。如果 TTL 没有刷新、或者 Key 名不对、或者数据是二进制乱码,问题出在哪个环节,基本一眼就能定位。
6.3 用 Nginx 做真实负载均衡,这一步怎么配
上面的验证方式是手动模拟。如果你想让整个验证链路更贴近生产环境,那么在两个实例前面加一个 Nginx 就对了。Nginx 的配置文件核心片段如下:
nginx复制upstream app_cluster {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://app_cluster;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
有了 Nginx,访问 http://localhost/api/user/login,请求会被 Nginx 随机分到 8080 或 8081,你可以在代码里加一行日志 server.port,看到实际是被哪个端口处理的。用同一个浏览器反复刷新,你会发现请求在两台实例之间来回跳,但登录态始终没有丢——这就是 Spring Session + Redis 的效果。
需要留意的点是:Nginx 默认的负载均衡策略是轮询,这个策略对 Session 共享完全没影响,因为状态在 Redis 里,而不是在某台实例上。这也是第三种方案相比粘性会话最大的优越性。
7. 常见问题与排查技巧:那些让你在 Redis 和 Session 之间反复横跳的坑
7.1 登录后偶尔失效,但刷新几次又正常了
这个问题如果出现在还没做 Session 共享的集群里,十有八九是负载均衡把请求分到了没有 Session 的那台机器。做了 Redis 共享之后,如果还会出现,通常要从两个方向排查:
第一,看一下 Redis 里所用的 Database 是否一致。Spring Boot 服务 A 配置了 database=0,服务 B 配置了 database=1,两边读的不是同一个库,Session 自然不共享。这种错误隐蔽性极强,因为两边 Redis 都能正常读写,没有任何报错,就是数据隔岛了。
第二,检查两个服务是否用了不同的序列化器。一个用 JDK 序列化,一个用 JSON 序列化,两边写入 Session 的二进制格式完全不同,互相读不了。规范的做法是,同一个集群内所有实例的配置必须保持一致,序列化器、Cookie 名称、Redis 前缀、过期时间,全都要对齐。
7.2 重启应用或 Redis 之后,所有用户都下线了
应用本身重启导致 Session 丢失,恰好说明你的 Session 还在本地内存里,没有真正挪到 Redis。检查一下依赖里是否真的引入了 spring-session-data-redis,以及是否被配置类正确生效。光有 spring-boot-starter-data-redis 是不够的,它只是 Redis 的基础访问能力,Session 接管靠的是 Spring Session。
Redis 重启导致 Session 丢失,这是持久化策略的问题。默认情况下,Redis 的 RDB 快照是周期性执行的,两次快照之间宕机,数据会丢一部分。解决方式有两个方向:
- 生产环境开启 AOF(Append Only File)持久化,并且配置
appendfsync everysec,最多丢一秒数据。 - 或者让 Session 数据具备可重建性:用户下次访问时如果发现 Session 失效,引导他重新登录。很多高可用系统其实就是这么设计的,Session 本身就是一种可丢失的缓存型数据,不必强求绝对的持久化。
7.3 Redis 中出现大量以 session 为前缀的 Key,内存告急
这是个常见的容量陷阱。访问量一大,Redis 里会堆积海量 Session Key,如果不及时处理,内存上涨得非常快。核心原因是过期 Key 没有被及时清理,或者过期时间设置太长。
Redis 对过期 Key 的清理是惰性删除 + 定期删除结合。惰性删除的意思是,只有当某个 Key 被访问时,才发现它过期了然后删除。定期删除则是后台每隔一段时间抽查一部分 Key 来删除。如果大量 Key 同时过期,删除可能来不及,Key 就会暂时驻留。
在应用层能做的有三件事:
- 合理缩短 Session 过期时间,很多内部系统 30 分钟可能偏长,15 分钟甚至 5 分钟就够了。
- 主动清理:在用户登出的接口里,跳过
session.invalidate()是常规操作,但别忘了它背后在 Redis 里是删除 Key,所以一定要确认这个方法被真正执行到了。如果用户是直接关浏览器而非点登出按钮,那就只能靠过期机制兜底。 - 监控告警:给 Redis 配内存使用率监控,超过阈值就告警,别等内存满了才被动处理。
7.4 分布式并发下重复提交或超卖问题:Session 与分布式锁的关系
聊到分布式 Session,很多人会顺带问分布式锁。两者其实解决的是不同层次的问题:Session 解决状态共享,分布式锁解决并发互斥。但它们的应用场景经常会交织在一起。
举个例子:用户登录后,前端连续点击了多次“提交订单”按钮,这些请求经过负载均衡被分到了不同的服务器上。对应用来说,这几次请求携带的是同一份 Session,但如果在“判断是否已提交过订单”的逻辑里操作了同一个共享资源(比如库存扣减),就可能出问题。这时候,仅仅靠 Session 共享不能解决并发问题,你需要的是分布式锁。
一个最简单的 Redis 分布式锁实现:
java复制public boolean tryLock(String lockKey, String requestId, long expireSeconds) {
// 只有当 Key 不存在时才能设置成功,返回 true
// requestId 作为锁持有者的唯一标识,防止误删别人的锁
return redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds));
}
public void unlock(String lockKey, String requestId) {
String value = (String) redisTemplate.opsForValue().get(lockKey);
if (requestId.equals(value)) {
redisTemplate.delete(lockKey);
}
}
setIfAbsent 是 Redis 的 SETNX 命令,它保证了多个线程同时调用时,只有一个能成功写入。requestId 则是锁的持有者标识,防止 A 线程把 B 线程的锁给解了。
这里强调一句:分布式锁的实现是有讲究的,上述是最简方案。生产环境最好直接使用 Redisson 这类成熟库,它们内置了看门狗续期、可重入、公平锁等能力,避免自己实现时踩到锁超时导致并发安全问题的坑。
7.5 跨域与前端对接时,SESSION Cookie 丢失或带不上
前后端分离项目里,前端跑在 http://localhost:5173,后端跑在 http://localhost:8080,二者端口不同,浏览器默认不会携带后端种下的 Cookie。这会让 Session 失效问题看起来像“后端不认登录态”。
这个问题的坑点在于“跨域”的界定。浏览器有同源策略,协议、域名、端口任一不同,都算跨域。别的跨域问题是影响“接口能不能调”,而 Cookie 的场景则是影响“请求带上不带身份信息”。
要解决,后端接口需要做两件事:
- 在响应头里明确指定允许携带凭证:
java复制@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); // 允许携带 Cookie
config.addAllowedOriginPattern("http://localhost:5173");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
return new CorsWebFilter(source -> config);
}
}
- 前端请求时要加
withCredentials: true(Axios 的写法),否则浏览器即使收到了Set-Cookie也不会帮你保存。
这两个条件缺一不可。后端不允许携带凭证,前端带了也被拒;后端允了,前端不带,等于没允。
还有一个更隐蔽的坑:后端做了跨域允许,但没有允许 Cookie 头的反射,浏览器预检(OPTIONS)请求就会失败,表现为前端控制台报跨域错误,请求根本没发出去。
8. 经验延伸:从 Session 共享到整个分布式系统的状态管理
Session 共享只是分布式系统状态管理的一个缩影。把思路打开,你会发现很多场景下,我们都在做同一件事:把原本绑定在单机内存里的数据,迁移到一个独立、集中、可水平扩展的存储中。
不只是登录态。比如:
- 用户购物车数据:可以用 Redis Hash 存储,key 是 userId,field 是商品 ID,value 是数量。
- 临时验证码:短信验证码、邮箱验证码,Redis 存 5 分钟,天然过期。
- 接口限流:用 Redis 的 INCR 加 EXPIRE,几行代码实现固定窗口限流,配合 Lua 脚本能做到滑动窗口。
- 幂等性校验:用
SETNX防止重复提交。 - 秒杀库存预扣减:Redis 的 DECR 是原子的,能够支撑高并发下的库存扣减需求。
这些场景的共同点非常明显:数据量不大、访问频繁、可丢失可重建、要跨实例共享。它们放在本地内存做不到共享,放到数据库压力太大,Redis 恰好在中间这个位置,兼具高性能和高可用。
我个人的一个体会是:做分布式改造,别把所有数据都往 Redis 里塞。Redis 的内存很贵,存一些必须快速读写、量级可控的数据是合理的,但如果你发现自己想把整个数据库塞进去,那就该回头想想,是不是系统设计本身出了什么问题。
另外,还有很多系统会在这套方案之上叠加一层“双写一致性”逻辑,为了减少 Redis 访问量,先在本地缓存查一遍,查不到再走 Redis。这种优化在极低延迟要求下有意义,但也引入了缓存一致性的问题,属于可选的高级进阶方向,不是上线初期的必要动作。
最后说一句实在话:Session 共享方案网上能搜到很多版本,有些文章会让你自己写 Filter、自己定 Key 结构、自己处理序列化。我的建议是,原理要理解,这样出了问题才有的排查;但正式项目里,交给 Spring Session 这类被大量验证的成熟框架,比什么都强。框架层面的坑,未来大概率会被框架升级解决;而你自己写的轮子出了问题,那就只能一个人维护到天荒地老了。
