
做后端的人基本都经历过这么一遭单机部署的时候 Session 用得好好的一到要扩容、要上多实例、要搞微服务了用户登录状态就开始各种“串台”。今天登录在 A 机器上下一次请求被负载均衡转发到 B 机器Session 就丢了用户被硬生生踢回登录页。网上搜一圈方案无非是 Session 粘滞、Session 共享而这些年真正被大规模落地、也是我最推荐的做法就是用 Redis 来替代传统 Session 存储。这个方案能解决的不只是“多实例共享登录态”这一个痛点它对会话过期管理、在线用户统计、强制下线这些业务需求都给出了远比 Servlet 容器内置 Session 灵活得多的实现空间。这篇文章我就从实际业务出发把“Redis 代替 Session”从登录到注销的完整业务流程拆开讲清楚每一步该怎么做、为什么要这么做、有哪些坑等着你踩。无论你是刚接触分布式开发的新手还是已经在项目里被 Session 问题折磨过的老手跟着这套流程走一遍基本就能在真实项目里落地了。1. 先想清楚为什么非要用 Redis 替代 Session1.1 传统 Session 在分布式环境下的硬伤很多人第一次意识到 Session 有问题是在部署多实例之后。默认情况下Servlet 容器Tomcat、Jetty的 HttpSession 是存在单台 JVM 内存里的SessionID 通过 Cookie 返回给浏览器。请求打到了 A 实例Session 在 A 上负载均衡下一次把请求打到 B 实例B 上没有这份 Session于是用户就要重新登录。有人会说我配置负载均衡的粘滞会话Sticky Session不就行了让同一个用户的请求始终打到同一台机器。这个方案在小规模集群下确实能凑合用但它有两个很恶心的副作用一是某台机器宕机时落在它上面的所有用户 Session 全部消失而且是不可恢复的二是扩容、缩容、重启应用时Session 分布会被打乱一样要出问题。再往下走如果你做了微服务拆分一个登录态可能要被多个服务校验这些服务往往部署在不同物理节点上Sticky Session 根本解决不了跨服务共享的问题。1.2 Redis 方案到底解决的是什么把 Session 数据从 JVM 内存搬到 Redis本质上就是把“会话状态的存储位置”从“每个应用的私有内存”变成“所有应用共享的中央存储”。这样任何一个实例收到请求只要拿着同一个 SessionID就能从 Redis 里查到对应的会话数据实例之间彻底不再有状态差异。这才是真正的无状态服务水平扩展想加几台机器就加几台某台机器挂了流量自动切走用户完全无感知。从业务角度讲Redis 的 TTL 天然适配 Session 的过期机制过期时间可以精确控制、可以动态续期Redis 支持丰富的数据结构可以存储对象、计数器、集合甚至能基于它实现“单用户单会话”“在线用户数统计”这类传统 Session 做不到或很难做的功能。很多项目把“Redis 替代 Session”作为分布式改造的第一步不是没有道理的它投入不大但立刻就能让登录体系具备向外扩展的 base 能力。2. 整体方案设计两条主流路线的取舍2.1 路线一引入 Spring Session低侵入替换如果你用的是 Spring Boot最省事的方式是用 Spring Session Data Redis。引入依赖后Spring Session 会接管 HttpSession 的创建和存取把 Session 数据透明地写入 Redis。业务代码里你继续用request.getSession()、session.setAttribute()基本不用改底层存储却已经换成了 Redis。这条路线适合什么场景老项目改造业务代码里大量直接操作 HttpSession不想大动干戈。Spring Session 还额外提供了 Session 事件监听、按用户查找 Session 等能力算是比较成熟的框架级方案。但我要提醒一句Spring Session 也有它的副作用。序列化默认用的是 JDK 序列化存进 Redis 的数据是一坨人眼没法读的二进制码排查问题不方便还有 Cookie 里的 SessionID 和 Redis 里的 Key 之间的关系是框架管理的你想自己手动去 Redis 里删某个用户的会话、做强制下线需要绕一下。另外把整个 Spring Session 引进来项目里会多不少概念对新手来说理解成本并不低。2.2 路线二自研 Token Redis完全掌控流程我个人的偏好也是后面要展开讲的是自研一套“Token Redis”方案用户登录成功后生成一个全局唯一的 Token把这个 Token 作为 Key用户会话信息作为 Value 写进 Redis同时把 Token 返回给前端放在 Header、Cookie 或者请求参数里。请求进来时后端从请求中取出 Token去 Redis 查一下查到就认为是有效登录态。这条路线的优势是第一不依赖 Servlet 容器和 Spring Session 的黑盒逻辑Token 可以放在任何请求位置传给后端天然适合前后端分离和移动端接入第二会话查询、删除、续期都是直接操作 Redis 的普通命令逻辑完全透明出了问题好排查第三你可以给 Token 设置业务上的过期策略自由度很高。当然它也要求你具备基本的 Redis 操作能力。不过说句实话只要会SET、GET、EXPIRE这几个命令这套方案就完全能撑起来。对大多数团队来说与其去理解 Spring Session 封装出的那一层抽象不如自己掌握这套直白的存储逻辑反倒更可控。3. 核心业务流程拆解一次登录到注销的完整链路3.1 登录成功后的会话创建与写入不管用哪种方案流程的起点都是用户登录成功。传统 Session 的做法是request.getSession(true)然后往里面塞数据Redis 方案的做法是生成一个不重复的 Token把用户信息序列化后写入 Redis再把 Token 交给前端。Token 的生成不能用简单的自增 ID容易被枚举和伪造。我的习惯是用 UUID 去掉横线或者用 SecureRandom 生成足够长的随机字符串。曾经有人用时间戳拼用户 ID 当 Token结果被同事写脚本遍历把全站用户会话都拿到手了这种问题属于安全红线一定不能踩。写入 Redis 的具体逻辑长这样// 生成全局唯一 token String token UUID.randomUUID().toString().replace(-, ); // 用户会话信息通常包含 userId、昵称、角色权限等 MapString, Object sessionData new HashMap(); sessionData.put(userId, user.getId()); sessionData.put(username, user.getUsername()); sessionData.put(role, user.getRole()); sessionData.put(loginTime, System.currentTimeMillis()); // key 前缀加上业务标识避免和其他业务数据混在一起 String key login:token: token; // 写入 Redis 并设置过期时间比如 30 分钟 redisTemplate.opsForHash().putAll(key, sessionData); redisTemplate.expire(key, 30, TimeUnit.MINUTES); // 返回给前端 return token;这里有几个细节值得展开。一是 Key 要带业务前缀比如login:token:这样做的好处是 Redis 里所有会话数据可以统一管理用SCAN命令可以扫描出所有在线会话线上排查时一眼就能区分业务数据。二是 Value 我建议用 Hash 结构而不是直接序列化一个对象字符串。Hash 的好处是你可以只更新某一个字段比如用户改了昵称只HSET一个字段就行不用把整个会话对象读出来再写回去而且 Hash 在 Redis 内部存储上对小对象有压缩优化内存占用更友好。三是过期时间一定要设置这就是业务上的“登录有效期”不需要依赖别的清理机制。3.2 请求阶段的会话鉴权与续期用户登录后每次请求都要带着 Token。后端需要一个拦截器或者过滤器在请求进入业务逻辑之前完成统一的校验。传统的 Session 校验发生在容器内部应用代码拿到的HttpSession已经是校验过的结果。而 Redis 方案里拦截器要做三件事取 Token、查会话、做续期。Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 1. 从请求头获取 token也可以从 Cookie、请求参数中获取 String token request.getHeader(Authorization); if (token null || token.isEmpty()) { throw new NotLoginException(未登录); } // 2. 以 token 为 key 查询会话 String key login:token: token; MapObject, Object sessionData redisTemplate.opsForHash().entries(key); if (sessionData.isEmpty()) { throw new NotLoginException(会话已过期或不存在); } // 3. 将用户信息放入 ThreadLocal供后续业务代码获取 UserContext.set(sessionData); // 4. 续期每次有效请求都会重置过期时间 redisTemplate.expire(key, 30, TimeUnit.MINUTES); return true; } }续期是一件非常重要但容易被忽略的事情。如果不续期用户挂着一直有操作登录状态也可能在某个时刻突然过期很影响体验。Redis 方案里续期成本极低就是一次EXPIRE命令。但这里要注意你不能每一次请求都无脑续期那样 Redis 会被写请求打爆。我的做法是做一个“滑动过期”的降频处理在会话数据里记录一个lastRefreshTime只有距离上次续期超过一定时间比如 5 分钟才真正执行EXPIRE否则只更新lastRefreshTime字段。这样既保证了长期活跃用户不掉线又避免了高频访问场景下大量无效命令。3.3 注销、踢人下线与超时兜底注销的流程正好是登录的逆操作核心动作只有一个删除 Redis 中对应的会话数据。// 用户主动注销直接删除会话 public void logout(String token) { String key login:token: token; redisTemplate.delete(key); }删除之后即使前端还留着这个 Token后续请求也查不到会话自然会被拦截器判定为未登录。这就是 Redis 方案做注销比传统 Session 更彻底的地方传统 Session 的invalidate()只能清掉当前容器里的会话如果 Session 已经被粘滞到别的机器还得找到那台机器Redis 方案一个DEL命令全局生效。再说“踢人下线”这个需求。这也是用 Redis 做会话存储的红利之一。传统 Session 时代想主动让某个用户下线你需要遍历所有节点的会话几乎没有可操作性。Redis 方案里你可以为每个用户维护一个“Token 索引”// 用户登录时除了写入会话数据还维护一个用户对应的 token 映射 redisTemplate.opsForValue().set(login:user: userId, token, 30, TimeUnit.MINUTES);要踢某个用户下线时先查出他当前的 Token再删除对应的会话数据。更极客一点的做法是利用 Redis 的发布订阅给该用户在线设备推一个“下线通知”。这套能力放到业务里就是 App 上的“账号在别处登录”“管理员强制封禁”的功能基础。超时兜底就更容易了Redis 的 Key 过期机制自动清理你甚至不需要专门写删除逻辑。只要所有会话 Key 都设置了过期时间过期的数据 Redis 会惰性删除加定期删除两步走最终腾出空间。这里唯一要留意的是如果你的 Redis 内存很大、写入频率又高可以打开maxmemory-policy allkeys-lru让 Redis 在内存吃紧时自动淘汰最久没用的数据相当于给会话缓存做了一层额外的资源保护。4. 实操细节数据结构、序列化与过期策略的血泪经验4.1 Redis Key 与 Value 的设计规范我见过很多项目Redis 里的 Key 随手写没有前缀、没有业务归属时间一长整个 Redis 变成一个大杂烩。尤其是会话数据这种和用户强相关的 Key如果全部乱放一旦需要排查线上问题想快速找出某个用户的登录会话都非常费劲。我的规范很简单Key 用“业务域:对象:标识”的三段式比如login:token:{token}、login:user:{userId}。这样做有三个好处一是不同业务的 Key 天然隔离可以使用不同的过期时间、不同的淘汰策略二是可以用SCAN扫描前缀统计在线用户数、清理异常会话都方便三是不容易和别的模块的 Key 冲突。Value 用什么结构我前面推荐了 Hash。但有一个场景可以换思路如果会话信息只是一个简单的 userId 对应 token 的映射直接用 String 就行。如果会话里要存对象的完整快照、而且要支持 JSON 反序列化到指定类String 加 JSON 序列化是更直观的。关键判断依据是“要不要频繁更新单个字段”。要就用 Hash不要就用 String。4.2 序列化方式JDK 序列化是个坑很多人会在这个地方栽跟头。默认情况下如果用 Spring Boot 的RedisTemplate操作 RedisValue 序列化器是 JDK 序列化好处是对象直接存、直接取但坏处也很明显存进去的数据是二进制redis-cli里看是一串\xAC\xED\x00\x05t...完全没法调试跨语言调用时比如有 Node.js、Python 服务也要校验同一个会话根本读不懂 Java 序列化的内容JDK 序列化性能一般序列化后的体积大浪费 Redis 内存。所以接入 Redis 会话的第一件事就是把 Value 序列化器换成 JSONKey 序列化器换成 String。Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // Key 使用 String 序列化 template.setKeySerializer(new StringRedisSerializer()); // Hash 的 Key 也使用 String 序列化 template.setHashKeySerializer(new StringRedisSerializer()); // Value 使用 JSON 序列化方便查看和跨语言读取 GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }注意如果会话对象里有复杂类型比如 LocalDateTime直接塞进去会反序列化报错。我的做法是在存 Redis 之前把会话对象统一转成 Map 或者 DTO里面只放基础类型和普通字符串避免掉进类型转换的坑。这也是为什么我推荐用 Hash 存会话你完全可以用StringRedisTemplate逐字段写入压根不涉及对象序列化问题。4.3 过期策略的精细控制过期时间是整个会话方案里最需要业务权衡的参数。设得太短用户随便刷个长列表回来就得重新登录设得太长Redis 里堆积的会话数据变多账号安全风险也变大。我的经验是普通 Web 管理后台30 分钟到 2 小时之间比较常见移动端 App一般会做得更长有的甚至 7 天如果是对安全性要求极高的系统支付后台、运维平台建议不超过 15 分钟并且开启操作审计。另外要理解 Redis 的过期删除机制过期 Key 不是实时被删除的而是惰性删除访问时发现过期再删加定期删除后台随机抽样清除的结合。所以你在业务上会有极小概率在 Key 已过期但尚未被清除的时间窗口内通过GET还能查到数据吗不会Redis 在收到请求时会先检查过期时间过期的 Key 立即返回空。所以从业务视角看过期判断是可靠的不用自己额外判断时间。但要注意如果会话非常大、并发读写非常高定期删除的 CPU 开销在极端情况下会有一点点影响这时适当调大hz配置可以缓解。5. 常见问题与排查技巧实录5.1 Token 伪造与会话串号问题Token 这个东西直接决定了会话请求的合法性。如果你用弱随机数发生器生成 Token比如 Java 的Random攻击者可以预测下一个 Token 值伪造合法登录态。我处理过的真实案例里有团队用Math.random()拼时间戳结果被安全测试工具直接猜出了有效 Token把别人的会话“借”过来用了。正确做法是用SecureRandom或者 UUID。你要是更讲究一点可以把 Token 设计成“随机部分 签名部分”的组合服务端收到后用 HMAC 校验签名这样即使 Token 泄露到日志里攻击者也无法篡改签名部分去伪造另一个用户的 Token。这里多说一句不要把用户名、用户 ID 直接拼进 Token。Token 一旦泄露等于把你的用户身份直接暴露给我们看虽然脱敏程度不高但你也不想日志里出现能对应用户身份的信息吧。5.2 过期时间到了但 Redis 里还残留大量会话红了一个误区Redis 过期 Key 是自动清理的不假但不是立时的。如果你往 Redis 里塞了几千万个带过期时间的 Key又几乎不会再去访问它们这些 Key 会一直占用内存直到定期删除扫到它们。极端情况下还没等定期删除跑完内存已经满了。解决办法有三个。第一把maxmemory-policy设置成allkeys-lru或volatile-lru让 Redis 在内存不足时自动淘汰最久未使用的 Key这相当于给会话存储加了一层保险丝。第二如果你对会话的实时性要求不高可以通过定时任务主动扫描并清理已过期 Key比如每 5 分钟执行一次SCAN TTL把 TTL 为负数的 Key 删掉。第三不要让过期时间全部设置为同一个值稍微加一点抖动避免定期删除的扫描压力集中。5.3 并发刷新导致续期失败或会话丢失在多线程并发下Session 的更新可能会出现竞态问题。比如用户同时在两个设备上请求同一个 userId 对应了两个不同的 Token如果你维护了login:user:{userId}到 Token 的映射后登录的设备会把先登录的设备的映射覆盖掉。这其实是业务规则问题如果产品要求“后登录踢掉先登录”这个设计刚好满足如果产品要求“多端共存”那你的映射就不能只有一个 Token要改成 Set 结构或者直接去掉这个映射。另一个并发场景是续期和注销互相竞争。用户在操作中途主动注销同时另一个请求正在执行续期EXPIRE可能导致注销删除了会话但续期又把会话“救活”了。虽然这个时间窗口极短但确实出现过。解决办法是在拦截器里做续期之前先判断会话是否还存在或者把续期的 Key 从请求 Token 维度和注销删除的 Key 用同一个字符串然后依靠 Redis 的单命令原子性来保证DELETE和EXPIRE如果有先后顺序后执行的会覆盖前者的效果所以我们的续期代码逻辑里要先查EXISTS再续期如果真的不放心可以加分布式锁做一致性保护。5.4 Redis 连接异常与会话雪崩把会话放到 Redis 后Redis 本身就变成了一个核心依赖点。如果 Redis 短暂不可用所有登录用户的请求都会在校验会话这一步失败整个系统看起来就像全面宕机了。这就是“会话雪崩”。针对这个问题我做几层防护第一Redis 本身部署成主从 哨兵模式避免单点故障第二应用侧要对 Redis 连接做合理的超时和重试配置Redis 连接超时设置太短容易误判故障设置太长又会让请求堆积第三业务上要设计“衰减降级”方案比如 Redis 挂了的时候允许最近几分钟内活跃的用户继续访问而不校验会话或者直接返回 503 并引导用户稍后重试。最忌讳的是让每次请求都无限期阻塞在获取 Redis 连接上那会导致整个应用线程池耗尽。5.5 排查黑话There is no session with id、Could not open Hibernate session这两个异常会让人一头雾水其实和我们的主题刚好相关。“There is no session with id” 最常见于 Spring Session 场景Redis 里找不到对应的 Session 数据。原因通常是会话过期、Redis 被清空、或者 SessionID 在 Cookie 和 Redis Key 之间的映射关系被框架忽略。排查思路很简单用浏览器 F12 看 Cookie 里的 SessionID再到 Redis 里查这个 SessionID 对应的 Key 是否存在如果 Key 不存在看是不是过期时间太短或者 Redis 里数据被其他程序误删了。“Could not open Hibernate session for transaction” 则完全是另一回事它跟 HTTP Session 没关系说的是 Hibernate 获取数据库连接会话失败通常是因为数据库连接池耗尽或数据库不可用。但因为它名字里也有 session经常被新手误以为是 Session 共享的问题。遇到这种报错时直接查数据库连接池状态和数据库本身别在 Redis 会话方案上浪费时间。6. 从单机到集群Redis 方案的部署与高可用落地方案在代码层面跑通之后下一个问题是 Redis 本身怎么部署。很多团队一开始只在本地装了一个单机 Redis开发环境能用上线后就暴露问题——Redis 也成了单点Redis 挂了全站用户都得重新登录。最经济的做法是搭一主一从加哨兵。主节点负责读写从节点做实时备份哨兵负责监控主节点的健康状态主节点宕机后自动把从节点升级为主节点。这个模式下应用的配置只需要指向哨兵地址连接时会自动发现当前的主节点。spring: redis: sentinel: master: mymaster nodes: 10.0.0.1:26379,10.0.0.2:26379,10.0.0.3:26379再往上一步如果你要支撑的会话量非常大百万级在线可以考虑分片集群让不同用户的会话落在不同的分片上。但这会引入跨分片操作的复杂性比如“在线用户全局搜索”这类场景会变得很麻烦。我的建议是绝大多数业务一主两从加哨兵就完全够用只有到了单节点内存明显吃紧、写入量明显膨胀的阶段再考虑集群分片不要一开始就把架构搞得过重。还有一个容易被忽略的点Redis 的持久化配置。会话数据本身就是短生命周期的似乎丢了也无所谓但 Redis 如果频繁重启又丢失数据会导致所有用户重新登录体验极差。我建议至少开启 AOF 持久化并设置appendfsync everysec即每秒刷盘一次。这样即使 Redis 意外宕机最多丢失一秒的写入量对会话数据来说完全在接受范围内却能避免大规模强制下线。7. 还可以再往前一步扩展玩法与后续演进一旦会话数据统一收归 Redis很多原来做不了的事情变成了顺手的事。比如在线用户统计通过SCAN扫描login:token:*前缀的 Key过滤出未过期的会话数量就能得到实时在线人数不用再埋点硬算。比如全员强制下线安全部门怀疑系统被入侵时只要执行一段 Lua 脚本把所有login:*前缀的 Key 删掉所有用户立刻全部下线这在纯 Session 时代是不可想象的。“用户登录行为分析”也可以做你可以在会话数据里记录登录 IP、设备信息、最后活跃时间需要做异地登录告警时直接在拦截器里比较当前 IP 和会话里存的 IP不一致就触发风控。我个人在实际操作中的体会是Redis 替代 Session 技术门槛真的不高真正的难点在设计阶段的业务规则梳理比如多端登录怎么算、过期时间定多长、主动踢人怎么通知这些才是影响用户体验和账号安全的关键。代码层面的实现吃透登录写入、请求校验、续期、注销这四步闭环再加上靠谱的 Redis 部署就能非常稳定地支撑起一个中等规模的分布式应用。最后分享一个小技巧你把“登录会话”当作一种独立的业务数据来管理而不是当作框架帮你维护的内部状态你就能自然地想清楚它的 Key 结构、过期策略和清理机制。想明白这一点Redis 替代 Session 这套流程就算真正学会了。