ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Session + Redis 实现分布式会话共享实战指南

Spring Session + Redis 实现分布式会话共享实战指南 1. 为什么分布式会话成了微服务的第一道坎1.1 单体时代Session好得很拆成微服务后就崩了先聊个最常见的场景。单体应用时代所有请求打到一个Tomcat节点上用户在登录接口里写入的HttpSession就存在这个Tomcat的JVM内存里。后面用户再点击其他页面请求还是回到同一个TomcatTomcat能从内存里找到那个Session对象整个体验非常顺畅。但你一旦把服务拆成多个微服务再把每个服务部署成多实例问题立刻就来了。假设用户服务有3个实例负载均衡器把第一个登录请求打到了实例ASession写进了A的内存用户刷新页面后请求被路由到实例BB的内存里根本没有这个Session于是用户被重新要求登录。这个现象在开发环境靠单机模拟很难复现到了生产环境一压测就变成“明明登录了过一会儿又自动掉线”。有人说可以用IP Hash或粘滞会话让同一个用户的请求总是落到同一个实例上。这在服务实例不多、拓扑不变的时候确实能撑一阵子但只要实例缩扩容、重启或者发布新版本粘滞关系就被打破了用户依旧会被踢下线。更麻烦的是粘滞会让流量不均匀有的机器忙到冒烟有的机器闲到发慌本质上是把问题往后拖不是解决。1.2 分布式会话的三种常见思路解决分布式环境下Session共享市面上基本就三条路。第一种是会话粘滞刚才说了简单但抗不了实例变化。第二种是Session复制让每个实例之间互相同步Session数据典型方案是Tomcat的DeltaManager。这个方案适合三五台机的小集群但一旦实例变多同步的网络开销和广播风暴会非常可怕而且数据强一致很难保证集群里每个节点都存了一份全量Session内存浪费也严重。第三种就是集中式Session存储把Session数据抽出来放到一个独立的中间件里所有微服务节点都从这个中间件读写Session。这个方案把状态和计算分离服务节点变成了无状态服务扩容缩容随意加机器不会影响用户会话。而提到集中式存储Redis基本是首选。方案优点缺点适用场景会话粘滞实现简单零额外存储扩容/重启影响会话负载不均临时过渡实例拓扑稳定Session复制节点本地读取快广播开销大数据冗余集群规模很小容忍延迟Redis集中存储无状态化扩展性好需要额外维护Redis高可用微服务集群多实例共享集中式方案最大的好处是用户的登录状态从“在某个进程里”变成了“在全链路可达的一个存储里”。不管请求落在哪个微服务的哪个实例上它都能去同一个Redis里把Session捞出来用户感知不到后端的路由变化。2. Spring Session Redis 这套组合到底解决了什么2.1 Spring Session是什么Spring Session是Spring生态里的一个子项目它干了一件很讨巧的事把Servlet容器里那套HttpSession的存取逻辑整体替换成自己的一套抽象。核心就一个接口叫SessionRepository定义了怎么创建Session、怎么读取Session、怎么删除Session。RedisSessionRepository就是它在Redis上的实现。有了这层替换业务代码里调用的还是request.getSession()还是session.setAttribute(user, user)但背后实际读写的不再是Tomcat内存而是Redis。这意味着你的业务代码几乎不用改只需要把Spring Session的依赖加进去做点配置整个应用的会话机制就换成了分布式实现。这也是Spring Session能流行起来的重要原因——迁移成本低不需要把每个getSession的地方都翻出来重写。我之前在一个老项目里就是这种直接接进来项目里有几十个Controller里都在用Session如果让我手写Redis工具类替换没有两三周做不完还要担心有没有漏改。Spring Session一个过滤器横切进来半天就把改造做完了这就是框架的生命力。2.2 Redis中Session的存储结构Spring Session的Redis实现不是简简单单把一个对象塞进去。它会用到多种数据类型如果你在Redis Desktop Manager里看过会发现有这么几类键。spring:session:sessions:{sessionId}Hash类型保存Session的基本信息比如创建时间、最后访问时间、最大空闲时间、以及session里的各个属性。每个用户会话对应一个这样的键。spring:session:expirations:{minute}Set类型记录这个分钟级时间槽内需要过期的SessionID。这个键专门用来解决一个坑Redis对某个键设置过期时间后到点不一定立刻物理删除Spring Session会用这个集合定时扫描保证过期的Session能够被及时清理并触发事件。spring:session:index:*用于按属性查找Session都是可选功能默认可以不开。这个设计比单纯设置EXPIRE sessionId 1800复杂但好处是Session过期可控而且可以监听Session创建、销毁事件。比如你可以实现SessionDestroyedEvent监听器在用户Session过期时清理购物车缓存这个在单体应用里做起来很麻烦在Spring Session里只需要写一个事件监听类就够了。2.3 为什么不推荐自己手写Redis Session网上有大量“手把手教你用Redis存Session”的教程核心代码通常就几步登录成功后生成UUID以UUID为key写入Redis返回Cookie给前端然后写个拦截器每次从Redis取。这套东西做出来跑Demo没毛病但要上生产你马上会遇到这些麻烦。会话过期了怎么保证Redis里没有残留垃圾数据用户不断操作Session的过期时间要不要顺延如果固定不过期怎么避免失效Session堆积Session里的对象要更新是整体覆盖还是只更新某个字段Cookie的路径、Domain、Secure等属性怎么设置才能让多个微服务共享Session创建和销毁事件怎么通知别的系统这些坑Spring Session全都替你踩过了。它的过滤器和Repository设计把“一个分布式Session应该有哪些行为”定义得很清楚。手写方案表面省事实际是把成本转嫁到了后续没完没了的修修补补上。3. 环境准备与项目搭建3.1 版本选型我在实操中用的组合是Spring Boot 2.7.x因为2.7还在维护期内稳定资料多遇到问题也好搜。如果你要上JDK17那直接Spring Boot 3.x也没问题但要注意Spring Boot 3对应的是Jakarta命名空间部分旧代码启动时会报ClassNotFoundException。Redis的版本我建议至少6.x7.x更好。Spring Session对Redis版本本身没有强依赖但Redis 6之后的ACL和IO多线程特性对生产环境更友好。你本机测试用Docker跑个Redis镜像就够生产环境再用云厂商的托管实例或自建集群。另一个要注意的是连接池Spring Boot 2.x默认用的Lettuce连接池是非阻塞的性能比老牌Jedis好而且Spring Session的官方文档里也默认Lettuce我们没必要换回Jedis。3.2 创建Spring Boot项目并引入依赖直接用Spring Initializr生成项目然后加三个核心依赖。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency注意spring-boot-starter-web里自带Servlet容器Spring Session靠Servlet过滤器工作没有web环境是跑不起来的。spring-session-data-redis不需要手工写版本号由Spring Boot的依赖管理统一锁定。启动类上加一个注解EnableRedisHttpSession这个注解是整套方案的开关。旧版本里它有很多参数比如maxInactiveIntervalInSeconds、redisNamespace、redisFlushMode但在Spring Boot 2.x以后更推荐把配置写在application.yml里注解上保留默认即可。我实际遇到过一个坑同事把redisNamespace写在注解上升级依赖后注解属性变了编译报错找不到属性最后统一改成配置文件才消停。3.3 Redis连接配置与关键参数application.yml里的配置大概是这样的。spring: redis: host: 127.0.0.1 port: 6379 password: yourpassword database: 0 timeout: 3000ms lettuce: pool: max-active: 50 max-idle: 10 min-idle: 2 max-wait: 3000ms session: store-type: redis timeout: 1800s redis: namespace: spring:session flush-mode: on_saveflush-mode有on_save和immediate两种。on_save表示等Session变更提交比如请求处理完才一次写入Redis减少访问次数immediate则是每次setAttribute都立刻写Redis适合强一致要求但性能差。默认on_save就好。连接池参数很多人懒得配但Session读取几乎是每个请求必经之路如果连接池太小高并发下会出现“连接等待超时”的报错。max-active估算公式大概是“峰值QPS乘以每个请求平均占用连接的时间秒数”。比如峰值1000 QPS平均每个请求占用连接50毫秒那大约需要1000 * 0.05 50个连接。min-idle建议保留几个预热连接避免流量突然上来时新建连接带来的抖动。4. 集成Spring Session Redis完整实操4.1 三步让Session切换到Redis第一步启动类加EnableRedisHttpSession。SpringBootApplication EnableRedisHttpSession public class AuthApplication { public static void main(String[] args) { SpringApplication.run(AuthApplication.class, args); } }第二步写一个简单的登录接口。RestController public class LoginController { PostMapping(/login) public String login(RequestParam String username, HttpServletRequest request) { HttpSession session request.getSession(true); session.setAttribute(loginUser, username); return login success, sessionId session.getId(); } GetMapping(/profile) public String profile(HttpServletRequest request) { HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { return not login; } return current user session.getAttribute(loginUser); } }第三步启动项目用curl模拟两次请求。curl -c cookie.txt -d usernamezhangsan http://localhost:8080/login curl -b cookie.txt http://localhost:8080/profile如果能看到current userzhangsan说明Session已经切到Redis了拿Redis客户端工具看一眼里面多出来的spring:session:sessions:*就是当前Session。这里有个细节request.getSession(false)如果返回null不要在代码里再去创建否则一个普通的静态资源请求也会创出一个SessionRedis里会塞满无意义的key。4.2 自定义Cookie策略与超时设置Spring Session默认会往浏览器写一个名为SESSION的Cookie路径是/没有设置Domain。如果多个微服务部署在同一个域名的不同路径下这个默认配置够用。如果服务分布在auth.example.com和order.example.com下那必须让Cookie跨子域共享。方式是实现CookieSerializer的Bean。Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer new DefaultCookieSerializer(); serializer.setCookieName(SESSION); serializer.setDomainName(.example.com); serializer.setUseSecureCookie(true); serializer.setCookieMaxAge(-1); // 浏览器关闭则失效 serializer.setCookiePath(/); return serializer; }参数中useSecureCookie在生产开了HTTPS后建议设为true但本地HTTP调试时先关掉否则Cookie根本存不下来。cookieMaxAge设-1表示会话级Cookie关浏览器就没了想要“记住我”的效果就设个正数秒数。Session超时时间在配置里设置。spring: session: timeout: 3600s注意spring.session.timeout和server.servlet.session.timeout不要同时设置Spring Boot会以server.servlet.session.timeout为准结果和你想的不一样。我遇到过一位同事两处都配了改为7200秒测试发现半小时就掉线查了半天才发现是server.servlet.session.timeout覆盖了Spring Session的配置。4.3 多微服务共享Session的验证方法要验证多服务共享至少起两个Spring Boot应用。假设用户认证服务和订单服务都连同一个Redis都配同一个CookieSerializer和同一个redisNamespace。第一个服务登录后拿到Cookie里的SESSION值然后带上这个Cookie请求第二个服务的接口。第二个服务里用同样的代码request.getSession(false)读取loginUser属性如果能读到说明共享成功。这里最容易忽略的是网关层。如果你用Spring Cloud Gateway或者Zuul转发请求时默认会把Cookie头原样往后传一般不用特殊处理。但如果你在网关做过自定义的Header改造或者使用了RequestContext中的ignoreHeaders配置把Cookie过滤掉了那Session就死活通不过。排查时先抓包看下游服务到底有没有收到Cookie头比看Redis省事得多。5. 核心机制深入Spring Session底层原理5.1 SessionRepositoryFilter是怎么切入的Spring Session的入口是一个叫做SessionRepositoryFilter的Servlet过滤器。Spring Boot在EnableRedisHttpSession生效后会注册这个过滤器并且它的Order优先级很高保证它能包装到业务逻辑最外层。这个过滤器做的事情是把当前的HttpServletRequest和HttpServletResponse包装成SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper。包装后业务代码里调用request.getSession()时并不会直接去见Tomcat的Manager而是通过SessionRepository去Redis加载或创建Session。等于说你的Controller全程以为自己在操作HttpSession其实操作的是Spring Session的数据结构。这是代理模式的一个经典应用也是为什么迁移成本那么低的根本原因。5.2 SessionId的生成与响应提交当浏览器第一次发起请求时Spring Session发现Redis里没有对应Session就会创建一个新的RedisSession它的ID默认是UUID去横线的字符串。这个ID会在响应提交阶段通过Cookie写回浏览器。这里有个常见误区很多人以为request.getSession().getId()只要一调用Cookie就会立刻写回。实际上Spring Session有响应提交的整合Cookie写入发生在请求处理的最后阶段由SessionRepositoryFilter在finally逻辑里显式触发commitSession。这样做的好处是一个接口里对Session做了多次修改最终只做一次Redis写和一次Cookie写。如果某个接口你希望立刻把Session保存到Redis可以调用sessionRepository.save(session)手动刷但一般没必要。真正的线上问题反而是另一个方向代码里拿到Session对象后手动System.out.println(session.getId())是没问题的但如果你试图在请求线程之外异步操作Session对象比如扔到线程池里再调setAttributeSpring Session很可能因为延迟加载和绑定关系导致写入不生效。5.3 Redis过期策略与自动清理机制Redis的键过期后不会立即删除而是通过主动过期扫描和惰性过期来处理。Spring Session只给spring:session:sessions:{id}设置了EXPIRE如果这个键不存在Session自然读不到。但Spring Session还有事件机制比如Session过期后要触发SessionDestroyedEvent如果完全依赖Redis惰性删除那这个事件可能延迟很久甚至不触发。所以Spring Session引入了spring:session:expirations:{minute}这个Set。Redis每分钟是一个时间槽每个Session的过期时间会被归入对应分钟槽的集合里。Spring Session内部有一个后台任务周期性地去扫描这些过期集合把已经到期的Session主动删除同时触发销毁事件。这样清理动作可控不会因为Redis惰性删除而让数据残留。理解了这个机制你就明白为什么spring:session:expirations的键不能乱删了。我见过有人看到Redis里一堆expirations键不顺眼写了批量删除脚本清理结果Session到了过期时间没人主动删Redis里残留了大量spring:session:sessions键Redis内存一直掉不下去。6. 生产环境实战坑与性能调优6.1 Redis部署模式主从、哨兵还是集群单机Redis跑开发环境没问题生产环境必须把高可用考虑进去。Session丢失的直接后果就是用户集体掉线体验非常差所以Redis的可靠性比单独一个缓存系统要求更高。我的建议是最低配也要一主一从加哨兵。Redis Sentinel负责自动故障切换主节点挂了从节点顶上Spring Session客户端通过spring.redis.sentinel.master和spring.redis.sentinel.nodes配置即可感知新主节点。数据量不大几百G以内主从架构完全够用。如果每天用户量在百万级别以上或者一个Session里塞了很多数据把Redis内存撑到了几十G那要考虑Redis Cluster。Cluster有自动分片Session的key会根据哈希槽分布在不同的节点上。但Cluster和Sentinel的配置方式不同使用Spring Session时只要改连接配置就行SessionRepository本身不用动。部署模式优点缺点单机简单成本低单点故障会全员掉线主从Sentinel自动故障转移可靠性较高主节点写压力大Cluster水平扩展容量大客户端配置复杂运维门槛高另外要提一句Docker。如果你用Docker跑Redis一定要把Redis的data目录挂载到宿主机或者使用命名卷。我见过不止一次容器重启后Redis内存里干干净净用户Session全没了就是因为容器本身是有状态服务没做持久化。即便你有主从重启后主从重新同步也要时间期间写入可能丢失。6.2 序列化方式选择JDK、JSON还是自定义Spring Session默认的序列化器是JdkSerializationRedisSerializerSession里的对象会变成Java二进制字节流。这在同一套代码内部没问题可读性很差你用Redis Desktop Manager打开spring:session:sessions看到的是一堆\xAC\xED\x00\x05开头的乱码而且Java序列化体积大占用内存多。更实际的问题是你可能引入了一个对象它没有实现Serializable接口一调用Session保存就抛NotSerializableException。这也是开发期最常见的报错之一。我建议生产环境换成GenericJackson2JsonRedisSerializer。配置方式是这样的Bean public RedisSerializerObject springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); }只要容器里存在这个BeanSpring Session就会用它来序列化Session内容。换成JSON之后Redis里存的是可读的JSON字符串排查问题时一眼能看出loginUser字段到底是谁。但JSON序列化有个坑泛型信息。如果你往Session里存的是一个User对象Jackson序列化时会把类型信息写到JSON里反序列化能正确还原。但如果你存的是一个ListUser类型信息可能丢失反序列化出来是ListLinkedHashMap强转时CCE。解决方法是存之前再包一层DTO或者使用带类型信息的序列化器。我踩过这个坑之后就很少往Session里放集合只放userId这种基础类型需要用户信息时再去数据库或缓存查。6.3 大对象与大Key问题很多人习惯把用户对象整个塞Session里面全是数据库查出来的字段一个对象序列化后几十KB甚至上百KB。看起来没压力但每个请求都会带着这个Session的引用一旦高并发Redis网络IO和内存占用立刻上去。我更倾向在Session里只存两个东西用户唯一ID、登录信息版本号。至于用户昵称、角色、权限菜单按需从Redis的另一个缓存key里读。我们真线上遇到过慢查询分析Redis的bigKeys列表里经常出现spring:session:sessions:*单个key的value超过200KB原因就是有人把用户收藏的商品列表全塞了Session每次请求都在传这个大头。如果确实需要往Session里放对象建议设置一个上限比如最大不超过50KB并且对Session数据进行定期统计Session平均大小超过10KB就要警觉了。6.4 并发写Session的更新丢失问题Spring Session的Redis实现在保存Session时是对整个Hash结构做hSet集合操作。多个服务实例同时修改同一个Session会出现后写覆盖先写的情况。比如购物车接口和积分接口同时操作同一个Session积分接口先写购物车接口后写积分数据可能就丢了。在一个普通Web应用里这种并发写Session的概率不高因为同一个用户不太可能同时点两个不同接口。但在微服务架构下一个请求经过网关可能并行调用多个服务每个服务都写Session丢更新就出现了。Spring Session官方没有提供乐观锁机制所以遇到这种场景要么不要把复杂状态放Session要么对Session更新加分布式锁要么把写Session的动作收敛到一个服务里。我个人更建议设计上避免这个问题Session只做登录态不承载业务数据。业务数据丢缓存或数据库这样不管并发多严重至少不会丢用户身份。7. 常见问题与排查清单7.1 重启后Session全部丢失排查顺序Redis是否重启过如果是Docker容器是否挂载了持久化卷是否执行过FLUSHALL或FLUSHDB配置里的spring.session.timeout是否被错误改小Redis使用的内存淘汰策略是不是allkeys-lru或allkeys-random这种策略会在内存压力大时随机淘汰keySession很可能被误伤。建议在Redis配置文件里设置maxmemory-policy volatile-lru只淘汰设置了过期时间的key并且业务Session之外的key建议都放在单独的database或者标志前缀下降低干扰。Redis持久化至少开启AOFappendfsync everysec兼顾数据安全和性能。7.2 Redis中Session数据无法阅读如果使用的是默认JDK序列化Redis里肯定是乱码。这不是数据损坏只是序列化格式不同。想验证数据内容可以写一个小的Java客户端用Spring Session依赖去反序列化。但更快的办法是直接改序列化器为JSON然后清掉旧Session让用户重新登录。必须注意改序列化器之后老的二进制Session还在Redis里新代码读旧数据时可能反序列化失败。这种情况最好停机维护时清一次Spring Session相关key或者设置一个可接受的过渡期等旧Session自然过期。7.3 多个微服务之间Session不同步先从这几个点排查确认多个服务的Redis连接指向同一个Redis实例和同一个database很多环境就是database配成了0和1表面共享实际隔离。确认Cookie的名称、路径、Domain一致。如果服务A写的是Domainauth.example.com服务B收不到example.com域下的Cookie。确认网关没有丢弃Cookie。用Postman直接带Cookie访问服务B如果能通说明应用层没问题问题在网关或负载均衡。确认服务的ContextPath没有乱改。如果服务A的server.servlet.context-path/auth而DefaultCookieSerializer会自动对Cookie路径做处理吗一般不会最好手动把cookiePath设为/。7.4 明明设置了过期时间Session却不过期先看代码里有没有动态修改Session的最大空闲时间。session.setMaxInactiveInterval(0)表示永不过期这很容易被误解成“0秒就过期”。很多框架里负数也可能被当成永不过期。排查时可以在Redis里查看spring:session:sessions:*的maxInactiveInterval字段是否还是配置值。还要注意Spring Session的过期策略不是严格的“最后访问后30分钟”而是基于session的创建时的lastAccessedTime计算每次getSession()访问会更新lastAccessedTime。如果你程序后台线程频繁读取Session会让它一直保持活跃。我之前在一个报表服务里定时任务每5分钟读一次Session统计在线人数结果Session全部不超时因为每次读都刷新了最后访问时间。后来把统计逻辑改成读Redis的key列表不调用getSession接口问题才解决。7.5 性能问题定位Session读取高频不能让Redis响应变慢。建议开启Redis的slowlog get 10查看慢查询如果出现大量针对spring:session的慢操作往往是网络往返次数多了或者Session体积太大了。另一个点是连接池。Lettuce连接池默认max-active是8这在很多场景是不够的尤其微服务网关并发高时会在连接获取上排队。可以观察RedisConnectionFailureException和超时日志监控WARN级别里的Redis连接等待。如果条件允许用Redis的INFO STATS里的rejected_connections来判断是否出现连接拒绝。我把Session相关key和普通缓存的key放到不同的Redis逻辑库这样监控时能通过INFO keyspace快速看每个库的key数量和内存占比。不要在一个Redis实例里既放缓存又放Session又不做任何容量规划最后互相挤占内存。8. 写在最后的实战体会这套方案我在生产环境用了好几年了说实话稳定程度很高但前提是别把Redis当玩具。踩过的坑里最痛的一次是线上Redis主节点宕机Sentinel自动切换到了从节点但因为没有配置AOF持久化从节点的数据落后不少用户Session丢了一小片结果就是一部分用户莫名其妙要重新登录。那次之后我把Redis的持久化、内存淘汰策略、慢查询监控全部拉起来并且给Session配置了独立的Redis集群才真正安心。还有一件事Session里能不放敏感用户数据就不放。我见过有团队把手机号、身份证号、家庭住址全塞进Session前端每次请求都带着Redis里明文存着一旦Redis被攻击或者误操作导出数据就泄露了。建议Session只存用户唯一标识和会话版本需要详情数据再按需获取。如果你正处在单体向微服务迁移的阶段不用犹豫Spring Session加Redis这套组合是性价比最高的选择。先把登录状态切过去再把业务数据逐步从Session里搬出来你的服务节点就能真正做到随意伸缩和重启。等哪天你发现所有Session相关的问题都变成了“改Redis配置就能解决”这套架构就算真正落地了。
返回列表