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 整体流程设计:从登录到踢人,一条链路讲清楚
整个流程我画在脑子里是这样的,文字描述如下:
用户提交账号密码后,服务端先做账号密码校验。校验通过后,生成一个Token,Token本身是一个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 Result<String> 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调用方式:
DefaultRedisScript<String> script = new DefaultRedisScript<>(); script.setScriptText("...上述Lua脚本..."); script.setResultType(String.class); List<String> keys = Arrays.asList("login:user:" + user.getId(), "login:token:" + oldToken); String result = redisTemplate.execute(script, keys, token, expireSeconds);这里有个坑:Lua脚本里如果oldToken为空,说明用户之前没有存活会话,那么KEYS[2]对应的是一个不存在的Key,DEL也不会报错,脚本逻辑没问题。但如果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后台 |
| 同端互斥(按端踢) | App+Web并存产品 | 同端互踢,跨端共存 | 中 | 是 | 视频网站、IM工具 |
| 多端共存(总数限制) | 企业协作、会员订阅 | 多端同时在线,超过上限才踢 | 中高 | 是 | 企业IM、视频会员 |
| 无状态纯JWT | 无踢人需求的API服务 | 不支持主动下线 | 低 | 否 | 开放API |
从表里可以看出,凡是需要“主动踢人”的方案,统一都逃不掉一个状态层。这也印证了前面说的:不要迷信JWT的“无状态”,有状态管理需求的系统,Redis该用还得用。
5. 实操过程:从零到一完整落地一个Demo
5.1 环境准备与依赖引入
为了让你直接抄作业,我给出一个最简Demo的完整实现路径。环境假设是:Spring Boot 2.7.x、Maven、Redis 6.x。JDK建议用1.8或11,别追求太新的版本,稳定为主。
首先在pom.xml里引入核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.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 ThreadLocal<LoginSession> 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存Token,Cookie的作用域可控,可以做到标签页隔离,每个标签页有自己独立的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只有8,40个并发线程同时请求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}”设计成了全局唯一的Token,A和B登录后,全局映射被先后覆写,后登录的B变成有效Token,A自然失效。要想让同一个浏览器下的多个标签页共存,就得像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是否都设置了EXPIRE |
6.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键怎么设计、踢人怎么做原子化、浏览器维度怎么兼容、前端怎么配合、压测怎么验证。把这些都想透了,写出来的代码才是真正能上生产的代码。希望这篇文章能把这条路给你趟平了。