JeecgBoot 3.2.0 用了一段时间后,很多团队都会撞上同一个尴尬场景:系统监控里的"在线用户"列表,同一账号翻来覆去出现好几条记录,IP、登录时间还各不相同;管理员在列表里点"强制下线",前端明明提示已退出,用户刷新一下页面又恢复了;甚至有人改了密码,旧账号还能通过一段时间内未被踢掉的 token 继续操作。
这不是偶发毛病,而是 token 刷新机制、Redis 存储结构、在线用户展示逻辑三者没有对齐造成的典型连锁反应。我在这篇文章里把整个链路翻出来讲清楚:token 是怎么签发、续期、存储和展示的,问题到底出在哪个环节,以及用什么样的代码改动才能真正收口。适合正在做 JeecgBoot 二次开发、或者刚升级到 3.2.0 后被在线用户模块坑过的同学参考。
1. 问题现象与影响范围先对齐
1.1 三个最容易感知的异常现场
第一个是"同人多记录"。用户只用一台电脑、一个浏览器登录后台,在线用户列表里却能出现两条甚至更多条一模一样的用户名记录。每条记录的登录时间、IP 可能都不相同,看起来就像被异地登录了一样。
第二个是"强制下线失效"。管理员在在线用户列表选中某条记录执行下线,返回成功,但这个用户实际仍然可以正常访问接口。原因往往是列表里展示的 token 和用户手里实际持有的 token 已经不是同一个了,下线删的是旧 token,用户用的却是新 token。
第三个是"登录时间/IP 错乱"。列表里显示的登录时间停留在第一次登录的时间,刷新 token 之后没有再更新;或者用户换了网络出口 IP 后,列表里记录的还是旧 IP。对于需要做安全审计的系统来说,这个数据失真比功能异常更危险。
1.2 影响范围往往超出预期
在线用户列表在 JeecgBoot 里不是一个纯展示页面,它背后牵扯三块业务:多端登录限制的判断逻辑、管理员的强制下线操作、登录审计与活跃用户统计。列表数据一旦不准,单点登录会误踢正常会话,安全审计报表会漏人,运维在看谁在线的结论也会被误导。
比如开启了"同一账号最多允许 N 个会话在线",用户正常使用过程中 token 刷新了几次,系统会把刷新产生的 token 当作新的登录端,从而踢掉用户在其他设备上真正在用的会话。这种问题排查起来最难受,因为它不是必现的,往往在用户连续操作超过一定时间后才触发,等接到反馈时 Redis 里的 key 已经被 TTL 清理了一部分,现场很难还原。
2. 先看底层:token 的签发、存储与在线展示逻辑
2.1 登录成功之后 Redis 里发生了什么
JeecgBoot 3.2.0 的登录逻辑走的是 JWT + Redis 缓存的方式。用户输入用户名密码校验通过后,后端生成一个 JWT 格式的 token,同时把这个 token 写入 Redis。写入时用的 key 是一个固定的业务前缀加上 token 字符串本身,也就是prefix_user_token_作为前缀拼接完整 token。
这段逻辑藏在登录服务内部,表面上看就是一个"生成 token + 调 redisUtil.set",但有几个关键细节影响后续排查:token 的超时时间、Redis key 的过期时间、value 里存了什么。很多团队改配置时只改了前端的登录过期提示,忽略了 Redis key 的 TTL,结果就是 token 还在有效期内,Redis 里的记录却先过期了,在线状态直接丢了一半。
value 的存储内容在 3.2.0 里并不是标准化的 JSON 会话对象,很多版本直接存 token 字符串本身,或者只存用户名。这意味着在线用户列表拿不到"用户登录时的 IP、登录时间、最后活跃时间"这些完整信息,只能靠 JWT 本身解析出的用户名去反查用户表,再补几个基础字段。数据结构上的薄弱,给后面几次改动埋了隐患。
2.2 在线用户列表的数据来源是怎么拼出来的
在线用户页面的后端接口,最常见的实现方式就是通过 Redis 的 keys 或者 scan 操作,把所有以prefix_user_token_开头的 key 拉出来,再逐个解析 key 里的 token,从 token 里取出用户名,最后组装成一个用户在线列表。
这里的关键点在于:页面上的"一条记录",本质上对应的是 Redis 里的"一个 key",而不是一个真实的自然人。用户只要持有两个有效 token,列表里就有两条记录;持有三个,就有三条。列表接口本身根本不去判断这些 token 是否属于同一个人。
按 key 数量展示在线人数的逻辑,在小规模内部系统里问题不大,一旦用户基数上来、token 有长 TTL,再叠加刷新产生的碎片 key,列表数据就会肉眼可见地膨胀。你在监控页面上看到"在线用户 500 人",实际活跃的人可能只有 200 多,剩下 300 条全是过期未清理的 token 残留。
2.3 刷新 token 的触发手段有哪些
JeecgBoot 3.2.0 里面存在多种 token 刷新路径,这是问题最容易扩散的原因之一。
第一种是后端自动续期。每次请求经过鉴权过滤器时,系统校验 token 正常且即将过期(或者已过期但还在 Redis 宽限期内),就自动生成一个新 token 写进 Redis,并通过响应头或响应体把新 token 返回给前端。
第二种是手动刷新接口。前端在请求拦截器里捕获到某种"需要刷新"的信号时,主动调用后端的刷新接口换取新 token,再拿着新 token 重放之前的业务请求。
第三种是重新登录。用户登录态完全失效后走一遍登录流程,生成一个全新的 token。
前两种刷新路径有个共同特征:生成新 token 时,系统只做了"写入新 key",几乎没有做"删除旧 key"。旧 key 会一直存在 Redis 里,直到它的 TTL 走完。而在线用户列表只看 key 数量,这就在两个模块之间形成了一个数据黑洞。
3. 问题根因:四条链路里的断裂点
3.1 刷新时新旧 token 并存,Redis 里淤积碎片
把刷新 token 的代码翻出来看,典型逻辑长这样:
// 伪代码:刷新token时只写入新key,没有删除旧key String newToken = JwtUtil.sign(username, password); redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + newToken, username, expireTime); // 缺少: redisUtil.del(CommonConstant.PREFIX_USER_TOKEN + oldToken)用户持有一个 token 正常访问,每次刷新都会在 Redis 里新增一个 key。刷新次数越多,旧 key 残留越多,在线列表就越臃肿。TTL 设得越长,这个问题被掩盖的时间越久;TTL 设得太短,又会造成 token 还没失效但 Redis 记录已经没了,用户被迫重新登录。
真正不合理的不是 TTL 本身,而是刷新动作没有执行"旧失效、新生效"的语义。一个会话的生命周期里,同时只应该有一个有效 token,旧 token 在生成新 token 的那一刻就应该被标记失效。
3.2 在线列表按"条数"展示,而不是按"用户"展示
接口在组装在线用户集合时,没有对同一个用户 ID 的 token 做去重聚合。Redis 里有多少个 key,列表就有多少行。
这里不只是一个展示美观问题。列表里的字段如果从不同 key 上解析,同一用户的多条记录可能带有不同的 IP、不同的登录时间,管理员根本无法分辨哪一条才是真正活跃的会话。更麻烦的是,前端的表格组件常常会把"用户名"作为排序或筛选维度,当同一个人出现多行时,管理员的每一次点击操作都面临选错行的风险。
3.3 强制下线依赖"精确 token 匹配"
JeecgBoot 在线用户列表里的下线按钮,通常是把这一行对应的 token 在 Redis 里删掉,让后续请求校验失败。这个逻辑在"列表展示的 token 就是用户当前实际持有 token"的前提下是有效的。
问题在于,token 历经刷新之后,列表展示的可能是旧 token,用户手里拿的是新 token。这时候管理员点下线,删除的是旧 key,用户的下一个请求带着新 token 过来,校验依然通过,看起来就是"点下线无效"。用户无感知,管理员很崩溃。
还有一种情况是用户在多个设备上登录,列表里同一个人的多条记录分别是不同设备的 token。管理员只想让用户在某台设备上退出,但列表里多行数据对应的设备关系又没有标识,操作时很容易误删其他设备的会话。
3.4 刷新时没有回写会话信息
在线用户列表里展示的"登录时间"和"登录 IP",如果是从登录时写入的数据取的,那么 token 刷新之后这部分信息天然是旧的。更严谨的设计应该把每次刷新动作当作会话活跃事件,刷新时更新该会话的 IP 和最后活跃时间。
实际情况是,刷新流程只关心 token 有没有更新,没有人去更新会话元数据。久而久之,在线列表的审计价值不断下降,运营同学对着数据根本说不出用户到底什么时候登录、最后一个请求从哪儿来。
4. 解决方案:四个改动逐步把问题收口
4.1 改动一:刷新 token 时执行"先写新、再删旧"
最直接有效的一步,是让刷新动作具备"旧 token 立即失效"的语义。推荐顺序是先写入新 key,再删除旧 key。
// 伪代码:刷新token时完成新旧替换 String oldToken = request.getHeader("X-Access-Token"); String username = JwtUtil.getUsername(oldToken); // 1. 基于用户名或旧token生成新token String newToken = JwtUtil.sign(username, oldToken); // 2. 先写新key,确保写入成功后旧key才删除 redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + newToken, username, expireTime); // 3. 删除旧key redisUtil.del(CommonConstant.PREFIX_USER_TOKEN + oldToken); // 4. 新token通过响应头返回 response.setHeader("X-Access-Token", newToken);先写新后删旧是为了避免中间一个时间窗口里,请求带着旧 token 过来却找不到 Redis 记录,导致被误判为未登录。如果先删旧再写新,刷新接口自身在写新 token 之前还有一小段间隔,极端情况下可能产生 401 抖动。
这里有一个并发风险:同一个用户发出多个并发请求,每个请求都触发刷新,就可能生成多个新 token,产生新的碎片。规避手段是在刷新逻辑里加一个按用户名粒度的锁,或者用 Redis 的原子操作保证同一个用户同时只有一个刷新任务在执行。如果并发量不大,用 JVM 内置的 ConcurrentHashMap 加锁也能凑合,但集群部署时就必须依赖 Redis 分布式锁或者严格的"旧 key 对比删除"策略了。
4.2 改动二:在线列表按用户维度聚合
即使刷新逻辑做了新旧替换,老数据在 Redis 里可能还有遗留。在线用户列表接口需要加一层聚合逻辑,按用户 ID 分组,每个用户只展示最新一条会话记录。
// 伪代码:在线用户列表按userId去重 List<TokenInfo> tokenList = loadTokenInfosFromRedis(); Map<String, TokenInfo> latestByUser = new HashMap<>(); for (TokenInfo info : tokenList) { TokenInfo old = latestByUser.get(info.getUserId()); if (old == null || info.getRefreshTime().isAfter(old.getRefreshTime())) { latestByUser.put(info.getUserId(), info); } } return latestByUser.values();聚合时选择"保留哪一条"的标准,建议用最后活跃时间而不是登录时间。因为最后活跃时间才反映用户当前正在使用的会话,登录时间早的会话可能是用户长期没关机挂着的。
如果产品上确实需要展示同一用户的多个在线端(手机、平板、PC),那列表接口要增加"设备标识"字段,否则多条记录无法区分。再进一步就是引入会话 ID 的概念,把"一条会话"作为第一公民,token 只是会话内部轮换的凭证。
4.3 改动三:强制下线改成"按用户清场"
管理员的诉求通常是"让这个用户下线",而不是"删除某一串 token"。下线接口应该接受一个用户 ID 或用户名,然后找到该用户关联的所有有效 token,一次性清掉。
// 伪代码:按用户id清理所有在线token Set<String> userTokenKeys = getKeysByUser(userId); for (String key : userTokenKeys) { redisUtil.del(key); }获取用户关联 token keys 的方式,可以在 Redis 里额外维护一个user_sessions:前缀的集合,每次登录或刷新时把 user -> token 的关系写进去,下线时读取集合逐一删除;也可以直接在登录时把 userId 拼进 key,比如prefix_user_token_+ userId + "_" + token,这样扫描范围更小、删除更精准。
按用户清场还有一个作用:解决刷新导致的"踢不掉"问题。不管用户手里换过多少个 token,只要生成新 token 时都把关系记录在案,下线时全部清理,用户就再也发不出有效请求。
4.4 改动四:会话信息结构化,刷新时同步更新
最后一步是把 Redis 里 token key 对应的 value 从简单的 token 字符串改为结构化的会话对象,包含 userId、用户名、登录 IP、登录时间、最后活跃时间、刷新次数等字段。登录时写入初始会话,刷新时只更新最后活跃时间和 IP。
// 伪代码:写入结构化会话对象 SessionInfo info = new SessionInfo(); info.setUserId(userId); info.setUsername(username); info.setLoginIp(ip); info.setLoginTime(new Date()); info.setLastActiveTime(new Date()); redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, JSON.toJSONString(info), expireTime);在线列表接口拿到 value 后直接反序列化,不需要反查用户表,响应速度更快,展示字段也更完整。登录 IP 在刷新时更新,审计数据才能反映用户真实的网络出口变化。
这一步改动量稍大,但收益也最实在:在线用户列表终于变成了"会话列表",而不是"token 列表"。
4.5 方案对比与选型建议
| 改动项 | 解决的核心问题 | 改动量 | 风险点 | 适用场景 |
|---|---|---|---|---|
| 刷新时删旧token | 碎片key淤积、同人多记录 | 小 | 并发刷新可能误删新token | 所有使用3.2.0的项目,建议立刻做 |
| 在线列表按用户聚合 | 列表展示虚高、操作选错行 | 小 | 多端登录场景被合并 | 内部管理系统、没有强多设备诉求时 |
| 下线按用户清场 | 强制下线失效 | 中 | 可能误杀用户所有设备 | 管理员诉求是"让用户下线"而非"踢设备" |
| 会话数据结构化 | 登录时间/IP不准确 | 中 | 需要兼容旧数据 | 有安全审计、运维展示诉求的项目 |
我建议至少完成改动一和改动三。改动一是从源头减少碎片,改动三是保证管理员的强制操作真实可控。改动二更多是缓解展示层症状,改动四是数据基础升级,可以按团队节奏排期。
5. 现场排查实操与验证清单
5.1 四步定位问题
如果你已经在生产环境撞上这个问题,不要急着改代码,先按下面几步确认根因,再动手。
第一步,确认 Redis 里 token key 的数量。用客户端连上 Redis,执行查询:
redis-cli keys "prefix_user_token_*"看返回的 key 数量,再对比在线用户页面的记录数。如果 key 数量远大于页面展示数,说明页面做了部分过滤;如果两者几乎一致,说明页面是纯 key 扫描。
第二步,复现一次 token 刷新操作。打开一个已登录用户的管理页面,等 token 接近过期时间,触发任意一个接口请求,然后立刻再执行一次 keys 查询,观察prefix_user_token_前缀下的 key 数量变化。如果原来一个 key,刷新后变成两个,而旧的还在,说明刷新逻辑确实没有删旧 key。
第三步,检查刷新前后在线用户列表接口的返回 JSON。找到同一个用户名对应的 records 条数变化。这一步能直接确认列表是否按 key 数量展示,以及刷新动作是否导致同用户多记录。
第四步,模拟强制下线。在线列表里定位到刚才那个用户,选择其中一条记录执行下线,然后用同一个账号再请求一次受保护的接口。如果接口仍然返回 200,说明前端持有的 token 并没有被删除,基本坐实了"旧 token 被删、新 token 还在"的问题。
5.2 常用辅助命令与注意事项
排查过程中,这几个 Redis 命令经常用到:
# 查看某个token key的剩余过期时间 redis-cli ttl "prefix_user_token_xxx" # 查看某个token key里存了什么 redis-cli get "prefix_user_token_xxx" # 按scan方式遍历,避免keys阻塞 redis-cli --scan --pattern "prefix_user_token_*"生产环境执行keys要非常克制,尤其在 key 数量大的时候,keys *这类操作会阻塞 Redis 线程。用--scan --pattern更加安全。如果是在 Java 代码里做同类扫描,优先使用 Spring Data Redis 的 scan 方法而不是redisUtil.keys。
5.3 配置项排查清单
代码层面的问题找完后,还要把配置过一遍:
- 确认 token 有效期的配置项,比如超时时间是否与 Redis key 的 TTL 匹配,一般建议 Redis TTL 略大于 JWT 过期时间,留出刷新时间窗口。
- 检查是否开启了多端登录限制,以及"最大同时在线数"的配置。刷新 token 在没有删旧 key 的情况下,会被误判为新的登录端,从而挤掉其他设备。
- 检查鉴权过滤器的 URL 排除规则,确认刷新 token 的接口不是被放行后遗漏了清理逻辑。
- 如果系统部署在 Redis 集群或者分片环境下,注意不同节点上的 key 扫描结果是否完整,避免漏掉某个节点的在线会话。
6. 避坑记录与个人建议
排查这类问题的过程中,有几个坑值得单独拿出来说。
不要一上来就flushdb清空 Redis 验证效果。生产环境的 Redis 里通常还放着权限缓存、字典缓存、验证码缓存等业务数据,一清全没,用户会大面积掉线。真想验证,就用前缀精确匹配,只删prefix_user_token_相关的 key。
刷新逻辑改动后要跟前端联调确认响应头里新 token 的传递方式。如果前端 Axios 拦截器只拦截了响应体里的 token,没处理响应头,新 token 不会真正生效,刷新链路等于断了。我见过不少团队改完后端刷新逻辑,前端频繁出现重复登录,最后发现是响应的新 token 没有写进本地存储。
多个浏览器标签页并存的场景也要提前考虑。A 标签页刷新了 token,B 标签页还在用旧 token,后续请求就会校验失败。解决思路一般是用 localStorage 加事件监听,token 更新后通知其他标签页同步更新,或者在服务端放宽 B 标签页场景下的校验容错。
升级版本前,建议先查一遍 3.2.0 相对 3.0 系列在登录鉴权这块有什么调整,重点看 token 前缀常量、刷新接口路径、Redis key 结构有没有变化。很多从旧版本升上来的项目,Redis 里残留着旧前缀的 key,新代码认不出,在线列表自然对不上。
最后再分享一个经验值:token 的 TTL 不要只拍脑袋定一个数。可以参考系统的真实活跃时长,先统计一下日常用户连续操作的最长间隔,再在这个基础上加 20% 余量作为 Redis key 的 TTL。刷新机制正常的前提下,用户长时间不操作才需要重新登录,频繁操作的用户应当在每次刷新时得到续期,而不是隔几个小时就被迫退出一次。这样配置出来的在线状态才贴近真实使用体验。