跳转到主内容
websoft网络软件专家 - 深耕网络技术,打造实用软件!

Golang 实现基于 Redis 位图的用户日活统计与连续登录检测

因为日活本质是布尔判断而非计数,SETBIT用1 bit表示“是否活跃”,空间极省(1亿用户一年约1.2 MB)、操作O(1)且天然去重;而INCR需累加、无法直接判断状态,还浪费存储与计算资源。 为什么
SETBIT
比
INCR
+ 时间戳更适配日活统计 因为日活本质是「某天内是否有任意一次行为」,不是次数累加。用
INCR
存每日计数,会浪费存储、增加读写开销,且无法支撑「是否登录过」这类布尔判断。而 Redis 位图用单个 bit 表示「用户 U 在第 D 天是否活跃」,1 亿用户一年仅需约 1.2 MB 内存(1e8 × 365 ÷ 8 ÷ 1024²),且
SETBIT
/
GETBIT
是 O(1) 操作。 实操注意点:
SETBIT
的 offset 必须是非负整数,不能直接传日期字符串;需将日期转为距某个基准日(如
2024-01-01
)的天数差,例如
2024-01-05
→ offset = 4 用户 ID 建议用自增整型或稳定哈希值(如
fnv32
),避免用 UUID 字符串作位图索引——Redis 位图 key 是字符串,但 offset 越大,底层 SDS 分配越不紧凑 一个 key 对应一个用户:比如
user:activity:1001
,不要把所有用户塞进同一个 key(位图无原生分片支持,单 key 过大会阻塞) Go 中调用
SETBIT
和
BITCOUNT
的正确姿势 用
github.com/redis/go-redis/v9
时,别直接拼接命令字符串。位图操作有专用方法,语义清晰且自动处理类型转换:
ctx := context.Background() uid := int64(1001) offset := daysSinceBase("2024-01-05") // 返回 int64

// 标记当日活跃 err := rdb.SetBit(ctx, fmt.Sprintf("user:activity:%d", uid), offset, 1).Err() if err != nil { log.Printf("setbit fail: %v", err) }

// 统计该用户近 7 天登录天数(offset 范围 [t-6, t]) start := offset - 6 count, err := rdb.BitCount(ctx, fmt.Sprintf("user:activity:%d", uid), &redis.BitCount{Start: start, End: offset}).Result() if err != nil { log.Printf("bitcount fail: %v", err) }

关键细节: 立即学习 “ go语言免费学习笔记(深入) ”; Redis 8.2.3 Redis 8.2.3 是一款安全优先的高性能键值存储系统。该版本紧急修复了可能引发远程代码执行(RCE)的高危漏洞(CVE-2025-62507),并解决了 HyperLogLog 及 Cuckoo Filter 等数据结构在特定场景下的崩溃问题。建议所有用户立即升级,以保障生产环境的系统稳定与数据安全。 下载
BitCount
的
Start
/
End
是 byte 索引,不是 bit 索引 —— 但
go-redis
v9 的
BitCount
结构体已自动按 bit 换算,你传入的仍是 bit offset(文档没明说,但源码里做了
div 8
和
mod 8
) 如果用
Do()
手动发
BITCOUNT key start end
,必须传 byte 偏移:即
start/8
,
end/8
,否则结果错乱 连续登录检测不能只靠
BITCOUNT
:它只返回总天数,不保证连续。得用
GETRANGE
拿出字节段再逐 bit 扫描,或改用 Lua 脚本原子判断 用 Lua 脚本原子化检测连续登录 N 天 纯 Go 侧循环查
GETBIT
有竞态风险(比如中间被另一个请求清空某天 bit),且 N 较大时网络往返多。直接在 Redis 执行 Lua 最稳:
const checkStreakLua = ` local key = KEYS[1] local offset = tonumber(KEYS[2]) local n = tonumber(KEYS[3]) for i = 0, n - 1 do if redis.call("GETBIT", key, offset - i) == 0 then return 0 end end return 1 `

// 调用 result, err := rdb.Eval(ctx, checkStreakLua, []string{fmt.Sprintf("user:activity:%d", uid), strconv.FormatInt(offset, 10), "7"}).Result() isStreak := result == int64(1)

注意边界: 脚本里
offset - i
可能为负数,Redis 会静默返回 0(即未设置),所以调用前必须确保
offset >= n-1
,否则永远判失败 key 名和 offset 都通过
KEYS
传入,避免 Lua 中拼接字符串引发注入(虽然业务可控,但养成习惯) 若需返回具体断点日,可改脚本为返回中断位置,但会增加序列化开销,一般用布尔结果就够了 位图 key 过期与冷热分离的实际取舍 Redis 位图本身不支持 per-bit 过期,只能给整个 key 设 TTL。但日活数据通常只需保留 90–180 天,长期累积会导致 key 膨胀。常见做法: 按月分 key:如
user:activity:1001:202401
,每月初创建新 key,旧 key 设 TTL 180 天后自动淘汰。缺点是跨月连续登录检测要查两个 key 用
BITOP AND
合并历史位图做快照,但会阻塞,慎用于高流量时段 更轻量的做法:日常只写当前 key,每天凌晨用
BITPOS
扫描上月 key 的最后有效 bit 位置,若全 0 就
DEL
掉——比设 TTL 更省内存,且避免 key 数爆炸 真正容易被忽略的是:位图稀疏时(比如用户只在首尾两天登录),
BITCOUNT
仍会扫描整个分配空间。Redis 底层用 rax 存储稀疏位图,但 Go 客户端无感知。压测发现,1 亿用户中 99% 只活跃 3 天以内,此时按用户分 key + 月粒度清理,比单 key 全局位图节省 70%+ 内存。

相关文章