因为日活本质是布尔判断而非计数,SETBIT用1 bit表示“是否活跃”,空间极省(1亿用户一年约1.2 MB)、操作O(1)且天然去重;而INCR需累加、无法直接判断状态,还浪费存储与计算资源。
为什么
比
+ 时间戳更适配日活统计
因为日活本质是「某天内是否有任意一次行为」,不是次数累加。用
存每日计数,会浪费存储、增加读写开销,且无法支撑「是否登录过」这类布尔判断。而 Redis 位图用单个 bit 表示「用户 U 在第 D 天是否活跃」,1 亿用户一年仅需约 1.2 MB 内存(1e8 × 365 ÷ 8 ÷ 1024²),且
/
是 O(1) 操作。
实操注意点:
的 offset 必须是非负整数,不能直接传日期字符串;需将日期转为距某个基准日(如
)的天数差,例如
→ offset = 4
用户 ID 建议用自增整型或稳定哈希值(如
),避免用 UUID 字符串作位图索引——Redis 位图 key 是字符串,但 offset 越大,底层 SDS 分配越不紧凑
一个 key 对应一个用户:比如
,不要把所有用户塞进同一个 key(位图无原生分片支持,单 key 过大会阻塞)
Go 中调用
和
的正确姿势
用
时,别直接拼接命令字符串。位图操作有专用方法,语义清晰且自动处理类型转换:
关键细节:
立即学习
“
go语言免费学习笔记(深入)
”;
Redis 8.2.3
Redis 8.2.3 是一款安全优先的高性能键值存储系统。该版本紧急修复了可能引发远程代码执行(RCE)的高危漏洞(CVE-2025-62507),并解决了 HyperLogLog 及 Cuckoo Filter 等数据结构在特定场景下的崩溃问题。建议所有用户立即升级,以保障生产环境的系统稳定与数据安全。
下载
的
/
是 byte 索引,不是 bit 索引 —— 但
v9 的
结构体已自动按 bit 换算,你传入的仍是 bit offset(文档没明说,但源码里做了
和
)
如果用
手动发
,必须传 byte 偏移:即
,
,否则结果错乱
连续登录检测不能只靠
:它只返回总天数,不保证连续。得用
拿出字节段再逐 bit 扫描,或改用 Lua 脚本原子判断
用 Lua 脚本原子化检测连续登录 N 天
纯 Go 侧循环查
有竞态风险(比如中间被另一个请求清空某天 bit),且 N 较大时网络往返多。直接在 Redis 执行 Lua 最稳:
注意边界:
脚本里
可能为负数,Redis 会静默返回 0(即未设置),所以调用前必须确保
,否则永远判失败
key 名和 offset 都通过
传入,避免 Lua 中拼接字符串引发注入(虽然业务可控,但养成习惯)
若需返回具体断点日,可改脚本为返回中断位置,但会增加序列化开销,一般用布尔结果就够了
位图 key 过期与冷热分离的实际取舍
Redis 位图本身不支持 per-bit 过期,只能给整个 key 设 TTL。但日活数据通常只需保留 90–180 天,长期累积会导致 key 膨胀。常见做法:
按月分 key:如
,每月初创建新 key,旧 key 设 TTL 180 天后自动淘汰。缺点是跨月连续登录检测要查两个 key
用
合并历史位图做快照,但会阻塞,慎用于高流量时段
更轻量的做法:日常只写当前 key,每天凌晨用
扫描上月 key 的最后有效 bit 位置,若全 0 就
掉——比设 TTL 更省内存,且避免 key 数爆炸
真正容易被忽略的是:位图稀疏时(比如用户只在首尾两天登录),
仍会扫描整个分配空间。Redis 底层用 rax 存储稀疏位图,但 Go 客户端无感知。压测发现,1 亿用户中 99% 只活跃 3 天以内,此时按用户分 key + 月粒度清理,比单 key 全局位图节省 70%+ 内存。
SETBITINCRINCRSETBITGETBITSETBIT2024-01-012024-01-05fnv32user:activity:1001SETBITBITCOUNTgithub.com/redis/go-redis/v9ctx := 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)
}
BitCountStartEndgo-redisBitCountdiv 8mod 8Do()BITCOUNT key start endstart/8end/8BITCOUNTGETRANGEGETBITconst 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 - ioffset >= n-1KEYSuser:activity:1001:202401BITOP ANDBITPOSDELBITCOUNT