首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
JeecgBoot二次开发:token刷新后在线用户列表重复的修复方案
📅 2026/10/14 5:02:25
✍️ 爱科研究院
👁 阅读 3,247
在 JeecgBoot 3.2.0 的二次开发里我踩过一个非常隐蔽的坑用户 token 刷新后后台“在线用户”列表仍然把旧会话当成在线状态甚至同一个账号能出现两条在线记录看起来像有两个“分身”在同时使用系统。单独看业务接口一切正常但一旦做强制下线、单点登录互踢或者在线时长统计数据就会错得离谱。这篇文章我会把当时的排查过程、底层链路、根因定位和完整的修复方案写清楚给正在改 JeecgBoot 登录模块的朋友做个参考尤其是用 Redis 管理会话、把在线用户独立维护的二次开发场景应该能省下不少排查时间。先说个结论这类问题大多不是 JeecgBoot 框架本身的逻辑缺陷而是我们在改造 token 刷新机制时没有让“旧 token 缓存清理”“新 token 会话建立”“在线用户记录迁移”这三件事保持原子一致。只要它们中间出现一步不一致在线用户列表就会失真。1. 问题现象与影响范围1.1 一个不起眼的异常现场我是在一个多端登录的模拟项目里发现这个问题的。项目基于 JeecgBoot 3.2.0把默认的单 token 登录改成了支持 Web 和移动端双端同时在线每端各持有自己的 token端内 token 过期时通过 refreshToken 接口刷新。测试流程是这样的先用账号 A 在 Web 端登录拿到 token1操作一段时间后手动调用刷新接口拿到 token2然后用 token2 继续访问业务接口接口全部正常没有报错。但切到系统管理里的在线用户页面问题出现了——账号 A 显示为在线但显示的会话创建时间还是 token1 创建时的旧时间登录 IP 也是旧的。更奇怪的是如果把 token1 对应的会话手动踢掉账号 A 在列表里仍然显示在线并且还能继续访问需要登录态的接口。这个现象初看起来像是页面缓存刷新不及时但清缓存、换浏览器、重启前端都没有用。后来我发现在线用户列表的数据其实是从 Redis 里动态读出来的不是静态缓存页所以问题一定在后端会话管理上。1.2 排查才意识到的影响范围当时我还没意识到问题的严重性直到把影响范围列出来才吓了一跳。首先是强制下线功能失效要给在线用户发下线指令系统必须按在线记录的 token 去 Redis 里删会话但由于旧 token1 的记录还占着在线列表后端拿到的 session 状态和新 token2 不一致踢人经常踢不干净。其次是单点登录互踢逻辑错乱如果账号在多个端登录用户只踢掉一个端在线列表可能显示所有端都还在线。第三是统计口径完全不可信报表里的在线用户数、活跃时长、最后操作时间全部可能偏大。这类问题的共同根因都是在线用户列表里出现“僵尸会话”。僵尸会话不出现在正常的 token 校验链路中却一直停留在在线用户缓存里导致管理人员看到的是虚假的在线状态。如果项目里有基于在线用户数做配额限制、风险控制或者并发告警的逻辑这种问题还会间接引发误判。2. token 生命周期的底层链路2.1 登录流程谁在什么时候创建了在线记录要理解这个问题先得把 JeecgBoot 里 token 的生成和在线用户记录的创建分开看。登录接口做的事情是校验用户名密码、生成 JWT token、把用户信息写入 Redis 缓存、建立在线用户记录。这几件事在代码里是先后执行的任何一步失败都会导致登录失败并被事务回滚掉所以正常情况下它们是完整一致的。具体执行顺序大概是这样的前端调用登录接口提交账号密码。后端校验通过后用 JWT 工具类生成 tokentoken 里包含用户名、用户 ID、过期时间并按照框架配置的密钥签名。后端拿着这个 token 作为 key在 Redis 里写入一份用户会话信息设置过期时间比如 2 小时或者 24 小时。后端另外维护一份“在线用户”缓存用 token 的前缀去标记这条记录同时记录登录时间、IP、用户 ID 等信息。这里关键在第四步。如果项目没有做多端改造默认情况下一个用户只有一个 token在线用户记录和 token 是一一对应的基本不会出问题。但做了多端登录之后同一用户会有多个 token在线用户记录会以 token 为单位存在多个。那在线列表到底应该展示多少条应该以什么字段去重这些就需要明确约定否则很容易出现显示问题。2.2 刷新流程新 token 产生后旧记录何去何从我在项目里实现 token 刷新时一开始的思路非常简单接收旧 token - 解析出用户名 - 重新签发一个新 token - 把新 token 写到 Redis 里覆盖旧 token 对应的用户信息。这个思路本身没问题但实现的时候漏了一个关键动作旧 token 对应的在线用户记录没有同步更新。具体表现是旧 token 作为 key 写入的那份 Redis 会话还在新 token 作为新的 key 又写了一份用户会话两个 key 指向同一个用户 ID。在线用户列表在扫描 Redis 时是按 token key 前缀逐个读取的于是自然读出了两份记录。旧记录因为还设了较长的过期时间短时间不会自动消失用户看起来就是“在线了两条”。这里有一个很容易被忽略的技术细节JWT 本身是无状态的它不依赖服务端保存的 session。但 JeecgBoot 为了保证登录态可控、支持踢人下线通常还会把 token 保存在 Redis 里做成“有状态”的样子。刷新接口如果只重新签发了 JWT却没有把 Redis 里的在线记录从旧 key 迁移到新 key那 JWT 是新的Redis 里的会话标记还是旧的两边就对不上了。这个“对不上”正是刷新后在线用户显示异常的核心。2.3 在线用户列表的读取逻辑在线用户列表读取逻辑一般是这样的从 Redis 中取出所有以某个前缀开头的 key比如online:token:*然后逐个解析 key拿到 token 字符串从 token 中解析出用户信息组装成列表返回前端。这个方案的优点是实现简单不用额外维护一张在线用户表缺点是 key 的粒度就是 token一个用户多端登录时会返回多条记录刷新后旧记录如果没清理就必然重复显示。基于这个读取逻辑可以推导出两类显示问题如果刷新接口没有清理旧 key列表会出现同一用户的重复在线记录。如果刷新接口只清理了旧 key但用户会话对象的最后活跃时间没有更新列表会显示一条“活着的旧记录”。实际排查中这两类问题经常叠加出现。只看线上接口日志根本发现不了因为 token 校验走的是新 key新 key 的数据是完整新鲜的所有业务校验都通过。但管理者在前端看到的在线用户页面却是以旧 key 为主的残影数据。3. 根因定位三条线索3.1 线索一旧 key 带着完整会话信息躺在缓存里我在出问题时先做了个验证把 Redis 里所有在线相关 key 全部导出来逐个查看里面的数据结构。结果发现同一个用户 ID 下面存在两个 key一个是最开始登录时建的一个是我刷新 token 后新建的。两个 key 里的用户信息都完整没有字段缺失但 key 本身不同过期时间也不同。这就指明了一个方向在线用户显示问题不是“查询代码读不到数据”而是“缓存里真的存在冗余数据”。所以修复不能只改前端展示层必须从源头处理旧 key。否则就算前端做了按用户 ID 去重也只能掩盖问题旧数据始终在 Redis 里占着空间而且会影响强制下线和单点登录的相关操作。3.2 线索二刷新接口只换了“钥匙”没换“门锁”我翻了自己写的刷新接口代码发现里面逻辑是“生成新 token - 写入 Redis - 返回新 token”但没有把旧的在线用户 key 进行删除或迁移。也就是说我踢掉了旧钥匙却让旧门锁还在那里显示有人住。这个问题在很多自研登录模块里都很常见因为开发者容易把注意力放在 JWT 签名算法和过期时间上而忽略 Redis 侧的会话标记。尤其当你使用框架内置的 RedisUtil 时set操作很顺手删除旧 key 这个动作却容易漏掉。实际上刷新 token 的正确逻辑应当是“创建新 key - 把旧 key 的会话数据完整迁移到新 key - 删除旧 key - 更新在线用户列表 key 指向”四步按顺序执行少一步都会留下隐患。3.3 线索三在线列表按 token 前缀拉数据导致旧会话照常展示在线用户列表的查询接口是造成问题被“放大”的最后一环。它按前缀扫描 Redis key然后把 key 当作 token 解出用户信息。这个逻辑本身没有错但它极度依赖一个前提Redis 中所有以该前缀开头的 key都必须是“真实有效”的当前会话。一旦缓存里混入历史残留 key在线列表就会照单全收。我在代码里确认过列表查询没有任何过滤规则不会去校验该 key 对应的 token 是否与 Redis 中最新一次刷新后的 token 匹配也不会校验该 key 是否已经被踢下线。这就意味着旧 key 只要没有过期就永远是列表里的一条“合法数据”。所以修复方案里我选择在查询层也加上“用户维度聚合”的兜底处理避免历史脏数据直接污染页面展示。4. 修复方案落地4.1 刷新接口补上旧缓存清理与会话迁移我最终把刷新接口的逻辑重构成四个步骤按顺序执行每一步都加了日志。核心代码思路类似下面这样public Result? refreshToken(HttpServletRequest request) { // 1. 从请求头获取当前使用的 token String oldToken request.getHeader(X-Access-Token); // 2. 根据旧 token 解析用户 LoginUser user JwtUtil.getLoginUser(oldToken); if (user null) { return Result.error(token无效请重新登录); } // 3. 生成新 token 并重新写入会话缓存 String newToken JwtUtil.sign(user.getUsername(), user.getPassword()); Object sessionData redisUtil.get(CommonConstant.TOKEN_SESSION_PREFIX oldToken); redisUtil.del(CommonConstant.TOKEN_SESSION_PREFIX oldToken); redisUtil.set(CommonConstant.TOKEN_SESSION_PREFIX newToken, sessionData, expireTime); // 也同步更新在线用户缓存里 token 指向 String oldOnlineKey CommonConstant.ONLINE_USER_PREFIX oldToken; String newOnlineKey CommonConstant.ONLINE_USER_PREFIX newToken; Object onlineData redisUtil.get(oldOnlineKey); if (onlineData ! null) { redisUtil.del(oldOnlineKey); redisUtil.set(newOnlineKey, onlineData, expireTime); } // 4. 返回新 token MapString, Object result new HashMap(); result.put(token, newToken); return Result.OK(result); }这段逻辑里有几个细节值得强调。先用旧 token 取出会话数据再删除旧 key再写入新 key顺序不能反如果你先删旧 key 再取数据遇到并发请求就很危险。另外在线用户 key 的迁移必须和会话 key 的迁移同时做部分迁移也会造成列表里的记录与真实会话不一致。4.2 在线用户查询按用户维度聚合去重修复好刷新接口之后我又对在线用户列表查询做了一层兜底。思路是扫描 Redis 得到所有在线 key 后按用户 ID 聚合同一个用户只保留“最近活跃时间最新”的一条记录。这样即使 Redis 里出现了历史残留页面也不会展示重复在线用户。这部分我用了一个简单的判断逻辑ListOnlineUserVO onlineUsers getOnlineUsersFromRedis(); MapString, OnlineUserVO latestUserMap new HashMap(); for (OnlineUserVO user : onlineUsers) { String userId user.getUserId(); OnlineUserVO old latestUserMap.get(userId); if (old null || user.getLastActiveTime().after(old.getLastActiveTime())) { latestUserMap.put(userId, user); } }这里要留一个心眼按用户 ID 去重只适合“同一用户只允许一条在线记录”的场景。如果是多端登录每个端都是一个独立会话则应该按“用户 ID 端类型”去重否则会把 Web 端和移动端两条记录错误合并成一条。我当时的项目就是双端登录所以去重维度用了 userId clientId而不是单纯的 userId。4.3 注销和过期清理策略的统一修复刷新逻辑和列表查询后我还把注销逻辑同步检查了一遍。注销接口必须做到根据当前登录态的 token 删除会话 key 和在线用户 key如果项目做了多端登录注销只删除当前端对应的 token不能把其他端的会话也删掉。另外Redis 会在 key 过期后自动清理数据但在线用户列表如果依赖 Redis 过期时间来兜底就会有一个很尴尬的时间窗口用户在旧 token 过期前刷新了新 token旧 key 的到期时间还是按登录时计算的并不会因为刷新而提前失效。所以正确的做法是刷新时主动删除旧 key不要靠 Redis 过期来清理。为了保险我还在项目里加了一个定时任务每小时扫描一次在线 key如果当前时间超过 key 里的过期时间戳就主动删除算是第二层保证。5. 部署验证与多节点场景5.1 验证步骤与前后效果修复完成后我按下面的步骤做了一次完整回归验证清空所有历史 token 和在线用户缓存保证测试环境干净。用测试账号登录确认在线用户列表出现一条记录。记录该记录的 token、登录时间和 IP。调用刷新接口拿到新 token。回到在线用户列表观察记录是否仍只有一条登录时间是否更新为新 token 的创建时间IP 是否保持一致。用新 token 继续访问业务接口确认登录态正常。调用注销接口确认在线列表中的记录立刻消失Redis 中相关 key 也被清理干净。这套流程我连续跑了三轮每次结果都符合预期。修复前的表现是第 5 步会出现两条记录第 7 步注销后旧记录仍然残留修复后所有场景都恢复正常。5.2 多实例部署时的 Redis 注意事项如果你的 JeecgBoot 项目是做多实例部署在线用户管理又是用 Redis 做的那还要特别注意一点所有实例必须配置同一个 Redis并且在线用户查询逻辑要避免使用KEYS这种全库扫描命令改用SCAN分批遍历否则在线用户量大的时候 Redis 会被拖慢。我测试时租了一个小型 Redis 实例先在单实例环境验证再模拟两个应用节点共享 Redis。两个节点没有任何节点本地内存查询在线列表都走同一个 Redis结果也是正常的。但有一处必须提醒如果某个节点用了本地缓存来存在线用户数据那这个节点刷新 token 后其他节点的在线列表就不会及时更新这属于架构层面的坑不是刷新逻辑能解决的。所以多实例部署时在线用户状态务必只存放在 Redis 这类共享存储中。5.3 实测后的性能对比修复前在线用户查询接口每次扫描 Redis 后返回的数据量会因为僵尸 key 的存在而持续膨胀。我在测试环境中制造了 200 个僵尸会话查询接口耗时从 20 毫秒增加到 120 毫秒而且这个数据只增不减直到 key 过期。修复后同样的环境下查询耗时稳定在 30 毫秒左右并且不会因为历史残留 key 的增多而持续劣化。这里说明一下我的测试机配置很普通Redis 也是云上小型实例数据量也不算大所以耗时差异看起来不算夸张。但真实生产环境如果用户量达到几千上万人僵尸 key 带来的额外扫描开销会被明显放大接口响应时间会有可感知的恶化。6. 常见问题速查与心得6.1 常见问题速查表问题现象可能原因排查路径修复建议刷新后在线用户列表出现同账号多条记录刷新接口未删除旧 token 对应的在线 key检查 Redis 中该用户 ID 下是否有多条在线 key刷新时同步迁移并删除旧 key刷新后在线列表显示旧 IP、旧时间在线记录数据未随 token 迁移查看在线 key 里的最后活跃时间字段刷新后更新最后活跃时间和登录时间用户已注销但列表仍显示在线注销逻辑只删了会话 key没删在线 key对比注销接口删除的 Redis key 清单注销时同步清理在线 key多端登录时一端的刷新影响另一端显示在线记录按 token 管理没有区分端类型检查在线 key 的命名是否包含端标识按 userId clientId 维护在线记录多实例部署后在线列表数据不一致部分节点本地缓存了在线状态检查是否存在本地内存缓存统一使用 Redis 共享在线状态这张表里的前四条是我实际遇到过的最后一条是给多实例架构提个醒不要在重构时才想起来那样排查成本会很高。6.2 我在实操中的几点体会第一token 刷新这类操作不要把眼光局限在“生成新 token”这一件事上要从整个会话生命周期来看待。所谓生命周期就是“登录创建 - 使用中更新 - 刷新迁移 - 注销清理 - 过期兜底清理”每一环都要有对应的代码动作。第二日志要足够详细。我在刷新接口里加了操作前后的 key 变化日志比如refreshToken oldKeyxxx newKeyxxx sessionDataxxx这样后续再出问题直接看日志就能定位是哪一步没执行不需要再抓 Redis 数据反复比对。第三在线用户显示是一种“最终一致性需求”查询层最好能做聚合兜底。我们不可能保证所有历史清理逻辑每时每刻都完美但查询层通过按用户维度聚合能最大程度避免脏数据展示给管理者。最后再分享一个实践经验如果你在二次开发时不确定自己改的代码是否会导致在线状态不一致可以写一个小脚本定时扫描 Redis 中的在线 key检查同一用户 ID 是否出现多个 key一旦发现就告警。我后来把这个检测逻辑做成定时任务后几乎再没有遇到过用户反馈“在线用户显示不对”的工单。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/14 5:02:25
软件测试实习笔记:用例设计、缺陷管理与接口验证核心经验
2026/10/14 4:57:25
AI在芯片领域的黑盒困境:从设计工具到芯片本身的可解释性挑战
2026/10/14 4:57:25
Keras深度学习入门实战:MNIST手写数字识别全流程与常见坑解析
2026/10/14 5:52:28
结构优化算法全解析:从原理选型到工程踩坑实践
2026/10/14 5:52:28
Gin框架表单处理与数据绑定实战:从结构体tag到ShouldBind全解析
2026/10/14 5:52:28
AI Native开发实战:能力原子化设计与七步落地法
2026/10/14 5:52:28
AI面试助手实战:从用户建模到模拟面试的完整方案
2026/10/14 5:52:28
AI Agent落地实战:从Gartner趋势到可运行代码
2026/10/14 5:47:28
面试题 - 什么是填谷电路?
2026/10/14 0:02:01
Oracle 19c Windows客户端安装配置与连接排错指南
2026/10/14 0:02:01
React Native截图优化:解决Android兼容性问题
2026/10/14 0:02:01
金属锈蚀速率计算模型与防护方案优化实践
2026/10/13 12:25:09
流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南
2026/10/13 18:15:04
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别
2026/10/13 8:14:22
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容
2026/10/12 19:53:23
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/11 21:41:11
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/11 23:43:10
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)