ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多节点部署下基于Redis的Session共享方案与Spring Session实战

多节点部署下基于Redis的Session共享方案与Spring Session实战 正文前段时间有个项目在做多节点扩容上线后不到半小时用户群里就有人反馈明明刚登录成功刷新一下页面又跳回登录页。我看了下接入层日志发现同一个用户的请求分别打到了不同的后端节点每个节点里都找不到对应的Session于是判定“未登录”。这个问题的根子就是守着单机版Session的逻辑直接上了多节点。要让session在多个节点之间共享用户登录状态最务实的路子是把Session从各节点的JVM内存里搬出来统一交给Redis托管。这篇文章就把我从方案选型、Spring Session接入、序列化处理、到线上排查踩坑的一整套实践完整记录下来给正在做集群化改造、或者被登录掉线折腾过的后端同学做一个参考。1. 多节点部署下的Session信任危机本地内存还能撑多久1.1 单机版Session的真面目数据只活在自己的JVM里很多人对HttpSession的理解停留在“登录后存一下用户信息”但没深入想过它到底存在哪。以最常用的Tomcat为例请求进来后Servlet容器会为每个会话创建一个HttpSession对象默认存放在当前Tomcat进程的JVM堆内存里。第一次请求时容器把Session ID写入Cookie默认叫JSESSIONID如果用Spring Session则默认叫SESSION之后每次请求带上这个ID容器就在本地内存里找到对应对象。这套机制在单机部署下运行毫无问题因为所有请求都打到同一台机器Session数据永远在同一块堆内存里读写。但这里面藏着一个隐患Session与“某一个进程”强绑定。你一旦把节点从1台扩到3台用户第一次请求落在Node1Session对象在Node1的堆里生成第二次请求如果被负载均衡器转发到Node2Node2的堆里根本找不到这个Session对象于是容器就认为这是一个新会话直接返回未登录。1.2 集群化之后请求会“串门”绝大多数分布式应用的接入层都会用Nginx做负载均衡。默认配置一般是轮询round-robin或者加权轮询请求按顺序分发给后端节点。这种分发策略不带任何“记忆”同一个用户的连续请求天然会被打散到不同节点。于是会出现我开头说的那种场景用户刚在Node1完成了登录下一个请求被Nginx分配到Node2Node2本地没有对应的HttpSessionSpring Security或者自定义拦截器检查不到登录标记直接302跳回登录页。用户感觉就是“掉线了”“登录状态没生效”。你在单机环境里反复测试都没问题因为所有请求都落在同一台机器上压根复现不了。要复现这个问题很简单本地起两个Spring Boot实例一个端口8080一个端口8081用curl直接请求两次第一次带登录接口种Cookie第二次把Cookie带到另一个端口访问业务接口马上就能看到未登录的返回。等你在两个节点之间反复横跳几次就能理解线上用户为什么骂人了。1.3 先别急着用ip_hash它只是权宜之计遇到这个问题很多人的第一反应是改Nginx的负载均衡策略从轮询改成ip_hash让同一个IP的请求固定落到同一台后端节点。这样做确实能缓解“请求串门”的问题但它有三个明显的副作用单点热点某个IP段流量大时会集中打在同一台节点上其他节点闲着整体负载不均衡。故障转移失效如果用户固定的那台节点宕机Nginx会把请求转发到其他节点其他节点没有该用户的Session用户依然掉线。扩容不友好节点从2台扩到3台后ip_hash的映射规则变化部分用户的请求会重新落点同样可能掉线。ip_hash适合那种“Session本地化但允许偶发掉线”的场景比如内部管理系统。对于用户量大、要求登录状态稳定的对外业务它不是正路。1.4 三个主流方案的真实对比除了ip_hash之外业内还有两个常见方向Session复制和Session共享。我把它们放在一起对比一眼就能看出差别。方案实现方式优点缺点推荐指数Sticky Sessionip_hash负载均衡器按用户/IP固定分发到同一节点改造量小不动代码热点不均、宕机掉线、扩容踩坑低Session复制节点间广播/同步Session数据如Tomcat Cluster各节点都有数据故障可转移同步延迟、内存浪费、节点多时网络风暴中Session共享RedisSession集中存储在Redis所有节点读写同一份节点无状态、扩缩容简单、服务化治理方便引入Redis依赖、需要关注序列化与高可用高我最终选了Session共享。理由很直接共享模式下每个后端节点都是无状态的负载均衡策略可以随意切换节点可以随意扩缩Session数据统一由Redis管理应用本身不需要感知“自己在哪台机器上”。这跟微服务“无状态化”的方向是一致的。后文所有内容都围绕这套方案展开。2. Redis接管Session后的数据模型为什么最终选了Hash2.1 先看清Redis里的Session存储形态用Spring Session接入Redis后你打开Redis客户端会看到一组以spring:session开头的key。默认命名空间下核心的key大概长这样spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:{用户名} spring:session:sessions:expires:{session-id} spring:session:sessions:{session-id} spring:session:expirations:{timestamp}其中spring:session:sessions:{session-id}这个key里面存的是真正的会话数据它是Redis的Hash结构。Hash里的字段通常包括creationTime会话创建时间lastAccessedTime最后访问时间maxInactiveInterval最大空闲时间sessionAttr:xxx应用往Session里放的属性比如登录用户对象、验证码等所以当你往session.setAttribute(loginUser, user)时底层其实是在这个Hash里写入了一个sessionAttr:loginUser字段。2.2 为什么不用String JSON一把梭有些同学觉得Session数据就是“一个用户对象”直接用String类型存JSON字符串不就行了吗我在早期确实试过这么干但实际用下来有几个痛点字段级操作麻烦用String存整个Session序列化结果更新任意一个属性都要重写整个字符串。而Hash可以只操作某一个字段比如只更新验证码不影响用户信息。类型保留困难JSON字符串存进去读出来时要把整个对象结构手动反序列化不同属性类型不一样解析逻辑容易写成一团。可观测性差线上想排查某个用户到底有没有登录态用HGET直接看某个字段比先取一个大JSON字符串再翻来翻去直观得多。Spring Session内部也是用Hash来实现SessionRepository的。如果你自己写一套Redis存储逻辑建议也优先选Hash而不是贪图String的简单。一句话Session天然就是“一个ID对应多个属性”的键值结构Hash和它的匹配度最高。2.3 Session过期时间的双保险机制Session的过期时间不能只靠应用层判断。Spring Session在Redis层面的设计很有意思它对每个Session维护了三条相关记录主key是spring:session:sessions:{session-id}负责保存数据。辅助key是spring:session:sessions:expires:{session-id}没有value只用来触发Redis的过期事件。每分钟还有一个spring:session:expirations:{timestamp}集合用于批量处理同一时间点过期的会话。当用户访问时框架会刷新lastAccessedTime同时更新所有相关key的过期时间。通过代理模式的Session实现把getAttribute、setAttribute等操作都接到Redis上这样每次操作都能刷新TTL实现“有操作就续期、空闲超时就失效”的滑动过期逻辑。实际配置时我在Spring Boot里通过server.servlet.session.timeout设置会话超时时间转成Redis的过期时间。线上一般设置为30分钟登录页面提示“长时间未操作请重新登录”就是基于这个机制。2.4 内存预算别小看一个会话Session放进Redis之后Redis的内存占用是需要提前估算的。我见过一个真实的案例业务方把所有用户信息、权限列表、购物车全部塞进Session单个Session对象序列化后超过50KB线上Redis内存几天就报警了。按常规业务估算如果每个会话平均存5个属性、用户对象精简到几KB那单会话大概占用Redis内存5~10KB。如果有10万在线用户就是1GB左右的Redis内存这个量级对于集群环境来说还是可以接受的。但一旦你在Session里放了大数据对象内存压力会直线上升。我的经验是Session里只放必要的信息用户ID、昵称、角色标识等复杂数据实时查DB或缓存不要把整表数据往里塞。Redis做共享Session关注的是登录状态的统一性而不是把它当成万能存储。3. Spring Session集成三步走依赖、序列化、Cookie联动细节3.1 第一步引入依赖版本坑先避掉要接入Redis共享Session最省事的方式是使用Spring Session。Maven的依赖配置如下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 2.xRedis连接配置项是spring.redis.*如果是Spring Boot 3.x改成了spring.data.redis.*。我当初升级某个项目时照抄旧配置忘了改前缀导致Redis连接一直走默认本地配置Session全部落到本地内存等于白白折腾了一下午。另外Spring Session 3.x对Redis连接工厂的自动配置和旧版有细节差异升级版本后最好跑一遍多节点验证用例确认Session真的进了Redis再放行。3.2 第二步配置Redis连接和Session存储类型以Spring Boot 2.x为例核心配置如下spring: redis: host: 10.0.0.10 port: 6379 password: xxx database: 0 timeout: 3000ms lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 session: store-type: redis timeout: 30m redis: namespace: mall:session几个配置项的作用说明一下spring.session.store-typeredis明确告诉Spring Boot使用Redis作为Session存储介质。spring.session.timeout会话超时时间这里配了30分钟每次访问会刷新。spring.session.redis.namespaceRedis中key的前缀建议加上项目标识避免与其他应用混淆。Redis连接池的核心参数max-active控制最大连接数并发量大的时候务必调大否则高流量下连接获取会阻塞。不需要自己注册ServletContainer之类的配置BeanSpring Boot Starter会实现内嵌容器的自动替换。换到外部Tomcat部署时需要在web.xml或代码配置里引入springSessionRepositoryFilter这一点在Spring Boot内嵌容器下容易忽略。3.3 第三步序列化方式是第一个大头坑这一步是很多教程不会细讲、但实际最容易埋雷的地方。Spring Session默认使用JDK序列化方式它在Redis里存的是一长串二进制字节流开头能看到\xAC\xED\x00\x05之类的魔数。这种方式的缺点有三个数据不可读排查看不到内容。序列化体积比JSON大很多浪费Redis内存。跨语言、跨应用版本解析困难如果两个服务用不同的类路径名反序列化直接报错。我建议统一使用GenericJackson2JsonRedisSerializer。通过一个简单的配置类实现Configuration public class SessionRedisConfig { Bean public RedisSerializerObject springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } }这个Bean会被Spring Session自动拾取用于Session的序列化。配上之后Redis里存储的就是可读的JSON结构字段清晰线上排查时一眼就能看到用户信息。但要注意使用JSON序列化时所有放进Session的实体类必须有无参构造方法并且字段要符合JavaBean规范。否则反序列化时Jackson会因为找不到无参构造直接抛异常。我踩过这个坑某个DTO为了“不可变性”把构造方法设置成全参的结果用户登录后第二个请求就报InvalidDefinitionException查了很久才发现问题出在这。3.4 Cookie的命名、路径和域登录态能否跨子域传播Session ID默认通过Cookie传递但Cookie的作用域直接影响登录状态的使用范围。有三个参数需要理解Cookie Path默认是/表示整站都携带该Cookie。如果改成/app那/api路径下的请求就带不上Cookie登录态就失效了。Cookie Domain默认不设置表示只属于当前域名。如果业务有多个子域比如www.example.com和admin.example.com需要共享登录态必须把Domain设置为.example.com。Secure和SameSite生产环境建议开启Secure保证Cookie只在HTTPS下传输SameSite建议设置为Lax或None。但SameSiteNone必须配合Secure使用否则部分浏览器会直接丢弃Cookie。这些参数在Spring Boot中可以通过server.servlet.session.cookie.*系列配置修改或者直接在启动类里配一个CookieSerializer的Bean。我建议直接自定义DefaultCookieSerializer:Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer new DefaultCookieSerializer(); serializer.setCookieName(MALL_SESSION); serializer.setCookiePath(/); serializer.setDomainName(.example.com); serializer.setUseHttpOnlyCookie(true); serializer.setUseSecureCookie(true); serializer.setSameSite(Lax); return serializer; }多子域共享登录态的场景Domain必须设置为公共父域否则两个子域拿着不同的Session CookieRedis里存了也白存。3.5 多环境隔离命名空间与Redis分库的选择开发、测试、生产环境如果用同一套Redis集群Session key很容易互相覆盖查问题的时候两个人互相踢下线。两种隔离方式我都在用Redis多DB早期用spring.redis.database0/1/2区分环境配置简单但Redis Cluster模式不支持多DB换集群后要改代码。Namespace前缀用spring.session.redis.namespace区分环境比如dev:session、prod:session这个方案更通用也适合后续迁移到Cluster。我最终选择了Namespace方案虽然Redis里key会多一些但隔离清晰排查问题也不会找错对象。4. 多节点登录状态验证实录那些验证不到和查不出的坑4.1 验证前先做三件事确认数据真的进了Redis接入完成后不要急着上生产。我每次改造完都会先做一轮最小验证验证步骤固定如下本地或测试环境起两个Spring Boot实例端口分别为8080和8081共用同一个Redis。在8080端口调用登录接口用curl保存Cookie。把Cookie带到8081端口的业务接口如果Redis共享Session生效返回结果应该和8080一致。用Redis客户端检查是否存在mall:session:sessions:*相关的key。curl验证的命令大致是curl -c cookie.txt -X POST http://localhost:8080/login -d usernametestpassword123 curl -b cookie.txt http://localhost:8081/order/list如果8081能返回数据说明登录状态已经跨节点共享。如果返回未登录第一件事不是改代码而是看Redis里到底有没有Session key。很多时候是序列化配置没生效或者Redis连接连到了错误的实例。4.2 坑一Redis重启后用户全部掉线问题出在持久化和淘汰策略第一次线上故障是这样的运维同事例行重启了Redis节点事后没太在意。半小时后客服反馈“大量用户被踢下线要求重新登录”。我排查后发现所有Session key在Redis里都消失了。原因是运维之前为了节省内存把Redis的maxmemory-policy设置成了allkeys-lru。这种策略在内存压力大时会淘汰包括Session key在内的所有key。Session本来设置了过期时间属于“应该被淘汰的候选”但它和其他缓存混在一个Redis实例里很容易被无差别清理。我的调整方案将淘汰策略改为volatile-lru只能淘汰设置了TTL的key避免误伤没有过期时间的业务缓存数据。如果Redis没有开启AOF持久化重启后内存数据全部丢失Session自然全没。生产环境务必开启AOF并把appendfsync设置为everysec。条件允许的话把Session相关key放到独立的Redis实例或独立的DB中和其他业务缓存隔离避免互相干扰。这个经验说明一个问题Session数据虽然带有“缓存”性质但在用户感知里它和账号密码同等重要绝不能拿普通缓存的淘汰策略去对待它。4.3 坑二乱码和ClassCastException序列化方案不一致引发的连锁故障有段时间线上日志频繁出现类似java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.example.UserInfo的报错。登录页没问题但后续请求一读取UserInfo就炸。原因是这个项目刚开始A服务用默认JDK序列化写入Session后面开了一个B服务为了排查看起来直观把序列化器换成了GenericJackson2JsonRedisSerializer。结果A服务的旧Session还是二进制数据B服务用JSON反序列化解析老Session类型对不上直接抛异常。用户必须清掉Cookie、删掉Redis里的旧key才能恢复正常。统一序列化器不是小事全链路所有节点、所有服务必须保持一致。如果已经跑了一段时间换序列化器时要把旧Session key清理干净否则线上大概率出现“部分用户掉线、部分报错”的混合故障。4.4 坑三Session过期事件没有那么实时别拿来当踢人下线业务方提出一个需求用户被管理员封禁后要立即踢下线销毁Session。我想当然地准备监听Spring Session的SessionDestroyedEvent以为Redis key过期时就能立刻触发事件通知。后来发现Spring Session的过期事件依赖Redis的Keyspace Notifications键空间通知这个功能在Redis里默认是关闭的。即使开启了notify-keyspace-events ExRedis的过期key删除是惰性周期性策略所谓“到期即删”并不准时。一个key到了TTL时间如果一直没有被访问Redis可能在几十秒甚至更久之后才真正清理触发事件也会延迟。所以我的结论是如果要做“封禁后立即下线”不要依赖Session过期事件而是让用户信息读取时实时校验账号状态。比如每次请求都检查Redis中的用户状态key或者做登录状态版本号对比发现被封禁就强制跳登录页。具体做法在下一章的登录互踢里展开。4.5 坑四Cookie在Node和Node之间“玩失踪”多节点环境下还有一类诡异问题Redis里明明有Session但业务请求就是带不上Cookie。最常见的原因是前面提到过的Cookie Domain设置错误。如果前端的接口域名是api.example.com而后端页面域名是www.example.comCookie的Domain没有设为.example.com两个域名之间的Cookie无法共享Redis那边再努力也没用。另外如果你用Nginx做了路径重写比如把/api/*反向代理到http://backend:8080/*如果Nginx层没有透传Cookie头那后端拿到的请求里压根没有Cookie。排查套路是先在浏览器开发者工具里确认Cookie是否发送再到后端打日志确认请求头Cookie字段是否有值最后再查Redis里是否存在对应Session key。三步下来基本上能把问题定位到传输层、应用层或存储层的某一环。5. 登录共享之后的高可用与并发会话控制5.1 Redis不可用时登录态要不要降级把Session全托管给Redis之后一个新的风险出现了Redis一旦不可用整个系统所有节点的登录校验都会失败用户集体掉线影响面比单机Session更大。所以高可用不能只挂在嘴边上。我的降级策略分三层连接层面Redis客户端设置合理的超时时间和连接池参数。比如超时控制在3秒内连接池打满时不要无限等待快速失败并输出告警日志。Redis高可用层面使用Redis Sentinel或Redis Cluster避免单点。主节点挂掉后自动故障转移Session读取无缝切换到从节点。K8s环境下也可以部署Redis集群StatefulSet节点挂掉自动重建。应用层面对登录态校验做缓存保护即本地内存短暂缓存最近校验成功的用户身份比如5秒Redis瞬时抖动时这层缓存能扛住大部分校验请求。线上如果对登录态极其严格降级可能导致安全问题但如果业务方愿意接受“Redis不可用时允许用户继续操作一小段时间”本地缓存方案是值得考虑的。5.2 同账号多端登录与互踢用Redis版本号实现共享Session还带来一个额外好处可以方便地控制同一个账号在不同设备上的登录行为。我之前做过一个“互踢”功能要求同一账号只能在一个设备上登录新设备登录后旧设备立即失效。实现逻辑不复杂核心是在Redis里存一个“当前有效登录标识”用户登录成功后生成一个新的token也可以用sessionId存入Redis的login:token:{userId}value就是token。每次请求校验登录态时除了读取Session里的用户信息还对比当前请求携带的token和Redis里的token是否一致。不一致就说明该账号在别处登录了当前请求直接返回“登录已过期”。这个方案比单纯依赖Session过期事件靠谱得多因为它不是被动等过期而是主动用最新登录状态覆盖旧状态。如果要实现“允许3个设备同时在线”在Redis里存一个登录token集合判断当前token是否在集合内即可。几个简单的Redis命令就能解决状态控制的问题这也是我当初坚持用Redis做共享而不用Session复制的关键原因之一。5.3 性能与网络开销会话越多越要盯着RedisSession转移到Redis后每个请求都要读取Session也就是每次请求都会有一次Redis交互。系统并发高时Redis的连接数和QPS都会上升。这里有三个优化点减少Session访问频率如果业务不需要在每次请求读Session可以只在登录校验过滤器里读取一次后续业务方法从上下文拿数据而不是反复session.getAttribute。使用Pipeline或批量读取某些网关场景下需要同时校验多个key可以用Pipeline减少RTT。Session瘦身前面提过Session里不要放大型对象。序列化体积直接决定了Redis内存大小和网络传输耗时体积越小吞吐越好。我给团队定的一个规范是Session里的单个属性序列化后不能超过2KB登录用户对象只放ID、昵称、头像、角色等基础字段权限和菜单全部动态查询。6. 一点实际操作中的体会Session Redis共享多节点登录状态技术本身不复杂核心思路就是把Session从“进程内”搬到“进程外”。但真正把这一套跑稳需要关注的不只是接入那几行配置还包括Redis的持久化策略、淘汰策略、序列化一致性、Cookie作用域、高可用方案这些容易被忽略的细节。我在实际项目里踩过的几个坑都是接入半小时就能搞定、但后续排查花了一整天的类型。个人建议是先跑通最小验证再逐步补全高可用和隔离策略不要一上来就把所有环境混在一个Redis里硬扛。今天写的这些点希望能帮你少走几段弯路。
返回列表