1. 核心需求与设计目标速览
这次我们看一个非常典型的账号体系设计问题:一个账号允许登录 5 台设备,当第 6 台设备发起登录时,系统自动把旧的 5 台中的某一台踢下线。这里的关键不是“登录数量限制”,而是“怎么踢、踢哪台、被踢的设备怎么感知、如何避免并发登录导致超卖”。
先说结论:这个需求本质上是一个“设备会话额度管理 + 主动失效通知”的组合问题,不涉及复杂的 AI 推理或大模型部署,核心在业务层设计。无论你的后端是 Spring Boot、Go、Node.js 还是 Python,实现思路都通用。
| 设计项 | 目标方案 |
|---|---|
| 最大设备数 | 常量或配置项,默认 5,支持按用户/会员等级动态调整 |
| 登录判定时机 | 登录接口内校验设备数量,超出则触发踢出策略 |
| 踢出策略 | 按最近活跃时间(LRU)淘汰 / 按最早登录时间(FIFO)淘汰 / 指定设备淘汰 |
| 被踢感知 | Token 级失效 + WebSocket 推送通知 + 接口鉴权二次校验 |
| 并发安全 | 数据库行锁 / Redis 分布式锁 / 乐观锁版本号 |
| 设备标识 | 设备 ID(客户端生成 UUID 或硬件指纹),避免重复注册 |
| 数据存储 | 设备会话表 + Token 缓存,推荐 MySQL 存储元数据 + Redis 管理在线状态 |
适合读者:正在做用户登录体系、会员多设备管理、SaaS 租户设备限额、IM 客户端多端登录的后端开发。这篇文章会带你把数据表设计、踢出策略、并发处理、接口示例、常见坑全部过一遍。
2. 需求拆解与判定流程
先把需求拆开。表面上是“5 台设备,第 6 台登录时踢 1 台”,实际上包含四个子问题:
- 设备识别:怎么判断这次登录的设备是不是已经算在 5 台里了?
- 数量判定:新设备登录后总量是否超过 5?如果超过,踢谁?
- 踢出动作:被踢设备的 Token 立即失效,且要主动通知该设备。
- 并发竞态:如果两台新设备同时登录,恰好都在临界点,会不会双发重复踢人或者登录失败?
先画一下核心判定流程(文字版):
用户发起登录 ↓ 校验账号密码 / 验证码 ↓ 根据设备ID查询该账号已登录设备列表 ↓ 判断该设备是否已存在于列表 ├─ 已存在:直接复用原会话或刷新 token └─ 不存在:统计当前在线设备数量 ├─ 数量 < 5:直接新增设备会话 └─ 数量 >= 5:触发淘汰策略,选出一台设备踢出 ↓ 让被踢设备 token 失效 ↓ 新增当前设备会话 ↓ 返回登录成功 + 被踢设备信息(可选)这里有一个容易被忽略的点:同账号同一台设备重复登录,不应该占用新的设备名额。也就是说,“5 台设备”指的是 5 个不同的设备 ID,而不是 5 个会话。如果用户在一台手机上有 3 个会话都算 3 台,那就明显不符合产品预期。所以在设计时,设备 ID 应该是客户端生成的、稳定不变的标识(UUID 或硬件指纹),而不是每次登录随机生成。
接下来进入数据表设计。
3. 数据表与会话存储设计
3.1 设备信息表
建议独立一张设备表,存储设备的元数据。这样即使用户被踢下线,设备信息也可以保留,方便后续做设备管理和“最近登录设备”展示。
CREATE TABLE `user_device` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', `device_id` VARCHAR(128) NOT NULL COMMENT '客户端生成的设备唯一标识', `device_name` VARCHAR(255) DEFAULT '' COMMENT '设备名称,如 iPhone 15 Pro', `platform` VARCHAR(32) DEFAULT '' COMMENT '平台:ios/android/web/pc', `last_active_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近活跃时间', `login_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次登录时间', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1在线 0被踢下线', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_device` (`user_id`, `device_id`), KEY `idx_user_status_active` (`user_id`, `status`, `last_active_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户设备登录表';这里要特别注意唯一索引uk_user_device(user_id, device_id)。它的作用是防止同一台设备在并发登录时重复插入两条记录。数据库的唯一约束,是最后一道兜底。
3.2 会话表
会话表用来记录每次登录产生的 Token 与设备的关系。这里推荐一个比较通用的结构:
CREATE TABLE `user_session` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` BIGINT UNSIGNED NOT NULL, `device_id` VARCHAR(128) NOT NULL, `token` VARCHAR(64) NOT NULL COMMENT '登录令牌,MD5或UUID', `expire_time` DATETIME NOT NULL COMMENT '过期时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_token` (`token`), KEY `idx_user_device` (`user_id`, `device_id`), KEY `idx_expire` (`expire_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;实际项目中,Token 的实时校验通常会放到 Redis,以拿到更快的读写速度。数据表可以理解成持久化存储,Redis 是加速层。
Redis 可以设计成这样的 Key 结构:
login:token:{token} -> { "user_id": 1001, "device_id": "abc-123", "expire_time": 1720000000 } login:devices:{user_id} -> Set 集合,存所有已登录设备的 device_id login:device_active:{user_id}:{device_id} -> 最近活跃时间戳使用 Redis 的 Set 来维护用户当前设备列表,做数量统计和淘汰选择都非常方便。后面会详细说配合 zset 做时间排序淘汰。
4. 登录校验与踢出策略实现
4.1 登录时判定核心逻辑
用伪代码描述业务层逻辑。这里以 Java 为主,其他语言思路一致:
public LoginResult login(String username, String password, DeviceInfo device) { // 1. 校验账号密码 User user = authenticate(username, password); // 2. 判断当前设备是否已登录 boolean exists = deviceService.isDeviceLoggedIn(user.getId(), device.getDeviceId()); if (exists) { // 同设备刷新 token,不占新名额 String newToken = refreshToken(user.getId(), device.getDeviceId()); return LoginResult.success(newToken); } // 3. 获取当前在线设备数 long currentCount = deviceService.getOnlineDeviceCount(user.getId()); int maxDevices = getMaxDevicesByUserLevel(user); if (currentCount < maxDevices) { // 直接新增 String token = createSession(user.getId(), device); return LoginResult.success(token); } // 4. 超出限制,选择一台踢出 String kickedDeviceId = kickStrategy(user.getId(), maxDevices); logoutDevice(user.getId(), kickedDeviceId, KickReason.OVER_LIMIT); // 5. 新增当前设备 String token = createSession(user.getId(), device); return LoginResult.success(token); }4.2 踢出策略:LRU 优先淘汰最久未活跃设备
大多数场景下,优先剔除“最久未活跃”的设备是用户体验最好的方案。你肯定不希望一个正在看视频的设备被突然挤下去。所以一般选择最近活跃时间最小的设备。
可以用 Redis ZSET 来维护,Score 就是最近活跃时间戳:
ZADD login:active:{user_id} 1720000000 "device_id_1" ZADD login:active:{user_id} 1720000100 "device_id_2"淘汰时直接取 Score 最小的成员:
ZRANGE login:active:{user_id} 0 0对应 Java 伪代码:
String kickedDeviceId = redis.zRevRange("login:active:" + userId, -1, -1).get(0); // 或者 String kickedDeviceId = redis.zRange("login:active:" + userId, 0, 0).get(0);ZRANGE key 0 0返回的是活跃时间最小的设备。如果希望“保留最早登录的设备、踢掉最近登录的设备”,那么改成ZREVRANGE key 0 0即可。具体策略看产品需求。
4.3 指定设备踢出
还有一种常见需求:用户在自己的“设备管理”页面,手动指定要退出某一台设备。这种就是产品上的“踢人”操作,实现上只需要对指定设备做会话失效处理:
public void forceLogoutDevice(Long userId, String deviceId) { // 1. 删除 Redis 中的登录态 Set<String> tokens = redis.sMembers("login:tokens:" + userId + ":" + deviceId); for (String token : tokens) { redis.del("login:token:" + token); } // 2. 删除设备在线状态 redis.zRem("login:active:" + userId, deviceId); // 3. 更新数据库状态 deviceMapper.updateStatusByDeviceId(userId, deviceId, 0); // 4. 推送下线通知 pushLogoutMessage(userId, deviceId, "您已在其他设备上手动退出"); }这里的关键是Token 不仅要存进 Redis,还要同时支持从设备维度反查所有 Token,否则手动踢出时没法快速定位删哪些 Token。
5. 被踢设备如何及时感知
这里有个容易被新手忽略的问题:服务端把 Token 删除了,但客户端如果一直不发请求,它自己并不知道自己被踢了。用户只会等下一次刷新接口时才发现“登录状态失效”,体验比较差。
更合理的方案是三层配合:
5.1 Token 失效兜底
被踢设备的 Token 在服务端已删除,后续任何带该 Token 的请求都会在鉴权中间件被拦截。
@Interceptor public boolean preHandle(HttpServletRequest request) { String token = request.getHeader("Authorization"); LoginUser loginUser = redis.get("login:token:" + token); if (loginUser == null) { response.setCode(401); response.setMessage("登录已失效,请重新登录"); return false; } // 刷新活跃时间 redis.zAdd("login:active:" + loginUser.getUserId(), now(), loginUser.getDeviceId()); return true; }这是最基础的方案,也是最后的兜底。
5.2 WebSocket / SSE 主动推送
如果客户端需要“秒被踢下线”,需要通过长连接通道推送一个消息。WebSocket 或者 SSE 都行。推送内容可以是:
{ "type": "KICKED", "reason": "DEVICE_LIMIT_EXCEEDED", "message": "您的账号已在其他设备登录", "kickedTime": 1720000000 }客户端收到这个指令后,清理本地登录态,返回登录页。如果有正在进行的操作,还可以提示用户“内容已保存”。
5.3 客户端心跳检测
对于不做长连接的场景,可以缩短 Token 的有效期,并让客户端定期调用一个轻量心跳接口。心跳接口返回401 KICKED时,客户端主动退出登录。这种方案简单,但会有延迟,极端情况下可能延迟一个心跳周期。
综合建议:
- 重要操作(支付、发布内容、修改密码)必须在后端重新校验 token 有效性。
- 普通浏览场景可以容忍心跳延迟。
- 即时通讯类应用必须上 WebSocket。
6. 并发登录与超卖问题
这是整篇文章最值得细看的部分。考虑一个边界场景:
用户当前在线 4 台设备。设备 A 和设备 B 同时发起登录请求,而且这两台都是新设备。
如果两个请求同时读取数据库,发现当前设备数都是 4,都小于 5,于是都执行新增设备。最后在线设备数变成 6,超过了限制。
这就是典型的并发超卖问题。解决方案有以下几种:
6.1 数据库行锁(最简单)
在用户表上加一行锁,把“判断数量 + 插入设备”放在一个事务里:
@Transactional public void addDeviceWithLock(Long userId, Device device) { // 对用户记录加行锁 User user = userMapper.selectByIdForUpdate(userId); int count = deviceMapper.countOnlineDevices(userId); if (count >= getMaxDevices(user.getId())) { // 触发淘汰 kickOneDevice(userId); } deviceMapper.insert(device); }selectByIdForUpdate会锁住用户表的这一行,其他并发请求必须等当前事务提交后才能继续。方案简单有效,缺点是吞吐量一般。对于登录这种低频操作,完全够用。
6.2 Redis 分布式锁
如果不想依赖数据库行锁,可以在 Redis 里加分布式锁:
String lockKey = "login:lock:" + userId; boolean locked = redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (!locked) { throw new BusyException("操作太频繁,请稍后重试"); } try { // 执行设备数量校验 + 踢出 + 新增 } finally { redis.del(lockKey); }注意锁的超时时间不能太短,否则在业务逻辑还没执行完时锁就自动释放了。也不能太长,否则用户会感觉“卡住”。
6.3 唯一索引兜底
尽管有锁,仍然建议在设备表上保留uk_user_device(user_id, device_id)唯一索引。万一并发插入重复数据,数据库会直接抛异常,至少保证同一设备不会出现脏数据。
6.4 预留额度(预扣)方案
更进一步的做法是“先扣减剩余额度,再写会话”。类似库存扣减的思路:UPDATE user SET available_device_slots = available_device_slots - 1 WHERE user_id = ? AND available_device_slots > 0。影响行数为 0 表示额度不足,需要触发淘汰。这种方案适合设备数有变化的业务,但实现复杂度更高。一般业务场景用行锁或 Redis 锁就够了。
7. 接口 API 设计与调用示例
这里给出一套可供参考的接口设计,实际项目里可以按自己的命名规范调整。
7.1 登录接口
POST /api/v1/auth/login Content-Type: application/json { "username": "test_user", "password": "******", "device": { "deviceId": "a1b2c3d4e5f6", "deviceName": "Xiaomi 14", "platform": "android" } }返回结果:
{ "code": 0, "message": "success", "data": { "token": "eyJhbGciOiJIUzI1NiJ9...", "expireTime": 1720000000, "kickedDevice": { "deviceId": "old_device_id", "deviceName": "iPhone 13", "reason": "DEVICE_LIMIT_EXCEEDED" } } }返回被踢设备信息是可选的。有些产品会在登录成功提示“已挤掉一台旧设备”,通过这个字段实现。
7.2 查询当前在线设备列表
GET /api/v1/devices Authorization: Bearer {token}返回:
{ "code": 0, "data": { "total": 5, "maxDevices": 5, "devices": [ { "deviceId": "a1b2c3d4e5f6", "deviceName": "Xiaomi 14", "platform": "android", "lastActiveTime": 1720000000, "current": true } ] } }7.3 主动踢出指定设备
POST /api/v1/devices/kick Content-Type: application/json Authorization: Bearer {token} { "deviceId": "target_device_id" }7.4 Python 调用示例
import requests BASE_URL = "https://api.example.com" # 1. 登录 login_payload = { "username": "test_user", "password": "******", "device": { "deviceId": "python-test-device-001", "deviceName": "MacBook Pro", "platform": "web" } } resp = requests.post(f"{BASE_URL}/api/v1/auth/login", json=login_payload) print(resp.json()) token = resp.json()["data"]["token"] # 2. 查询设备列表 headers = {"Authorization": f"Bearer {token}"} devices = requests.get(f"{BASE_URL}/api/v1/devices", headers=headers) print(devices.json()) # 3. 踢出指定设备 kick_payload = {"deviceId": "a1b2c3d4e5f6"} kick_resp = requests.post(f"{BASE_URL}/api/v1/devices/kick", json=kick_payload, headers=headers) print(kick_resp.json())8. 设备标识策略与风险边界
8.1 设备 ID 怎么生成
设备 ID 是整个设计的基石。如果设备 ID 每次登录都变化,那“5 台设备”的限制就形同虚设,用户可以不停换 ID 绕过限制。
推荐的客户端生成方式:
- iOS:
identifierForVendor,卸载重装可能变化。 - Android:
Settings.Secure.ANDROID_ID,部分国产 ROM 可能有风险。 - Web:浏览器指纹 + 本地 LocalStorage 持久化 UUID,清除浏览器数据会丢失。
- 更严格的方案:登录前下发一个设备凭证接口,基于硬件信息组合生成并绑定账号。
这里必须提醒一句:设备唯一标识涉及用户隐私,不能把 IMEI、MAC 地址这类硬件标识直接上传作为设备 ID。业界做法是客户端基于硬件特征生成一个不可逆的哈希值,或使用系统官方提供的匿名标识符。
8.2 防绕过:设备 ID 不能作为唯一安全边界
设备 ID 只是用于计数和展示,不能作为安全凭证。如果客户端能自己伪造设备 ID,那“5 台设备限制”就可以靠修改客户端参数绕过。所以服务端还需要辅助手段:
- 校验登录 IP 变化频率。
- 绑定 Token 与设备 ID。
- 对频繁更换设备 ID 的账号做风控标记。
- 在异常场景下要求二次验证。
8.3 合规与用户权益
这个设计涉及账号安全和用户隐私,有几条必须守住:
- 在登录页或用户协议里明确告知“本账号最多同时登录 5 台设备,超出后最久未使用的设备将被下线”。
- 用户主动踢出设备时,只影响自己的其他设备,不能影响他人账号。
- 不能将设备 ID、位置、IP 等敏感信息直接明文暴露在日志或前端。
- 如果后续把该能力做成 SDK 或开放平台服务,需要提供完整的管理后台,允许用户查看、删除、停用已授权设备。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 登录到第 6 台时没有触发踢出 | 当前设备数统计错了,可能统计了重复设备或已过期会话 | 查数据库当前在线设备数,看是否存在 status 未更新 | 定时任务清理过期会话,统计时过滤 status |
| 一台设备被反复踢下线 | 设备 ID 每次重新生成,服务端判定为新设备 | 客户端抓包检查设备 ID 是否稳定 | 改为本地持久化存储设备 ID,不在内存中临时生成 |
| 两台新设备同时登录,在线数变成 6 | 并发查询数量都是 4,未加锁 | 查看日志中两个请求是否交错执行 | 加数据库行锁或 Redis 分布式锁 |
| 被踢设备不能立即感知 | 客户端没有长连接,只能等下一次请求报 401 | 查看客户端日志,确认请求是否被 401 拦截 | 接入 WebSocket 推送,或在关键页面加心跳检测 |
| 手动踢出后,对方仍能访问几分钟 | 被动 token 校验没做,只删了 Redis | 检查鉴权中间件是否每次请求都校验 Redis | 每个受保护接口都执行 token 校验,别只校验 JWT 签名 |
| 设备列表数量正确但老设备踢不掉 | 淘汰策略选择的是最近活跃设备,而不是最早登录设备 | 查看日志确认选择排序规则 | 按产品需求调整 ZSET 排序方向 |
| 数据库出现重复设备记录 | 应用层未做唯一约束,并发插入导致 | 查询 user_device 表是否有同 user_id+device_id 双条 | 加唯一索引uk_user_device |
| 接口报 500,提示 row lock wait timeout | 用户表行锁等待超时,长事务导致 | 查看慢日志和锁等待时间 | 拆小事务,把业务逻辑从加锁事务里尽量精简 |
10. 批量测试与模拟验证
这个设计能不能扛住并发,需要一套简单的批量验证流程。可以写一个脚本模拟 10 个设备并发登录同一个账号,观察最终在线设备数是否始终等于 5。
import requests import random import string from concurrent.futures import ThreadPoolExecutor BASE_URL = "http://127.0.0.1:8080/api/v1/auth/login" def random_device_id(): return ''.join(random.choices(string.ascii_lowercase + string.digits, k=16)) def login_one(device_id): payload = { "username": "test_user", "password": "123456", "device": { "deviceId": device_id, "deviceName": "batch-test-device", "platform": "android" } } try: resp = requests.post(BASE_URL, json=payload, timeout=5) data = resp.json() return device_id, data.get("code"), data.get("message") except Exception as e: return device_id, -1, str(e) device_ids = [random_device_id() for _ in range(10)] with ThreadPoolExecutor(max_workers=10) as executor: results = list(executor.map(login_one, device_ids)) for r in results: print(r)验证标准:
- 10 个并发请求全部返回成功。
- 数据库里该用户的在线设备数最终为 5。
- 前 5 个登录的设备中,至少有一部分被踢出,剩下的是最近活跃的设备。
- 没有被踢的设备在下次请求时返回 401。
如果出现以下情况,需要重点排查:
- 有请求返回“登录失败”或“系统繁忙”。可能是锁冲突或并发处理逻辑不对。
- 数据库在线设备数超过 5。说明没有正确加锁或唯一约束没有生效。
- 所有请求都成功且数据库只有 1~2 台设备。可能是设备 ID 生成逻辑有误,导致所有请求都用了同一个 ID。
11. 会话过期、数据一致性与日志审计
除了踢出策略,还要考虑会话过期后的清理。如果只做踢出,不做过期清理,数据库的user_device表会越来越大。建议启一个定时任务:
每 5 分钟扫描一次 user_device 表 条件是 status = 1 且 last_active_time 距今超过 N 天 将该设备置为下线状态N 的建议取值范围是 30~90 天,根据业务活跃度调整。清理过期会话时,不能只删除数据库记录,还要同步删除 Redis 中对应的 Token。
日志审计也很重要。每次踢出设备时,记录操作来源:
| 字段 | 示例 |
|---|---|
| 操作时间 | 2025-07-23 10:15:32 |
| 操作类型 | AUTO_KICK / MANUAL_KICK / ADMIN_KICK |
| 用户 ID | 1001 |
| 触发设备 | device_a |
| 被踢设备 | device_b |
| 触发原因 | DEVICE_LIMIT_EXCEEDED |
| 结果 | success |
这样可以快速定位“用户说我的设备被不明原因踢下线了”这类投诉。
12. 优化方向与扩展思路
核心功能做完后,可以继续扩展的方向:
12.1 设备额度分级
不同会员等级可以给不同的设备数上限,例如普通用户 2 台,VIP 用户 5 台。实现上只需要在用户表增加max_devices字段,登录时从用户表读取,不再使用全局常量。
12.2 白名单设备
某些设备标记为“信任设备”,在淘汰时优先保护,不被自动踢出。比如用户自己的主力手机,不希望因为平板登录被踢下线。需要设计一个“优先保留”标志位。
12.3 风险设备自动下线
根据登录 IP、设备行为、登录频率等维度,对可疑设备执行主动下线。这个方向可以结合设备指纹、风控评分系统一起做,参考热搜中“设备老化测试全自动执行脚本”的思路,把“设备生命周期管理”做成一套自动化规则:新设备加入、长时间未活跃降级、风险行为触发下线。
12.4 多端类型分开管理
有些产品要求“手机端最多 5 台,PC 端最多 3 台”或“同一端只能登录一台”。这种情况下,数据表里需要增加platform字段,并且在统计数量时按user_id + platform分组统计,踢出策略也要限定在同端内选取。
12.5 会话续签与滑动过期
如果用户一直活跃,不应该 7 天后强制下线。可以考虑滑动过期:每次请求时重新计算过期时间。但要注意,如果用户一直挂在后台自动刷新,会导致会话永不过期,这里要结合业务实际做平衡。可以参考“设备老化测试全自动执行脚本”的管理思路,用自动化检测来识别长期无效会话。
12.6 与被踢设备的数据同步
如果被踢设备上有未同步到服务端的本地编辑数据,比如笔记、收藏、聊天记录,需要在被踢前尽量提示用户保存,或者在客户端收到被踢通知时先本地缓存,待用户重新登录后继续上传。这个点虽然不属于登录设计范畴,但在真实业务里直接影响用户口碑。
13. 最佳实践与落地总结
给这套方案做一个工程化总结:
- 先确定设备 ID 方案。设备 ID 是整个系统的基石,优先保证稳定、唯一、无法轻易伪造。
- 设备表加唯一索引。无论业务层做了多少校验,数据库唯一索引永远是最后的防线。
- 登录接口加锁。数据库行锁或 Redis 分布式锁二选一,优先推荐行锁,简单可靠。
- Redis ZSET 维护活跃排序。用 ZSET 存设备最近活跃时间,淘汰时只需要一条命令,性能高、代码少。
- 踢出要三层生效。删除 Redis Token 保证请求即刻失效,WebSocket 推送保证客户端即时感知,数据库状态更新保证长期一致性。
- 日志必须记录完整。自动踢出、手动踢出、后台强制下线都要有日志,方便排查用户投诉。
- 上线前跑并发验证。用 10 个并发线程模拟新设备登录,看最终在线数量是否严格等于上限。
- 隐私与合规。设备标识、登录日志、踢出行为都涉及用户敏感信息,遵循最小化采集原则,在隐私政策中明确说明数据用途。
这个设计最值得先动手验证的部分,是“并发登录场景下在线设备数是否始终不超过上限”。最容易踩的坑有两个:一是设备 ID 不稳定导致重复计数,二是并发情况下数据库行锁没有生效导致超卖。先跑通登录、踢出、批量并发这三条主链路,再考虑扩展多端类型和风险风控,整个设备登录体系就算立住了。