ARTICLE DETAIL

资讯详情

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

Spring Boot+Redis实现分布式Session共享,彻底解决集群登录掉线

Spring Boot+Redis实现分布式Session共享,彻底解决集群登录掉线 前阵子公司上线新版本刚跑了一天客服那边就炸了用户明明登录成功了过几分钟再点别的页面又跳到登录页而且不是固定某几个人是随机性的。当时我们刚把应用从单机部署成两台服务器问题立刻暴露——分布式环境下共享Session没做好所有登录态全乱套。这篇文章我打算把Session核心知识从头到尾捋一遍再用Redis把分布式共享Session登录的完整方案讲透。内容偏保姆级适合正在做Java Web、Spring Boot开发或者刚接手集群项目、想搞懂登录状态到底存哪的同学。你会看到原理、环境准备、完整代码也会看到上线后最容易踩的几个坑包括那个很多人搜了很久的there is no session with id报错。1. 从随机掉线说起Session到底干了什么活1.1 HTTP的无状态真相你每次请求都是陌生人的原因要理解Session先得理解HTTP协议的一个“祖传毛病”无状态。无状态的意思是服务器不记得你上一个请求干了什么。你第一次请求首页时它看到的是A第二次请求同一个页面时它看到的还是A但它并不知道这两个A是同一个浏览器发来的。有点像每扇门都新开一间房客人什么信息都没登记第二次进来服务员完全不认识他。这在最早期浏览纯静态页面时没什么问题但只要有登录、购物车、个人中心这类需求就全都不成立了。你登录完了服务器本来想记住这个用户是张三结果下一次请求一来它又忘了。解决办法也很朴素让客户端主动带一个“身份凭证”过来。服务器看到一个凭证就去自己的内存里翻出对应的记录这样它就能认出这个用户。这条记录就是Session概念的雏形。1.2 一次完整会话的七个关键动作我把一次最典型的Session生命周期拆成七步你按照这个顺序走一遍基本就通了浏览器第一次访问服务器接口请求里没有携带任何会话凭证。服务器发现当前没有会话于是创建一个Session对象并给它分配一个唯一的会话ID。服务器把Session对象保存在自己的存储区单机时代一般就是本地内存。服务器通过响应头里的Set-Cookie把会话ID返回给浏览器。浏览器收到后把这个会话ID存进Cookie里。后续再发请求会自动带上这个Cookie。服务器收到请求后解析Cookie里的会话ID拿着ID去存储区查找对应的Session对象。找到对象继续处理业务找不到或者已过期就当这个请求是陌生人可能要重新登录。其中第2步到第4步就是Session“种”下去的过程。而第6步是所有问题爆发的起点——单机内存里查找当然快可一旦服务器不止一台这个ID去另一台机器的内存里查结果一定是查不到。1.3 Cookie与Session的分工边界很多初学者会把Cookie和Session当成同一个东西或者当成对立概念其实它们分工完全不同。Cookie是浏览器端的存储大小一般受限在4KB左右每次请求都会自动随着Header发送到服务器。而Session是服务器端的存储存储空间理论上比Cookie大得多只在服务端维护客户端只保存一个会话ID。可以这样理解Session是服务器仓库里的储物柜Cookie是你手里的柜门钥匙。储物柜里放着用户登录后的详细信息钥匙上只刻了一个编号。钥匙丢了可以凭编号重配前提是服务器还保留储物柜但如果你把用户信息全塞进钥匙里也就是全写在Cookie里那钥匙丢了一切都暴露。有个关键点必须强调Session默认依赖Cookie来传递会话ID但它也可以不依赖Cookie比如把会话ID拼在URL后面。只不过URL传ID很容易泄露还容易被历史记录、日志抓取生产环境里基本不用。所以一般说Session机制底层默认就是“Cookie传递ID 服务端存储数据”的组合。2. 为什么单机没问题一上集群就掉登录分布式Session困境2.1 集群环境下Session面临的几种情况单机部署的时候所有Session都在同一台服务器的内存里查找非常快闭着眼睛都能跑通。但你一旦上了集群用户请求会被负载均衡分发到不同实例Session的“归属地”就成了大问题。假设你有两台服务器A和B。用户第一次请求被分到A登录成功Session存在A的内存里。用户再点一下按钮这次请求被负载均衡分到BB的内存里根本没有这个会话ID它认为用户是未登录状态直接把用户踢回登录页。这就是我们前几天遇到的那个“随机掉线”的根本原因。我再把集群下Session的几种情况列一下你可以对号入座所有请求都恰好命中同一台服务器登录态始终正常但你无法保证负载均衡永远这么听话。第一次访问A第二次访问B瞬间变成未登录这是最常见的现象。会话ID在B里找不到B可能还会重新创建一个Session导致乱象加剧甚至多个会话ID混在一起。用户数量上来后每台服务器内存分叉有的服务器Session爆炸有的服务器Session闲置资源利用极差。2.2 三种常见共享方案的对比与取舍要解决“每台机器自己管自己”的问题常见的思路无非三种粘性会话、Session复制、集中式Session存储。粘性会话是最偷懒的方案。负载均衡器根据用户标识比如IP或者某个Cookie把同一个用户的请求固定转发到同一台服务器。好处是零代码改动坏处也很明显一旦这台服务器重启或宕机这台机器上的所有用户会话全丢负载均衡器的配置还变成了额外依赖失去弹性扩展的意义。Session复制是很多传统中间件喜欢做的方案。服务器之间互相同步Session数据A机器上新写入的Session会复制到B、C机器。这样任何一台机器都能处理任意用户的请求。但同步是有代价的实例越多复制开销越大网络带宽被大量占用而且遇到会话大规模变更时容易出现短暂不一致。集中式Session存储把Session从应用服务器里抽出来放到一个独立的存储中间件里比如Redis。所有应用实例都从同一个地方读写Session谁都不需要本地保存。用户无论被负载均衡分到哪台机器都能拿到同一个Session。这是目前最成熟的方案也是这篇文章要重点讲的。三种方案我用一个表格总结方案实现成本跨实例共享效果主要风险粘性会话低部分解决依赖负载均衡策略实例故障或重启导致会话丢失Session复制中可以共享但同步开销大多实例时网络风暴、数据不一致集中式Session存储中高完全共享天然统一依赖存储中间件的高可用2.3 集中式存储的前提不只是把Map塞进Redis很多人一听“集中式存储”就以为是把原来的HttpSession对象直接往Redis里一丢读的时候再拿出来。实际远没有这么简单。Session写入Redis首先要考虑存储结构key怎么命名、value用什么格式、要不要加业务前缀。其次要考虑过期时间Session天然有有效期Redis的过期机制能不能精确配合。然后要考虑序列化方式对象直接存进去是一坨二进制乱码还是转成JSON字符串出了问题怎么排查。最后还要考虑安全性和并发登录成功后要不要换会话ID同一账号多端登录怎么处理。这些问题如果不在接入前想清楚后面上线基本是要加班的。所以下面先花一章讲清楚Redis侧的准备和设计再进入代码实战。3. 前置准备Redis环境、存储设计与序列化细节3.1 为什么偏偏是Redis数据结构、过期机制与原子操作存Session的中间件有很多选择MySQL、Memcached、Redis都能存但Redis是最合适的。先说过期机制。Session最重要的特征就是“过一段时间自动失效”Redis的键可以设置TTL到了时间自动删除不需要你专门写定时任务去清理。而MySQL存Session要自己处理过期删除要么每条记录记个过期时间然后定时扫表要么写事件调度成本明显高。再说数据结构。Session的value一般就是一个用户对象用Redis的String结构就能解决。但如果你要玩更复杂的场景比如一个用户的多个登录会话、同时踢掉某台设备Redis的Hash、Set结构也能承载比Memcached那种简单的key-value灵活太多。Memcached虽然也快但数据类型贫瘠做复杂会话管理很吃力。最后说原子操作。共享Session之后你经常会遇到同一个人在别处登录我要把旧登录踢下线这类需求。Redis提供了SETNX、EXPIRE、Lua脚本等原子能力可以保证判断和设置两步操作不被并发请求打断。这一点在你做登录踢人、登录频率限制时非常有用。我自己的经验是能用Redis解决的会话问题尽量不要自己造轮子。你在Redis里积累的这套过期经验、连接池配置、序列化方案以后做分布式锁、接口幂等等也能直接复用。3.2 本地环境搭建装好Redis与可视化客户端既然要用Redis开发环境至少得先跑起来一个Redis实例。Linux服务器上安装很简单官方仓库装完直接redis-server启动。Windows开发机比较麻烦官方一直不维护Windows版本目前最常见的做法是装Docker一条命令启动docker run -d --name redis-session \ -p 6379:6379 \ redis:7 \ redis-server --appendonly yes如果你本机没有Docker也可以用社区维护的Windows移植版或者直接在WSL里安装原版Redis。我需要提醒一句生产环境不要用Windows跑RedisWindows移植版性能和稳定性都不可控老老实实部署在Linux上。装好Redis之后强烈建议装一个可视化客户端比如Redis Desktop Manager也就是搜索热度很高的那个RDM。调试Session时可以直观看到Redis里有哪些key、value长什么样、过期时间还剩多少比命令行反复敲KEYS和TTL高效太多。命令行工具当然也要会后面排查生产问题会用到redis-cli # 查看某个会话key的剩余过期时间 TTL login:session:xxxxxx # 查看某个key的值 GET login:session:xxxxxx需要强调生产环境的Redis不要乱用KEYS命令。它会全量扫描所有key在key数量多的时候阻塞Redis主线程线上很容易出事。排查时如果想找某个前缀的key优先用SCAN。3.3 存储设计的关键点Key命名、过期时间与序列化在写任何代码之前先把存储结构定下来通常比写代码更重要。Key命名建议带一个统一前缀比如login:session:{sessionId}。这么做最大的好处是Redis里可能同时存放缓存数据、分布式锁、业务数据遇上报错或者想清理测试数据你可以根据前缀精准定位不至于把别人的缓存误删。多业务部署在同一套Redis时前缀甚至可以精确到业务线例如order:session:和user:session:。过期时间通常取30分钟但要看你的业务场景。用户习惯是“登录一次一整天都有效”还是“离开键盘5分钟就要重新登录”两者差别很大。建议把默认值做成配置项而不是写死在代码里。另外要留意一个细节大量Session如果设置了完全相同的过期时间Redis里会出现“同批次key同时到期”的现象轻则Redis短暂阻塞重则所有用户同时需要重新登录。解决办法是给过期时间加一点随机扰动比如1800 随机0-300秒把过期压力打散。序列化是小白最容易翻车的地方。默认的RedisTemplate如果是JDK序列化存进去的value就是一坨\xAC\xED\x00\x05开头的二进制数据看着完全不知道是什么。所以强烈建议统一使用JSON序列化或者直接用StringRedisTemplate把用户对象手动转成JSON字符串。这样出现问题打开可视化客户端一眼就能看到用户的ID、姓名、角色信息排查成本直线下降。当前设计建议是设计项推荐配置原因Key格式login:session:{sessionId}便于隔离和排查value格式JSON字符串可读性高跨语言友好过期策略TTL 随机扰动防止过期风暴Cookie中传递ID仅存会话ID本身避免敏感数据暴露在客户端4. 实战接入从Spring Boot依赖到共享登录实现4.1 两条路线框架集成与手写原理在Spring Boot生态里实现“Redis共享Session”有两条路可选。一条是直接用Spring Session Data Redis把核心逻辑交给框架配置简单稳定性高适合绝大多数生产项目。另一条是自己写一套基于RedisTemplate的Session管理器过程更透明能帮你彻底理解框架背后发生了什么适合学习原理和排查疑难问题。我的建议是先用框架把业务跑通再手写一遍理解核心逻辑。这样既不会因为中途失败打击信心又能真正掌握背后的机制。下面两条路我都会给完整示例。4.2 路线一Spring Session Data Redis从依赖到配置第一步加依赖。在pom.xml里引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency第二步写配置。application.yml里把Redis连接和Session存储类型指过去spring: redis: host: localhost port: 6379 # password: 如果有密码就填 session: store-type: redis timeout: 30m第三步加一个配置类启用Redis Session管理Configuration EnableRedisHttpSession(maxInactiveIntervalInSeconds 1800) public class RedisSessionConfig { }这里maxInactiveIntervalInSeconds就是Session的默认过期秒数按你业务需求调整。加完这一步Spring Session会用Redis替代Tomcat自带的本地Session存储原来的HttpSession用法基本不用改request.getSession()、session.setAttribute()都照常工作只是背后落库的地方从服务器内存变成了Redis。还有一个值得自定义的点会话ID的Cookie名称和属性。默认Cookie名是SESSION生产环境建议改成更有辨识度的名字并明确设置HttpOnly。可以用下面的方式定制Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer new DefaultCookieSerializer(); serializer.setCookieName(MY_SESSION_ID); serializer.setCookiePath(/); serializer.setUseHttpOnlyCookie(true); serializer.setUseSecureCookie(false); // 如果上了HTTPS改成true serializer.setSameSite(Lax); return serializer; }如果你项目本来就用了Spring Security那更省事Spring Security默认做了Session固定攻击防护登录成功后会自动换Session ID你只需要保证共享Session的底层是Redis就行。4.3 路线二手写一套基于RedisTemplate的轻量级Session管理器框架虽好但为了搞明白原理我还是建议你手写一个极简版本。这套方案的思路也很直白用Redis的String结构存用户信息用Cookie存Session ID用一个过滤器做登录态识别。先写一个核心的SessionService负责往Redis里写入、读取、删除会话Component public class SessionService { private static final String SESSION_KEY_PREFIX login:session:; private final StringRedisTemplate redisTemplate; public SessionService(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } // 登录成功后创建会话 public String createSession(LoginUser user, long timeoutSeconds) { String sessionId UUID.randomUUID().toString().replace(-, ); String key SESSION_KEY_PREFIX sessionId; long realTimeout timeoutSeconds ThreadLocalRandom.current().nextLong(0, 300); redisTemplate.opsForValue().set(key, JSON.toJSONString(user), realTimeout, TimeUnit.SECONDS); return sessionId; } // 通过会话ID获取用户 public LoginUser getBySessionId(String sessionId) { if (sessionId null || sessionId.isEmpty()) { return null; } String json redisTemplate.opsForValue().get(SESSION_KEY_PREFIX sessionId); if (json null || json.isEmpty()) { return null; } return JSON.parseObject(json, LoginUser.class); } // 登出时删除会话 public void removeSession(String sessionId) { if (sessionId ! null !sessionId.isEmpty()) { redisTemplate.delete(SESSION_KEY_PREFIX sessionId); } } }再写一个过滤器每次请求进来都从Cookie里取Session ID然后去Redis查用户Component public class SessionFilter implements Filter { public static final String CURRENT_USER CURRENT_USER; private final SessionService sessionService; public SessionFilter(SessionService sessionService) { this.sessionService sessionService; } Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; HttpServletResponse httpResponse (HttpServletResponse) response; String sessionId readSessionIdFromCookie(httpRequest); LoginUser user sessionService.getBySessionId(sessionId); if (user ! null) { httpRequest.setAttribute(CURRENT_USER, user); } chain.doFilter(request, response); } private String readSessionIdFromCookie(HttpServletRequest request) { Cookie[] cookies request.getCookies(); if (cookies null) { return null; } for (Cookie cookie : cookies) { if (MY_SESSION_ID.equals(cookie.getName())) { return cookie.getValue(); } } return null; } }看到这里你应该能发现手写方案的核心其实只有三个动作登录时生成ID并写Redis、请求时读Cookie查Redis、登出时删Redis。Spring Session框架做的事情本质上也是一样的只不过它把HttpSession接口、Cookie管理、并发控制、自动续期这些细节都替你处理了。4.4 登录、鉴权、登出三个核心接口的完整实现有了底层的SessionService业务接口就非常简单了。登录接口校验用户名密码后创建会话然后把Session ID写入Cookie返回给浏览器侧。注意生成新会话之前最好把同账号其他端的老会话踢掉这个看业务需求后面再展开。PostMapping(/login) public Result login(RequestBody LoginRequest req, HttpServletResponse response) { // 1. 校验用户名密码假设user是DB查出来的 LoginUser user authService.verify(req.getUsername(), req.getPassword()); if (user null) { return Result.fail(用户名或密码错误); } // 2. 创建Redis共享Session String sessionId sessionService.createSession(user, 1800); // 3. 写Cookie Cookie cookie new Cookie(MY_SESSION_ID, sessionId); cookie.setPath(/); cookie.setHttpOnly(true); cookie.setMaxAge(1800); response.addCookie(cookie); return Result.ok(登录成功); }鉴权接口从过滤器里拿用户没有就返回401。GetMapping(/profile) public Result profile(HttpServletRequest request) { LoginUser user (LoginUser) request.getAttribute(SessionFilter.CURRENT_USER); if (user null) { return Result.fail(401, 未登录或登录已过期); } return Result.ok(user); }登出接口删除Redis里的会话再把Cookie置空。PostMapping(/logout) public Result logout(HttpServletRequest request, HttpServletResponse response) { String sessionId readSessionIdFromCookie(request); sessionService.removeSession(sessionId); Cookie cookie new Cookie(MY_SESSION_ID, null); cookie.setPath(/); cookie.setMaxAge(0); response.addCookie(cookie); return Result.ok(已退出登录); }这个手写版本在单机上用完全没有问题放到两台服务器上也依然有效因为所有Session都集中在Redis里了。而且你遇到问题时排查路径也很清晰登录不上去看Redis有没有写入key跨实例掉线看Redis连接有没有问题。搞懂了这套手写逻辑再看Spring Session的文档就没那么玄乎了。5. 上线必踩的坑会话ID异常、攻击防护与Redis运维风险5.1 there is no session with id到底是不是Session问题搜过技术问题的人多少都会见过这句话there is no session with id。但我要先泼一盆冷水这句报错大概率跟你要解决的Web Session共享问题没有任何关系。它通常出现在Hibernate相关报错里格式类似could not open hibernate session for transaction; nested exception is org.hibernate...。这里的Session指的是Hibernate的数据库会话对象是操作数据库连接、事务、一级缓存的那个概念不是用户登录会话。你如果在搜索引擎里搜Web Session问题时看到它很容易被带偏。如果是Web端Session找不到典型表现是“未登录或登录已过期”日志里通常不会有这么明确的英文报错。当你真正遇到Redis里的会话ID查不到时优先排查下面几个方向Redis连接是否正常、Redis是否重启导致数据丢失、Session过期时间是否设置得太短、Cookie里的Session ID是否被浏览器清理、多实例的Redis是不是连到了同一个实例。记住Web Session问题的排查看点是“请求有没有带上正确的ID、ID对应的数据在存储里存不存在”。5.2 Session固定与会话劫持登录成功后必须换ID接下来这个坑可能很多人没意识到。假如用户登录前后的Session ID完全没有变化会有一个叫做“Session固定攻击”的风险。攻击者可以先在自己浏览器里获取一个有效Session ID然后想办法让受害者使用这个ID去登录。登录成功后服务器如果没有重新生成Session ID攻击者就能拿着这个ID冒充受害者。这就好比你捡到了一把别人储物柜的钥匙而储物柜主人换完衣服之后居然没有换锁。防御方法非常简单登录成功后必须让旧的会话ID失效给用户换一个新的Session ID把用户数据从旧ID迁移到新ID上。Spring Security默认已经做了这件事手写方案里则在登录校验成功后主动调用一次“rotate”逻辑删掉旧ID对应的Redis key生成新的ID再写入Cookie。还有个细节是Cookie的HttpOnly属性。如果你的Session ID能被JavaScript读取那么一旦页面被注入恶意脚本攻击者就能拿到Cookie并发起请求。务必在所有写会话Cookie的地方加上setHttpOnly(true)。上线HTTPS后还要把Secure属性打开确保Cookie只通过HTTPS传输。5.3 Session膨胀、过期风暴与Redis单点风险集中式Session解决了“每台服务器自己记自己”的问题但也把所有鸡蛋放进了同一个篮子。Redis只要一挂所有用户同时掉线这个后果比单机Session丢失严重得多。所以生产环境必须给Redis做主从加哨兵或者直接上Redis Cluster别拿单机Redis顶线上。内存膨胀也是常见问题。Session里如果塞了大量数据比如把整个用户详情、权限列表、购物车全放进去Redis的内存会肉眼可见地涨。建议Session里只存必要字段比如用户ID、用户名、角色标识其他重数据在每次请求时再查库。Redis再快也扛不住你把几MB对象塞进会话键里。过期时间的坑我也再强调一次。大量用户在同一时间登录过期时间又完全一致意味着过期时间也会集中在同一时间爆发。Redis处理过期键有两种方式惰性删除和定期删除大批key同时到期的瞬间CPU会有一个明显尖峰。解决方案就是在设置过期时间时加随机偏移量错峰过期。最后是运维习惯。很多团队喜欢在Redis里什么数据都放一起缓存、Session、分布式锁全在一个实例里。这时候如果谁手滑执行了FLUSHDB那就是全线事故。建议至少用不同DB或不同前缀隔离业务数据Session的key必须带独立前缀这个习惯能从底层减少误操作损失。排查Session问题时也尽量用可视化工具或SCAN命令而不是直接KEYS。我在实际项目里踩过的最大坑是当时为了省事直接用了RedisTemplateObject,Object没配序列化器结果Redis里全是一堆看不懂的二进制排查问题全靠猜。后来统一改用StringRedisTemplate加JSON果然世界清净了。所以如果你正打算上手先把序列化这块想明白再往下写业务代码。
返回列表