在线用户列表是即时通讯、游戏匹配和后台监控系统的核心模块。传统做法把连接状态直接放在应用进程内存里,不仅重启即丢失,而且在多节点部署时各实例状态无法互通。Redis作为内存数据库,提供了过期键、有序集合和发布订阅等结构,非常适合用来集中维护全局在线状态和心跳续期。本文从数据结构选型、心跳写入与清理、以及集群下的边界问题三个角度,详细说明如何基于Redis搭建一套可靠的在线用户心跳维护方案。

一、核心数据结构与键设计
维护在线用户列表首先要解决的是“如何表示一个用户在线”以及“如何自动剔除掉线用户”。最简单直接的方案是为每个用户设置一个带过期时间的字符串键,例如 online:uid:1001,其值可以是最后一次心跳时间戳,过期时间设为心跳间隔的两倍。Redis会在键到期后自动删除,应用层只需在收到心跳时调用 SET 并附带 EX 参数即可完成续期。这种方案实现简单,但对“统计当前全部在线人数”不够友好,因为要统计必须全量扫描匹配前缀的键,在用户量较大时会造成阻塞。
更优的做法是引入有序集合(ZSET)。我们用一个全局键 online:users 来保存所有在线用户,成员是用户ID,分数是最后一次心跳的Unix毫秒时间戳。用户上报心跳时,使用 ZADD online:users <now_ms> <uid> 写入或更新分数。判断某用户是否在线,用 ZSCORE 获取分数并与当前时间比较;统计在线总数直接用 ZCARD。对于需要按房间或设备区分的场景,可以设计多个ZSET,如 online:room:888,从而支持多维度的在线查询而不必扫描全库。
下面给出一个简单的键设计对照表,帮助理解不同结构在心跳维护中的适用点:
| 结构 | 示例键 | 优点 | 缺点 |
|---|---|---|---|
| String+EX | online:uid:1001 | 自动过期、实现极简 | 统计需扫描、难分维度 |
| ZSET | online:users | 天然计数、范围查离线 | 需手动清理过期成员 |
| Hash | online:meta:1001 | 存附属信息如IP | 不能直接全局过期 |
实际生产中常将ZSET与Hash组合:ZSET负责在线与时间轴,Hash负责存用户连接元数据。这样既能高效维护列表,又能附带业务字段。
二、心跳上报与离线清理的实现
心跳的本质是客户端周期性地向服务端发送轻量请求,服务端据此刷新Redis中的活跃时间。假设心跳间隔为30秒,我们在服务端接口中执行如下逻辑:先校验身份,再更新ZSET分数,同时写入或刷新一个用于单用户过期的String键作为兜底。之所以加String键,是因为ZSET本身不会自动移除长期不更新的成员,必须依靠后台任务或读取时判断来清理。
清理过期成员的常用方式是定时任务调用 ZREMRANGEBYSCORE online:users 0 <now_ms - 30000>,该命令会删除所有分数小于阈值(即超过30秒未心跳)的用户,时间复杂度与删除数量相关,在低峰期执行可减小对主线程影响。另一种是在读取在线状态时做懒清理:先 ZSCORE 判断,若过期则从ZSET删除并标记离线。两种方案可并行,定时任务做批量回收,接口内做实时修正,避免脏数据被业务误用。
以下Java风格伪代码展示了一次心跳处理的核心流程,包含续期与异常保护:
// 处理用户心跳
public void heartbeat(long uid) {
long now = System.currentTimeMillis();
// 更新有序集合分数(最后活跃时间)
jedis.zadd("online:users", now, String.valueOf(uid));
// 用String键做单用户过期兜底,两倍心跳间隔
jedis.setex("online:uid:" + uid, 60, String.valueOf(now));
// 可选:同步写入Hash存连接IP
jedis.hset("online:meta:" + uid, "ip", "192.168.0.1");
}
上述代码中,setex 的60秒对应30秒心跳间隔的两倍,即使ZSET清理任务延迟,该键过期后也能在兜底逻辑中判定用户离线。需要注意的是,网络抖动可能导致心跳丢失,因此过期阈值应明显大于心跳间隔,通常取二到三倍,防止误踢。
三、集群部署与时钟一致性问题
当系统使用Redis Cluster时,ZSET和String键会通过哈希槽分布到不同节点。由于 ZREMRANGEBYSCORE 只能操作单个键,而单键必定落在同一槽位,因此批量清理不会跨节点,性能可控。但如果业务按用户ID哈希分片,多个ZSET可能分布在不同节点,统计全局在线数就需要聚合各节点结果,此时可借助Hash Tag将相关键绑定到同一槽,例如用 online:{global}:users 确保统计键不分散。
另一个容易被忽视的问题是服务器时钟漂移。ZSET分数依赖 System.currentTimeMillis(),若应用服务器之间时间不同步,同一用户的连续心跳可能分数回退,导致被误判离线。因此所有节点应开启NTP服务,或在架构上改为由Redis服务端用 TIME 命令返回统一时间戳,客户端不参与时间生成。如下代码展示用Redis时间写入分数:
-- 使用Redis服务器时间作为分数,避免客户端时钟不一致
local t = redis.call('TIME')
local ms = (t[1] * 1000) + math.floor(t[2] / 1000)
redis.call('ZADD', 'online:users', ms, ARGV[1])
return ms
此外,在容器化环境中,如果Redis实例发生主从切换,未持久化的心跳数据可能丢失。对于在线列表这种允许短暂不一致的场景,可关闭AOF只留RDB定时快照,减少写放大;若要求更高可靠性,则开启AOF并设为每秒刷盘,在重启后仍能恢复大部分在线状态。综合来看,Redis维护在线用户列表的关键在于合理选结构、宽限过期时间、统一时钟源,并在集群下规划好键的槽位分布。