news 2026/9/6 5:42:54

账号多设备登录只踢一人的设计与实现:Redis+Lua会话管理方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
账号多设备登录只踢一人的设计与实现:Redis+Lua会话管理方案

咱们平时做后台管理系统、App 应用或者 SaaS 平台时,登录模块是逃不掉的。早期很多系统只做单设备登录,就是一个账号同时只能在一台设备上登录,后登录的会把前面登录的踢掉,设计起来简单,用户也容易理解。

但越往后做,需求就变得越细。尤其是面向会员、企业用户或者教育类产品时,产品经理往往会提一个很常见的需求:

“一个账号允许同时在 5 台设备上登录,当第 6 台设备登录时,把最早登录的那 1 台设备踢下线,其他设备保持在线状态不受影响。”

这个需求看起来只是把“踢掉所有旧设备”改成“只踢掉一台旧设备”,但是在实际设计时,涉及的细节要比想象中多:怎么记录设备、怎么判断“最旧”、怎么保证并发登录时不出错、怎么通知被踢设备、踢完以后旧 token 还能不能用。本文就围绕这个场景,从数据结构设计、Redis 存储、踢人策略、Lua 脚本原子操作、异常处理和工程实践几个方面,完整讲一讲账号登录 5 台设备只踢 1 台的设计方案。

如果你是后端开发、系统架构师,或者正在做用户登录与会话管理的功能,这篇文章可以作为一份落地方案参考。代码示例以 Java + Spring Boot + Redis 为主,但设计思路是通用的,换成 Go、Python、Node.js 或 PHP 同样适用。

1. 多设备登录与互踢的需求场景

先明确一下本文讨论的范围:它和“单设备登录”不同,也和“不限制设备数”不同,属于两者之间的折中方案。

1.1 为什么产品会要求多设备登录

单设备登录在早期产品中很常见,尤其是社交软件和游戏平台。一账号一设备的好处是安全边界清晰、风控简单、用户身份不容易被盗用后多端扩散。但对于很多业务来说,单设备登录体验太差了。

举几个常见场景:

  • 用户同时拥有手机、平板、公司的电脑、家里的电脑,想在多台设备上无缝切换。
  • 教学平台中,老师可能在办公室电脑、教室一体机、个人笔记本上登录同一个账号。
  • 企业内部系统,员工经常换电脑或同时在多台办公设备上操作。
  • 视频会员、文档协作类产品,允许用户在多设备上共享账号权益。

如果产品直接不做限制,风险会上升。比如账号被恶意盗用后,攻击者可以在任意设备上保持登录而不被察觉;再比如按“同时在线设备数”作为会员等级权益时,不限制设备数会让付费体系失去意义。

所以“限制设备数量 + 自动踢掉最旧设备”就成了一种比较平衡的设计。

1.2 什么是“踢掉第 6 台设备”

以“最多 5 台设备”为例,当账号已经存在 5 个有效会话时,如果第 6 个设备发起登录,系统需要选择一个已有的会话将其失效,并让新设备成功登录。最终效果是:总是只有 5 台设备在线,其中 5 台是最近活跃的,被踢掉的是相对“最旧”的一台。

这里有几个关键点需要提前定义清楚,否则开发过程中会出现各种歧义:

  • “设备”是什么?通常用设备的唯一标识(deviceId)来区分,而不是用 IP 或浏览器指纹。
  • “旧”怎么定义?可以是最早登录的时间,也可以是最长时间没有活跃的时间,甚至可以是“用户指定踢哪台”。
  • “踢掉”的表现是什么?是删除服务端 session,让旧 token 调用接口时返回 401,还是直接向旧设备推送一条下线通知。
  • “同一台设备重复登录”算不算新增?通常同一台设备再次登录应该复用或刷新已有会话,不挤占设备名额。
  • “第 6 台设备”是指同一时刻并发数到达 6,而不是累计登录过的设备数。

这些细节如果不提前定好,开发过程中就会反复改逻辑。

1.3 设计与单设备登录的差异

单设备登录的实现逻辑通常是这样:

用户登录时,先把该用户在所有设备上的会话记录删除,再创建新会话。这样无论用户有多少个 token,只有一个 token 有效,旧的 token 即使没被删除,在鉴权时也会因为查不到会话而失败。

多设备限制数量登录则不同,它的核心逻辑是:

if 当前有效会话数 < 5: 直接创建新会话 else: 按策略找到最旧会话,删除它 创建新会话

难点在于两个地方:第一,判断“当前有效会话数”和“最旧会话”需要可靠的数据结构;第二,并发登录时不能出现两个线程同时发现会话数不足 5 个,结果最终变成 6 个会话。

下面先从整体设计开始讲。

2. 总体设计:会话、设备与踢人策略

一个完整的“5 台设备只踢 1 台”功能,至少包含三个模块:

  • 登录认证模块:负责验证用户名密码、生成令牌。
  • 会话管理模块:负责维护用户所有在线设备的会话记录。
  • 设备下线模块:负责踢掉指定会话,并通知被踢设备。

2.1 核心组件

组件作用技术选型参考
用户信息用户的唯一标识,如 userIdMySQL、用户中心
设备标识标识某台具体的设备,如 deviceId客户端生成 UUID 或服务端首次分配
会话令牌登录成功后签发的凭证,如 tokenJWT、Opaque Token
会话存储保存“用户-设备-token”之间的映射关系Redis、数据库
踢人策略决定踢掉哪台设备最旧优先、最长未活跃优先、手动指定
在线通知让被踢设备感知下线WebSocket、推送、轮询

