做用户运营的同学几乎每天都要看一个指标:日活跃用户数,也就是常说的DAU。如果直接在业务数据库里写SQL做count查询,用户量一大查询就会变慢,数据库压力也随之上升。Redis提供的BitMap结构天生适合解决这个问题:一个用户对应一个比特位,活跃记为1,不活跃记为0,一天的数据就是一个巨大的位数组。假设用户ID是数字且上限一亿,一天的数据只占约12MB内存,统计日活只需要一条BITCOUNT命令,性能开销几乎可以忽略。这篇文章带你把这个方案彻底搞清楚。

一、BitMap的底层存储原理
Redis中的BitMap并不是一种独立的数据类型,它是基于String类型实现的。String在Redis底层以字节数组的形式存储,而BitMap就是把这个字节数组当作位数组来用。第一个字节表示偏移量0到7的比特位,第二个字节表示偏移量8到15,以此类推。当我们对某个偏移量执行SETBIT操作时,Redis会定位到对应的字节,再通过位运算修改其中的某一位。
这里有一个容易被忽略的细节:如果SETBIT操作的偏移量超出了当前位数组的长度,Redis会自动扩容。扩容的过程会分配新的内存并把旧数据拷贝过去,如果某个偏移量特别大(比如几十亿),可能会造成Redis主线程短暂的阻塞。所以使用BitMap时,用户ID必须经过映射,不能把原始的大数值ID直接当偏移量用。
另外要注意,Bitmap中间不活跃的用户对应的位是0,这些0也占内存。也就是说,BitMap的内存占用取决于最大偏移量,而不是活跃用户的数量。如果用户ID分布稀疏,比如最大ID是一亿但只有一万活跃用户,BitMap依然要占12MB左右的空间。这是后面讨论方案取舍时的关键点。
二、核心命令详解与代码示例
BitMap相关的命令不多,常用的就四个。SETBIT负责写入,GETBIT负责读取某一位,BITCOUNT统计为1的位数,BITOP做位运算。下面用一个Java场景演示完整的日活统计流程,假设我们已经把用户ID映射成了从0开始的连续序号。
// 用户登录时调用,userId是映射后的序号
// key按天生成,方便按天统计
public void recordActive(String date, long userId) {
String key = "dau:" + date; // 例如 dau:20250101
jedis.setbit(key, userId, true);
}
// 统计某天的日活跃用户数
public long countDailyActive(String date) {
String key = "dau:" + date;
return jedis.bitcount(key);
}
// 统计连续三天的活跃用户(求交集)
public long countActiveInRange(String d1, String d2, String d3) {
jedis.bitop(BitOP.AND, "dau:result", "dau:" + d1, "dau:" + d2, "dau:" + d3);
return jedis.bitcount("dau:result");
}
几点实现细节值得说明。第一,key按日期命名,每天一个BitMap,这样天然支持按天统计,过期的数据还可以通过设置TTL自动清理。第二,BITOP的AND运算可以得到同时活跃在多天的用户,OR运算可以得到这段时间内至少活跃过一次的用户,连续活跃、周活、月活都可以由此组合出来。第三,BITOP是O(N)操作,如果bitmap很大且频繁调用,建议把结果缓存起来,或者放到从库上执行。
还有一个进阶用法:BITCOUNT支持指定字节范围参数,比如BITCOUNT key 0 99只统计前100个字节。利用这一点可以做分页统计或分片计算,在集群模式下把大bitmap拆到多个节点并行计算再汇总。
三、用户ID与偏移量的映射设计
前面反复强调偏移量不能乱用,那具体怎么处理?如果系统的用户ID本身是从1开始的自增整数,可以直接用ID减1作为偏移量,这是最理想的情况。但实际项目中,用户ID往往是分布式ID生成器产生的,数值可能非常大,或者干脆是手机号、UUID这类字符串,直接用会出大问题。
通用的做法是建立一张映射表,给每个用户分配一个紧凑的自增序号,可以用Redis的INCR命令维护一个全局计数器实现。用户首次登录时分配序号,之后每次活跃都用这个序号作为偏移量。映射关系可以存到数据库或者Redis的Hash结构里,同时建议在本地缓存一份,避免每次记录活跃都要多查一次映射。
// 为用户分配紧凑序号,首次登录时调用
public long getUserIndex(long userId) {
String field = String.valueOf(userId);
String index = jedis.hget("user:index", field);
if (index != null) {
return Long.parseLong(index);
}
long newIndex = jedis.incr("user:index:seq");
jedis.hset("user:index", field, String.valueOf(newIndex));
return newIndex;
}
这个映射层虽然增加了一点复杂度,但换来的是内存占用可控、偏移量连续、统计准确,总体上非常划算。如果不想维护映射,只想快速估算一个大致的日活数字,那就该考虑HyperLogLog了,它只需要约12KB就能估算任意规模的基数,误差在百分之零点八一左右,代价是只能估算不能精确统计,也无法告诉你具体是哪些用户活跃。
四、BitMap方案的常见坑与选型建议
第一个坑是前面提到的偏移量膨胀。曾经有团队把手机号直接当偏移量写入,11位的手机号意味着偏移量达到百亿级别,一次SETBIT就要分配超过1GB的内存,Redis直接卡死。第二个坑是BITOP的key数量不宜过多,对几十个key做运算时耗时明显,做月活统计建议分批进行或预计算。第三个坑是不要忘记给过期的日活key设置过期时间,否则数据会无限堆积。
选型方面可以记住一个简单的判断标准:需要精确统计、需要知道具体活跃用户名单、需要做活跃用户交集运算,选BitMap;只需要一个大致的日活数字,对精度要求不高,选HyperLogLog;需要留存分析、活跃天数统计这类更复杂的需求,可以把BitMap和Hash组合使用,或者考虑ClickHouse这类列式存储。BitMap最大的优势在于常数级的内存占用和极低的统计成本,只要偏移量设计得当,它是日活统计场景里性价比最高的方案之一。
Redis BitMap日活跃用户统计SETBIT命令修改时间:2026-09-04 03:32:41