外观
从30亿行到12MB:Redis Bitmap签到实战
问题背景
签到系统通常要同时支撑三类查询:第一,用户当前连续签到了多少天;第二,按近 7 天或近 30 天签到天数把用户分为低活、常活、高活;第三,运营侧需要 DAU、WAU、次日或 7 日留存等全局指标。
当用户规模达到 1 亿,仅 30 天签到记录就可能达到 30 亿行。使用关系型数据库存储时,即使有索引,大范围 COUNT、GROUP BY 和多日去重也会产生大量 IO。Redis Bitmap 本质上是在 String 上按位操作,一个用户只占一个 bit,特别适合“是否做过某事”的去重统计。
以 1 亿用户为例,一天的位图约为 100000000 / 8 = 12500000 字节,约 11.9 MiB;365 个日位图约 4.3 GiB,远低于数十亿行关系记录的存储成本。
方案对比
| 方案 | 适合场景 | 主要问题 |
|---|---|---|
| MySQL 签到表 | 强一致、复杂条件查询 | 30 亿行后聚合和去重成本高 |
| Redis Set | 小批量用户去重 | 每个成员均有对象开销,亿级集合内存很大 |
| Redis Bitmap | 大规模布尔状态、去重、活跃统计 | 要求用户 ID 尽量连续,大 key 和位区间需要谨慎处理 |
| HyperLogLog | 仅估算 UV、DAU | 不能判断某个用户是否签到,也不能计算连续签到 |
Bitmap 有两种常用布局:
- 日期全局位图:key 为
sign:yyyyMMdd,offset 为用户 ID。适合 DAU、WAU、留存等全局统计。 - 用户年度位图:key 为
sign:u:{uid}:{year},offset 为一年中的第几天。适合单用户连续签到和活跃天数查询。
实战中推荐混合方案:日期位图负责全局指标,单用户查询通过用户年度位图或 30 个日位图的 Pipeline 完成。若 Redis Cluster 中 key 数量压力过大,可只保留日期分片位图,用户侧用 Pipeline 聚合。
实操步骤与代码示例
1. 写入用户签到
用户在某天签到时,将日期位图中对应用户 ID 的位设置为 1。重复签到仍然写 1,天然具备幂等性。
redis
# 2025-01-01,用户 10001、10002 签到
SETBIT sign:20250101 10001 1
SETBIT sign:20250101 10002 1
# 2025-01-02,用户 10001 再次签到
SETBIT sign:20250102 10001 1
# 判断用户当天是否签到
GETBIT sign:20250102 10001
# 统计当天签到人数
BITCOUNT sign:202501012. 统计 WAU 与留存
多个日期位图可以通过 BITOP 做并集、交集。OR 可统计一段时间内的活跃用户数,AND 可统计两日都活跃的留存用户。
redis
# 近 7 天任意一天签到即算周活
BITOP OR active:7d:20250107 sign:20250101 sign:20250102 sign:20250103 sign:20250104 sign:20250105 sign:20250106 sign:20250107
# 统计周活用户数
BITCOUNT active:7d:20250107
# 统计 1 月 1 日和 1 月 7 日都签到的用户
BITOP AND retain:20250101_20250107 sign:20250101 sign:20250107
BITCOUNT retain:20250101_20250107
# 聚合结果设置过期时间,避免长期占用内存
EXPIRE active:7d:20250107 86400
EXPIRE retain:20250101_20250107 6048003. 计算连续签到天数
连续签到不能只统计总签到天数,必须从最新日期向前逐天判断,遇到 0 立即停止。下面的 Lua 脚本保证多次 GETBIT 在同一个 Redis 节点上原子执行。
redis
EVAL "
local uid = tonumber(ARGV[1])
local streak = 0
-- KEYS 必须按日期从旧到新传入,从最新日期向前检查
for i = #KEYS, 1, -1 do
if redis.call('GETBIT', KEYS[i], uid) == 1 then
streak = streak + 1
else
break
end
end
return streak
" 3 sign:20250128 sign:20250129 sign:20250130 10001如果业务允许“今天未签到但昨天连续签到仍展示历史连续天数”,则日期列表应截止到昨天;如果要求今日已签到才展示当前连续 streak,则日期列表必须包含今天。
4. 按签到天数做活跃度分层
用户年度位图以一年中的第几天为 offset,适合直接计算单用户的累计签到和区间活跃天数。
redis
# 第 0 天表示 1 月 1 日,第 1 天表示 1 月 2 日
SETBIT sign:u:10001:2025 0 1
SETBIT sign:u:10001:2025 1 1
SETBIT sign:u:10001:2025 2 1
SETBIT sign:u:10001:2025 6 1
# 统计全年签到天数
BITCOUNT sign:u:10001:2025近 7 天活跃度可以使用下面的脚本精确统计第 0 到第 6 位:
redis
EVAL "
local cnt = 0
-- 精确统计 [start, end] 位区间内的签到天数
for i = tonumber(ARGV[2]), tonumber(ARGV[3]) do
cnt = cnt + redis.call('GETBIT', KEYS[1], i)
end
local layer = 'inactive'
if cnt >= 6 then
layer = 'high'
elseif cnt >= 2 then
layer = 'regular'
elseif cnt >= 1 then
layer = 'low'
end
return cnt .. ':' .. layer
" 1 sign:u:10001:2025 0 6分层规则可以按业务调整,例如近 7 天签到 0 天为未活、1 天为低活、2 到 5 天为常活、6 到 7 天为高活。
5. 亿级容量与分片设计
单日全局位图只有约 12 MB,但所有写入都打向一个 key。在 Redis Cluster 中,这个 key 只会落在一个节点,容易形成写热点。可以按用户 ID 范围拆成 16 或 32 个位图分片,例如 sign:{b0}:20250101、sign:{b1}:20250101,每个分片内使用局部 offset。
花括号 hashtag 保证同一个桶的多日位图落在同一槽位,便于分片内执行 BITOP;全局结果由应用层把各分片的 BITCOUNT 结果相加。
避坑总结
不要把稀疏 ID 或字符串 ID 直接当 offset。 常见现象是某个用户 ID 异常巨大,
SETBIT一次性分配几百 MB 内存,甚至报 String 超过最大长度错误。Bitmap 内存由最高 offset 决定,而不是由 1 的个数决定。应维护连续数值 UID,或先做 ID 映射,再配合范围分片。注意
BITCOUNT的 start、end 单位是字节,不是位。 例如BITCOUNT sign:u:1 0 6统计的是前 7 个字节,即 56 个 bit,不是前 7 天。统计任意日期区间时应使用 Lua 循环GETBIT,或按字节对齐后再用额外掩码修正多余位。不要在在线主节点频繁对大位图执行
BITOP。 30 个 12 MB 位图做并集时会生成相近大小的结果 key,计算期间阻塞事件循环,还可能造成主从延迟。应放到从节点、离线任务或按桶分片执行,结果缓存并设置 TTL。Redis Cluster 中要处理写热点和 CROSSSLOT。 单个日期 key 会集中到一个节点;多个日期 key 直接放入 Lua 还可能报跨槽错误。修正方式是按 UID 范围分桶,并用 hashtag 保证同桶多日 key 同槽;单用户查询也可以改用客户端 Pipeline 逐个
GETBIT后聚合。统一时区和 offset 口径。 建议按业务时区在零点切日,例如统一使用 UTC+8;年度位图明确第 0 天还是第 1 天对应 1 月 1 日。时区或起始位差一天,会导致连续签到和留存数据全部错位。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。