☰
Spring Session整合Redis与Spring Security,实现分布式会话共享实战
2026/10/16 9:46:37 网站建设 项目流程

Spring Session、Spring Security 和 Redis 这套组合拳,是分布式场景下绕不开的标配。很多团队做到集群部署这一步,第一道坎就是登录状态失效,明明在 A 服务器登录了,请求一打到 B 服务器就被踢回登录页。解决思路很多,主流方案就是让 Session 不再躺在各自的 Tomcat 内存里,而是统一丢到 Redis,配合 Spring Security 做认证授权,再把会话数据无状态化地共享出去。

这篇文章我把自己的经验梳理一遍,从依赖引入开始,到配置类和过滤器链的整合原理,再到实际跑通验证,最后把 Redis 里存的到底是什么、序列化方案怎么选这类细节也讲透。这次就用最简单直白的方式呈现,适合刚接触会话共享、卡在 Spring Security 和 Redis 整合这一步的 Java 后端开发者,看完可以直接抄作业,也搞清楚背后每条配置的取舍逻辑。

1. 整合前必须想明白的三个问题

1.1 不做会话共享,分布式部署会踩什么坑

得先理解问题本身。传统单机部署时,用户登录之后,服务器把 Session 保存在自己的内存里,再通过 Cookie 里的 JSESSIONID 与用户一一对应。同一个浏览器始终访问同一台服务器,一切都很和谐。

但一旦上 Nginx 做负载均衡,流量被分发到多个节点,问题就来了。用户第一次请求落在节点 A,Session 也建在节点 A;下一次请求被 Nginx 转发到节点 B,节点 B 的内存里根本没有这个 Session,于是被判定为未登录。这就是经典的 Session 不一致问题,很多人第一次遇到时以为是 Security 配置写错了,折腾半天才发现是会话存储的位置出了问题。

解决方案无非三种:

  • Nginx 配置 IP Hash,让同一个 IP 固定打到同一台节点,治标不治本,移动网络下 IP 变化照样失效,节点宕机迁移时也照样丢状态。
  • Tomcat 集群会话复制,节点之间同步 Session,实现简单但广播风暴严重,节点一多性能雪崩。
  • 把 Session 集中存到 Redis,所有节点共用同一个存储层,这是目前最主流、最推荐的做法,也是 Spring Session 的核心价值所在。

1.2 Spring Session 在这套架构里扮演什么角色

Spring Session 不是一个独立运行的中间件,它本质上是 Servlet 容器与 Redis 之间的一层桥接。它通过一个 Filter 拦截所有请求,把原本由 Tomcat 管理的 HttpSession 替换成 RedisSession,底层读写全部走 Redis,对业务代码完全透明。

这层替换的核心动作有两个:

  • 请求进来时,从 Cookie 读出 SessionId,拿这个 Key 去 Redis 查数据,查到就恢复成完整的 Session 对象。
  • 响应返回时,把 Session 的变化写回 Redis,同时更新过期时间,并把 SessionId 通过 Cookie 返回给浏览器。

整个过程对应用无感知,业务代码里 request.getSession() 拿到的是同一个接口,但背后存储已经从本地内存切换到了 Redis。这就是 Spring Session 的独特价值,不需要改业务代码,只需要引入依赖和配置,就完成了会话存储的搬迁。

1.3 Spring Security 与 Session 到底谁依赖谁

这个点很多人其实没理清。Spring Security 本身并不一定需要 Session,它基于过滤器链做认证和授权,但框架默认会把认证成功的 SecurityContext 存进 Session,通过 session id 在后续请求中恢复上下文。

具体来看,SecurityContextPersistenceFilter 这个过滤器负责在请求开始时从 Session 中取出 SecurityContext,放到 ThreadLocal 里;请求结束后再把 ThreadLocal 里的 SecurityContext 写回 Session。如果 Session 存到 Redis,那么这个读写操作的存储介质也就自然变成了 Redis。

所以整合逻辑就通了:Spring Security 负责认证,Spring Session 负责把认证结果存到 Redis,Redis 是整个会话数据的统一载体。三者的关系不是堆叠,而是各司其职的协作。

2. 依赖引入与基础配置

2.1 Maven 依赖清单

用 Spring Boot 的话,整合工作极大简化,我用的版本是 Spring Boot 2.7.x,依赖如下:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <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> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> </dependencies>