2.2 关键概念解释

设备标识:也就是 deviceId,客户端在首次启动时生成一个稳定的 UUID 并保存到本地,后续所有登录请求都携带这个 deviceId。服务端用它来区分不同物理设备。如果没有 deviceId,也可以退而求其次,用“用户ID + 客户端类型 + 浏览器指纹”来生成设备指纹,但可靠性会差一些。

会话令牌:本文以最简单的随机 token 为例,不展开 JWT 的细节。核心原因是 JWT 本身是无状态的,如果要实现“踢人”,服务端必须额外保存销毁状态或黑名单,反而增加了复杂度。实际项目中,内部系统或后台管理系统用 Redis 存储会话的方式更常见,也更容易控制。

踢人策略:最常见的策略有三种:

  • 按登录时间排序,踢掉最早登录的设备;
  • 按最后活跃时间排序,踢掉最久没有活跃的设备;
  • 由用户自己在“设备管理”页手动指定踢掉某台设备。

如果产品没有明确要求,一般优先使用“最早登录优先踢出”。因为用户如果在老设备上长时间未活跃,新设备登录时把老设备踢掉,用户感知最小。如果按最后活跃时间的话,一个设备 10 天前登录过但今天刚好没操作,另一个设备 3 小时前登录过,两者只能留一个时,踢掉 10 天前登录的设备更合理。但这个“合理”不是绝对的,需要产品根据用户习惯决定。

2.3 整体登录流程

我们可以把整个流程用文字拆分如下。

用户携带用户名密码和设备标识请求登录:

第一步,校验用户名密码,失败则直接返回。

第二步,查询该用户当前有效的会话列表。

第三步,检查当前会话数量。

第四步,如果数量小于 5,创建新会话。

第五步,如果数量大于等于 5,从现有会话中选出一个最旧的会话,将其删除,然后创建新会话。

第六步,把新会话信息写入 Redis,并返回 token 给客户端。

在并发场景下,第三步和第四步之间存在竞态条件,必须依赖 Redis 的 Lua 脚本或分布式锁保证原子性,这一点后面会单独说明。

2.4 “踢掉”的语义

服务端把旧会话从 Redis 删除之后,旧 token 立刻失效。此时如果旧设备继续请求业务接口,会在鉴权阶段因为查询不到会话信息而被拒绝。

但“服务端拒绝”和“用户立刻感知”是两回事。如果旧设备没有发起请求,它不会知道自己已经被踢下线。所以对于真正需要“踢人提示”的产品,还需要配套推送机制,例如向该设备的 WebSocket 连接发送一条消息,告诉它“您已在其他设备登录”。

后文会在完整实现中分别说明会话删除和通知下线的做法。

3. 数据结构设计:Redis 怎么存这些会话

3.1 为什么选择 Redis

多设备登录会话管理,对存储的要求是:

  • 读取快:每次接口请求鉴权时都要查询 token 是否有效。
  • 支持过期时间:每个会话都应该有过期时间,避免长期占用名额。
  • 数据结构丰富:需要存储会话列表,并且能排序、能删除指定元素。
  • 易于实现并发原子操作:最好能在服务端脚本中完成“判断数量-删除-新增”的整段逻辑。

Redis 天然满足这些条件,因此是首选方案。也可以用数据库表实现,但数据库查询写在接口鉴权路径中会比较吃力,而且过期清理麻烦。Redis 的方案简单直接。

3.2 Redis Key 设计

假设我们使用 userId 作为维度,把某个用户的所有在线设备会话集中存储。

一个常用方案是:

key:login:session:{userId} type:Hash field:deviceId value:token

同时为了记录每个设备的最早登录时间或最后活跃时间,需要另一个结构:

key:login:session:sort:{userId} type:ZSet member:deviceId score:登录时间戳或最后活跃时间戳

Hash 用来保存 deviceId 到 token 的映射,ZSet 用来做“踢人”时的排序选择。当一个会话过期或被踢时,两个结构需要同步删除。

也可以把两份数据合成一份,比如将 token、登录时间、最后活跃时间都塞到 Hash 的 value 里,然后 ZSet 只存排序字段。不过这样会增加查询时的序列化开销,代码也更绕。个人建议 Hash + ZSet 双写,简单清晰。

3.3 数据结构示例

假设用户 10001 当前有 3 台设备在线:

Hash 结构 login:session:10001

fieldvalue
device-atoken-a-xxx
device-btoken-b-xxx
device-ctoken-c-xxx

ZSet 结构 login:session:sort:10001

memberscore
device-a1710000000
device-b1710003600
device-c1710007200

score 是登录时间戳。需要踢人时,执行 ZRange 或 ZRangeWithScores 取得 score 最小的 deviceId,这个就是最早登录的设备。

3.4 会话过期与续期

会话必须有过期时间。假设业务要求 7 天内免登录,Redis 对 Hash 和 ZSet 分别设置 expire 7 天即可。

但这里有个细节:用户如果一直在活跃,不应该 7 天后就被踢下线。所以每次用户请求接口时,应该顺便把 session 的过期时间续上。

方案可以是:

1. 登录时设置 login:session:{userId} 和 login:session:sort:{userId} 的过期时间为 7 天 2. 用户每次请求业务接口时,如果鉴权通过,则续期

续期时做的操作:

EXPIRE login:session:10001 604800 EXPIRE login:session:sort:10001 604800

同时可以更新 ZSet 中该 deviceId 的 score 为当前活跃时间。这一步的作用是:如果踢人策略改为“踢最长未活跃设备”,更新 score 可以保证排序的准确性。如果坚持按“登录时间顺序踢人”,则更新 score 会导致误判,所以要看策略选择。

还要考虑一点:同一台设备重复登录时,不应该增加设备数量。比如用户已经在手机 A 上登录,再次用手机 A 登录,应该刷新 token 并更新时间,而不是把手机 A 当作第 6 台设备去踢其他设备。这个分支在登录逻辑中要单独判断。

3.5 Token 的生成与保存

Token 可以使用 UUID 或更安全的 secureRandom 生成。保存时建议只保存 token 的哈希值,避免数据库或 Redis 泄露导致 token 大面积泄露。但实际很多内部系统为了排查问题方便,直接保存明文 token,这个根据项目安全等级权衡。

如果使用 Spring Boot,token 可以保存在 Hash 的 value 中,也可以单独建 key:

key:login:token:{token} value:userId expire:7天

这样每次请求只需要根据 token 反查 userId,不需要遍历 Hash。但一个用户有多个设备就对应多个 token,因此还需要一个 token 与 deviceId 的映射关系。

综上所述,完整 Redis 结构可以设计为:

login:session:{userId} Hash deviceId -> token login:session:sort:{userId} ZSet deviceId -> 时间戳 login:token:{token} String userId:deviceId

三个结构共同维护一套会话。登录、踢人、鉴权都需要同步操作它们。

4. 踢人策略的五种设计变体

“5 台设备只踢 1 台”只是最基础的需求,如果产品经理后续提出更细的策略,你需要在设计阶段就留好扩展点。下面列举几种常见的变体。

4.1 方案一:按登录时间,踢最旧的

这是默认方案。ZSet 的 score 保存“首次登录时间”。每次登录时向 ZSet 中添加 deviceId,score 为当前时间戳。

当设备数超过限制时:

取出 score 最小的 member,该 deviceId 就是最早登录的设备

这个方案实现最简单,用户也容易理解。

4.2 方案二:按最近活跃时间,踢最久没有操作的

ZSet 的 score 保存“最后活跃时间”。当用户请求接口时,更新当前设备在 ZSet 中的 score 为当前时间戳。

当设备数超过限制时,同样取出 score 最小的 member,即最久没有活跃的设备。

这种方案比“按登录时间”更合理,因为有些设备登录后可能 20 天没有打开,而另一台设备是 5 天前登录的但最近一直有操作,踢掉 5 天前登录的设备反而不合理。

但缺点是需要写一个“活跃续期”逻辑。每次请求都要更新 Redis,对 Redis 的压力比方案一大一些。

4.3 方案三:用户手动踢掉指定设备

产品中通常还有一个“设备管理”页面,展示当前登录的所有设备,用户可以手动删除某台设备。这相当于在原有逻辑之外增加一个删除指定会话的接口。

手动踢人的实现其实比自动踢人简单,只需要根据传入的 deviceId 删除对应会话,并通知该设备下线即可。

4.4 方案四:按设备类型区分名额

例如一个账号最多 3 台手机 + 2 台电脑,那就要把设备类型作为维度拆分,分别限制。此时 Redis 的 key 可能设计为:

login:session:{userId}:{deviceType}

这属于扩展场景,本文不展开,但设计时可以考虑把 deviceType 作为一个字段存到 Hash 的 value 中。

4.5 方案五:指定账号踢特定设备

例如管理员在后台选择“把用户 A 在设备 B 上的会话强制下线”。这种操作一般通过单独的管理接口实现,直接删除指定会话即可。

综合来看,如果产品需求没有特别说明,建议先用“按登录时间踢最旧”的方式落地,后续再根据用户反馈升级到“按活跃时间踢最旧”,因为活跃时间策略需要增加续期逻辑,开发成本和 Redis 压力都会增加。

5. 核心实现:Spring Boot + Redis + Lua 原子操作

下面给出一个可运行的完整示例,以 Java Spring Boot 为主。示例不涉及数据库操作,重点演示会话管理逻辑。

5.1 环境准备

环境版本建议
JDK8 或 11
Spring Boot2.x 或 3.x
Redis5.0 及以上版本,支持 Lua 脚本
IDEIDEA 或 Eclipse

需要说明的是,Spring Boot 3.x 使用 Jakarta 命名空间,如果导入示例后报包名错误,改成实际项目对应版本即可。

本文示例基于以下依赖:

<!-- 文件路径:pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>

Redis 连接配置:

spring.redis.host=127.0.0.1 spring.redis.port=6379 spring.redis.password= spring.redis.database=0 spring.redis.lettuce.pool.max-active=16 spring.redis.lettuce.pool.max-idle=8

5.2 定义参数对象和常量

先定义一个登录请求参数类,包含用户 ID、设备 ID、设备类型等字段。

