Java解决单个账户并发登录:基于Redis和Token的会话管理方案 1. 项目概述与需求拆解先把这个项目标题掰开揉碎说清楚。所谓“Java解决单个账户并发登录”本质上是一个Web应用里的会话管理问题涉及的核心场景是同一个用户名或手机号在短时间内被多个终端同时发起登录服务端需要决定到底要不要允许这些会话共存。我在实际项目中接手过一个典型的业务系统——某电商后台管理平台运营人员和财务人员共用同一批账号当时就出现过非常尴尬的局面某位运营同事在办公室电脑登录了系统下班回家用手机再登一次回到公司又发现自己被“挤下线”了。原因就是系统默认了“一个账号只允许一个会话存活”的规则。更头疼的是同一个浏览器里开着两个标签页同时操作也会因为会话互踢导致表单提交失败。所以说这个需求看起来简单真正落地时才明白里面全是细节。这个需求适合谁来参考我拆成三类人第一类是刚接触Web开发的学生或转行新人需要通过这个案例理解“会话Session是什么”“Token和Session的区别到底在哪”第二类是正在做SaaS平台、会员系统、后台管理系统的初级或中级Java开发手里正好有一个多端登录限制的需求排期希望能拿到一套可落地的方案第三类是技术负责人或架构师需要评估“单端登录”“同端互斥”“多端共存”这三种策略在业务层面的取舍以及对应技术方案的复杂度。从解决思路来说目前主流做法大致分两种一种是基于Session的会话管理另一种是基于Token的无状态认证比如JWT。前者的优点是服务端可以随时主动失效某个会话缺点是横向扩展时需要有会话共享机制后者的优点是天然适合分布式环境缺点是“主动踢人”比较麻烦需要额外的状态层配合比如Redis。实际项目里绝大多数可落地的方案都是“JWT Redis状态层”的组合而不是纯JWT或者纯Session。后面我会详细展开为什么。这个项目标题虽然只有一句话但它背后真正要解决的问题其实是三个层次第一层是“登录态如何标记”第二层是“登录态如何互斥”第三层是“互斥之后用户如何感知”。把这三层打通方案才算完整。2. 核心设计方案与技术选型逻辑2.1 先想清楚业务规则再谈技术实现很多开发一上来就写代码结果做着做着发现业务规则根本不清。我在做这个需求时第一步是和业务方确认了三个关键问题同一账号是否允许不同终端同时登录同一浏览器多个标签页是否允许共存被踢下线的终端下一跳请求应该返回什么提示这三个问题的答案直接决定了技术方案的复杂度。比如第一个问题如果回答“允许”那其实谈不上“并发登录限制”只需要做多端会话记录如果回答“不允许”那就牵扯到“踢人”策略也就是后登录的顶掉先登录的。第二个问题如果回答“同一浏览器不允许开多标签页”那技术实现上就不能只依赖Token本身还要用浏览器指纹或者本地标记来做同源判断。第三个问题则决定了前端拦截逻辑是弹出提示框“您的账号已在其他设备登录”还是静默跳回登录页。我最终采用的规则如下同一账号在同一时刻只允许一个有效登录态新的登录会覆盖旧的登录态但同一个浏览器内允许最多两个标签页共存因为运营场景里经常需要一边看数据一边做审核。同时被踢的终端在下次请求时收到特定错误码前端根据错误码弹出一次性提示并跳转登录页。这里有一个很容易踩坑的地方很多团队会把“同一浏览器允许共存”做成无限量结果就是用户开几十个标签页每个标签页都带着同一个Token服务端存储的会话记录不断膨胀清理策略又跟不上最后内存或者Redis被塞满。合理的方式是给“每浏览器会话数”设置上限超过上限的旧标签页再踢掉。2.2 技术选型为什么不建议纯JWT也不建议纯Session我先说纯JWT的做法。JWT本身是无状态的登录时签发一个Token返回给前端后续请求携带这个Token服务端验签通过就认为是合法请求。如果要做“单端登录限制”最直观的想法是在Redis里存放一个“当前有效Token”的映射每次请求时把请求里的Token和Redis里存的Token做比对不一致就拒绝。这个思路本身没错但有一个容易被忽视的隐患JWT一旦签发在过期之前是无法从服务端“撤销”的。你虽然把Redis里的映射标记成了新Token但老的Token如果不做额外校验依旧可以通过验签。换句话说你还需要在拦截器里额外查一次Redis才能实现“踢人”。再看纯Session方案。SessionId由服务端生成服务端可以随时把某个Session销毁天然支持主动踢人。但Session方案在分布式环境下很麻烦——用户的请求可能被负载均衡转发到不同的节点每个节点的Session内存里未必有对应的会话数据所以必须引入Session共享比如把SessionId作为Key把会话数据存到Redis。这样一来其实和“Token Redis”已经没什么本质区别了反而多了一层容器管理的复杂度。所以我的最终选型是Token选UUID或类似的高随机度字符串而非JWT状态层用Redis配合拦截器做统一校验。选择UUID而不是JWT的理由很直接UUID就是一个随机字符串服务端只有查到了Redis里对应的Key才知道它是有效的天然支持“服务端主动失效”而且不存在验签逻辑省掉密钥管理的负担。JWT适合的场景是“服务端不保存状态、各节点通过验签互相信任”但在“需要主动踢人”的需求面前JWT的优势反而变成了包袱。这里补充一个实际经验即使用JWT也不要完全依赖它的“无状态”特性。但凡涉及到登录态互斥、踢人、会话记录这类需求Redis这层状态管理是躲不掉的。那还不如从一开始就统一用随机Token Redis逻辑更清晰排查问题也更容易。2.3 整体流程设计从登录到踢人一条链路讲清楚整个流程我画在脑子里是这样的文字描述如下用户提交账号密码后服务端先做账号密码校验。校验通过后生成一个TokenToken本身是一个UUID字符串。接着以“账号ID”为维度在Redis里查询是否已有存活会话。如果已有就根据业务规则决定是否踢掉旧会话——通常是把旧Token标记为失效或者直接删除旧Token的Key。然后写入新的会话记录Key可以是“login:token:{token}”Value里存放用户ID、登录时间、浏览器类型、IP等基本信息同时维护一个“login:user:{userId}”的映射指向当前有效Token。最后把Token返回给前端。前端拿到Token后存到本地比如localStorage或内存。后续每次请求在请求头里带上Token。服务端拦截器先查Redis里这个Token是否存在且未失效然后再查“login:user:{userId}”里记录的当前有效Token是否等于本次请求携带的Token。两者都通过才放行请求。被踢的场景是这样的用户在A浏览器登录后又去B浏览器登录。B登录时服务端把“login:user:{userId}”更新为B的Token同时删除A的Token对应的Key。此时A浏览器再发请求拦截器发现“login:token:{A的Token}”已经不存在了直接返回401或自定义错误码。前端收到后弹出提示跳转登录页。同一个浏览器多标签页共存这个需求在拦截器里需要额外判断。我用的办法是在Token生成时额外生成一个随机的浏览器端标识比如存在localStorage里叫“browserId”。同一个浏览器的多个标签页共享这个browserId登录时把这个browserId提交到服务端。服务端允许同一个browserId有多个Token存活但全局只允许一个账号同时有一个browserId的会话集。换句话说“真正的互斥维度”是浏览器而非标签页。这个设计本质上是在“账号级互斥”和“标签页级互斥”之间取了一个折中。3. 核心代码实现与关键细节拆解3.1 Redis键值结构设计先定义清楚Redis里要存哪些数据。我在这个项目里用了三类Key第一类是“登录Token映射”Key格式是login:token:{token}Value是一个JSON字符串存放userId、loginTime、browserId、ip等字段。这个Key是每次请求都要查询的和用户命中的频率最高所以Value的序列化方式要足够轻量最好直接存JSON字符串而不是Java对象序列化后的二进制因为JSON对排查问题更友好用命令行就能直接看内容。第二类是“用户当前会话映射”Key格式是login:user:{userId}Value存放的是当前有效Token以及对应的browserId。这个Key的作用是快速判断“当前哪个Token是有效的”避免每次请求都要遍历用户的所有会话。第三类是“浏览器会话计数”Key格式是login:browser:{userId}:{browserId}Value是当前这个浏览器下存活的会话数量。这个Key是为了限制“同一浏览器最多允许多少个标签页共存”。我用表格整理一下Key格式Value内容主要作用过期时间login:token:{token}用户ID、登录时间、浏览器ID、IP校验请求Token是否有效与登录态有效期一致如2小时login:user:{userId}当前有效Token、浏览器ID快速定位账号的当前会话与登录态有效期一致login:browser:{userId}:{browserId}标签页会话数量限制同浏览器标签页数量与登录态有效期一致这里要特别提醒一个细节同一账号被新会话顶掉后旧Token对应的Key虽然被删除了但旧Token本身还在前端的localStorage里躺着。前端如果不去清理下次请求依旧会带上这个“幽灵Token”。好在拦截器查Redis查不到就直接拒绝了不会产生数据安全问题。但要注意前端必须在拿到错误码之后主动清除本地Token否则用户会一直停留在“已登录”的假象里——页面不跳转但所有接口都在报401。3.2 登录接口实现终于到了写代码的环节下面给一个可落地的登录接口核心逻辑示例。我直接用Spring Boot的风格写但思路可以平移到任何Java Web框架上。PostMapping(/login) public ResultString login(RequestBody LoginRequest request) { // 1. 校验账号密码省略具体校验逻辑实际项目里会走用户服务 User user userService.checkPassword(request.getUsername(), request.getPassword()); if (user null) { return Result.error(账号或密码错误); } // 2. 生成Token与浏览器标识 String token UUID.randomUUID().toString().replace(-, ); String browserId request.getBrowserId(); if (StringUtils.isBlank(browserId)) { // 前端没传browserId说明是老版本客户端可以生成一个并种到Cookie里返回 browserId UUID.randomUUID().toString().replace(-, ); } // 3. 检查并清理旧的会话踢人逻辑 String userSessionKey login:user: user.getId(); String oldToken redisTemplate.opsForValue().get(userSessionKey); if (StringUtils.isNotBlank(oldToken)) { // 删除旧Token的映射 redisTemplate.delete(login:token: oldToken); // 如果旧Token和当前browserId相同需要递减浏览器会话计数 // 这里先省略后续小节展开 } // 4. 写入新会话记录 LoginSession session new LoginSession(); session.setUserId(user.getId()); session.setLoginTime(System.currentTimeMillis()); session.setBrowserId(browserId); session.setIp(request.getIp()); redisTemplate.opsForValue().set( login:token: token, JSON.toJSONString(session), 2, TimeUnit.HOURS ); redisTemplate.opsForValue().set(userSessionKey, token, 2, TimeUnit.HOURS); return Result.success(token); }这里有两个值得展开的细节。第一个是“踢人”的时序问题新登录来了先删旧Token再写新Token这两个操作之间如果发生并发请求会出现什么情况答案是最坏情况下旧Token还没被删完新Token已经写进去了此时两个会话同时“有效”但这种窗口极小而且只影响登录态一致性不影响数据安全。如果要做严格一致性可以用Redis的Lua脚本来保证“读取旧Token并删除”是一个原子操作。我在生产环境里就是用Lua脚本处理这个逻辑的后面会单独写一个小节。第二个细节是过期时间。Token的过期时间要和业务场景匹配。后台管理系统一般两个小时比较合适过长会有安全风险过短会让用户频繁登录。另外每次成功请求都应该顺延一下过期时间——这就是“滑动过期”。如果不做滑动过期用户一直在操作结果两小时一到照样被踢下线体验很糟糕。滑动过期的实现很简单在拦截器里每次校验通过后对login:token:{token}和login:user:{userId}这两个Key都执行一次expire命令。3.3 同浏览器多标签页的计数逻辑前面提到了login:browser:{userId}:{browserId}这个Key。它的实现逻辑是这样的登录时如果检测到本次请求的browserId已经存在会话那么允许共存但先把当前浏览器下的会话数量取出来如果超过上限比如5个就把最早的Token删掉保持总数不超限。具体的伪代码如下String browserCountKey login:browser: user.getId() : browserId; // 自增计数 Long count redisTemplate.opsForValue().increment(browserCountKey); redisTemplate.expire(browserCountKey, 2, TimeUnit.HOURS); // 如果超过上限删除最早的会话Token if (count ! null count MAX_SESSIONS_PER_BROWSER) { // 从有序集合里取最早的Token这里简化处理用队列保存Token顺序 String earliestToken redisTemplate.opsForList().leftPop(login:browser:tokens: browserId); if (StringUtils.isNotBlank(earliestToken)) { redisTemplate.delete(login:token: earliestToken); } }这里用Redis的自增命令配合列表来维护“会话产生顺序”。简化方案里我用一个列表存储登录顺序每次新登录往右推Token超出数量就从左侧弹出最老的Token也就是最旧的那个会话。这个设计适合同浏览器多标签页的场景因为同一浏览器下的会话天然共享同一个浏览器环境互相踢来踢去没有意义但如果不加限制内存压力会很大。3.4 拦截器里的Token校验与踢人判断登录态校验的核心在拦截器。我用的拦截器逻辑如下public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录接口和静态资源 if (isPublicUri(request.getRequestURI())) { return true; } // 从请求头获取Token String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { writeUnauthorized(response); return false; } // 校验Token是否存在 String sessionJson redisTemplate.opsForValue().get(login:token: token); if (StringUtils.isBlank(sessionJson)) { writeUnauthorized(response); return false; } // 校验是否被新会话顶掉 LoginSession session JSON.parseObject(sessionJson, LoginSession.class); String currentToken redisTemplate.opsForValue().get(login:user: session.getUserId()); if (!token.equals(currentToken)) { // 被顶掉了 writeUnauthorized(response); return false; } // 滑动过期 redisTemplate.expire(login:token: token, 2, TimeUnit.HOURS); redisTemplate.expire(login:user: session.getUserId(), 2, TimeUnit.HOURS); // 把用户信息放入ThreadLocal方便后续业务代码获取 UserContext.set(session); return true; }这个拦截器的逻辑有一个性能层面的取舍每次请求查两次Redis一次查Token映射一次查用户会话映射。对于访问量一般的后台系统完全够用但如果是高并发场景这两次Redis访问的耗时会被放大可以考虑用本地缓存做一层加速但要注意缓存的一致性问题——被踢用户可能在一段时间内依旧能访问系统。我个人的经验是宁可多一次Redis查询也要保证踢人逻辑的即时性因为“账号被别人登录”这件事本身就意味着潜在风险延迟几分钟让被踢的人继续操作业务上很难接受。写拦截器时还有一个隐藏问题调用writeUnauthorized时如果接口里已经有一些业务代码开始执行响应就会被污染。所以拦截器的顺序一定要排在业务调用之前同时Response的ContentType要设置成JSON前端才能正确解析错误信息。3.5 Redis Lua脚本保证踢人原子性前面提到过严格一致性场景下“读取旧Token并删除”应该是一个原子操作。用Redis的Lua脚本可以解决这个问题。脚本内容很简单local oldToken redis.call(GET, KEYS[1]) if oldToken then redis.call(DEL, KEYS[2]) end redis.call(SET, KEYS[1], ARGV[1]) redis.call(EXPIRE, KEYS[1], ARGV[2]) return oldToken对应的Java调用方式DefaultRedisScriptString script new DefaultRedisScript(); script.setScriptText(...上述Lua脚本...); script.setResultType(String.class); ListString keys Arrays.asList(login:user: user.getId(), login:token: oldToken); String result redisTemplate.execute(script, keys, token, expireSeconds);这里有个坑Lua脚本里如果oldToken为空说明用户之前没有存活会话那么KEYS[2]对应的是一个不存在的KeyDEL也不会报错脚本逻辑没问题。但如果oldToken对应的Token已经过期了Redis会自动清理那么DEL还是不会报错。真正需要注意的问题是脚本里SET的过期时间需要以秒为单位传入不能直接传毫秒值。使用Lua脚本的好处是把“判断旧会话是否存在、删除旧Token、写入新Token”变成了一个不可分割的操作中间不会插入其他命令彻底避免了并发下“旧Token还没有被删掉、新Token已经写好了”这个窗口期。生产环境里我强烈建议这么做虽然平时看不出来差别一旦遇到并发登录压测这种细节就是决定成败的地方。4. 三种常见方案横向对比与场景取舍4.1 单端登录踢人下线——最常用的方案这是标题里最核心的诉求限制多个浏览器登录同一账号。实现逻辑就是第3节里展示的那套——新登录顶掉旧登录被顶掉的终端收到错误码后跳转登录页。这个方案适合绝大多数后台管理系统、SaaS平台的用户端以及部分对账号安全比较敏感的App场景。业务逻辑简单用户感知明确我用新设备登录旧设备收到提示。这个提示本身还能起到“账号安全问题预警”的作用——如果用户自己并没有新设备登录那提示反而说明账号可能被盗了这算是个附加价值。落地时最需要注意的是错误码的语义要清晰。我在项目里定义了一个全局错误码比如10001表示“会话已失效”10002表示“账号在其他设备登录”。前端拿到10002后弹出提示并强制清除本地登录态。这里要小心的是10002和10001在用户侧的感受差别很大10001是正常过期静默跳登录页就好10002必须明确告知原因否则用户会以为是系统bug。4.2 同端互斥同浏览器类型互踢——移动端常用的折中方案所谓“同端互斥”意思是同一账号在iOS上登录不影响Android端的登录但在iOS端再开一个Safari登录会把之前iOS端的登录顶掉。这个方案在移动互联网产品里很常见尤其是有App和Web同时存在的产品。背后的逻辑是同一个平台的多个会话大概率是同一台设备或者同一类浏览器环境允许共存意义不大还增加风险但不同平台之间同时在线是有真实业务需求的比如用户手机上看视频、电脑上继续看观看记录要同步互相踢来踢去会严重影响体验。技术实现上同端互斥比单端登录稍复杂一点。“端”的判定需要客户端主动上报设备信息或浏览器UA服务端按端类型分组然后对组内会话做互斥。我的做法是在Token生成时把端类型作为一个字段写入会话记录踢人时只比较“同端类型”下的Token是否变更而不是全局比较。4.3 多端共存——企业产品里的常见例外有些场景其实是允许甚至鼓励同一账号多端登录的比如企业IM、协作办公软件、视频会员系统。用户在公司电脑上登录着路上用手机接着开会回家再用iPad看视频这些都是真实需求。如果粗暴地做成单端登录用户会骂娘。所以“多端共存”本质上不是“不做限制”而是“做精细化的会话管理”。技术实现上关键是限制“总会话数”比如一个账号最多允许5个会话存活超过5个就淘汰最久未活跃的那个。这个淘汰策略可以用LRU思路实现每次请求时刷新会话的最近活跃时间新登录时如果总数超限就删除最旧的那个会话。从项目标题看“限制多个浏览器或同一浏览器登录同一个账号”明确指向了单端登录或同端互斥所以第3节的实现仍然以这两种策略为主线多端共存作为扩展方向提一句即可。4.4 方案对比总览方案适用场景用户体验技术复杂度是否需要状态层典型产品单端登录新踢旧后台系统、普通会员账号新设备登录后旧设备被踢低是大部分Web后台同端互斥按端踢AppWeb并存产品同端互踢跨端共存中是视频网站、IM工具多端共存总数限制企业协作、会员订阅多端同时在线超过上限才踢中高是企业IM、视频会员无状态纯JWT无踢人需求的API服务不支持主动下线低否开放API从表里可以看出凡是需要“主动踢人”的方案统一都逃不掉一个状态层。这也印证了前面说的不要迷信JWT的“无状态”有状态管理需求的系统Redis该用还得用。5. 实操过程从零到一完整落地一个Demo5.1 环境准备与依赖引入为了让你直接抄作业我给出一个最简Demo的完整实现路径。环境假设是Spring Boot 2.7.x、Maven、Redis 6.x。JDK建议用1.8或11别追求太新的版本稳定为主。首先在pom.xml里引入核心依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version1.2.83/version /dependency简配版不需要引入Spring Security或Sa-Token这类安全框架因为核心逻辑就集中在拦截器里手写反而更清楚。如果你用的是Sa-Token这类现成框架它本身提供了“同端互斥登录”之类的功能但搞懂底层原理比直接调API更有价值这也是我写这篇文章的原因之一。application.yml里配置Redis连接信息没什么特别的但有两个参数值得关注spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 4这里要特别提醒Redis客户端的连接池参数直接影响高并发下的表现。max-active太小请求高峰期会排队等连接太大又占内存。后台管理系统的并发量一般不大16个连接池远远够用。如果你做的是高并发面向C端的服务建议压测后再定别拍脑袋。5.2 登录与拦截器核心代码整合我把登录接口与拦截器的核心代码整合成一个更完整的版本方便你直接复制调试。先定义一个ThreadLocal工具类用来在请求链路里传递用户信息public class UserContext { private static final ThreadLocalLoginSession HOLDER new ThreadLocal(); public static void set(LoginSession session) { HOLDER.set(session); } public static LoginSession get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }ThreadLocal这里要特别注意内存泄漏问题。Tomcat的工作线程是复用的如果拦截器里set了值但没有在afterCompletion里remove当前线程处理完一个请求后下次处理另一个请求时ThreadLocal里还残留着上一个用户的会话信息。轻则数据串了重则直接泄露用户身份信息。所有Web应用的拦截器里afterCompletion方法中必须调用UserContext.clear()这是红线。再来看拦截器的afterCompletion部分Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); }这两行代码别省省了迟早出事。注册拦截器的配置类Configuration public class WebConfig implements WebMvcConfigurer { Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /static/**, /error); } }注意如果你用了Spring Security这类框架拦截器的顺序要和过滤链的优先级配合好。最稳妥的做法是让登录校验拦截器在Security的过滤链之后执行因为Security负责真正的认证授权而这里的拦截器只负责“会话是否被顶掉”的校验两者职能不同不要混在一起。5.3 前端配合要点localStorage还是Cookie登录态在前端的存储方式直接影响“同一浏览器多标签页”逻辑的实现。如果你用localStorage存Token那么同一浏览器下所有标签页共享同一份Token天然就是“同一浏览器同账号”的状态不需要额外传browserId就能实现同端互斥。但如果你用Cookie存TokenCookie的作用域可控可以做到标签页隔离每个标签页有自己独立的Token这样就需要browserId来标识“我属于哪个浏览器”。我在实际项目中推荐的做法是Token存localStorage同时暴露一个接口返回当前浏览器的browserId。前端在应用启动时检查localStorage里有没有browserId没有就发请求让后端生成一个并缓存到localStorage。登录时把browserId一并提交后端按这个维度做会话分组。还有一个细节单点登录或者多标签页共存时前端要监听“token失效”事件。我的做法是封装一个全局的HTTP响应拦截器当返回10002错误码时弹出一个一次性模态框提示“您的账号已在其他设备登录”同时清理本地Token并跳转登录页。这个提示只弹一次不然用户会很烦躁。5.4 压测验证并发登录到底会不会踩坑写完代码不代表完事必须验证并发登录场景下逻辑是否可靠。我用的工具是JMeter模拟的场景很简单两个线程同时登录同一个账号压测100次每次登录后立刻用返回的Token调用一个需要鉴权的接口观察有多少请求被正确地拦截下来。压测时发现的第一个问题是两个登录请求并发进来处理顺序无法预测有可能出现“后写进去的Token反而先被删除”的情况导致两个会话同时失效。这就是我在3.5节提到的原子性问题。没上Lua脚本之前偶尔会出现两次登录都成功的现象用Lua脚本保证“删旧写新”原子执行后才做到严格一进一出。第二个问题是并发量稍微上来之后Redis出现连接池耗尽。排查后发现是lettuce连接池参数没调好max-active只有840个并发线程同时请求Redis就不够用了。调大连接池后问题消失。这里也提醒一点别把Redis连接池配得太大连接数是有限资源无脑调大只会让Redis自身成为瓶颈。压测数据最终显示稳定情况下每秒钟可以处理约2000次登录请求无异常Redis平均响应时间在5毫秒以内这个性能对后台管理系统来说绰绰有余。6. 常见问题与排查技巧实录6.1 典型问题一明明配置了有效期却还是永久登录这个问题我踩过很深。现象用户登录后关闭浏览器再打开浏览器发现还是登录状态完全没有过期。原因排查后发现是前端把Token存在了localStorage而我又在拦截器里做了“滑动过期”——每次请求都快到期时往后顺延。用户一直在使用所以Token理论上永远不会过期这本身是符合预期的。但问题是如果用户长时间不操作Token不会续期按道理2小时后应该失效。实际却存在“闭关浏览器后重新打开仍登录”的情况。排查后发现前端每次加载页面时如果发现localStorage里有Token会自动向后端发一个“静默续期”请求哪怕用户没有做任何操作这个请求也会把过期时间刷新。这个行为等于变相地让Token永远不会过期。解决方案静默续期请求不享受滑动过期策略拦截器里针对特定接口不执行expire。或者干脆规定底层基础数据接口可以滑动过期但是页面初始化信息接口不能有滑动过期。这个坑在做“登录有效期”需求时很容易出现务必注意。6.2 典型问题二同一浏览器多个标签页互相踢来踢去这是标题里明确提到的“同一浏览器登录同一个账号”场景。现象用户打开A标签页登录再打开B标签页登录A标签页瞬间被踢下线了。原因是我把“用户会话映射login:user:{userId}”设计成了全局唯一的TokenA和B登录后全局映射被先后覆写后登录的B变成有效TokenA自然失效。要想让同一个浏览器下的多个标签页共存就得像3.3节那样引入browserId维度把“用户会话映射”从全局唯一改成“每个browserId唯一”。注意这里的业务规则要事先确认清楚。如果你希望“同一个浏览器不管多少个标签页都算一个会话”那就不需要browserId所有标签页共享一个Token天然共存且不会互相踢。如果你希望“同一个标签页一个独立会话”但同浏览器允许极限5个那就要上browserId加上限策略。两种规则最终的技术成本差很多只能说根据业务需求来。6.3 典型问题三被踢之后旧Token还能继续用一小段时间这个现象在分布式部署、并且用了Gateway转发请求的场景下可能出现。原因是出现了多个节点同时访问Redis、其中某个节点的本地缓存还没失效的情况。如果你在拦截器里加了本地缓存来加速Token校验那么被踢用户在一段时间内比如Caffeine过期时间内的几十秒还会被认为是合法会话。解决方案有两个层面第一踢人时主动删掉用户会话的本地缓存比如调用本地缓存框架的删除方法第二本地缓存时间设置得很短比如5秒让不一致窗口缩小到可接受范围。我对安全要求较高的系统直接取消本地缓存宁可用Redis扛点压力也不冒这个风险。6.4 典型问题四前端收到10002后登录页跳转有延迟这个问题的根源通常不在后端而是前端的全局拦截器把10002和401混在了一起。看到401就静默跳转看到10002也只是跳转但跳转时附带了一些额外的页面参数例如重定向URL导致用户还没看清提示就被带走了。建议的写法是10002分支先弹一次提示框用户确认后再跳转不要自动跳。另外跳转时不要带重定向参数因为被踢的用户再次登录后如果自动跳回原页面很可能又触发一次被踢操作形成死循环。踩过一次之后就长了记性。6.5 排查技巧汇总我把排查这类问题最常用的手段整理成了一张速查表现象排查方向检查点两个会话同时有效Redis原子性是否用了Lua脚本还是普通的查-删-写流程被踢后还能访问本地缓存是否引入本地缓存缓存过期时间多长登录有效期失效滑动过期刷接口是否有静默续期接口在拦截器放行列表同一浏览器互踢browserId前端是否携带browserId服务端是否按浏览器维度做互斥Redis连接池耗尽连接池配置max-active、max-idle参数是否满足并发峰值Token过期时间异常过期时间设置每个写Redis的Key是否都设置了EXPIRE6.6 关于Redis内存的额外提醒刚才提到过会话类数据都有一个过期时间但过期Key并不会被立即物理删除。Redis的过期删除机制是惰性删除加定期删除所以内存里可能会存在一些“逻辑上已过期但物理上还没被清理”的Key。解决方案是开启Redis的主动过期清理策略或者定期用scan命令清理。更重要的是一定要给Redis实例留足内存余量别把maxmemory配置得刚好够用——会话Key在高峰期快速增长很容易把内存打满。我生产环境的做法是给会话Key设置比较紧凑的过期时间同时给Redis配置maxmemory-policy为volatile-lru这样即便内存逼近上限也是优先淘汰有过期时间的Key而不影响其他数据。7. 认证框架的适用性与现成方案对比7.1 自己写 vs 用框架聊了这么多手写逻辑再来说说要不要用现成的认证框架。Java生态里做登录认证无外乎几种选择自己手写拦截器、集成Spring Security、集成Sa-Token、Shiro或者结合Spring Authorization Server统一做OAuth2。就“单账号并发登录限制”这个需求本身手写拦截器其实完全够用代码量也就两百行左右而且完全可控。但如果你的系统里还有复杂的角色权限、接口级别权限、多因素认证等需求那手写就有点吃力不讨好了建议直接上框架。以Sa-Token为例它内置了会话管理和“同端互斥登录”的能力。它的设计是把登录会话看成一套独立的令牌体系支持指定“设备类型”进行互斥登录天然支持你在登录时传入设备类型框架会自动维护同类设备的互斥关系。某种意义上讲我上面写的这套手写逻辑Sa-Token已经帮你封装好了。用框架的代价是“黑盒”。一旦遇到框架行为不符合业务预期的情况排查问题的难度远高于自己写的代码。我的建议是学习阶段自己手写一遍搞懂原理生产项目中如果时间紧且需求匹配直接上框架没问题。7.2 安全框架与登录态校验的分工有一种常见的架构误区把账号并发登录限制逻辑放在Spring Security的过滤器链里。Spring Security的职责是认证与授权也就是验证“你是谁、你能访问什么”而“你这个会话是否被顶掉”属于会话管理范畴。两者有关系但不应该做成强耦合。实践中更好的做法是Spring Security负责最外层的基础认证比如判断用户是否持有合法凭证而“当前会话是否还是有效Token”的校验下沉到更靠近业务层的拦截器。这样做的另一个好处是即使某个接口被排除出Spring Security安全范围比如白名单的跳转接口拦截器仍然能保证会话有效性。如果项目用了微服务架构网关层可能还会校验一次Token的有效性这时候要做好职责分层网关只校验Token存在性和基本签名业务服务的拦截器再校验踢人状态。别把所有校验都堆在网关否则网关既要做路由转发又要承担业务判断会变得非常臃肿。7.3 几种框架的优劣势速览框架/方案上手难度并发登录支持可定制性适合场景手写拦截器低自己实现完全可控高需求单一、学习向Spring Security中高需自写逻辑或扩展高企业级、高安全需求Sa-Token低开箱即用中中小型项目、快速交付Shiro中需扩展中老项目维护8. 一些实践经验的收尾项目上线之后我在真实使用中还发现了一个容易被忽略的细节被踢下线的用户如果正在填写一个长表单突然被弹回登录页表单内容全部丢失体验非常糟糕。后来我调整了前端逻辑在收到10002错误码时先静默记录一个“待恢复表单状态”用户重新登录后可以选择是否恢复之前填写的内容。这个需求虽然不算核心但运营团队反馈特别好。另一个体会是关于日志的。并发登录相关的排查如果只靠接口日志你会发现特别难追踪——两个Token之间没有任何关联根本不知道“是谁把谁踢下线了”。我在每个Token生成的日志中加入了userId和browserId同时在踢人动作发生的地方输出旧Token的前几位和新Token的前几位。这样一来排查问题时可以用userId关联出完整的会话替换链路。最后分享一个小技巧为了让前端快速感知登录态的变化除了在请求错误码里做拦截还可以考虑在WebSocket或者SSE连接里推送“账号在其他设备登录”的事件。某些业务流程里用户可能打开页面后长时间不做任何操作只有刷新时才发请求导致“被踢”的感知明显滞后。加上服务端主动推送之后体验会好很多。这个扩展不算复杂但效果立竿见影。说回核心单账户并发登录限制这个需求技术含量不在于某个惊天动地的算法而在于把“会话管理”这件事想得足够细Token怎么生成、Redis键怎么设计、踢人怎么做原子化、浏览器维度怎么兼容、前端怎么配合、压测怎么验证。把这些都想透了写出来的代码才是真正能上生产的代码。希望这篇文章能把这条路给你趟平了。