这几个依赖要注意几点:

  • spring-session-data-redis 是核心,负责把 Session 存储到 Redis。
  • spring-boot-starter-data-redis 提供 Redis 连接管理,两者需要同时引入。
  • commons-pool2 是 Lettuce 连接池所需,不加的话如果配置了连接池参数会启动报错。

Spring Boot 3.x 的用法有差异,Spring Security 配置改用 Lambda DSL,Spring Session 也需要额外引入 spring-session-core,这里不展开,我用 2.7.x 做演示,生产项目用 3.x 的可以参照官方迁移文档做适配。

2.2 YAML 配置里最容易忽略的细节

配置文件和普通 Redis 项目没有太大区别,重点是几个容易被忽略的参数:

spring: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 5s lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 max-wait: -1ms session: store-type: redis timeout: 30m

spring.session.store-type=redis 是核心开关,告诉 Spring Boot 使用 Redis 作为 Session 存储,同时自动配置 SessionRepositoryFilter。

spring.session.timeout=30m 是 Session 过期时间,注意它和 server.servlet.session.timeout 的优先级关系。两者都存在时,spring.session.timeout 优先生效。如果没配置,默认是 30 分钟。

Redis 的 database 必须统一。如果同一个 Redis 实例被多个项目共用,建议给不同项目分不同 database,或者用不同 key 前缀区分。Spring Session 默认的 key 前缀是 spring:session,多个项目共用一个 Redis 时,需要覆盖默认的 RedisIndexedSessionRepository 或 RedisSessionRepository 的 key 命名规则,否则不同项目的 Session 会互相污染。

2.3 Redis 连接配置的三个参数心得

host 和 port 没什么好说的,有几个参数是生产环境必须考虑的:

  • timeout 建议设置 3-5 秒,避免 Redis 阻塞时线程无限等待,拖垮整个应用。
  • lettuce.pool.max-active 控制连接池最大连接数,默认 8,压测时如果发现连接不够,可以通过 Redis 的 INFO clients 命令查看实际连接情况再调整。
  • max-wait 不建议设成 -1(无限等待),改成 3000ms 左右的有限等待,Redis 抖动时不会把线程全部耗尽。

很多初学者踩过的坑是只写了 host 和 port,连接池参数完全没配,一旦业务流量上来,Lettuce 的默认连接数不够用,日志里出现 connection pool exhausted 的异常。这个问题不致命,但排查起来比较费劲。

3. Spring Security 配置核心类

3.1 安全配置类的写法

引入依赖之后,Spring Boot 会自动生成一个默认的 Security 配置,会弹出一个默认的登录页,并且默认用户名是 user。我们要替换成自己的配置类:

@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .antMatchers("/login", "/logout", "/public/**").permitAll() .antMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .defaultSuccessUrl("/index", true) .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .deleteCookies("JSESSIONID") .invalidateHttpSession(true) ) .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(5) .maxSessionsPreventsLogin(false) ); return http.build(); } }

几个关键点:

  • usernameParameter 和 passwordParameter 默认是 username 和 password,如果登录请求参数名不一样,需要在 formLogin 配置里指定。
  • loginPage 指定的是自定义登录页,如果项目里有对应的页面就填页面路径,没有的话可以用 "/login" 让 Spring Security 渲染默认页面。
  • csrf.disable() 这个操作我平时只在开发环境或者纯 API 接口项目里做,有页面交互的项目强烈建议保留 CSRF 防护,具体原因后面单独说。
  • maximumSessions(5) 表示同一个用户最多允许 5 个会话同时在线,超过时旧会话会被踢下线,这个参数要结合业务场景考虑,太严会影响正常多端登录。

3.2 UserDetailsService 的用户加载逻辑

默认配置是使用内存用户,真实项目必须换成数据库或其他存储。最直接的方式是实现 UserDetailsService:

@Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserDO user = userMapper.findByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } return org.springframework.security.core.userdetails.User .builder() .username(user.getUsername()) .password(user.getPassword()) .roles("ADMIN", "USER") .build(); } }

这里有一个常见困惑:为什么登录时只是传了用户名和密码,最后却要把完整的权限和角色都交给 Security?因为认证成功后,SecurityContext 里保存的就是这个 UserDetails 对象,后续每次请求从 Session 恢复出来的也是它。如果角色在这里面缺失,后面做方法级权限校验(@PreAuthorize)就会直接失败。

还有一个细节:PasswordEncoder 必须和数据库里的密码加密方式一致。如果数据库里存的是 BCrypt 加密的密文,那么 PasswordEncoder 必须对应配置:

@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }

如果密码是明文存的,可以临时用 NoOpPasswordEncoder,但生产环境千万不要这样做,明文密码在任何正规项目里都是红线。

3.3 SecurityContext 是怎么进到 Session 的

这块讲透的话,后面排查问题会少走很多弯路。用户提交登录请求后,UsernamePasswordAuthenticationFilter 拦截到请求,调用 AuthenticationManager 做认证,认证成功后拿着 Authentication 对象创建一个 SecurityContext,然后把它塞进 HttpSession 里。

这个塞入动作发生在 SecurityContextPersistenceFilter 的最后阶段。每个请求进来时,它会先检查当前 Session 里有没有 SecurityContext,有就恢复到当前线程;请求结束时,如果 SecurityContext 变化了,就再写回 Session。

所以答案很清晰:Spring Security 不直接操作 Redis,它操作的是 HttpSession 这个抽象层,Spring Session 把这个抽象层搬到了 Redis。这也解释了为什么 Spring Security 配置本身和 Redis 没有直接整合关系,整合的关键在中间层。

4. Spring Session 的工作机制与 Redis 底层数据

4.1 SessionRepositoryFilter 过滤器如何接管会话

Spring Session 的核心是一个名叫 SessionRepositoryFilter 的过滤器,它的优先级最高,在 Spring Security 的过滤器链之前执行。

它的工作流程:

  • 请求进来时,包装原始的 HttpServletRequest,把 getSession() 方法的实现替换为从 RedisSessionRepository 读取 Session。
  • 请求放行给下游过滤器(包括 Spring Security 的过滤器链)处理。
  • 请求结束后,将 Session 的变更同步到 Redis,同时写 Cookie 响应给浏览器。

这个过滤器的优先级决定了它不能被随意替换或跳过。如果项目里自定义了优先级特别高的过滤器,要小心是否会干扰 Session 的恢复,尤其是那些依赖 session 做业务初始化的过滤器,最好在 Session 恢复之后执行。

4.2 Redis 里 Session 的实际保存形式

实现层面,Spring Boot 2.7.x 默认配置 RedisSessionRepository(旧版是 RedisIndexedSessionRepository),它会为每个 Session 生成一组 Hash 结构:

spring:session:sessions:1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed

这里的 key 是 SessionId,Hash 里保存的字段包括:

  • creationTime:创建时间
  • lastAccessedTime:最后访问时间
  • maxInactiveInterval:过期时间间隔
  • sessionAttr:SPRING_SECURITY_CONTEXT:SecurityContext 的序列化数据

另外还有一个用于按用户名查找 Session 的索引结构:

spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME

这个索引结构是实现并发会话控制(maximumSessions)的基础,需要查一下哪些 Session 属于同一个用户时,就是通过这个索引去找的。

4.3 序列化两种方案:JDK vs JSON

Spring Session 默认使用 JDK 序列化,也就是 Java 自带的 ObjectInputStream/ObjectOutputStream。简单但有一个致命问题:序列化后的数据二进制格式对 Redis 不友好,人类不可读,而且如果 Session 里放了某个内部类对象,这个类的结构改动后,Redis 里的旧数据反序列化会直接失败,抛 ClassNotFoundException 或 InvalidClassException。

另一种方案是使用 JSON 序列化,通过配置自定义 RedisSerializer 实现。

第一种方式简单可靠,但是有兼容性隐患,适合临时调试。第二种方式推荐用于生产环境,但你要为 Session 里放的对象自定义反序列化逻辑,实现起来更繁琐。最关键的一点是:SecurityContext 里保存的 UserDetails 对象必须有默认构造函数,并且字段不能有密码等敏感信息直接裸露(加密除外),否则 JSON 反序列化时会遇到很多坑。

我个人的实践是:早期直接用默认 JDK 序列化,后来改了一次 UserDetails 的字段名,所有在线的用户全部登录失效,排查了半个多小时才定位到是序列化版本不匹配。后来干脆配了 JSON 序列化,才彻底摆脱这类问题。

@Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); }

注意:这个 Bean 的命名必须叫 springSessionDefaultRedisSerializer,Spring Boot 才有自动装配逻辑去识别。Username 和 authorities 都会作为 JSON 字段保存,调试起来很直观。

5. 完整测试代码与验证步骤

5.1 测试用 Controller 代码

