简介:面向Java Web初中级开发者的账号单一登录实现资料,聚焦同一账号重复登录问题,实现类似QQ登录的“踢人”效果,适用于需要提升登录安全性、加固会话管控的中小型Web项目。资料围绕过滤器Filter与HttpSession展开,通过检查当前会话是否已登录,在重复登录时强制剔除前一次会话,并结合Ajax登录请求规避异步返回顺序错乱造成的前端异常,技术路线清晰、可直接复用。内容覆盖单一登录的需求分析、实现思路、分步编码、优缺点总结,以及结合验证码、双因素认证进行安全扩展等方向;同时附有登录页、主页面及关键脚本的代码示意,方便读者对照理解过滤器在真实登录流程中的位置和调用方式。包体为1个PDF文档,大小约149KB,文件精炼但信息密度高,适合快速阅读后落地改造。该资源已有3377人学习下载,适合正在开发Java Web登录模块或研究会话管理的开发者参考。
1. 一个账号同时登录两处,坑的不只是用户
在很多 Java Web 项目里,“账号单一登录”都是个逃不掉的需求:在线考试系统要防止同一个考生开两个窗口互相看答案;后台管理系统的操作日志要能追到人,同一个账号多人共用会让日志失去意义;哪怕是在头歌上刷 Java Web 实训题,或者在做毕业设计,答辩老师也常会问一句“你怎么防止同一账号重复登录”。其实做法不复杂:维护一张“用户 -> 会话”的全局映射,每次登录都检查这张表,发现已有会话就让旧会话失效,也就是所谓的“踢人效果”。这篇笔记从原理、代码到踩坑一条线讲清楚,适合正在做 Java Web 单体项目、需要快速上手的同学,也适合想把这个点讲明白的毕业设计负责人。
2. 单一登录的本质:把登录状态从“私有”变成“全局”
2.1 Session 是怎么区分浏览器的:JSESSIONID 与 Session 域
先回到 Servlet 规范。用户第一次访问 Java Web 应用时,Tomcat 容器会创建一个 HttpSession 对象,并通过 Set-Cookie 把 JSESSIONID 写给浏览器。之后浏览器每次请求都带上这个 Cookie,Tomcat 就能在请求到达时找到对应的 Session。这个 Cookie 默认有路径属性,通常为应用上下文路径,所以同一浏览器访问同一个应用时会带上,而不同浏览器(不同 Cookie 存储)之间互不相认。
这个 Session 默认只属于一个浏览器会话,也是绝大多数登录状态的存放位置。登录成功后代码通常会执行session.setAttribute("loginUser", user),后续请求通过session.getAttribute判断是否登录。问题在于:这个属性只有当前这个 Session 自己能看到。同一个账号在另一个浏览器里登录,Tomcat 会再创建一个全新的 Session,两个 Session 互相不知道对方也存了同一个账号的登录标志,重复登录就这样发生了。
所以要实现单一登录,关键动作不是去 Session 里做文章,而是把“账号已登录”这个状态从某个 Session 的私有属性提升为整个应用可见的全局状态。常见实现是用一个 Map 存放所有在线账号和它们的 Session 引用,这样每次请求都能查表判断当前 Session 是否“过期”。这里还要注意一个边界:同一个浏览器的多个标签页共享同一个 Session,所以单一登录针对的是“不同浏览器/不同设备”之间的互斥,而不是同一浏览器的多标签页互踢。
顺带说一个容易被忽略的边界:Session 的超时机制。如果用户关掉浏览器但没有点退出登录,Session 并不会立刻销毁,要等到超时时间(默认 30 分钟)之后由容器触发销毁事件。所以 Map 中记录的会话引用有可能已经失效但还留在 Map 里,这个问题会在第 4 章展开讲。
2.2 三种常见实现:SessionMap、SessionListener、Redis Token
我见过的方案主要有三类。
方案 A:全局 SessionMap。用ConcurrentHashMap<String, HttpSession>保存账号与会话,登录时put覆盖,下次请求时比较 Map 中的会话是否就是当前会话。优点是代码量小、依赖少,单机部署时完全够用;缺点是 JVM 内存里的东西在多实例集群下不共享,而且需要手工处理 Session 销毁时的清理,否则 Map 会残留垃圾。
方案 B:SessionMap + HttpSessionListener。在上面的 Map 之外,实现一个HttpSessionListener,在sessionDestroyed事件里把对应的键移除。这个方案能解决一部分内存泄漏问题,但要注意从销毁的 Session 里取用户名再 remove 时,可能误删新会话的映射,细节特别容易翻车。
方案 C:Redis 保存登录 Token。用户登录后生成一个 UUID 作为 token,存到 Redis 的 String 结构里,key 是用户 ID,value 是 token;同时把 token 放到 Session 或前端存储中。每次请求都从 Redis 取 token,和请求里带的 token 比对,不一致就说明被顶下线。这个方案跨实例、跨应用都好使,但要多维护一套 Redis,还要处理 token 过期时间。
三个方案对比如下:
| 方案 | 是否需要额外组件 | 集群支持 | 代码量 | 典型场景 |
|---|---|---|---|---|
| SessionMap | 不需要 | 不支持 | 小 | 单体应用、课程设计 |
| SessionMap + Listener | 不需要 | 不支持 | 中 | 单体应用,想省内存 |
| Redis Token | 需要 Redis | 支持 | 大 | 微服务、多实例部署 |
选择方案时要先问自己两个问题:项目是否只部署一台机器?用户能否接受平均几十毫秒的 Redis 读延迟?如果都是“是”,那就用 SessionMap。如果项目上了 Nginx 负载均衡,那 SessionMap 在架构上就不成立,直接考虑 Redis。
2.3 快速验证:用全局 ConcurrentHashMap 实现的最小踢人逻辑
在我本地验证这个逻辑时,一般会先把依赖都省掉,只写一个 Controller 来模拟。核心代码只有三行:登录时map.put(username, session),校验时map.get(username) == session。你可以先跑通这个最小模型,再往 Spring Boot 里搬。
@RestController public class QuickLoginController { private static final Map<String, HttpSession> ONLINE_MAP = new ConcurrentHashMap<>(); @PostMapping("/quick-login") public String quickLogin(@RequestParam String username, HttpSession session) { ONLINE_MAP.put(username, session); // 登录即覆盖,旧会话下次请求会发现自己不再是“最新” return "login ok"; } @GetMapping("/quick-check") public String quickCheck(@RequestParam String username, HttpSession session) { if (ONLINE_MAP.get(username) == session) { return "active"; } return "kicked"; // 当前会话已不是最新,说明账号在别处登录了 } }注意Map用了ConcurrentHashMap,而不是HashMap。原因很实际:登录请求是并发的,HashMap在并发 put 时可能丢数据,甚至在 JDK7 之前会发生链表死循环;ConcurrentHashMap在 put 覆盖时能保证原子语义,get和put之间的竞态在单次 put 覆盖下不会出现明显问题。跑的时候用两个浏览器各登录一次,第一个浏览器再访问/quick-check就会返回kicked,第二个返回active,这个现象就是你想要的踢人效果。
补充一点:如果是同一浏览器的普通窗口和隐身窗口,Cookie 存储隔离,也会生成两个不同 Session,也能用来验证。但普通窗口内多个标签页共享同一个 Session,不算重复登录,不能用来验证踢人。
2.4 选型建议:单体优先 SessionMap,集群优先 Redis Token
如果你只有一台 Tomcat/Spring Boot 实例,用户量不大,我建议直接用 SessionMap 方案。它能把问题约束在一个类里,出问题时两分钟就能定位。如果你一开始就想做得更完整,可以在此基础上加 SessionListener 防止内存泄漏。如果项目部署了多个实例,前面挂了 Nginx 负载均衡,SessionMap 方案立刻失效,因为两个实例各自维护一张 Map。这时要么配置 Nginx 粘滞会话,让同一个账号的请求尽量落到同一台实例;要么干脆改用 Redis Token,把登录状态抽到公用的 Redis 里。我在 5.2 会给 Redis 化的迁移思路。
在实际项目里,有的团队会把 SessionMap 放到application作用域的 ServletContext 里,这其实和 static Map 差不多,但不建议,因为 ServletContext 在集群环境下同样不共享,而且静态 Map 更容易单独测试。我一般直接用一个普通的 static 容器,把所有会话管理代码集中在一处。
需要补充的是:SessionMap 方案本质上是“后登录的顶掉先登录的”,也叫后登录优先。有的业务要求反过来,先登录的保持在线,后登录的直接被拒绝。这个实现更简单,在登录时检查 Map 中是否已有在线会话,有就返回错误提示即可。本文主讲的踢人效果是前者,因为它更能体现“防止同一账号重复登录”的对抗性。
注意:SessionMap 方案的前提是应用本身能拿到同一个 ClassLoader 下共享的 JVM 内存。如果你用了前后端分离、接口部署在两个不同应用里,那么 SessionMap 或者 Session 本身就不适用,应该优先考虑 Token + Redis。
3. 在 Spring Boot 里落地 SessionMap 踢人:完整代码与参数说明
3.1 项目结构与依赖
这里我以 Spring Boot 2.7 为例,模板引擎用不用都行,登录和重定向逻辑不依赖页面技术。项目结构里我习惯把全局会话管理单独抽到一个common包,不让它跟 Controller 耦合。
src/main/java/com/example/singlelogin/ ├── SingleLoginApplication.java ├── config/ │ ├── LoginInterceptor.java │ ├── WebConfig.java │ └── SessionListener.java ├── controller/ │ └── LoginController.java └── common/ └── UserSessionManager.javapom 中只需要spring-boot-starter-web,不需要额外引入 session 相关依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>如果你用的是纯 Servlet 项目,下面的代码一样能用,只需要把拦截器换成Filter,把@WebListener注册到web.xml。Spring Boot 的好处是省去一堆 XML 配置,但核心的UserSessionManager在两种架构下是通用的。
3.2 UserSessionManager:全局会话登记表
完整代码:
package com.example.singlelogin.common; import javax.servlet.http.HttpSession; import java.util.concurrent.ConcurrentHashMap; public class UserSessionManager { // 用用户ID作为 key 更稳,用户名可能被修改 private static final ConcurrentHashMap<String, HttpSession> SESSION_MAP = new ConcurrentHashMap<>(16, 0.75f, 16); private UserSessionManager() {} public static HttpSession register(String userId, HttpSession session) { return SESSION_MAP.put(userId, session); } public static boolean isActive(String userId, HttpSession session) { return session != null && SESSION_MAP.get(userId) == session; } public static void logout(String userId, HttpSession session) { SESSION_MAP.remove(userId, session); } public static void clear() { SESSION_MAP.clear(); } }参数说明里要讲清楚几个点:
ConcurrentHashMap<>(16, 0.75f, 16)的三个参数分别是初始容量、负载因子、并发级别。初始容量 16 表示内部桶数组默认 16 个桶;负载因子 0.75 表示当元素个数达到容量乘以负载因子时触发扩容;并发级别 16 在 JDK8 之前决定 Segment 数量,JDK8 之后主要影响初始化时桶的分散能力。对于在线几千人的单体应用,默认参数就够,不用刻意调。
register返回旧 Session,这正是“踢人”的关键信息。如果oldSession != null && oldSession != session,说明账号此前已经有一个在线会话,现在被新会话覆盖了。isActive是核心校验逻辑:只有 Map 中最新存的 session 等于当前请求的 session,才认为当前会话是合法在线的。
logout里的remove(userId, session)用了两个参数的重载。ConcurrentHashMap的这个方法只有在 key 映射的 value 与传入的 session 相等时才删除,能避免误删并发登录的新会话。这是我在生产环境踩过坑之后才改过来的写法,后面第 4 章会详细解释为什么。
3.3 LoginInterceptor:每次请求校验“当前 session 是否还是最新”
代码:
package com.example.singlelogin.config; import com.example.singlelogin.common.UserSessionManager; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; public class LoginInterceptor implements HandlerInterceptor { public static final String LOGIN_USER_ID = "LOGIN_USER_ID"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); if (session == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } Object userIdObj = session.getAttribute(LOGIN_USER_ID); if (userIdObj == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } String userId = userIdObj.toString(); if (!UserSessionManager.isActive(userId, session)) { response.sendRedirect(request.getContextPath() + "/login?kicked=1"); return false; } return true; } }说明几个细节:
request.getSession(false)不会主动创建 Session。如果 Session 已经因为超时不存在了,说明用户之前登录的会话早已销毁,应当走未登录流程。这里用false而不是无参版本,是为了避免一个无效请求也创建一个新 Session 的浪费。
被踢的旧会话在拦截器里没有立刻调用invalidate()。如果直接销毁,会把旧会话中别的属性一并删掉,而且同一浏览器里两个标签页会相互影响。这里的策略是“放行但重定向”,让用户看到一个明确的“被踢下线”提示,Session 真正的生命周期由容器管理。
LoginInterceptor.LOGIN_USER_ID作为常量字符串,便于在 Controller 和 Listener 里复用,避免魔法值。
然后是注册拦截器:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/doLogin", "/css/**", "/js/**", "/error"); } }addPathPatterns("/**")拦截所有请求,再通过excludePathPatterns放行登录页、登录接口和静态资源。如果不放行静态资源,登录页的 CSS/JS 全部会被拦,页面直接没有样式,这是一个很容易遇到的坑。
3.4 LoginController:登录时覆盖旧会话,退出时摘除登记
代码:
@Controller public class LoginController { @PostMapping("/doLogin") public String doLogin(@RequestParam String userId, @RequestParam String password, HttpSession session) { if (!"123456".equals(password)) { return "redirect:/login?error=1"; } session.setAttribute(LoginInterceptor.LOGIN_USER_ID, userId); HttpSession oldSession = UserSessionManager.register(userId, session); if (oldSession != null && oldSession != session) { session.setAttribute("kickNotice", "检测到该账号在其他位置登录,已强制下线"); } return "redirect:/index"; } @GetMapping("/logout") public String logout(HttpSession session) { Object userIdObj = session.getAttribute(LoginInterceptor.LOGIN_USER_ID); if (userIdObj != null) { UserSessionManager.logout(userIdObj.toString(), session); } session.invalidate(); return "redirect:/login"; } }这里的顺序有讲究:先把LOGIN_USER_ID写入 session,再执行register。这样即使register之后发生异常,当前 session 也已经有了登录标记,下一次请求能被拦截器正常识别。register返回的oldSession用于判断是否真的发生了踢人,如果旧会话存在且不是当前会话,就把提示放在当前 session 属性里。
退出登录时,必须先从 Session 中取出LOGIN_USER_ID,调用UserSessionManager.logout后再session.invalidate()。如果先invalidate,Session 里的属性全部失效,后面就取不到 userId,Map 里会残留一个无法清理的条目。
实际项目中,密码校验应该换成数据库或认证服务的调用,登录接口也要做防刷限制。这里写死123456只是为了让代码聚焦在“单点登录”这个主题上。
3.5 SessionListener:Session 销毁时同步清理 Map
代码:
package com.example.singlelogin.config; import com.example.singlelogin.common.UserSessionManager; import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; @WebListener public class SessionListener implements HttpSessionListener { @Override public void sessionCreated(HttpSessionEvent se) { } @Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession session = se.getSession(); Object userIdObj = session.getAttribute(LoginInterceptor.LOGIN_USER_ID); if (userIdObj != null) { UserSessionManager.logout(userIdObj.toString(), session); } } }sessionDestroyed会在三种情况下触发:用户主动调用session.invalidate()、Session 超时后容器自动销毁、应用重启导致内存中的 Session 全部失效。只有在这里清理SESSION_MAP,才能保证 Map 中不会堆积大量已经失效的 Session 引用,这是一个防内存泄漏的兜底逻辑。
Spring Boot 默认扫描不到@WebListener注解,需要在启动类上加@ServletComponentScan:
@SpringBootApplication @ServletComponentScan public class SingleLoginApplication { public static void main(String[] args) { SpringApplication.run(SingleLoginApplication.class, args); } }UserSessionManager.logout里用到remove(userId, session),传的是当前正在销毁的 session 对象。这样即使 Map 里已经存在新会话,也不会被误删。如果把第二个参数漏掉,直接用remove(userId),新登录用户的合法会话也会被清掉,这个坑很多人在初次实现时都会踩到。
4. 踩坑记录:五个能让你翻车的边界问题
4.1 退出登录时,SessionListener 把新会话也删了
现象:A 浏览器登录后,B 浏览器登录同一个账号。A 刷新被踢下线,此时 A 的 Sessioninvalidate()触发sessionDestroyed。如果 listener 里用的是SESSION_MAP.remove(username),而 B 的会话已经登记在 Map 中,那么 B 的会话映射会被一并删除。结果 B 在接下来的请求里发现自己未登录,又跳回登录页。
原因:sessionDestroyed事件只能拿到“正在销毁的旧会话”,但remove(key)会无条件删除这个 key 对应的条目,不管它当前关联的是不是正在销毁的会话。当 A 的 session 销毁时,Map 里对应 key 的值已经是 B 的 session,所以 B 中枪。
解决:统一使用remove(key, value)。ConcurrentHashMap的这个重载方法要求 key 映射到传入的 value 时才删除,否则什么都不做。这样旧 session 销毁时只能清理自己对应的条目,不会误伤新登录的会话。
4.2 同一账号两边同时登录,互相把对方顶掉
现象:用户快速在两个浏览器提交登录。理想结果是后提交的会话在线,先提交的会话被踢。但有时两边都被踢,或者两边都提示已在别处登录。
原因:SESSION_MAP.put(userId, session)本身是原子的,但两个并发登录请求在 put 之前会做一些耗时操作(比如写日志、查库),时序不可控。如果两个请求的 put 顺序相互交叉,可能出现请求 1 put、请求 2 put、请求 1 又 put 的乱序,最后 Map 里存的是请求 1 的会话,而两个请求都分别拿到了对方的旧会话,于是各自把自己当成了“新登录”,把对方当成了“旧登录”。
解决:让同一个 userId 的登录操作串行化。可以用一个专门的锁 Map 做细粒度控制:
private static final ConcurrentHashMap<String, Object> LOCKS = new ConcurrentHashMap<>(); public HttpSession registerWithLock(String userId, HttpSession session) { Object lock = LOCKS.computeIfAbsent(userId, k -> new Object()); synchronized (lock) { return SESSION_MAP.put(userId, session); } }说明:computeIfAbsent会在 key 不存在时创建一个锁对象,存在时返回旧的。这样不同 userId 之间互不阻塞,只有同一账号的登录动作串行。如果更激进一点,可以使用SESSION_MAP.compute(userId, (k, old) -> session),它在单个桶节点的锁内完成替换,也能避免并发覆盖问题,但回调里不应该再去操作旧 session 的属性,否则容易引起锁重入或死锁。
4.3 Ajax 请求被踢后不跳转,页面一直转圈
现象:用户在某页面停留,另一个浏览器登录,旧页面用户点击一个 Ajax 按钮。后端拦截器发现被踢,sendRedirect返回 302。但浏览器的 XHR 引擎不会自动跟随重定向到新页面,而是把/login的 HTML 作为响应体交给前端 JS,导致页面行为异常,甚至弹乱码。
原因:sendRedirect只对页面主帧的导航有效,对 fetch/XMLHttpRequest 来说 302 依然会被 XHR 接收,但 JS 拿到的是一个普通 HTML,无法触发整页跳转,于是卡死。
解决:拦截器里判断请求是否 Ajax。常见判断是请求头X-Requested-With: XMLHttpRequest或者Accept头包含application/json。如果是 Ajax,不重定向,而是设置响应状态 401 并返回 JSON;前端在全局拦截 401 后执行window.location.href。示例:
String requestedWith = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录已失效或已被顶下线\"}"); return false; }前端配合axios.interceptors.response或fetch的全局响应包装,收到 401 就清空本地登录标记并跳登录页。这个方案同样适用于 Session 超时后的 Ajax 请求,建议在拦截器里统一处理。
4.4 集群部署后,踢人效果“时灵时不灵”
现象:两个 Tomcat 实例通过 Nginx 负载均衡,同一个账号在 A 浏览器登录落在实例 1,在 B 浏览器登录落在实例 2。B 登录后,A 的请求有时被踢,有时不被踢,甚至出现 A、B 都提示登录成功的奇怪现象。
原因:SessionMap 是实例本地 JVM 内存,实例 1 的 Map 里存的是 A 的会话,实例 2 的 Map 里存的是 B 的会话,两者各自为政。如果 Nginx 把 A 的请求后续都路由到实例 1,B 的请求路由到实例 2,两边互不知晓,踢人自然无效。
解决:两个方向。短期:在 Nginx 层配置粘滞会话,如ip_hash或基于 Cookie 的sticky路由,让同一账号尽量固定在同一个实例,但这依赖负载均衡策略,不保证一定命中。长期:把登录登记迁到 Redis,用全局 key 保存最新 token,见 5.2。做课程设计时一般单实例部署,不用考虑这条,但如果你准备把项目部署到云服务器上,建议提前了解集群下 Session 的不一致性。
4.5 静态资源也被拦截,导致登录页样式全丢
现象:登录页没有样式,页面全是纯文本,浏览器控制台报错——CSS 文件返回 302 后被当成 HTML 解析。排查发现拦截器把/css/app.css也当成了未登录请求,重定向到了/login,而<link>标签加载到的是一段 HTML,自然识别失败。
原因:addPathPatterns("/**")匹配了所有路径,其中包括静态资源。有些开发者图方便,把所有请求全都拦,结果静态资源也被迫走登录校验。
解决:在 WebConfig 的excludePathPatterns中显式排除静态资源目录:/css/**、/js/**、/images/**、/favicon.ico等。如果有验证码接口、注册接口,也要排除。这个坑虽然低级,但在实际开发里出现的频率很高,尤其是不熟悉 Spring MVC 拦截器路径匹配规则的初学者。
5. 进阶:被踢用户实时收到通知:SSE 推送与 Redis 化改造
5.1 用 SseEmitter 让被踢用户立刻看到提示
SessionMap 方案的判定时机是“下一次请求”,如果用户停留在页面不操作,他无法立刻感知。要做得更细腻,常见做法是浏览器和服务端建立一条 SSE 或 WebSocket 长连接。SSE 是服务端单向推送,实现简单,适合“你被踢下线”这个场景。
登录成功后,浏览器启动一个 SSE 连接,服务端把SseEmitter和 userId 绑定:
private static final ConcurrentHashMap<String, SseEmitter> EMITTER_MAP = new ConcurrentHashMap<>(); @GetMapping("/sse") public SseEmitter sse(@RequestParam String userId, HttpSession session) { SseEmitter emitter = new SseEmitter(0L); EMITTER_MAP.put(userId, emitter); emitter.onCompletion(() -> EMITTER_MAP.remove(userId, emitter)); emitter.onTimeout(() -> EMITTER_MAP.remove(userId, emitter)); return emitter; }当新登录发生时,在register之后,从EMITTER_MAP里取出旧连接并推送消息:
SseEmitter oldEmitter = EMITTER_MAP.get(userId); if (oldEmitter != null) { oldEmitter.send(SseEmitter.event().name("kicked").data("您的账号已在其他设备登录")); oldEmitter.complete(); }前端用EventSource监听名为kicked的事件,收到后弹窗提示并跳转登录页。这里有个细节:同一个 userId 只保留一个有效推送连接,旧的连接在complete()之后会自动移除,避免重复通知。
5.2 从 SessionMap 迁移到 Redis Token 的改造思路
如果项目上了集群,SessionMap 就得退役。常见改造是:登录成功时生成一个全局唯一的 token(UUID),把userId -> token写入 Redis,同时把 token 放到 Session 里。每次请求拦截器从 Session 里取 token,再和 Redis 中的最新 token 比对,不一致就说明账号已在新设备登录。
登录操作的 Redis 部分:
String newToken = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("login:token:" + userId, newToken, 30, TimeUnit.MINUTES);请求校验:
String latest = redisTemplate.opsForValue().get("login:token:" + userId); if (latest == null || !latest.equals(tokenInSession)) { // 视为被踢或登录失效 }这个方案天然支持多实例,因为 Redis 是共享的。代价是每次受保护请求多一次 Redis 读。如果要用本地缓存优化,可以给 token 加一个 5 分钟缓存,但那样会带来“踢人后最长 5 分钟才生效”的问题,需要业务上权衡。我的建议是单体项目先别迁 Redis,等确实上了集群再说。
5.3 我的一点习惯
我每次做这类功能,第一反应不是上 Redis,而是先问项目部署在几台机器上。单机就 SessionMap,踩完上面那些坑,足够稳定;多机才考虑 Redis Token。SSE 推送不是必须的,但它是答辩和评审时很加分的亮点,代码量也不大。我自己的习惯是把 SessionMap 和 SSE 绑定的代码单独抽成一个LoginSessionManager类,避免散落得到处都是,后面迁移 Redis 时只改这个类就行。
希望帮到你。
本文还有配套的精品资源,点击获取