// 文件路径:src/main/java/com/example/multilogin/model/LoginRequest.java public class LoginRequest { private Long userId; private String deviceId; private String deviceType; public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } public String getDeviceId() { return deviceId; } public void setDeviceId(String deviceId) { this.deviceId = deviceId; } public String getDeviceType() { return deviceType; } public void setDeviceType(String deviceType) { this.deviceType = deviceType; } }

定义一个常量类,管理 Redis 的 key 前缀和过期时间。

// 文件路径:src/main/java/com/example/multilogin/common/SessionConstants.java public class SessionConstants { public static final String SESSION_HASH_KEY_PREFIX = "login:session:"; public static final String SESSION_SORT_KEY_PREFIX = "login:session:sort:"; public static final String TOKEN_KEY_PREFIX = "login:token:"; public static final String DEVICE_ID_FIELD = "deviceId"; public static final int MAX_DEVICE_COUNT = 5; public static final long SESSION_TIMEOUT_SECONDS = 7L * 24 * 60 * 60; }

5.3 生成随机 Token

Token 使用 SecureRandom 生成 32 位随机字符串,保证不可预测。

// 文件路径:src/main/java/com/example/multilogin/util/TokenGenerator.java import java.security.SecureRandom; public class TokenGenerator { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final char[] BASE = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toCharArray(); private static final int TOKEN_LENGTH = 32; public static String generateToken() { StringBuilder sb = new StringBuilder(); for (int i = 0; i < TOKEN_LENGTH; i++) { sb.append(BASE[SECURE_RANDOM.nextInt(BASE.length)]); } return sb.toString(); } }

5.4 基于 Lua 的原子登录逻辑

这一节是核心。为什么必须用 Lua?因为整个“判断数量、删旧会话、加新会话”的过程需要保证原子性,如果不加保护,两台设备同时发起登录时,可能出现以下情况:

  • 当前在线设备数为 5。
  • 设备 A 查询到会话数等于 5,准备删除最旧的。
  • 设备 B 也查询到会话数等于 5,也准备删除最旧的。
  • 两个线程都认为“我删除后还有 4 个,我再加 1 个,刚好 5 个”。
  • 结果最终变成了 6 个会话。

Redis 是单线程执行的,Lua 脚本可以保证多条命令作为一个整体执行,中间不会有其他命令插入。这是解决并发登录竞态条件的最佳方式。

下面编写一个 Lua 脚本,完成以下逻辑:

  1. 检查该用户在 ZSet 中的会话数量。
  2. 如果数量小于最大限制:
    • 判断该设备是否已存在,如果存在则直接刷新会话(重新生成 token、更新时间戳)。
    • 如果不存在,则新增会话。
  3. 如果数量达到最大限制:
    • 判断该设备是否已存在,如果存在则直接刷新会话。
    • 如果不存在,则从 ZSet 中选出 score 最小的设备作为被踢设备,删除旧会话,然后新增当前设备。

脚本如下:

-- 文件路径:src/main/resources/lua/login_session.lua -- KEYS[1] = login:session:{userId} -- KEYS[2] = login:session:sort:{userId} -- ARGV[1] = deviceId -- ARGV[2] = token -- ARGV[3] = 当前时间戳 -- ARGV[4] = 最大设备数量 -- ARGV[5] = 会话过期时间(秒) local hashKey = KEYS[1] local sortKey = KEYS[2] local deviceId = ARGV[1] local token = ARGV[2] local currentTime = tonumber(ARGV[3]) local maxCount = tonumber(ARGV[4]) local ttl = tonumber(ARGV[5]) -- 该设备是否已经存在 local exists = redis.call('HEXISTS', hashKey, deviceId) if exists == 1 then -- 同一设备再次登录,不挤占设备名额,只刷新会话 redis.call('HSET', hashKey, deviceId, token) redis.call('ZADD', sortKey, currentTime, deviceId) redis.call('EXPIRE', hashKey, ttl) redis.call('EXPIRE', sortKey, ttl) return { 0, token, '' } end -- 统计当前在线设备数量 local count = redis.call('ZCARD', sortKey) if count < maxCount then -- 未达到上限,直接新增 redis.call('HSET', hashKey, deviceId, token) redis.call('ZADD', sortKey, currentTime, deviceId) redis.call('EXPIRE', hashKey, ttl) redis.call('EXPIRE', sortKey, ttl) return { 1, token, '' } end -- 已达到上限,选出 score 最小的设备作为被踢设备 local members = redis.call('ZRANGE', sortKey, 0, 0, 'WITHSCORES') local kickDeviceId = members[1] local kickScore = members[2] if kickDeviceId == false or kickDeviceId == nil then -- 理论上不会走到这里 return { -1, '', '' } end -- 踢掉旧设备 redis.call('HDEL', hashKey, kickDeviceId) redis.call('ZREM', sortKey, kickDeviceId) -- 保存被踢设备的旧 token,方便后续通知 local oldToken = redis.call('HGET', hashKey, kickDeviceId) -- 新增当前设备 redis.call('HSET', hashKey, deviceId, token) redis.call('ZADD', sortKey, currentTime, deviceId) redis.call('EXPIRE', hashKey, ttl) redis.call('EXPIRE', sortKey, ttl) return { 2, token, kickDeviceId }

注意,上面脚本中先 HDEL 再 HGET,顺序有问题,应该先 HGET 旧 token,再 HDEL。修正后的逻辑:

-- 已达到上限,选出 score 最小的设备作为被踢设备 local members = redis.call('ZRANGE', sortKey, 0, 0, 'WITHSCORES') local kickDeviceId = members[1] -- 保存旧 token 用于后续通知 local oldToken = redis.call('HGET', hashKey, kickDeviceId) -- 踢掉旧设备 redis.call('HDEL', hashKey, kickDeviceId) redis.call('ZREM', sortKey, kickDeviceId)

这个脚本返回三个值:

  • 第一个表示结果类型:0 表示同一设备刷新会话,1 表示新增会话,2 表示踢掉其他设备后新增会话,-1 表示异常。
  • 第二个是新 token。
  • 第三个是被踢设备的 deviceId,如果本次没有踢人则为空字符串。

5.5 在 Spring Boot 中调用 Lua 脚本

在 Spring Boot 中,可以通过 RedisTemplate 执行 Lua 脚本。使用 DefaultRedisScript 封装脚本内容。

// 文件路径:src/main/java/com/example/multilogin/service/LoginService.java import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.util.Arrays; import java.util.List; @Service public class LoginService { private final StringRedisTemplate redisTemplate; private DefaultRedisScript<List> loginScript; public LoginService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @PostConstruct public void init() { loginScript = new DefaultRedisScript<>(); loginScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/login_session.lua"))); loginScript.setResultType(List.class); } public String login(Long userId, String deviceId) { String token = TokenGenerator.generateToken(); long currentTime = System.currentTimeMillis() / 1000; String hashKey = SessionConstants.SESSION_HASH_KEY_PREFIX + userId; String sortKey = SessionConstants.SESSION_SORT_KEY_PREFIX + userId; List<String> keys = Arrays.asList(hashKey, sortKey); Object result = redisTemplate.execute(loginScript, keys, deviceId, token, String.valueOf(currentTime), String.valueOf(SessionConstants.MAX_DEVICE_COUNT), String.valueOf(SessionConstants.SESSION_TIMEOUT_SECONDS)); List<?> resultList = null; if (result instanceof List) { resultList = (List<?>) result; } if (resultList == null || resultList.isEmpty()) { throw new RuntimeException("登录失败"); } int code = Integer.parseInt(resultList.get(0).toString()); String newToken = resultList.get(1).toString(); String kickDeviceId = resultList.get(2).toString(); if (code == -1) { throw new RuntimeException("会话创建异常"); } if (code == 0) { // 同一设备重新登录,旧 token 是否要失效由业务决定 System.out.println("设备 " + deviceId + " 刷新登录"); } else if (code == 1) { System.out.println("设备 " + deviceId + " 新增登录"); } else if (code == 2) { System.out.println("设备 " + deviceId + " 新增登录,踢掉设备 " + kickDeviceId); // 这里可以发送推送通知给被踢设备 notifyKick(userId, kickDeviceId); } return newToken; } private void notifyKick(Long userId, String deviceId) { // 伪代码:通过 WebSocket 或消息推送通知该设备下线 } }

RedisTemplate 的 execute 方法返回类型取决于脚本返回类型,实际调试中需要留意泛型转换问题。如果返回值类型不对,可以先用execute(RedisScript<T>, List<K>, Object... args)这种方式,把 resultType 设置为 List。

5.6 鉴权与续期

用户携带 token 请求接口时,需要通过 token 查询用户信息。这里需要单独维护一个 token -> userId 的映射。

最简单的方式是在登录时额外设置一个 key:

login:token:{token} value: {userId}:{deviceId} expire: 7天

但这样操作散落在 Lua 脚本之外,需要另写代码。更优雅的方式是把 token 映射也写进 Lua 脚本里,不过那样脚本需要接收更多参数,复杂度上升。实际项目中建议分两步:Lua 脚本负责核心会话管理,Java 代码负责写 token 映射。

示例代码如下:

// 文件路径:src/main/java/com/example/multilogin/service/AuthService.java import org.springframework.data.redis.core.StringRedisTemplate; public class AuthService { private final StringRedisTemplate redisTemplate; public AuthService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } /** * 保存 token 与用户的映射 */ public void saveToken(String token, Long userId, String deviceId) { String tokenKey = SessionConstants.TOKEN_KEY_PREFIX + token; String value = userId + ":" + deviceId; redisTemplate.opsForValue().set(tokenKey, value, SessionConstants.SESSION_TIMEOUT_SECONDS); } /** * 鉴权:根据 token 获取用户信息 */ public TokenInfo getTokenInfo(String token) { String tokenKey = SessionConstants.TOKEN_KEY_PREFIX + token; String value = redisTemplate.opsForValue().get(tokenKey); if (value == null) { return null; } String[] parts = value.split(":"); TokenInfo info = new TokenInfo(); info.setUserId(Long.parseLong(parts[0])); info.setDeviceId(parts[1]); return info; } /** * 用户活跃时续期 */ public void refreshSession(Long userId, String deviceId, String token) { long currentTime = System.currentTimeMillis() / 1000; String hashKey = SessionConstants.SESSION_HASH_KEY_PREFIX + userId; String sortKey = SessionConstants.SESSION_SORT_KEY_PREFIX + userId; String tokenKey = SessionConstants.TOKEN_KEY_PREFIX + token; redisTemplate.expire(hashKey, SessionConstants.SESSION_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS); redisTemplate.expire(sortKey, SessionConstants.SESSION_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS); redisTemplate.expire(tokenKey, SessionConstants.SESSION_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS); // 如果按最近活跃时间排序,需要更新 ZSet 的 score if (isActiveScoreStrategy()) { redisTemplate.opsForZSet().add(sortKey, deviceId, currentTime); } } }

