☰
Java实现单账号并发登录互斥:Redis+Token多浏览器踢人方案
2026/10/15 5:22:17 网站建设 项目流程

如果你维护过一套用户量不大、但内部员工会互相借账号的系统,一定体会过这种痛:A同事在工位电脑登录了某个后台账号,B同事在自己电脑上登录同一个账号,结果A的页面一刷新就被踢回登录页。两边都不知道发生了什么,只能来回沟通,最后闹到管理员那里问“系统是不是坏了”。这不是系统坏了,是“单账号并发登录”没有做限制。

标题里说的“Java解决单个账户并发登录,限制多个浏览器或同一浏览器登录同一个账号”,本质上是在做登录会话互斥。我前段时间正好在某公司的内部管理系统(叫它某项目X吧)里完整落地了这个需求,包括多浏览器互踢、同一浏览器多标签页互踢、被踢提示、分布式部署下的并发边界处理。这篇文章把整个设计思路、核心代码、踩过的坑一次说清楚,不管你是刚接手老系统、还是在做新项目的登录模块,里面这套方案都可以直接抄作业。

1. 先把需求拆明白:并发登录到底要限制什么

很多开发看到这个标题,第一反应是“登录的时候把旧的Session干掉不就行了”,但真正动手前,最好先把业务需求翻译成技术规则。这个需求里藏着几个关键判定维度,漏掉任何一个,上线后都容易出幺蛾子。

1.1 三种“并发登录”形态的区分

我把常见的并发登录限制需求分成了三类,你可以在产品经理提需求时直接对着归类:

需求类型业务描述技术本质
全局限一一个账号同时只能有一个在线会话,不管手机还是电脑以用户ID为维度,后登录顶掉先登录
分端限一手机端可以登录一个,PC端可以登录一个,互不影响以“用户ID+端类型”为维度
同端限一同一端只能有一个会话,但PC和手机可以各登一个以“用户ID+客户端指纹”为维度

标题里说的“限制多个浏览器或同一浏览器登录同一个账号”,属于最严格的第一种形态:不管你是Chrome、Firefox还是手机浏览器,不管你是不是同一个浏览器开的不同标签页,一个账号在任意时刻只能存在一个有效登录会话。

这里有个容易忽略的点:同一浏览器开多个标签页,本质上是多个并发会话。因为每个标签页登录时都会创建新的Token信息,如果不做互斥,用户完全可以通过“复制链接到新标签页重新登录”的方式绕开限制。所以,限制多个浏览器只是门槛,限制同一浏览器才是这个需求的完整形态。

1.2 技术规则怎么定

把需求翻译成技术语言,我需要明确三个判定要素:

  1. 用户身份:账号ID,这个最简单,登录后从库里查或者从上下文里取。
  2. 会话凭证:登录成功后生成的Token,每次请求带上。
  3. 客户端身份:识别“这是哪个浏览器”,不能只信请求头里的User-Agent,因为很多浏览器UA都长得很像。

最后落地的互斥规则是:

  • 新登录请求到达时,只要账号密码正确,就无条件让新会话生效;
  • 同时把旧会话标记为“已被顶替”,旧Token立即失效;
  • 被顶替的浏览器在下一次发请求时,会收到一个特定的“被踢”状态码,前端根据这个码弹提示并跳转登录页。

这套规则里最关键的技术点就是:用什么标识来识别“同一个浏览器”。我一开始图省事直接用了User-Agent,结果发现同一台电脑上Chrome和Edge的UA前几十个字符完全一样,根本区分不了。后来改成了“UA + 几个关键特征拼接后做哈希”,才真正解决了多浏览器区分的问题。这块具体怎么做,我在后面第三节展开。

1.3 为什么Session方案不适合这个场景