整合做完,要验证效果,我习惯用一个带 Session 读写和当前用户信息的接口来实测:

@RestController @RequestMapping("/demo") public class DemoController { @GetMapping("/set") public String setSession(HttpServletRequest request, String value) { request.getSession().setAttribute("demoKey", value); return "set " + value; } @GetMapping("/get") public String getSession(HttpServletRequest request) { Object value = request.getSession().getAttribute("demoKey"); return "value = " + value; } @GetMapping("/user") public String currentUser() { Object principal = SecurityContextHolder.getContext() .getAuthentication() .getPrincipal(); return "user = " + principal.toString(); } @GetMapping("/admin") @PreAuthorize("hasRole('ADMIN')") public String admin() { return "admin ok"; } }

这里的 @PreAuthorize 需要额外开启方法级安全校验,在配置类或启动类上加 @EnableGlobalMethodSecurity(prePostEnabled = true)(Spring Boot 2.7)或 @EnableMethodSecurity(Spring Boot 3.x)。

5.2 多节点模拟与负载均衡实测

先本地启动一个实例,端口是 8080:

mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8080

再启动一个实例,端口改成 8081:

mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8081

然后用 Nginx 把两个端口做负载均衡,或者直接用 Postman 分别请求两个端口来模拟节点切换。

第一步,请求 8080 的 /demo/set,拼上 value=hello,拿到响应里的 JSESSIONID Cookie。

第二步,拿着同一个 Cookie,请求 8081 的 /demo/get,如果返回 value = hello,说明 Session 成功从 8080 共享到了 8081,Redis 中间层生效。

第三步,请求 8080 的 /login,用实现了 UserDetailsService 的账号登录;登录之后拿到新的 JSESSIONID(也可能沿用同一个,看 SessionId 策略),请求 8081 的 /user,能正常获取当前用户名,说明 SecurityContext 也成功跨节点恢复。

5.3 检查 Redis 数据的三种方式

验证完业务接口,最好去 Redis 里看一眼数据到底存了什么。

命令行方式:

redis-cli keys * type spring:session:sessions:xxx hgetall spring:session:sessions:xxx

可视化工具方面,我常用 Another Redis Desktop Manager,连接后可以直接看 Hash 结构,比较直观。

还有一种情况,RDB 持久化开启后,可以在 dump.rdb 里看到 Session 数据。但这只作为排查辅助手段,正常验证功能时用 keys 命令就够了。

6. 常见问题与排查技巧实录

6.1 会话不一致现象

症状是偶尔出现未登录,刷新一下又好。排查思路是先确认负载均衡策略是不是轮询,然后分别在两个节点上看 Redis 里 Session 是否存在。

项目里如果自己玩读写分离,或者主从同步延迟,也会出现这类问题。Redis 写入后立即读取,如果读到的是旧数据,就是复制延迟问题。一般可以通过强制走主库读来解决,或者把 Session 的 Key 做一致性哈希分片,但这个复杂度就高了。

6.2 反序列化异常

表现是登录后正常,第二次请求开始抛异常,常见报错有:

  • Failed to deserialize object type
  • java.lang.ClassNotFoundException
  • InvalidClassException

原因通常是 Session 里放的对象没有默认构造函数、类结构变更、JDK 序列化与 JSON 序列化混用等。

排查方法是看 Redis 里存的内容,用 JSON 序列化时直接看字段,JDK 序列化时就是二进制乱码,不好排查。这类问题最有效的规避手段是统一序列化方案,并且 Session 里放的对象 DTO 化,不要直接放实体类。

6.3 CSRF 问题导致登录失效

症状是最开始登录没问题,过一段时间再次提交登录发现没有生效,或者表单提交后 403。

原因是项目里保留了 CSRF 防护,Spring Security 默认把 CSRF Token 也存到了 Session 中。由于某些原因 Token 过期或 Session 被重建,Token 不匹配导致请求被拒绝。

处理方法:

  • 保留 CSRF 的项目,确保页面里的 Token 是从当前 Session 读出并动态渲染的。
  • 前后端分离项目,可以禁用 CSRF,但前提是接口做 Token 鉴权(比如 JWT)。
  • 调试阶段直接禁用 CSRF 快速验证,但上线前记得评估。

我个人的习惯是:有页面的项目保留 CSRF,纯 API 项目禁用。至于很多人纠结的“禁用 CSRF 后安全性下降”问题,纯 API 项目主要靠 Token 认证而不是 Cookie,风险是可控的。

