必须用lua脚本原子性维护在线用户集合,因set+expire非原子导致过期残留或丢数据,hset+扫描有延迟和竞态,而setex/expireat无法对集合成员单独设ttl,仅lua能保证sadd与expire一步完成。

直接用 SET + SCARD 不够准,必须用 Lua 脚本原子性维护在线用户集合,否则并发写入会导致重复计数或漏计。
为什么不能只靠 SET 或 HSET 记录用户 ID
单纯把用户 ID 存进 SET 看似简单,但实际场景中:用户可能多端登录(App、Web、小程序),每次心跳都触发一次 SADD,而 SADD 本身虽幂等,问题出在「过期逻辑」——如果用 EXPIRE 单独设置 key 过期,和 SADD 非原子,中间若服务重启或网络中断,就可能残留无效 ID;更糟的是,用 HSET 存用户+时间戳再靠后台扫描清理,会引入延迟和竞态。
关键矛盾在于:「记录上线」和「续期过期时间」必须一步完成。
-
SETEX只能设整个 key 过期,不适用于集合内单个成员 -
EXPIREAT操作的是 key 级别,无法绑定到某个 user_id - 客户端自己算时间 + 多条命令组合,在 Redis 集群或故障重试时必然丢原子性
用 Lua 脚本实现「添加并续期」的原子操作
核心思路:把「用户 ID 加入集合」和「为整个 key 设置过期时间」打包成一个 Lua 脚本执行,由 Redis 保证中间不被打断。注意不是给每个 member 设 TTL(Redis 不支持),而是让整个 SET key 的生命周期反映最新活跃窗口。
示例脚本(保存为 online_user_add.lua):
local key = KEYS[1]
local uid = ARGV[1]
local expire_sec = tonumber(ARGV[2])
redis.call('SADD', key, uid)
redis.call('EXPIRE', key, expire_sec)
return redis.call('SCARD', key)
调用方式(以 redis-cli 为例):
redis-cli --eval online_user_add.lua online:users , 12345 1800
-
KEYS[1]是集合 key 名,如online:users -
ARGV[1]是用户 ID 字符串,必须传字符串(即使数字也要转成"12345") -
ARGV[2]是过期秒数,比如 1800 表示 30 分钟无新心跳则自动淘汰整个集合 - 脚本返回当前集合大小,可直接用于监控上报
真实业务中必须处理的三个细节
这个脚本看着短,但上线后常因忽略以下点导致统计失真:
- 心跳频率要明显小于
EXPIRE时间(例如设 30 分钟过期,心跳间隔必须 ≤ 10 分钟),否则某次网络抖动就让整个 key 被删,所有用户“瞬间掉线” - 不要用
INCR或GETSET类命令去维护单个用户过期时间——SET集合天然去重,额外加哈希结构纯属冗余且破坏原子性 - 集群模式下,
KEYS[1]必须是哈希标签一致的 key(如online:{users}),否则--eval会报CROSSSLOT错误
最易被忽略的是:这个方案统计的是「过去 N 分钟内至少心跳过一次的用户数」,不是「此刻 TCP 连接数」。如果你的业务需要区分长连接存活状态,得在应用层配合 WebSocket 心跳或连接池探活,Redis 层只负责低成本、高并发的窗口去重统计。