上面代码中的 isActiveScoreStrategy 是示意方法,表示如果你想使用“按最后活跃时间踢出”的策略,就需要在用户活跃时更新 ZSet 的 score;如果使用“按最早登录时间踢出”的策略,则不要更新 score,否则排序基准会乱。

5.7 主动退出与强制下线

除了自动踢人,用户也可以主动退出。主动退出时只删除当前设备的会话即可,其他设备不受影响。

// 文件路径:src/main/java/com/example/multilogin/service/LogoutService.java import org.springframework.data.redis.core.StringRedisTemplate; public class LogoutService { private final StringRedisTemplate redisTemplate; public LogoutService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public void logout(Long userId, String deviceId, String token) { String hashKey = SessionConstants.SESSION_HASH_KEY_PREFIX + userId; String sortKey = SessionConstants.SESSION_SORT_KEY_PREFIX + userId; String tokenKey = SessionConstants.TOKEN_KEY_PREFIX + token; // 删除会话记录 redisTemplate.opsForHash().delete(hashKey, deviceId); redisTemplate.opsForZSet().remove(sortKey, deviceId); // 删除 token 映射 redisTemplate.delete(tokenKey); } }

强制下线某个用户的指定设备,逻辑与主动退出一致,只是调用时机不同。管理员在后台触发时,也调用类似的删除会话方法。

5.8 查询在线设备列表

“设备管理”页面需要展示当前用户所有登录的设备。查询逻辑就比较简单了,遍历 Hash 对象即可。

// 文件路径:src/main/java/com/example/multilogin/service/DeviceQueryService.java import org.springframework.data.redis.core.StringRedisTemplate; import java.util.ArrayList; import java.util.List; import java.util.Map; public class DeviceQueryService { private final StringRedisTemplate redisTemplate; public DeviceQueryService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public List<DeviceInfo> listDevices(Long userId) { String hashKey = SessionConstants.SESSION_HASH_KEY_PREFIX + userId; String sortKey = SessionConstants.SESSION_SORT_KEY_PREFIX + userId; Map<Object, Object> entries = redisTemplate.opsForHash().entries(hashKey); List<DeviceInfo> devices = new ArrayList<>(); for (Map.Entry<Object, Object> entry : entries.entrySet()) { String deviceId = entry.getKey().toString(); String token = entry.getValue().toString(); Double score = redisTemplate.opsForZSet().score(sortKey, deviceId); DeviceInfo info = new DeviceInfo(); info.setDeviceId(deviceId); info.setToken(token); info.setLoginTime(score == null ? null : score.longValue()); devices.add(info); } devices.sort((a, b) -> Long.compare(a.getLoginTime(), b.getLoginTime())); return devices; } }

6. 并发登录的竞态条件与 Lua 脚本分析

6.1 什么是竞态条件

竞态条件是指在多线程或多进程环境下,程序的执行结果依赖各个线程执行的顺序。放到“5 台设备只踢 1 台”场景中,如果两个用户同时登录不同设备,服务端可能出现两个线程同时查询到当前会话数为 5,然后都执行“删一个再加一个”,最终会话数变成 6。

这种问题在单机部署时可能概率较低,但在高并发或微服务多实例部署时非常容易出现。

6.2 为什么用分布式锁不够好

有人可能会想:那我在登录方法上加一把分布式锁,比如 Redis 的 SETNX 锁,不就行了?

分布式锁确实可以解决问题,但是:

  • 锁的粒度不好控制。如果按 userId 加锁,同一个用户同时登录时只能串行执行,性能会下降。
  • 锁需要设置超时时间,超时处理不好容易出现死锁或误删锁。
  • 锁只是保护了 Java 代码逻辑,但代码中每次查询 Redis 和写 Redis 之间仍然有网络 RTT,如果锁过期了,竞态问题依然可能发生。

相比之下,Lua 脚本将判断、删除、新增整个逻辑发送给 Redis 一次性执行,Redis 单线程保证脚本执行期间不会有其他命令插入,从根上解决了竞态问题。

6.3 Lua 脚本在 Redis 中的执行原理

Redis 从 2.6 版本开始支持执行 Lua 脚本。Redis 服务器在执行 Lua 脚本时,会阻塞其他客户端的命令,直到脚本执行完毕。所以 Lua 脚本中的所有操作都是原子的,不存在中间状态被其他客户端看到的情况。

使用 Lua 脚本时需要注意:

  • 脚本尽量不要执行耗时操作,避免阻塞 Redis 太长时间。
  • 脚本中不要调用会导致不确定性的命令,例如 TIME 命令、随机数命令等,否则 Redis 复制到从节点时可能出现数据不一致。
  • 脚本中的 KEYS 和 ARGV 要严格区别,不能用 table 拼接来做动态 key。

本文示例中的 Lua 脚本都是 O(1) 或 O(log N) 操作,ZRange 范围只取一个成员,性能开销很小。

6.4 并发场景验证

假设用户当前有 4 台设备在线,设备 E 和设备 F 同时发起登录。