6.4 并发会话控制不生效

配置了 maximumSessions(2),但同一用户登录第三个设备时,旧的 Session 没有被踢下线。

原因一般是索引数据失准,也就是 Redis 里 FindByIndexNameSessionRepository.PRINCIPAL_NAME 这个索引没有正确维护。常见情况是用户信息变更后,principal 名称变化,旧的索引还保留着。

解决方法是查一下核心配置里 setIndexName 是否被正确设置,或者干脆重写注册一个 FindByIndexNameSessionRepository 的 Bean,确保索引同步更新。

6.5 Session 过期时间混乱

症状是明明配置了 30 分钟,但实际过了几分钟就失效。

先排查 spring.session.timeout 是否生效。注意 server.servlet.session.timeout 这个参数在 Spring Boot 2.x 里也会对 Session 生效,如果两个都配置了,要搞清楚哪个优先级更高。

再排查 Redis 的过期策略和内存淘汰机制。如果 Redis 设置了 maxmemory-policy allkeys-lru,那内存压力大时,Redis 可能把 Session Key 给淘汰掉,导致 Session 提前失效。这个要特别留个心眼,生产环境给 Session 用的 Redis 实例要单独规划,不要和缓存数据混用一个库,尤其不要开 LRU 淘汰策略。

6.6 安全配置里踩过的坑汇总

  • 登录接口必须在 permitAll 里,否则登录请求直接被拦截。
  • defaultSuccessUrl 只在直接访问登录页时生效;如果用户是访问受保护资源被重定向到登录页的,登录成功后会回到原资源,此时 true 参数决定是否强制跳转到默认页。
  • 静态资源(CSS、JS、图片)要 permitAll,否则页面样式加载不出来,浏览器调试器里一片红,容易误判成框架问题。
  • logout 配置里 deleteCookies 建议加上 JSESSIONID,否则退出登录后 Cookie 还存在,下次访问还会带一个无效的 SessionId。

7. 一些实操经验和扩展思路

这套整合方案我用过不少实际项目,有几点经验值得单列出来分享。

第一,序列化方案尽量在生产环境用 JSON,别贪图默认 JDK 序列化的省事。JDK 序列化在代码迭代时带来的版本兼容问题,比配置 JSON 的麻烦更让人头疼。

第二,Redis 实例要独立规划。把 Session 和业务缓存混在一起,出问题时互相干扰,尤其遇到 Redis 内存淘汰,Session 被无辜清掉,排查场景非常隐蔽。

第三,从安全性角度考虑,Session 里不要放敏感明文数据。Session 存到 Redis 后,能访问这个 Redis 的人就能看到这些数据,Redis 本身没有强认证机制,权限控制必须做好。

再往深了走,可以扩展的方向有这么几个:

  • Session 固定攻击防护:Spring Security 默认会在认证成功后轮换 SessionId,但在配置里显式确认 changeSessionId 策略是安全的。多端登录、移动端和 PC 端并存场景,需要在这种安全性和便利性之间做权衡。
  • 分布式场景下的踢人下线功能:基于 Spring Session 的事件监听(SessionDestroyedEvent / SessionExpiredEvent),可以实现被顶号下线的业务逻辑。
  • Redis 高可用和连接稳定性:Spring Session 对 Redis 的依赖是强依赖,Redis 挂掉,整个应用的登录体系全部瘫痪。所以要考虑 Redis Sentinel 或者 Cluster,同时本地应用层要做好快速失败和告警。
  • 性能调优:Session 数据量大的情况,Redis 网络 IO 会成为瓶颈。可以做 Session 数据瘦身,尽量减少存入 Session 的对象大小,该用的分页放数据库的就别塞 Session 里堆积。

项目上线之后,我通常会加一个监控,定时扫描 Redis 里 Session Key 的数量和过期分布。这能在问题发生前提前看到风险,比等用户反馈投诉要主动得多。如果哪天生产环境用户突然开始集体掉线,记得第一反应去查 Redis 的慢查询日志和内存状况,大概率能快速定位问题。

最后补充一个小技巧:本地调试时,用两个不同端口的实例加 Postman 手动携带 Cookie,是最快的验证手段。每次检查 Redis 数据前先看下系统时间,确认时区没有偏移,否则 Session 过期时间的计算会让你一头雾水。这套链路到现在我已经跑通好多回了,照着配一遍,半小时内能验证完整个流程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询