跳到主内容

从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,远低于数十亿行关系记录的存储成本。

方案对比 ​

两种 Redis Bitmap 布局对比日期位图用户位图Key:sign:2026-10-13Offset:userId每天一个全局位图11010用户 ID 位序列BITCOUNT 统计 DAU适用于 DAU / 签到统计Key:sign:{userId}Offset:年内第 N 天每用户一个年度位图11010日期位序列BITCOUNT 统计年度出勤适用于连续签到分析同一组位操作,按 Key 与 Offset 的维度对调即可切换布局

方案适合场景主要问题
MySQL 签到表强一致、复杂条件查询30 亿行后聚合和去重成本高
Redis Set小批量用户去重每个成员均有对象开销,亿级集合内存很大
Redis Bitmap大规模布尔状态、去重、活跃统计要求用户 ID 尽量连续,大 key 和位区间需要谨慎处理
HyperLogLog仅估算 UV、DAU不能判断某个用户是否签到,也不能计算连续签到

Bitmap 有两种常用布局:

  1. 日期全局位图:key 为 sign:yyyyMMdd,offset 为用户 ID。适合 DAU、WAU、留存等全局统计。
  2. 用户年度位图: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:20250101

2. 统计 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 604800

3. 计算连续签到天数 ​

连续签到不能只统计总签到天数,必须从最新日期向前逐天判断,遇到 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 结果相加。

避坑总结 ​

  1. 不要把稀疏 ID 或字符串 ID 直接当 offset。 常见现象是某个用户 ID 异常巨大,SETBIT 一次性分配几百 MB 内存,甚至报 String 超过最大长度错误。Bitmap 内存由最高 offset 决定,而不是由 1 的个数决定。应维护连续数值 UID,或先做 ID 映射,再配合范围分片。

  2. 注意 BITCOUNT 的 start、end 单位是字节,不是位。 例如 BITCOUNT sign:u:1 0 6 统计的是前 7 个字节,即 56 个 bit,不是前 7 天。统计任意日期区间时应使用 Lua 循环 GETBIT,或按字节对齐后再用额外掩码修正多余位。

  3. 不要在在线主节点频繁对大位图执行 BITOP。 30 个 12 MB 位图做并集时会生成相近大小的结果 key,计算期间阻塞事件循环,还可能造成主从延迟。应放到从节点、离线任务或按桶分片执行,结果缓存并设置 TTL。

  4. Redis Cluster 中要处理写热点和 CROSSSLOT。 单个日期 key 会集中到一个节点;多个日期 key 直接放入 Lua 还可能报跨槽错误。修正方式是按 UID 范围分桶,并用 hashtag 保证同桶多日 key 同槽;单用户查询也可以改用客户端 Pipeline 逐个 GETBIT 后聚合。

  5. 统一时区和 offset 口径。 建议按业务时区在零点切日,例如统一使用 UTC+8;年度位图明确第 0 天还是第 1 天对应 1 月 1 日。时区或起始位差一天,会导致连续签到和留存数据全部错位。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。