很多老系统用的是Session来做登录态,登录成功后request.getSession().setAttribute("user", xxx),互斥就遍历Session,销毁旧的那个。这个方案在原生的服务端渲染项目里勉强能用,但放到现在前后端分离、多实例部署的场景下,问题很多:

  • Session默认存在单机内存里,一旦服务部署了多个实例,A实例存的Session在B实例根本查不到,互斥逻辑会因为“看不到对方的Session”而失效;
  • 前后端分离后,前端拿到的只是Cookie里的SessionID,后端要想“踢掉某个会话”,必须能定位到那个Session存哪了,分布式环境下定位非常麻烦;
  • SessionID在Cookie里传递,CSRF防护、跨域携带、移动端适配都要额外处理,远不如自定义Token灵活。

所以我的方案从一开始就定了:Token + Redis集中存储。Token本身就是会话凭证,Redis统一保存所有在线会话,任何一个实例都能看到“这个账号当前的有效会话是谁”,互斥判断不需要关心请求落在哪个节点上。

2. 为什么选Redis做互斥中心:Key设计与数据结构

方案选型定了之后,最需要想清楚的是Redis里存什么、怎么存。这个环节直接决定了互斥逻辑的复杂度,我在设计时反复推敲了Key的结构,下面分享最终版和当时的取舍过程。

2.1 Key设计的两种思路对比

第一种思路是**“以用户为中心”**:key是login:current:{userId},value直接存当前有效Token。判断互斥时,拿请求里的Token和这个key里存的Token比对,不一致就说明被顶了。优点是逻辑简单,新登录直接覆盖写;缺点是每次请求都要多查一次这个key,并且一旦Token被顶替,旧的Token本身还残留在Redis里没有删掉。

第二种思路是**“以Token为中心”**:key是login:token:{token},value存userId和客户端指纹。校验时直接查这个key在不在;互斥时,需要额外用一个key记录“当前哪个Token有效”,否则无法判断“这个Token虽然存在,但已经被后登录的会话顶掉了”。

实际项目里我采用了两种Key维度混合的方案,原因很简单:

  • login:token:{token} -> userId + fingerprint:用于日常请求的身份校验,O(1)查询,命中率高;
  • login:current:{userId} -> 当前有效token的详情:用于互斥判断,新登录时覆盖写,旧登录校验时发现不一致即判定为“被踢”。

混合方案的Redis数据流是这样的:

登录成功 -> 生成token T_new -> 查 login:current:{userId} 是否有旧token T_old -> 若有,写 login:kick:{T_old} = 1(标记被踢,过期时间=旧token剩余有效期) -> 写 login:token:{T_new} = userId + "|" + fingerprint -> 写 login:current:{userId} = T_new + "|" + fingerprint + "|" + loginTime -> 设置统一过期时间(比如8小时)

这样设计的好处是,被动被踢的旧Token不会凭空消失,而是有一个显式的“被踢标记”,排查问题时能看到“这个Token是被顶掉的,不是自然过期的”。生产环境里这个细节帮了我大忙,有一次用户反馈“刚登录就被提示登录过期”,我一查,发现是旧Token在Redis里被覆盖了但标记没写好,顺着这个标记立刻定位到了问题。

2.2 过期时间与滑动续期的矛盾

在线Token设置过期时间,这个所有人都能想到。但实际里有一个细节:如果过期时间定死了,用户连续使用8小时后突然被要求重新登录,体验很糟;如果不设过期时间,Redis内存又会被无限增长的Token撑爆。

我的折中方案是滑动过期:Token本身有过期时间,但每次请求通过时都重新刷新这个过期时间。Redis里实现很简单,用expire命令重置TTL即可。每次刷新时同时刷新login:current:{userId}的过期时间,保证用户长时间在线不会被误踢。

但这里藏着一个容易被忽略的坑:被踢过的旧Token也会被滑动续期。比如A、B两个浏览器先后登录同一个账号,A被顶掉了。如果A浏览器在被踢后的某个瞬间还在发请求,而这个请求恰好先经过拦截器、还没做互斥校验就把过期时间刷新了,那旧Token的生命周期就被拉长了。虽然最终互斥校验还是会拦住它,但这个“已经死掉的会话”多存活了一段时间,属于资源浪费。我的处理是:先做互斥校验,通过后才刷新过期时间,顺序不能反。

