
咱们平时做后台管理系统、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 作为维度把某个用户的所有在线设备会话集中存储。一个常用方案是keylogin:session:{userId} typeHash fielddeviceId valuetoken同时为了记录每个设备的最早登录时间或最后活跃时间需要另一个结构keylogin:session:sort:{userId} typeZSet memberdeviceId score登录时间戳或最后活跃时间戳Hash 用来保存 deviceId 到 token 的映射ZSet 用来做“踢人”时的排序选择。当一个会话过期或被踢时两个结构需要同步删除。也可以把两份数据合成一份比如将 token、登录时间、最后活跃时间都塞到 Hash 的 value 里然后 ZSet 只存排序字段。不过这样会增加查询时的序列化开销代码也更绕。个人建议 Hash ZSet 双写简单清晰。3.3 数据结构示例假设用户 10001 当前有 3 台设备在线Hash 结构 login:session:10001fieldvaluedevice-atoken-a-xxxdevice-btoken-b-xxxdevice-ctoken-c-xxxZSet 结构 login:session:sort:10001memberscoredevice-a1710000000device-b1710003600device-c1710007200score 是登录时间戳。需要踢人时执行 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 Boottoken 可以保存在 Hash 的 value 中也可以单独建 keykeylogin:token:{token} valueuserId expire7天这样每次请求只需要根据 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 中添加 deviceIdscore 为当前时间戳。当设备数超过限制时取出 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 或 11Spring Boot2.x 或 3.xRedis5.0 及以上版本支持 Lua 脚本IDEIDEA 或 Eclipse需要说明的是Spring Boot 3.x 使用 Jakarta 命名空间如果导入示例后报包名错误改成实际项目对应版本即可。本文示例基于以下依赖!-- 文件路径pom.xml -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependencyRedis 连接配置spring.redis.host127.0.0.1 spring.redis.port6379 spring.redis.password spring.redis.database0 spring.redis.lettuce.pool.max-active16 spring.redis.lettuce.pool.max-idle85.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 生成随机 TokenToken 使用 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 脚本完成以下逻辑检查该用户在 ZSet 中的会话数量。如果数量小于最大限制判断该设备是否已存在如果存在则直接刷新会话重新生成 token、更新时间戳。如果不存在则新增会话。如果数量达到最大限制判断该设备是否已存在如果存在则直接刷新会话。如果不存在则从 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 DefaultRedisScriptList 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; ListString 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(RedisScriptT, ListK, Object... args)这种方式把 resultType 设置为 List。5.6 鉴权与续期用户携带 token 请求接口时需要通过 token 查询用户信息。这里需要单独维护一个 token - userId 的映射。最简单的方式是在登录时额外设置一个 keylogin: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 ListDeviceInfo listDevices(Long userId) { String hashKey SessionConstants.SESSION_HASH_KEY_PREFIX userId; String sortKey SessionConstants.SESSION_SORT_KEY_PREFIX userId; MapObject, Object entries redisTemplate.opsForHash().entries(hashKey); ListDeviceInfo devices new ArrayList(); for (Map.EntryObject, 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) );插入新会话时使用事务查询该用户在线会话数量。如果小于 5直接插入。如果大于等于 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检查连接管理器确认按 userIddeviceId 建立映射两台设备用同一个 deviceId 登录客户端 deviceId 生成策略有问题检查客户端首次启动是否持久化保存了唯一 IDRedis 中的数据混乱修复困难没有统一管理 session多处直接操作收敛到 LoginService统一入口处理9.1 排查清单如果线上出现“多设备登录异常”建议按以下顺序排查。查看 Redis 中该用户的所有 keyredis-cli keys login:*查看 Hash 和 ZSet 中的内容。redis-cli hgetall login:session:10001 redis-cli zrange login:session:sort:10001 0 -1 withscores检查 token 映射是否存在。redis-cli get login:token:{token}检查日志中登录返回的 code是刷新登录、新增登录还是踢人登录。检查客户端是否真正保存并携带了 deviceId。如果是并发问题复现时可以用两个线程同时请求登录接口观察最终会话数量是否为 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 和常量也可以根据业务命名习惯修改但核心设计思路是通用的。如果本文对你有帮助可以收藏备用后续开发登录会话管理时直接用。