资讯动态

Java Web实现账号单一登录:基于Redis的Token覆盖踢人方案

发布时间:2026/10/5 7:42:04 来源:尧图企业网站定制
简介一份面向Java Web开发者的账号单一登录解决方案内容聚焦如何防止同一账号重复登录并实现类似QQ的踢人效果适用于采用JSP/Servlet的传统Java Web项目也适合需要快速理解Session与Filter协作原理的学习者。整套资源包仅1个PDF文件大小约149KB轻量易读资源包小下载快但内容密度较高不仅讲做法也点出了常见实现方案的缺陷和应对方式。PDF中系统梳理了使用Filter过滤器、HttpSession与AJAX实现单一登录的完整思路包含登录页和主页面的关键代码、前端JS提交逻辑、优缺点分析及验证码、双因素认证等扩展建议内容组织清晰从需求分析到实现步骤再到拓展方向均有说明。针对部分方案中轮询Session导致资源占用、以及AJAX请求返回值顺序冲突等问题文档提供了实践性说明与排错参考能帮助开发者避开常见坑。已有3377人学习下载适合正在开发登录模块或需要做系统安全增强的中初级Java Web程序员阅读。1. Java Web 实现账号单一登录为什么“踢人”比“禁登”更符合真实业务做 Java Web 开发的迟早会遇到这个需求同一个账号不允许在两个浏览器同时登录。表面看是安全要求实际上是业务方对账号使用边界的默认假设——员工账号不能共享、教学平台不允许一个学生账号全班用、视频会员不允许多人同时看。而“踢人效果”这几个字正是这个需求里最容易做歪的地方。很多开发者第一次接到这个任务第一反应是“登录的时候判断一下 session 里有没有呗”真做下去才发现session 作用域、分布式环境、请求并发这些问题一叠上来最开始的思路直接翻车。单一登录Single Login也有叫 Single Active Session 的要做的事简单说就是同一账号在同一时刻只允许一个有效会话新登录的会话生效后旧会话必须被强制失效。这里有个业务上的选择点——是禁止新用户登录还是把旧用户踢下线绝大多数实际项目会选择后者因为用户换电脑、换浏览器、密码泄漏后异地登录这类场景禁登会造成“我自己把自己锁在外面”的糟糕体验。所以标题里强调的“踢人效果”正是这个功能落地时真正要和产品对齐的地方。这篇笔记我从技术选型讲到代码实现再讲到生产环境里的坑全程给一个能直接用的方案。适合做传统 Java WebServlet/JSP/Spring MVC或 Spring Boot 项目的开发者也适合刚被分配“把账号踢人功能加上”但不知道从哪下手的初级工程师。读完你可以拿这套思路去改自己的项目而不是到处搜一段看不懂的代码。2. 单一登录的技术实现路径Session 失效、Token 比对还是监听器2.1 三种主流方案的核心逻辑与适用边界先说清楚底层思路。Java Web 里标识一个登录用户靠的是会话机制。而“踢人”的本质就是让旧的会话凭证失去效力。围绕这个目标业界常见的做法分成三条路。**方案一服务端 Session 失效法。**用户在 HttpSession 里存用户信息登录时把旧 Session 直接 invalidate。这是最原始的思路代码量很小但有一个硬伤你要在用户 B 登录时找到用户 A 的 Session 对象。在单机部署下可以用一个全局 Map 以 userId 为 key、Session 为 value 存起来但一旦上了 Nginx 负载均衡Session 不在同一台机器上这个 Map 就成了黑匣子——A 的 Session 在节点 1B 的请求落到节点 2节点 2 根本不知道 A 存在过。除非你引入了 Session 粘滞Sticky Session否则这条路在集群环境下先天残疾。**方案二Token 落库/落 Redis 比对法。**登录成功后生成一个唯一的 TokenUUID 够用把 Token 和账号的绑定关系存到 Redis 或数据库。每次请求进来从请求头或 Cookie 里取 Token再和服务端存的“最新 Token”做比对一致就放行不一致就判定为“被踢了”。这个方案是目前生产环境里最常用的一种因为它把会话状态从“Session 对象”降级为“一条可比对的字符串”天然支持多节点部署——Redis 本来就是共享的。踢人的逻辑也直白用户 B 登录后生成新 Token 覆盖旧值用户 A 再带着旧 Token 来请求时比对失败直接回到登录页。**方案三监听器 Session 扫描法。**用 HttpSessionListener 监听会话创建和销毁事件定义静态变量记录在线用户新登录时把同一账号的旧 Session 手动 invalidate。这种方案比方案一稍微好一点但它依然逃不开“分布式环境下 Session 的位置追踪”问题而且监听器拿到的 Session 引用在超时销毁时容易出空指针还要自己处理并发时的并发修改异常。我一般不建议生产环境用这个除非你只是写个演示项目应付静态页面。2.2 选型的关键变量单体、集群还是微服务做技术选型前先回答一个问题你的部署环境是什么单体应用、单机 Tomcat用方案一最省事。用户在项目里维护一个 ConcurrentHashMapString, HttpSession登录时 put登出时 removesession 销毁时也 remove。但要处理 Session 复制、粘滞和失效通知这个 Map 的并发操作要想清楚get 和 put 之间可能存在的竞态也要处理。集群或微服务直接上方案二而且 Redis 几乎是必选项。原因不只是“共享存储”更重要的是 Redis 自带过期时间TTL可以兜底——用户删了浏览器 Cookie 也不怕Token 到期自动失效不会在 Redis 里堆积成内存炸弹。数据库方案可以吗可以但每次请求都要查一次表性能差一个量级。流量小到可以忽略比如管理后台日均请求几百次的情况下用数据库也说得过去但我不推荐因为后面要清理历史 Token 数据又是一堆维护活。还有一点要提醒微服务架构里用户信息可能不只在一个服务里校验。比如网关层面做了登录态校验业务服务里还要拿 userId 做权限判断。这种情况下Token 不能只是“一个字符串”还得能从 Token 解出用户身份。常见做法是 Token 里直接带 userId 或用一个全局的 Redis Key 映射关系。我自己的习惯是无论单体还是集群直接按照方案二来设计只是存储介质从 Redis 替换成内存 Map。因为代码结构是通用的——拦截器里取 Token、查最新值、比对这套逻辑在单体环境下照样跑后面如果系统扩容上集群不用改设计。3. 动手实现“踢人”功能基于 Redis 的 Token 覆盖方案完整代码3.1 登录接口改造生成 Token 并记录最新会话先定义一个核心的常量类把 Redis 里的 Key 前缀统一管起来public class LoginConstants { // key 的格式login:token:{userId} public static final String LOGIN_TOKEN_PREFIX login:token:; // Redis 中每个 token 的有效期单位秒这里设置为 2 小时 public static final long TOKEN_EXPIRE_SECONDS 2 * 60 * 60; // 请求头中携带 token 的字段名 public static final String HEADER_TOKEN Authorization; }这个类的逻辑不复杂但有几个设计点是刻意的。LOGIN_TOKEN_PREFIX给 Redis Key 加了一个业务前缀而不是让 Key 光秃秃就是一个 userId是因为项目里以后可能还有别的业务要往 Redis 里存数据没有前缀容易撞 Key。TOKEN_EXPIRE_SECONDS代表会话最长存活时间超过两小时没操作就要重新登录这个值通常和运营策略挂钩不能拍脑袋定。HEADER_TOKEN定义了前端每次请请求时把 Token 放哪个请求头里前后端要严格约定换了字段两边一起改牵一发动全身。接下来是登录逻辑。我用一个 LoginService 来承载public class LoginService { private StringRedisTemplate redisTemplate; public String login(String username, String password) { // 1. 校验用户名密码这里略去密码加密校验细节 User user userMapper.findByUsername(username); if (user null || !matchPassword(password, user.getPassword())) { throw new LoginException(用户名或密码错误); } // 2. 生成一个全新的 Token String newToken UUID.randomUUID().toString().replace(-, ); // 3. 写入 Rediskeylogin:token:{userId}valuenewToken String redisKey LoginConstants.LOGIN_TOKEN_PREFIX user.getId(); redisTemplate.opsForValue().set(redisKey, newToken, LoginConstants.TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS); return newToken; } }这段代码里最关键的一步在第 3 步set操作没有做“先删旧再写新”而是直接用新 Token 覆盖掉旧值。这是整个踢人机制的心脏——Redis 是单线程执行命令的一个set操作天然是原子的不存在并发时两个请求同时写导致的不一致。用户 B 登录成功后Redis 里这个 Key 的 Value 已经变成了 B 的 Token用户 A 再拿着旧 Token 来访问取出来比对发现不一致直接被判定为失效会话。可能有人会问为什么不先查一下旧 Token 存不存在存在就删掉再写入新的纯属多此一举。直接覆盖的效果一样还少一次查询也避免了查询和删除之间产生空隙。但有一点要注意set操作是覆盖写如果同一个 userId 被两个请求同时触发登录后面到达的请求会覆盖前面请求的 Token前一个请求拿到的 Token 立刻失效。这种场景在极端并发登录时会发生表现为“刚登录成功就被踢下线”后面我专门讲怎么兜底。3.2 拦截器统一校验请求级别实现“被踢”感知登录接口改造完了剩下的问题是用户 A 的旧会话在后续请求中如何感知自己“被踢了”这需要一个拦截器在每个请求进来时做统一校验。我基于 Spring MVC 的 HandlerInterceptor 来实现public class LoginInterceptor implements HandlerInterceptor { private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 放行登录接口本身 String uri request.getRequestURI(); if (uri.startsWith(/login) || uri.startsWith(/static)) { return true; } // 2. 从请求头获取 Token String token request.getHeader(LoginConstants.HEADER_TOKEN); if (StringUtils.isEmpty(token)) { response.sendRedirect(/login); return false; } // 3. 从 Redis 查出当前账号最新 Token // 注意这里需要从 token 反向解析出 userId因此 token 不能是毫无规则的 UUID // 常见做法是 token 拼接 userId或者用 loginToken 与 userId 的映射 Long userId parseUserIdFromToken(token); String latestToken redisTemplate.opsForValue().get(LoginConstants.LOGIN_TOKEN_PREFIX userId); // 4. 比对不匹配说明已被踢下线 if (latestToken null || !latestToken.equals(token)) { response.setStatus(HttpStatus.NETWORK_AUTHENTICATION_REQUIRED.value()); return false; } // 5. 校验通过把 userId 放到 request 上下文供业务代码使用 request.setAttribute(currentUserId, userId); return true; } }这个拦截器的第 3 步有一个隐含的设计要求拦截器从请求头里拿到的只有 Token怎么定位 Redis Key两种方案任选其一。第一种是在生成 Token 时直接拼接 userId比如userId : UUID然后把它作为 Key 的组成部分第二种是维护一个login:uid-by-token:{token}的映射。我这边为了演示直观用的第一种方案Token 格式类似userId:uuid。你说它不够安全确实userId 是明文拼接的外部用户很容易猜到别人的 userId 去构造 Token。但它构造出 Token 后要和 Redis 里最新的 Value 做精确比对猜到了 userId 也猜不到那份 UUID安全性依然由 UUID 的不可预测性保障。如果项目里对信息隐蔽性有特别要求可以改成“双重 Redis Key”的做法代价是多一次 Redis IO。你注意到第 4 步里 response 的状态码——我用了 401。前端拿到 401 需要做什么统一处理跳到登录页并弹提示“您的账号在其他地方登录”。这里有个常见的错误做法返回 200 但 body 里带一个错误码。产品和技术会因为这里扯皮——前端说“我判断一下 body 的 code 不就行了”但 HTTP 语义既然有 401就应该用上否则你还得在响应体里单独约定一套码表。拦截器注册到配置里也很简单# 如果用 Spring Boot 的 Java Config实现 WebMvcConfigurer 接口 # 在 addInterceptors 方法中 addInterceptor(loginInterceptor).addPathPatterns(/**)具体的 Java 配置代码就不贴了不同的 Spring 版本配置写法略有差异。核心要确认的是拦截器要覆盖所有需要登录态才能访问的 URL而登录接口、静态资源要显式放行。漏掉放行会导致登录还没完成就被自己的拦截器挡回去这种鬼打墙问题排查半天。3.3 登出接口的对称设计不能只清 Cookie很多项目做登出的时候只做一件事把前端存的 Token 删掉。问题来了服务端 Redis 里如果还留着这个 Token那这个 Token 仍然有效。某个用户如果在登出之后浏览器里还残留着旧 Token事实上浏览器根本不删用户手动设的东西别人拿着这个 Token 可以直接访问数据。登出必须对称——前端删本地 Token后端删 Redis Key。public void logout(String token) { if (StringUtils.isEmpty(token)) { return; } try { Long userId parseUserIdFromToken(token); String redisKey LoginConstants.LOGIN_TOKEN_PREFIX userId; String latestToken redisTemplate.opsForValue().get(redisKey); // 只删除“当前这份 Token”对应的 Key防止把后来登录的新 Token 误删 if (token.equals(latestToken)) { redisTemplate.delete(redisKey); } } catch (Exception e) { log.warn(注销失败token{}, token, e); } }登出接口里有一个容易忽略的细节if (token.equals(latestToken))判断不能少。设想一个场景用户 A 在设备 1 登录然后设备 2 也用 A 的账号登录把设备 1 踢下线。此时设备 1 上的用户手动点了登出——如果不做这个判断设备 1 发起的登出请求会把设备 2 的 Token 一起删掉等于设备 2 的登录态也被意外清除。这种“旧会话登出误伤新会话”的问题在没做过踢人功能的项目里几乎必然踩一次。4. 防守与优化配置 Redis 过期策略和 Token 自动续期机制4.1 并发登录的边界情况处理不能只靠覆盖直接覆盖的方案有个要正视的问题——在极端并发场景下连续两次快速登录前面那次生成的 Token 会被立刻覆盖掉吗答案是只保留最后一次请求的 Token前一次登录在毫秒级内“被踢下线”。这就是踢人逻辑在并发下可能引发的翻车用户手滑点了两次登录按钮第一次的请求因为网络慢还没有返回第二次的请求先到了服务端处理后返回第二个 Token前端最后把手里的 Token 更新成第二次返回的那个倒还好但如果前端更新 Token 的时机是响应返回后就覆盖且后端 AIO 处理中第一次响应晚于第二次返回第一次的 Token 一旦被前端拿到就会造成“刚登录就失效”。怎么兜底我一般会在登录接口里做一个去重同一个 userId 在极短时间窗口内的重复登录请求直接返回同一个 Token。实现方法不复杂在生成新 Token 前先get一次如果已经存在且未过期就直接复用旧 Token并顺手把过期时间续上。这个策略在用户手动刷新登录页、多次点击登录按钮的场景下能有效减少误踢。String redisKey LoginConstants.LOGIN_TOKEN_PREFIX user.getId(); String existingToken redisTemplate.opsForValue().get(redisKey); if (StringUtils.isNotEmpty(existingToken)) { // 时间窗口内同一账号尝试重复登录直接复用现有会话 // 避免并发请求导致互相踢下线 redisTemplate.expire(redisKey, LoginConstants.TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS); return existingToken; }但这带来一个权衡如果这个账号真的在另一个设备上登录了此时这里复用旧 Token新设备可能感觉“为什么我这边登录不了”所以这个去重逻辑只适用于短时间窗口比如同一秒内的重复提交时间稍长还要正常走踢人流程。更严谨的做法是给登录接口加一个防重令牌前端提交时带上后端发现同一令牌重复提交就拒绝这属于接口幂等性设计范畴不展开了。简单说这一小段代码解决的问题是“防手滑”不是“防盗号”。4.2 Token 自动续期避免用户用着用着突然被踢前面设置了 Redis TTL 为 2 小时但用户在系统里挂机、不刷新、不请求2 小时后 Token 自动过期被要求重新登录这还能接受——毕竟安全策略如此。但更闹心的是用户一直活跃每隔几分钟就点一下页面点了一小时五十分钟后Token 过期了下个请求又要求登录。这种体验很撕裂明明我在干活怎么就掉线了所以生产环境里一般会设计滑动过期每次有效请求进来把 Token 的过期时间重置为满值。这样活跃用户不会中途掉线只有真正长时间不用的人才被踢出。在拦截器的校验通过后加一行刷新过期时间的逻辑if (latestToken ! null latestToken.equals(token)) { // 续期把过期时间刷新为 2 小时 redisTemplate.expire(LoginConstants.LOGIN_TOKEN_PREFIX userId, LoginConstants.TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS); }这项操作的代价是每个请求都多一次 Redis 的expire调用。对于内部管理系统QPS 不高完全没问题如果是高并发 C 端系统这会导致 Redis 写入量翻倍。优化办法是只在“距离过期时间不足 N 分钟”的时候才续期减少写操作。因为expire是写操作高 QPS 下会对 Redis 产生额外的写压力。有一个隐藏的坑有些系统把 Token 存在 Cookie 里Cookie 自己也配了一个过期时间。服务端 Redis 做了滑动续期但 Cookie 的过期时间没同步更新用户浏览器本地到点就丢了 Cookie也会导致登录态丢失。所以如果走 Cookie 方案前端或后端在每次请求后要顺带 Set-Cookie 刷新过期时间。或者干脆把 Token 放在 localStorage / 请求头里服务端管好 Redis 过期就行。我更倾向于后者省去 Cookie 过期时间与服务端 TTL 不一致的坑。4.3 配置一个兜底任务给 Redis Key 做“二次清理”你可能会想Redis Key 不是有 TTL 吗过期了自己就删除了还要清理什么但有一种极端情况expire续期操作有可能被漏掉比如拦截器逻辑异常时。另外用户在登录页停留了几分钟但 Redis 里已经由登录接口写入了一个 Token该 Token 如果一直没人用2 小时内过期没问题——万一用户登录后异常退出Key 留在那里也没事反正 TTL 过了就没了。真正要清理的是那些“Key 没有 TTL”的情况。比如你的项目早期代码里set时忘了传过期时间或者 Redis 的persist命令被调用过。这种 Key 存在就是 Redis 内存泄漏源之一。你可以定期用scan命令游标式地遍历login:token:*前缀的 Key检查 TTL 是否为 -1是的话先补齐过期时间。这个属于运维层面的事不展开写了但你要知道——踢人功能上线后不是所有问题都能靠代码兜住定期观测 Redis 内存增长曲线是必须的。5. 避坑指南Java Web 会话管理中的 4 个高频雷区5.1 雷区一Session 对象直接作为登录态的“唯一凭证”这个坑在刚开始做方案一的时候最容易踩。开发者把session.setAttribute(user, user)当作登录成功的标志然后把User对象里的字段直接拿来当 userId 用。真到了需要按 userId 踢人的时候发现自己手里只有一个HttpSession没有 userId 和 Session 一一对应的索引。想从一堆 Session 里找到目标用户的 Session要么遍历在线用户列表要么再维护一张 user-session 映射表。映射表要处理并发、要处理 Session 销毁事件、要处理多节点复杂度远超预期。所以我的建议是从一开始就不要把 Session 当作登录态的全部用 Token 方案剥离“会话载体”和“会话凭证”。Session 只负责存一些临时数据比如图形验证码登录态是否有效完全交给 Token 和 Redis 决定。将来系统扩展Session 换成 JWT 或者换成别的机制登录校验代码不用动。5.2 雷区二没有区分“会话过期”和“被踢下线”Redis 里的 Token 不存在和 Token 不匹配是两种完全不同的业务语义。Token 不存在可能是过期了、被 TTL 清掉了、用户登出了Token 不匹配是用户还在活跃甚至几秒前刚发过请求但登录凭证已经被另一个会话顶掉了。这两种情况在拦截器里不能都返回 401至少前端拿到的提示文案要区分开。具体到实现上可以有三种响应策略第一种统一返回 401前端给一个通用文案“登录状态已失效”第二种Token 不存在返回 401Token 不匹配返回 409 Conflict第三种后端在响应体里加一个业务码前端根据业务码区分。我的习惯是后两种因为“你被踢了”这件事需要明确告知用户避免用户以为系统出了 bug 还是误操作反复刷新页面。体验上的差别很大被踢后用户看到“您的账号在其他设备登录”会主动去改密码找原因如果只是“会话过期”就会一头雾水地再次登录然后又被踢陷入循环。5.3 雷区三分布式环境下用本地内存做 Token 存储单体应用用 ConcurrentHashMap 存 Token 是可以的但部署了两台实例还这样干就是个大坑。用户 A 的请求第一次落在节点 1Token 存在节点 1 的内存里下一次请求被负载均衡转发到节点 2节点 2 内存里没有这个 Token直接返回 401。这种问题不是必现而是和请求分发策略强相关最恶心的是它的随机性。另一个连带问题节点 1 重启了内存里的 Token 全没所有登录用户集体掉线。如果踢人功能的存储用什么——Redis 集群或者共享数据库。如果项目里暂时没有 Redis可以用数据库表但每次请求的校验都变成一次数据库select性能上要认。或者用 Hazelcast / Ehcache 的分布式缓存但引第三方依赖要评估学习成本。我的观点很简单既然要做会话状态共享Redis 是不二选Java Web 项目里 Spring Boot 整合 RedisTemplate 也就是加个依赖的事。5.4 雷区四拦截器里做 Redis 查询后忘了处理异常拦截器里执行redisTemplate.opsForValue().get(...)如果 Redis 连接超时了怎么办大多数人的第一版代码都不会处理这个异常——Redis 挂了拦截器直接抛异常请求 500。用户在页面上一刷新发现系统崩了。踢人功能本来是辅助功能结果反而变成了可用性瓶颈。正确的做法是拦截器捕获 Redis 相关异常按“鉴权失败”或“服务不可用”处理根据业务对安全性的要求选择倾向。内部系统可以降级为放行让请求进入业务代码代价是安全策略暫時失效C 端系统建议失败拦截避免在 Redis 故障时出现越权访问。至于要不要做 Redis 不可用时的降级策略要根据业务安全等级决定。如果决定降级至少要在日志里打出一个醒目的 warning否则 Redis 挂了半小时你都不知道踢人功能一直在静默失效。5.5 排查思路用户说“我被踢了”时先查这三类日志接到反馈说账号被踢别急着改代码。先按顺序排查第一查登录日志看这个账号在哪个 IP、什么时间点有新登录成功记录。有可能是用户自己在别的设备登录了压根不是 bug。第二查 Redis 里该账号 Token 的创建时间和当前值用redis-cli执行TTL login:token:{userId}和GET login:token:{userId}看是否被续期或覆盖。第三查 Web 服务器日志里最近的 401 响应序列确认被踢请求发生的具体时间点和频次。如果 Redis 里 Token 不变但用户仍然反馈经常掉线那八成是前端的 Token 在本地丢了——localStorage 被清理、Cookie 过期时间太短、请求头没正确携带。这种问题后端怎么查都查不出毛病因为后端认证逻辑是通畅的。所以排查踢人问题时一定要把“后端没踢、前端丢了”这个方向列进去否则很容易在没有问题的代码里反复证明自己没错最后发现是移动端 WebView 的浏览器设置自动清了存储。6. 给踢人逻辑“上保险”并发控制、日志追踪与会话生命周期验证6.1 一个容易忽视的细节userId 从 Token 里解析时的类型约定我在前面的代码里用了parseUserIdFromToken这个函数但一直没有交代它的实现。这里要强调一个约定如果 userId 是 Long 类型在拼接 Token 字符串和解析时要注意前导零的问题。UUID 拼接 userId 一般来说不会产生歧义因为 userId 是数字、UUID 是十六进制字符可以用分隔符拼。但如果是把 userId 直接转字符串再接随机串没有分隔符后面解析时就得靠正则或固定长度截取很容易出错。我习惯的写法是userId : UUID一冒号分隔解析时split(:)[0]再把字符串转 Long。注意split出来的数组要判断长度防止格式不对的 Token 导致数组越界异常。有人为了省一次字符串分割采用“Token 里直接存 Redis Key”的方式——即 Value 是整个 Key请求时直接用 Token 作为 Key 去 Redis 查 value。这个方案的优点是不用解析 userId缺点是 Redis 里会多一类无前缀的 Key排查问题时看到一堆字符串根本不知道属于哪个业务。清晰度优先多一次字符串分割的损耗可以忽略。6.2 压力测试时关注的平均响应时间和 Redis 写入量功能上线前要做一轮简单压力测试目的不是测极限性能而是验证踢人逻辑在高频请求下不会产生异常。用 JMeter 模拟 50 个并发用户循环登录、操作、登出观察三个指标登录接口平均响应时间、拦截器校验耗时、Redis 每秒写入次数。如果拦截器的校验逻辑里包含“续期”操作每个请求都要发生一次get和一次expire这两个是串行执行的加起来的耗时大约在 0.5ms 到 2ms 之间取决于 Redis 网络延迟。50 并发下这个开销不会成为瓶颈如果到了 500 并发Redis 的写操作量会明显上升。如果压测发现单请求校验耗时超过 5ms优先检查是不是每次都建立了新的 Redis 连接——Spring Boot 的 RedisTemplate 默认使用 Lettuce 连接池但如果你在拦截器里自己 new 了连接对象那就是每次都建连慢到离谱。6.3 验证“被踢”效果的三步手工测试方案写完代码后建议用三个浏览器或普通窗口 隐身窗口 手机浏览器做一轮全流程验证。第一步窗口 A 登录账号 X访问一个需要登录的页面正常显示。第二步窗口 B 登录同一个账号 X窗口 A 立即发起任意一个请求预期返回 401且页面出现“被踢”的提示。第三步窗口 B 继续正常操作 A 的数据确认数据正确、没有被误踢。然后重复以上步骤但把顺序反过来——B 登录后用 A 的旧 Token 直接调接口确认同样被拦截。这个测试只验证了“踢人”生效还有一层要验证的是“踢人后的数据隔离”被踢的那个会话如果还持有 userId比如前端把 userId 存在内存里它不能继续访问新会话的数据吗严格来说如果被踢会话的 userId 还在前端存储里下次请求带着自己的 Token 也没用因为拦截器比对的是 Redis 里的最新值旧 Token 不可能通过校验。所以放心旧会话可以拿到 userId 这个“身份标识”但拿不到“会话凭证”也没办法冒充新会话去操作数据。6.4 我的习惯上线后先灰度观察“误踢率”最后说一个我踩过坑之后养成的习惯踢人功能上线后不全局放开先让一部分用户试用观察日志里被踢下线的频次和 AWS 信息。如果某天日志里出现大量“某账号几分钟内被踢了三次”的记录那多半不是外部攻击而是代码里某个地方逻辑写歪了——比如两个后端节点用的不是同一个 Redis 库或者登录接口被监控脚本频繁调用导致 Token 被不断覆盖。关注“单账号被踢次数”这个指标比看总请求量更能发现问题。工具层面给 Redis Key 打日志或者用 Redis 的 monitor 命令跟踪某个 userId 的读写时序能快速定位是哪一段逻辑覆盖了 Token。但 monitor 命令在 Redis 5 以上版本会拉低吞吐建议只在压测环境用不要在生产上开着。前端配合在登录页埋一个被踢事件的日志记录设备的 IP、UA、时间点后端一排查就能知道是不是用户自己多点了几次登录按钮。希望这篇笔记里的设计思路和代码能帮到你——在做账号单一登录时少走几趟弯路。我当年第一次做这个功能时用的还是“全局 Map 存 Session”的方案上线第二天就被集群环境教会了做人。后来切到 Token 覆盖方案才真正体会到什么叫“设计选型决定了功能的天花板”。如果你按这套方案落地的过程中遇到更奇葩的情况大概率是你碰上了我也没见过的新问题那就恭喜你了又有新的经验可以积累。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