Spring Session整合Redis与Spring Security,实现分布式会话共享实战 Spring Session、Spring Security 和 Redis 这套组合拳是分布式场景下绕不开的标配。很多团队做到集群部署这一步第一道坎就是登录状态失效明明在 A 服务器登录了请求一打到 B 服务器就被踢回登录页。解决思路很多主流方案就是让 Session 不再躺在各自的 Tomcat 内存里而是统一丢到 Redis配合 Spring Security 做认证授权再把会话数据无状态化地共享出去。这篇文章我把自己的经验梳理一遍从依赖引入开始到配置类和过滤器链的整合原理再到实际跑通验证最后把 Redis 里存的到底是什么、序列化方案怎么选这类细节也讲透。这次就用最简单直白的方式呈现适合刚接触会话共享、卡在 Spring Security 和 Redis 整合这一步的 Java 后端开发者看完可以直接抄作业也搞清楚背后每条配置的取舍逻辑。1. 整合前必须想明白的三个问题1.1 不做会话共享分布式部署会踩什么坑得先理解问题本身。传统单机部署时用户登录之后服务器把 Session 保存在自己的内存里再通过 Cookie 里的 JSESSIONID 与用户一一对应。同一个浏览器始终访问同一台服务器一切都很和谐。但一旦上 Nginx 做负载均衡流量被分发到多个节点问题就来了。用户第一次请求落在节点 ASession 也建在节点 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 负责把认证结果存到 RedisRedis 是整个会话数据的统一载体。三者的关系不是堆叠而是各司其职的协作。2. 依赖引入与基础配置2.1 Maven 依赖清单用 Spring Boot 的话整合工作极大简化我用的版本是 Spring Boot 2.7.x依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-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 DSLSpring 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: 30mspring.session.store-typeredis 是核心开关告诉 Spring Boot 使用 Redis 作为 Session 存储同时自动配置 SessionRepositoryFilter。spring.session.timeout30m 是 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 的用户加载逻辑默认配置是使用内存用户真实项目必须换成数据库或其他存储。最直接的方式是实现 UserDetailsServiceService 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 是 SessionIdHash 里保存的字段包括creationTime创建时间lastAccessedTime最后访问时间maxInactiveInterval过期时间间隔sessionAttr:SPRING_SECURITY_CONTEXTSecurityContext 的序列化数据另外还有一个用于按用户名查找 Session 的索引结构spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME这个索引结构是实现并发会话控制maximumSessions的基础需要查一下哪些 Session 属于同一个用户时就是通过这个索引去找的。4.3 序列化两种方案JDK vs JSONSpring Session 默认使用 JDK 序列化也就是 Java 自带的 ObjectInputStream/ObjectOutputStream。简单但有一个致命问题序列化后的数据二进制格式对 Redis 不友好人类不可读而且如果 Session 里放了某个内部类对象这个类的结构改动后Redis 里的旧数据反序列化会直接失败抛 ClassNotFoundException 或 InvalidClassException。另一种方案是使用 JSON 序列化通过配置自定义 RedisSerializer 实现。第一种方式简单可靠但是有兼容性隐患适合临时调试。第二种方式推荐用于生产环境但你要为 Session 里放的对象自定义反序列化逻辑实现起来更繁琐。最关键的一点是SecurityContext 里保存的 UserDetails 对象必须有默认构造函数并且字段不能有密码等敏感信息直接裸露加密除外否则 JSON 反序列化时会遇到很多坑。我个人的实践是早期直接用默认 JDK 序列化后来改了一次 UserDetails 的字段名所有在线的用户全部登录失效排查了半个多小时才定位到是序列化版本不匹配。后来干脆配了 JSON 序列化才彻底摆脱这类问题。Bean public RedisSerializerObject springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); }注意这个 Bean 的命名必须叫 springSessionDefaultRedisSerializerSpring 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或 EnableMethodSecuritySpring Boot 3.x。5.2 多节点模拟与负载均衡实测先本地启动一个实例端口是 8080mvn spring-boot:run -Dspring-boot.run.arguments--server.port8080再启动一个实例端口改成 8081mvn spring-boot:run -Dspring-boot.run.arguments--server.port8081然后用 Nginx 把两个端口做负载均衡或者直接用 Postman 分别请求两个端口来模拟节点切换。第一步请求 8080 的 /demo/set拼上 valuehello拿到响应里的 JSESSIONID Cookie。第二步拿着同一个 Cookie请求 8081 的 /demo/get如果返回 value hello说明 Session 成功从 8080 共享到了 8081Redis 中间层生效。第三步请求 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 typejava.lang.ClassNotFoundExceptionInvalidClassException原因通常是 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 过期时间的计算会让你一头雾水。这套链路到现在我已经跑通好多回了照着配一遍半小时内能验证完整个流程。