// 伪代码:先校验,后续期 boolean valid = validateTokenAndCurrent(token, request); if (valid) { refreshExpire(tokenKey, currentKey); } else { reject(); }

2.3 浏览器指纹:不能只靠User-Agent

接下来说说怎么区分“多个浏览器”和“同一浏览器”。我最开始只用了User-Agent做客户端标识,上线后马上遇到误判:Chrome和Edge基于同样的内核,UA几乎一样,两个浏览器被识别成了同一个客户端;反过来,用户升级了一下Chrome版本,UA变了,同一个浏览器被识别成了两个客户端。

后来我把浏览器指纹的素材扩展成了四部分,前端配合采集,后端拼接后哈希:

  1. User-Agent,作为基础素材;
  2. Accept请求头;
  3. Accept-Language请求头;
  4. 前端生成的客户端ID(存在LocalStorage里,首次访问生成UUID,后续请求附带在Header里)。

后端把这些素材按固定顺序拼接,用SHA-256做哈希,得到32字节的指纹串。前端客户端ID的存在很关键,它是用户本地的稳定标识,只要用户不清LocalStorage,这个ID就不会变。实际经验是,带上它之后,同一浏览器的识别准确率大大提升,误判率明显下降。

public static String fingerprint(HttpServletRequest request) { String ua = nullToEmpty(request.getHeader("User-Agent")); String accept = nullToEmpty(request.getHeader("Accept")); String language = nullToEmpty(request.getHeader("Accept-Language")); String clientId = nullToEmpty(request.getHeader("X-Client-Id")); String raw = ua + "|" + accept + "|" + language + "|" + clientId; return sha256Hex(raw); }

不过,指纹素材里只有UA是后端能直接拿到的,X-Client-Id依赖前端传。如果你的项目不想动前端,可以退而求其次只用UA+Accept+Accept-Language,但要做好“同内核浏览器会互相顶号”的心理准备。这个取舍我在第五节还会再聊。

3. 核心实现:登录互斥与拦截器校验的完整代码

代码落地我用的是Spring Boot + RedisTemplate,拦截器用的Spring MVC的HandlerInterceptor。下面把登录逻辑、指纹生成、校验逻辑分步讲清楚。

3.1 登录接口:踢旧会话的核心流程

登录接口本身不复杂,核心在于“踢旧会话”的先后顺序。我的实现分四步:

@Service public class LoginService { @Autowired private StringRedisTemplate redis; private static final long TOKEN_EXPIRE_SECONDS = 8 * 60 * 60L; // 8小时 public LoginResult login(String username, String password, String fingerprint) { // 1. 校验账号密码(省略实际查询逻辑) User user = userMapper.findByUsername(username); if (user == null || !passwordEncoder.matches(password, user.getPassword())) { throw new BizException("用户名或密码错误"); } // 2. 生成新Token,同时记录旧Token String newToken = UUID.randomUUID().toString().replace("-", ""); String userId = String.valueOf(user.getId()); String userKey = "login:current:" + userId; String oldToken = null; String currentVal = redis.opsForValue().get(userKey); if (currentVal != null && currentVal.contains("|")) { oldToken = currentVal.split("\\|")[0]; } // 3. 标记旧Token被踢,再写入新Token // 注意:必须先标记旧Token被踢,再写新的用户会话映射。 // 顺序反了的话,旧Token在被踢标记写入前可能还能通过校验。 if (oldToken != null && !oldToken.isEmpty()) { String kickKey = "login:kick:" + oldToken; redis.opsForValue().set(kickKey, "1", TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS); } String tokenKey = "login:token:" + newToken; String tokenVal = userId + "|" + fingerprint; redis.opsForValue().set(tokenKey, tokenVal, TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS); String currentValNew = newToken + "|" + fingerprint + "|" + System.currentTimeMillis(); redis.opsForValue().set(userKey, currentValNew, TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS); // 4. 返回新Token给前端 return new LoginResult(newToken); } }

注意第3步的顺序:先写踢人标记,再写新Token映射。有一个边界情况:如果同一账号两个请求几乎同时进来,都读到了同一个旧Token,都去写踢人标记,这时候旧Token只会被标记一次,不会出现“自己踢自己”的问题。因为踢人标记是针对“旧Token”的,新Token不同,互不影响。

但这里其实还有一个并发隐患:两个登录请求同时成功,都会写login:current:{userId},最终谁后写谁生效,于是先登录的那个人的新Token会被后登录的人覆盖,导致“先登录的人反而被踢”。一般业务下这没问题——两个请求本来就是并发登录,后处理的那个会话理应生效。如果你的系统要求“已经有一个在线会话时,新登录直接拒绝”,那就需要额外加一把分布式锁,逻辑会不一样。这个项目里产品要的是“后登录顶掉先登录”,所以保持现状。

3.2 拦截器:校验Token、判断被踢、滑动续期

登录态校验我用一个AuthInterceptor实现,它需要同时做三件事:

  1. 从Header里取Token,查login:token:{token}是否存在;
  2. 对比login:current:{userId}里记录的有效Token是否等于当前Token;
  3. 如果都通过了,刷新过期时间,放行请求;否则返回固定的错误码。
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redis; public static final int CODE_KICKED = 1001; // 被顶下线 public static final int CODE_EXPIRED = 1002; // 登录过期 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (CorsUtils.isPreFlightRequest(request)) { return true; // 放行预检请求 } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); } String tokenKey = "login:token:" + token; String tokenVal = redis.opsForValue().get(tokenKey); if (tokenVal == null) { writeCode(response, CODE_EXPIRED); return false; } String userId = tokenVal.split("\\|")[0]; String currentKey = "login:current:" + userId; String currentVal = redis.opsForValue().get(currentKey); if (currentVal == null || !currentVal.startsWith(token + "|")) { // 当前有效会话不是这个Token:要么被顶,要么被删 String kickKey = "login:kick:" + token; boolean kicked = Boolean.TRUE.equals(redis.hasKey(kickKey)); writeCode(response, kicked ? CODE_KICKED : CODE_EXPIRED); return false; } // 校验通过:滑动续期 redis.expire(tokenKey, 8 * 60 * 60L, TimeUnit.SECONDS); redis.expire(currentKey, 8 * 60 * 60L, TimeUnit.SECONDS); request.setAttribute("userId", userId); return true; } private void writeCode(HttpServletResponse response, int code) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":" + code + ",\"message\":\"\"}"); } }

这里有个小细节:判断是否被踢,我用login:kick:{token}有没有这个key作为依据。如果当前有效会话不是这个Token,而且这个Token有被踢标记,就返回1001;没有标记,说明用户主动注销或者Token被删,返回1002。前端拿到这两个码,可以分别提示“账号已在其他浏览器登录”和“登录已过期,请重新登录”。

3.3 主动注销与密码修改时的清理

用户点“退出登录”时,不能只删当前Token,还必须让login:current:{userId}失效,否则下次登录会拿到一个残留的映射关系。我的注销逻辑很简单:

public void logout(String token) { String tokenKey = "login:token:" + token; String tokenVal = redis.opsForValue().get(tokenKey); if (tokenVal == null) return; String userId = tokenVal.split("\\|")[0]; String currentKey = "login:current:" + userId; String currentVal = redis.opsForValue().get(currentKey); if (currentVal != null && currentVal.startsWith(token + "|")) { redis.delete(currentKey); } redis.delete(tokenKey); }

注意注销时要判断login:current里的有效Token是不是当前这个Token,防止出现这种情况:用户A的会话被顶掉了,但他在被顶的页面里点了“退出登录”,结果把新登录的B的会话也删了。这种跨会话误删在业务里非常容易踩,一定不能省略这步判断。

还有一个延伸场景:用户修改密码后踢掉所有在线会话。做法是遍历所有login:token:{token}比较麻烦,最简单的办法是给每个用户维护一个login:version:{userId}的版本号,Token里带上版本号,改密码时版本号+1,所有旧Token因为版本不一致全部失效。但这会引入额外的查询逻辑,如果业务里暂时没有“改密码踢全端”的需求,可以不做,先保持简单。

4. 高并发与分布式部署下的三个隐藏坑

代码能跑通只是第一步。在并发登录和分布式部署的真实环境里,有三个坑我踩过之后才真正理解,这里详细说说。

4.1 并发登录时旧Token被误删的竞态问题

前面提到,并发登录极端情况下会出现“先登录的人被自己后写入的login:current覆盖”的问题。但如果你的业务是“已有会话时,新登录直接拒绝”,就必须解决这个竞态。

我的做法是给登录入口加一把Redis分布式锁,锁的key是login:lock:{userId},加锁后在锁内完成“查当前会话 -> 判断是否拒绝 -> 写入新会话”这三个动作。锁用SET key value NX EX timeout命令实现,释放用Lua脚本保证原子性。伪代码如下:

String lockKey = "login:lock:" + userId; String lockVal = UUID.randomUUID().toString(); boolean locked = redis.opsForValue().setIfAbsent(lockKey, lockVal, 3, TimeUnit.SECONDS); if (!locked) { throw new BizException("系统繁忙,请重试"); } try { // 查当前会话、判断、写入,都在锁内执行 } finally { // Lua脚本原子释放锁:比对值再删除 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redis.execute(new DefaultRedisScript<>(script, Long.class), List.of(lockKey), lockVal); }

但这里有个体验问题:如果用户在多台设备上同时尝试登录,后到的请求会直接拿到“系统繁忙”,而不是“顶掉旧会话”。所以锁的粒度、等待时间要根据业务调整。如果产品允许“后登录顶先登录”,其实不加锁也能接受,用上面的覆盖写方案即可。方案取舍不是越复杂越好,而是匹配业务预期。

4.2 Redis单点故障时的降级策略

登录态全部放在Redis里,Redis挂了,系统就退化成“所有请求都无法校验登录态”,线上事故级别。这个问题我在设计之初没有重视,直到有一次运维重启Redis集群,用户集体掉线才意识到严重性。

降级策略我后来调整成这样:

  • Redis连接正常:走完整校验链路,互斥逻辑生效;
  • Redis连接异常:降级为“放行请求,同时记录告警日志”,保证业务可用,等Redis恢复后自动回到完整链路。

这个策略看起来会让“互斥限制”暂时失效,但在可用性和严格互斥之间,我选择先保可用。如果你做的是金融、电商这类强一致场景,可以反过来降级为“拒绝所有在线请求”,但内部管理系统这么做,业务方会疯掉。具体降级时,要在RedisTemplate的catch块里打日志,最好接上告警通知,方便第一时间处理。

4.3 网关层重复拦截与Token透传

如果你的系统前面有网关或统一拦截器,需要小心“拦截器被调用两次”。有一次我把AuthInterceptor注册到了addInterceptors里,同时又有一个Filter做了类似逻辑,结果每次请求查了两次Redis,高峰期把Redis连接数打满了。

排查后发现,其实两级拦截做的是不同的事:网关Filter做Token是否存在的前置判断,应用层Interceptor做完整的互斥判断。但只要有一方处理了,另一方就不该重复做同一件事。最后的规范是:

  • 网关层:只做Token是否存在和格式校验,不查Redis;
  • 应用层:做完整校验和互斥判断。

这样Redis的查询次数就稳定下来了。还有一个容易踩的地方:跨域预检请求OPTIONS会被拦截器拦截,导致前端报CORS错误。我在preHandle里加了预检请求直接放行的逻辑,这个细节看起来小,但漏掉了会浪费一整天排查时间。

5. 实测记录:四个场景验证互斥是否真的可靠

代码写完了,不代表需求做完了。我把整个功能在测试环境跑了四轮,下面这四种场景都通过,才算真正验收通过。

5.1 多浏览器互踢验证

场景:Chrome先登录账号A,再用Firefox登录同一个账号A。 预期:Firefox登录成功;Chrome下一次请求返回code=1001。 实际:Chrome在登录后的页面上点了一下刷新,前端收到1001,弹出“账号已在其他浏览器登录”,本地Token被清空,跳回登录页。验证通过。

这个场景里最值得说的是1001的触发时机。它不是登录瞬间立刻发生的,而是Chrome下一次发请求时才生效。因为互斥的过程是“新会话写入时标记旧Token被踢”,旧Token不会马上删除,而是等到被校验时发现“当前有效会话已经变成了新Token”才拒绝。这种“被动踢出”的设计在体验上有一个小延迟,但避免了主动去通知所有连接断开这种复杂实现,性价比更高。

5.2 同一浏览器多标签页互踢验证

场景:Chrome打开两个标签页,标签页1先登录账号A,标签页2再登录账号A。 预期:标签页2登录成功;标签页1被踢。 实际:标签页2登录后,标签页1的下一次请求被拒,返回1001。这个场景验证了“限制同一浏览器登录”这个标题里的关键需求成立。

这里我额外验证了“标签页2登录成功后,标签页1立刻刷新”的时序。结果是标签页1先收到1001后跳转登录页,标签页2保持登录状态。整个流程符合预期。唯一的体验瑕疵是,如果标签页1停留在某个页面不操作,它不会立刻感到被踢,只有下一次发请求才会感知。对内部系统来说,这个延迟可以接受。

5.3 主动注销与再登录验证

场景:用户点退出登录,然后再用同一个浏览器登录。 预期:退出后本地Token清空,旧Token在Redis里被删除;再登录能正常成功。 实际:注销后旧Token删除、login:current:{userId}删除;再登录生成全新Token。验证通过。我重点验证了“注销时不会误删新登录会话”的代码分支,也就是第三节注销代码里的那句currentVal.startsWith(token)判断,这个判断在用户快速注销再登录、两个Token交替的时序里很关键。

5.4 指纹变化导致的误踢场景

场景:用户登录后,浏览器自动升级或换了网络出口IP,导致UA部分变化。 预期:如果严格校验指纹,用户会被误判为“在其他浏览器登录”;如果宽松校验,用户保持登录。 实际:这个问题我最终选择了“宽松模式”,即指纹不参与强制互斥校验,只作为日志里“显示是哪个浏览器被顶掉”的说明。原因是:同一个浏览器升级版本后UA会变,如果指纹参与强制校验,用户什么都没干就被踢下线,体验太差。

给所有做这个功能的人一个建议:客户端指纹只适合做“辅助提示”,不太适合做“硬性校验”。除非你的业务场景对安全性要求极高,宁可误踢也要按指纹严格控制,否则我建议走宽松模式。标题里“限制多个浏览器或同一浏览器登录同一个账号”的核心逻辑依赖的是Token互斥,而不是指纹互斥,指纹只是锦上添花。

最后再分享一点这个需求最容易忽略的地方

项目上线之后,我复盘时发现整个需求真正难的不是“踢人”,而是被踢之后的用户体验。如果前端只是收到401就简单跳登录页,用户完全不知道发生了什么,会反复去登录、被踢、再登录,最后认为是系统Bug。所以我在前端加了一个全局响应拦截:收到1001时,清掉本地存储的Token,页面弹出一句明确的提示“该账号已在其他浏览器登录,如非本人操作请检查账号安全”,再跳转登录页。这样用户一看就明白了原因,反馈量明显降下来。

如果你后续要在这个方案上做扩展,可以考虑“分端限一”的变体:比如手机端一个会话、PC端一个会话互不影响,只需要把login:current:{userId}换成login:current:{userId}:{deviceType},互斥维度从用户维度拆成“用户+端类型”维度即可。这套框架的核心——Token + Redis + 覆盖写 + 被踢标记——完全不用动,改改Key的拼接就能适配更多业务场景。希望这篇文章能帮你在做并发登录限制时少走几条弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询