Redis Bitmap实现亿级用户签到统计的内存优化
为什么传统方案行不通?
为亿级用户(如1亿)存储每日签到状态,最直观的方法是为每位用户创建一个记录。使用传统关系型数据库,一张表可能包含 user_id、date、is_signed 等字段。即使 is_signed 用最小的 TINYINT(1) 类型,存储 一天全量用户的签到数据也需要约 100,000,000 * 1 字节 ≈ 95.4 MB。如果查询“某用户本月连续签到天数”,需要扫描该用户当月所有记录,效率低下。
Redis Bitmap(位图)提供了一种极致压缩的解决方案。它将每个用户的状态映射为一个二进制位(bit),8个用户的状态才占用1个字节。查询 连续签到只需对位进行高效的操作。
第一步:理解 Bitmap 的核心思想
Redis Bitmap 不是独立数据结构,而是对字符串(String)类型的一系列按位操作命令的封装。一个字符串键值,可以视为一个无限长的、按顺序编号的二进制位数组。
核心优势:
- 空间效率:每个用户状态仅占1 bit,是传统
TINYINT方案内存占用的 1/8。 - 操作高效:签到、查询、统计等操作底层是二进制位运算,时间复杂度为 O(1)。
第二步:设计你的键(Key)策略
高效的键设计是所有优化的开始。对于每日签到,我们通常以 “天” 或 “月” 为粒度设计键。
推荐方案:使用 sign:202310 作为键名,代表 2023年10月 的用户签到集合。每位用户根据其 user_id 映射到该键的第 user_id 个 bit 位上。
操作步骤:
- 生成 键名。格式示例:
sign:{年份}{月份}, 如sign:202310。 - 确保 键的类型为 Redis String。Bitmap 操作会自动将字符串视为位数组。
第三步:执行每日签到操作
当一个用户(假设ID为 10086)在某天(假设为10月1日)完成签到,我们需要将对应位设置为 1。
操作步骤:
- 确定 目标键:
sign:202310。 - 确定 该用户在当前月份的第几天签到:10月1日即为第1天,偏移量为
0(因为位数组从0开始计数)。 - 执行
SETBIT命令。
# 命令格式:SETBIT key offset value
# 将键 `sign:202310` 的第 `10086` 位(代表用户ID 10086)的值设置为 `1`。
# 注意:这里的 offset 就是 user_id, day - 1 是当日序号(从0开始)
# 例如,用户10086在10月1日(本月第1天)签到:
SETBIT sign:202310 10086 1
解释:offset 直接使用用户 user_id。这样,每个用户在月键中都有一个固定的、唯一的 bit 位。一个键可以管理全月所有用户的所有天数签到。
修正:上述设计 offset 仅为 user_id,只能存储该用户本月是否签过到(一次),无法记录具体哪天签到。正确的键设计应为每天一个键,或使用 sign:202310:{day} 作为键名, offset 为 user_id。
修正后的签到操作步骤(以天为粒度):
- 生成 今日键名:
sign:20231001(代表2023年10月1日)。 - 执行
SETBIT sign:20231001 10086 1。
第四步:查询用户签到状态
检查用户 10086 在2023年10月1日是否签到。
操作步骤:
- 执行
GETBIT命令。# 命令格式:GETBIT key offset GETBIT sign:20231001 10086 - 解析 返回值。返回
1表示已签到,返回0表示未签到。
第五步:统计关键指标
Bitmap 的强大之处在于可以对多个键进行位运算,瞬间得到统计结果。
场景一:统计某天签到总人数
操作步骤:
- 执行
BITCOUNT命令。# 统计 `sign:20231001` 这个键中,值为1的bit位数量。 BITCOUNT sign:20231001 - 获取 结果。返回值即为当日签到总人数。
场景二:统计用户本月连续签到天数(核心优化)
操作步骤:
- 确定 月份和用户:
user_id=10086,年月=202310,截止日=15。 - 收集 截止到指定日期的所有单日键:
sign:20231001到sign:20231015。 - 使用
BITOP命令对多个键进行“与”操作,合并出一个临时结果键。# BITOP AND destkey key [key ...] # 对多个键的每个对应bit位进行AND(与)运算,结果存入 destkey。 # 只有用户在这15天里每天都签到了(每天对应位都是1),结果键中的该位才为1。 BITOP AND user:10086:oct15:check sign:20231001 sign:20231002 ... sign:20231015 - 检查 结果键中用户对应的 bit 位。
GETBIT user:10086:oct15:check 10086 - 如果返回
1,则该用户从1号到15号连续签到。通过循环或二分查找(配合BITOP),可以高效确定最长连续签到天数。
更高效的“OR”合并查询:通常,我们更想知道用户本月哪些天签到了。可以使用 BITOP OR 合并一个月的所有键。
# 合并10月1日到31日的所有签到数据
BITOP OR sign:202310:merged sign:20231001 sign:20231002 ... sign:20231031
# 然后对合并后的键进行查询
GETBIT sign:202310:merged 10086 # 查询该用户本月是否签过到(任何一天)
BITCOUNT sign:202310:merged # 统计本月至少签到一次的用户总数
第六步:内存占用精确计算
我们来计算存储1亿用户一个月签到数据的内存消耗。
计算公式:
用户总数(N) / 8 = 字节数(Bytes)
字节数 / 1024 = 千字节(KB)
千字节 / 1024 = 兆字节(MB)
计算过程(以存储一个月(31天)数据为例,方案A为每天一个键):
- 单个日键 内存:
100,000,000 / 8 = 12,500,000 Bytes ≈ 11.92 MB。 - 一个月总内存(31个键):
11.92 MB * 31 ≈ 369.6 MB。
这看似不小,但对比传统方案:
| 方案 | 存储逻辑单位 | 每条记录大小 (假设) | 存储1亿用户1个月数据 | 优化比例 |
|---|---|---|---|---|
| 关系型数据库 | 行 (Row) | 约 20 Bytes (id, date, status等) | 100,000,000 * 31 * 20 B ≈ 58.18 GB |
基准 |
| Redis Hash | 字段 (Field) | 约 50 Bytes (key overhead + field + value) | 100,000,000 * 31 * 50 B ≈ 145.5 GB |
更高 |
| Redis Bitmap | 位 (Bit) | 1/8 Byte |
100,000,000 * 31 / 8 B ≈ 369.6 MB |
降低约 160倍 |
结论:Bitmap 方案将内存消耗从数十GB级别,压缩到了数百MB级别,实现了数量级的优化。
第七步:关键优化与注意事项
- 键的过期:务必为每日签到键(如
sign:20231001)设置TTL,例如EXPIRE sign:20231001 2592000(30天),避免磁盘空间无限增长。 - 稀疏用户ID的处理:如果用户ID不连续(如
1, 100000, 200000),直接使用ID作为offset会极大浪费内存。此时应建立一个 “用户ID到连续数字” 的映射表,将稀疏ID转换为从0开始的连续序号。 - 批量操作:对于初始化或数据迁移,使用
Pipeline或Lua脚本批量执行SETBIT命令,减少网络开销。 - 监控:使用
MEMORY USAGE key命令定期检查键的实际内存占用,确保符合预期。
稀疏ID映射示例:
# 假设有一个全局自增ID,用于生成紧凑的位偏移量。
# 1. 将原始用户ID映射到紧凑ID。
SET compact:id:10001 0
SET compact:id:10002 1
SET compact:id:10003 2
# ... 在业务代码中维护这个映射。
# 2. 签到时使用紧凑ID作为offset。
SETBIT sign:20231001 0 1 # 紧凑ID 0 的用户签到
SETBIT sign:20231001 2 1 # 紧凑ID 2 的用户签到
通过以上步骤,你可以使用 Redis Bitmap 构建一个能够支撑亿级用户、存储开销极低、查询速度极快的签到统计系统。

暂无评论,快来抢沙发吧!