两个请求同时进入 Lua 脚本执行。Redis 是单线程的,所以实际上是:

  • 请求 1 执行脚本:ZCard 得到 4,小于 5,新增设备 E,会话数变为 5。
  • 请求 2 执行脚本:ZCard 得到 5,达到上限,从 ZSet 中选出 score 最小的设备(可能是最早登录的设备),删除它,再新增设备 F,会话数保持 5。

最终效果:始终不超过 5 台设备在线,并且同时只踢掉了 1 台。这正是需求要求的结果。

如果没有 Lua 脚本,请求 1 和请求 2 可能都读到 4,然后都新增,最终变成 6 台设备,这就是我们要避免的问题。

7. 被踢设备的感知与通知机制

7.1 服务端删除会话后的表现

当服务端删除了某个设备的会话,该设备后续请求业务接口时,会在鉴权阶段失败。

典型的失败响应:

{ "code": 401, "message": "登录状态已失效,请重新登录" }

但这种机制依赖设备发起请求。如果设备一直开着页面但不操作,就不会收到任何反馈。

7.2 长连接主动通知

如果产品希望被踢设备立刻感知“您已在其他设备登录”,可以通过 WebSocket、SSE 或长轮询来实现。

WebSocket 方案示意:

  • 设备登录成功后,建立 WebSocket 连接,并将连接绑定到 userId + deviceId。
  • 服务端在踢人时,从连接管理器中找到该 userId 下对应的 deviceId 连接。
  • 发送一条下线通知消息。
  • 客户端收到消息后,清除本地登录态,并提示用户。

这种方案的开发成本较高,需要维护连接状态。但如果产品是即时通讯类、协作类,通常本身就有长连接通道,复用即可。

7.3 轮询方案

如果不想引入 WebSocket,可以在客户端做定时轮询。例如前端每隔 30 秒调用一次“查询登录状态”接口,如果返回 401,则主动退出登录。

这种方案简单,但实时性差,且增加了服务端压力。

7.4 消息队列方案

在微服务架构中,登录服务和推送服务可能是独立的。踢人时,可以通过消息队列发送一条“强制下线”消息,由推送服务负责转发。

这样做的好处是解耦,但需要考虑消息丢失、重复消费等问题。

建议:中小型项目先用轮询或接口请求时返回 401;如果项目已经有 WebSocket 基础设施,再升级为主动推送。

8. 数据库方案对比:什么时候不用 Redis

虽然本文主要介绍 Redis 方案,但也有团队用数据库表来实现。下面做一个对比。

维度Redis 方案数据库表方案
读取速度高,适合接口鉴权相对低,需要查询表
过期清理Redis 自动过期需要定时任务清理
并发控制Lua 脚本原子性需要数据库锁或事务
跨服务共享分布式环境天然支持需要共享数据库
可维护性简单需建表、写 SQL
事务回滚

如果你的项目并发量很低,或者团队对 Redis 不熟悉,也可以使用数据库表:

CREATE TABLE user_session ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, device_id VARCHAR(64) NOT NULL, token VARCHAR(64) NOT NULL, login_time BIGINT NOT NULL, active_time BIGINT NOT NULL, expire_time BIGINT NOT NULL, UNIQUE KEY uk_user_device (user_id, device_id), KEY idx_user_id (user_id) );

插入新会话时,使用事务:

  1. 查询该用户在线会话数量。
  2. 如果小于 5,直接插入。
  3. 如果大于等于 5,删除登录时间最早的记录,再插入。

但数据库方案在并发时需要使用 SELECT ... FOR UPDATE 锁行,或者分布式锁保护。高并发场景下性能不如 Redis Lua。

如果使用 MySQL 事务,伪代码如下:

BEGIN; SELECT id FROM user_session WHERE user_id = ? ORDER BY login_time ASC LIMIT 1 FOR UPDATE; SELECT COUNT(*) FROM user_session WHERE user_id = ?; -- 判断数量,如果达到 5,删除最早一条 DELETE FROM user_session WHERE id = ?; -- 插入新会话 INSERT INTO user_session(user_id, device_id, token, login_time, active_time, expire_time) VALUES (?, ?, ?, ?, ?, ?); COMMIT;

注意,FOR UPDATE 应该在事务中先锁住该用户的最旧会话行,或者锁一个全局 user 锁,否则并发时可能仍然超限。

9. 常见问题与排查思路

下面整理一些实际开发中容易遇到的问题。

问题现象常见原因解决思路
第 6 台设备登录后,第 7 台也登录成功了并发登录时未使用 Lua 脚本,出现了竞态条件改用 Lua 脚本保证原子操作
同设备重复登录导致设备数增加没有先判断设备是否已存在在 Lua 脚本中先执行 HEXISTS,存在则刷新不新增
按登录时间踢人,但偶尔踢错设备ZSet 的 score 被活跃续期更新了明确策略,登录时间排序时不要更新排序 score
被踢设备还能继续访问接口只删了 Hash 中的记录,没有删除 token 映射检查 login:token 映射是否同步删除
Redis 中的会话数量正确,但设备列表为空使用 Redis 桌面工具直接修改了数据不要手工改 Redis,统一通过接口操作
Lua 脚本执行报错参数个数不匹配或返回值类型不对使用EVAL命令在 redis-cli 中调试,确认 KEYS 和 ARGV 数量
用户几个小时没操作,再操作发现登录失效会话过期时间太短,且没有续期在鉴权时补充续期逻辑,更新过期时间
手动退出后,设备仍在在线列表里退出时未删除 ZSet 中的 member同时删除 Hash 和 ZSet 中对应设备数据
消息推送通知不到被踢设备WebSocket 连接没有绑定 deviceId检查连接管理器,确认按 userId+deviceId 建立映射
两台设备用同一个 deviceId 登录客户端 deviceId 生成策略有问题检查客户端首次启动是否持久化保存了唯一 ID
Redis 中的数据混乱,修复困难没有统一管理 session,多处直接操作收敛到 LoginService,统一入口处理

