不能直接用SETBIT标记上线就完事,因网络抖动、强杀等导致客户端无法下线,bit将永久为1而误判在线;必须用分钟级时间戳作offset、每30秒心跳写入、保留2小时窗口、服务端每分钟BITPOS扫描判定超180秒离线、按时间维度建key统计实时在线数、禁用EXPIRE而用BITFIELD清旧位。

为什么不能直接用 SETBIT 标记用户上线就完事?
因为网络抖动、App强杀、进程崩溃都会让客户端无法发出下线指令,SETBIT user:online:1001 0 0 永远不会被执行,位图里那个 bit 就永远是 1——系统会误判用户“一直在线”。这不是 Redis 的问题,是状态同步模型本身缺失容错机制。
必须引入心跳时间戳位移:客户端每 30 秒执行一次 SETBIT user:online:1001 <code>timestamp_sec / 60 1,把 offset 换成分钟级时间戳(比如当前时间戳 1752224880 → ÷60 ≈ 29203748),只保留最近 120 分钟(120 个 bit)的历史。这样每个用户 key 实际只存 120 位,内存可控,且天然带时间衰减。
- offset 必须是非负整数:Go 中若用户 ID 是
int64,别直接uint64(id) & 0xFFFFFFFF——负 ID 会转成极大正数,触发 Redis 底层 realloc 卡顿 - 服务端不能依赖客户端“主动清旧位”,要每分钟用
BITPOS user:online:1001 0 -120从右往左扫,找到第一个 0 的位置 p;若(now - p) * 60 > 180(即超 3 分钟无心跳),才判定离线 - 别对 key 设置
EXPIRE:SETBIT不刷新 TTL,key 会永久存在;改用BITFIELD user:online:1001 SET i8 0 0清零最旧位,或定期DEL整个 key
统计“此刻在线人数”为什么不能用 BITCOUNT user:online:1001?
BITCOUNT user:online:1001 数的是这个用户最近 120 分钟内有多少次心跳,不是“当前是否在线”。你要的是“现在有多少人在线”,得把视角翻过来:不是每个用户一个 key,而是每个时间粒度一个 key,每位代表一个用户。
正确做法是固定 key 名,例如 online:20260810:17(精确到小时),然后把用户 ID 映射到 offset:offset = uid % (1 (即 mod 16777216)。只要保证 uid ≥ 1 且不超模数,就能把千万级用户均匀打散到 2MB 内存的位图中。
立即学习“go语言免费学习笔记(深入)”;
- 禁止用大 offset(如直接用 uid 当 offset):Redis 对稀疏 offset(比如只写 offset=1 和 offset=1000000)会分配大量零字节,浪费内存;超过
2^32-1还会触发 realloc,单次操作卡顿可达毫秒级 -
BITCOUNT online:20260810:17是 O(1) 时间复杂度,查全量也只要微秒级,比遍历用户列表快几个数量级 - 小时级 key 可配合定时任务清理:比如凌晨 2 点
DEL online:20260809:*,避免冷数据堆积
Gin + go-redis/v9 调用位图命令的三个避坑点
别手拼 Do(ctx, "SETBIT", key, offset, 1)。看似灵活,实则容易传错类型、漏处理负 offset、忽略返回值语义。go-redis/v9 提供了封装方法,该用就得用。
- 用
rdb.SetBit(ctx, key, int64(offset), 1):它内部自动转类型,且对负 offset 直接 panic,逼你提前校验 - 批量查状态时,
GETRANGE比循环调GETBIT快 10 倍以上:比如查 1000 个用户,先算出它们的 offset 区间,再rdb.GetRange(ctx, key, start, end)一次性取回字节块,自己解析 bit - 注意 context 超时:位图操作虽快,但若 key 极大(比如错误地用了超大 offset),
BITCOUNT可能阻塞,务必设context.WithTimeout(ctx, 100*time.Millisecond)
心跳上报频率和位图精度怎么权衡?
30 秒心跳 + 分钟级 offset 是平衡点:太短(如 10 秒)会让位图膨胀 3 倍,且多数业务并不需要秒级在线感知;太长(如 5 分钟)则超时判定延迟过大,用户已退出却还显示“在线”。
真正关键的是服务端扫描节奏。BITPOS 扫描范围必须和心跳周期严格对齐:心跳写入用分钟级 offset,服务端就扫 -120;如果心跳是 30 秒一发,那 offset 应该是 timestamp_sec / 30,扫描范围就得改成 -240(覆盖 2 小时)。
- 扫描不能太频繁:每分钟一次足够,高频 BITPOS 会增加 Redis CPU 压力
- 扫描不能太迟钝:若间隔超过心跳周期的 2 倍,就可能漏判刚掉线的用户
- 生产环境建议加监控:记录每分钟 BITPOS 返回的 p 值分布,突然大量返回 -1(没找到 0)说明心跳大面积中断,需告警
实际部署时最容易被忽略的,是 offset 映射函数和 key 命名规则必须全局一致——Gin 服务、后台扫描任务、离线分析脚本,哪怕换语言实现,都得用同一套模运算和时间格式。一旦不一致,位图就变成不可读的乱码。


