9.1 排查清单

如果线上出现“多设备登录异常”,建议按以下顺序排查。

  1. 查看 Redis 中该用户的所有 key:
redis-cli keys "login:*"
  1. 查看 Hash 和 ZSet 中的内容。
redis-cli hgetall login:session:10001 redis-cli zrange login:session:sort:10001 0 -1 withscores
  1. 检查 token 映射是否存在。
redis-cli get login:token:{token}
  1. 检查日志中登录返回的 code,是刷新登录、新增登录还是踢人登录。

  2. 检查客户端是否真正保存并携带了 deviceId。

  3. 如果是并发问题,复现时可以用两个线程同时请求登录接口,观察最终会话数量是否为 5。

10. 工程建议与扩展设计

10.1 命名与配置规范

Redis Key 要有统一前缀,方便排查和清理。建议格式:

业务:模块:维度:ID

例如:

login:session:userId login:session:sort:userId login:token:token

最大设备数量不要硬编码,放到配置中心,因为产品可能随时调整。

@Value("${login.max.device:5}") private int maxDeviceCount;

10.2 异常处理与降级

如果 Redis 访问异常,登录功能不能直接瘫痪。实际项目中可以这样设计:

  • 先尝试 Redis 会话管理。
  • Redis 熔断或不可用时,降级为“允许登录但不限制设备数”,同时记录异常日志。
  • 等 Redis 恢复后,再恢复正常逻辑。

降级听起来简单,但要小心,如果 Redis 挂了,token 鉴权也可能失效,需要确认业务是否可以接受。

10.3 安全性

会话管理涉及登录态,安全方面要注意:

  • token 生成必须使用安全随机数。
  • 不要在日志中打印完整 token。
  • 密码验证不要用明文比对。
  • 会话删除时,应同时删除所有相关 key,避免垃圾数据。
  • 管理后台的强制下线操作需要鉴权,防止任意用户调用。
  • 引入风控时,可以记录每台设备的 IP、地域、User-Agent,帮助用户识别异常登录设备。

10.4 会话清理

虽然 Redis 设置了过期时间,但在高并发场景下,还是会有一些过期数据没有及时被清理。如果 Redis 内存有限,可以启动一个定时任务,定期扫描会话数量异常的 key,或者使用 Redis 的主动过期机制来兜底。

10.5 扩展方向

本文的方案已经覆盖“5 台设备只踢 1 台”的核心需求,实际项目中还可以继续扩展:

  • 设备管理页面:提供在线设备列表、手动踢人入口。
  • 登录日志:记录每次登录的设备、IP、时间。
  • 异常提醒:新设备登录时,向用户绑定手机号发送通知。
  • 按业务重要性分级:某些设备类型不受数量限制。
  • 会话版本号:在一次踢人时,增加一个全局会话版本,旧版本强制失效。

11. 总结

“账号允许登录 5 台设备,只踢掉最早登录的 1 台”看似简单,实际涉及会话存储结构、踢人策略、并发原子性、设备感知、安全与扩展等许多环节。本文以 Redis 为主,给出了完整的 Hash + ZSet + Lua 脚本方案,并分析了为什么需要 Lua 脚本保证并发安全。

整个方案的核心可以归纳为:

  • 用 Redis Hash 保存设备到 token 的映射,用 ZSet 保存设备排序信息。
  • 登录时通过 Lua 脚本一次完成“判断数量-删除旧会话-新增新会话”的原子操作。
  • 同一设备重复登录时,刷新会话而不是新增,避免设备数虚高。
  • 被踢设备通过删除 token 映射,确保旧 token 失效。
  • 有实时性要求时,通过 WebSocket 或推送通知设备下线。

代码示例中的文件名和类名可以按项目结构调整,Redis Key 和常量也可以根据业务命名习惯修改,但核心设计思路是通用的。

如果本文对你有帮助,可以收藏备用,后续开发登录会话管理时直接用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/6 5:37:30

基于MCU UID的防抄板与MQTT一机一密认证方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 5:37:12

2026年硬件低蓝光护眼显示器选购与超详细护眼方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 5:36:31

PLECS栅极驱动建模:从理想源到参数化等效电路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 5:33:24

广域网加速优化实战:TCP调优与数据去重双管齐下

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 5:32:54

零基础也能装!OpenClaw 可视化部署全流程,跟着做就完事

&#x1f539; 工具简述 OpenClaw 是一款备受开发者与办公人群青睐的开源本地智能工具&#xff0c;凭借离线本地运行、可视化图形面板、全流程自主任务处理三大核心特点&#xff0c;积累了众多忠实用户。与普通对话类 AI 产品不同&#xff0c;它能够直接调用电脑的软硬件操作权…

作者头像 李华
网站建设 2026/9/6 5:32:05

基于SHAP可解释AI的放射组学-临床融合生存预测模型构建

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华