统计活跃用户(DAU、MAU)是几乎所有后台系统都绕不开的需求。最朴素的做法是在用户表里加一个last_login_time字段,每次登录就更新,统计的时候再count一下。这种方式在用户量小的时候没什么问题,但一旦用户量上到百万、千万级别,count查询就会拖垮数据库。Redis的位图(Bitmap)天生适合这个场景:它底层就是一个字符串类型的字节数组,每个用户占据一个比特位,1亿用户的活跃标记只需要大约12MB内存,而且设置和统计都是O(1)或近似O(1)的操作,性能非常优秀。下面我们结合Node.js客户端,完整讲解如何用位图实现分布式环境下的活跃用户统计。

位图统计活跃用户的核心原理
Redis的Bitmap并不是一种独立的数据类型,它本质上是操作字符串类型的一组位命令。SETBIT命令可以在指定偏移量上写入0或1,GETBIT则读取某个偏移量的值,BITCOUNT统计整个key中值为1的比特位数量。把这套命令映射到活跃用户统计上非常自然:用日期作为key的一部分,用用户ID作为位偏移量,用户当天登录就SETBIT为1,统计日活时直接对当天的key执行BITCOUNT。
举个例子,key为active:2025-06-01,用户ID为10086的用户登录后执行SETBIT active:2025-06-01 10086 1。这一天结束后执行BITCOUNT active:2025-06-01,得到的就是当天的活跃用户数。这种设计的美妙之处在于,无论有多少用户登录,每个用户每天最多占用1个比特位,内存消耗与登录次数无关,只与用户ID的最大值有关。
如果用户ID是自增整数,可以直接用ID作为偏移量。但如果ID是无规律的UUID或雪花ID,就需要维护一张用户ID到序号的映射表(比如用一个Hash或MySQL自增表),否则偏移量会大得离谱,内存会被白白浪费。这一点在方案设计阶段就要想清楚,后面我们会详细讨论。
Node.js操作Redis位图的基础实现
Node.js生态里最常用的客户端是ioredis,它对位图命令做了完整的封装。先安装依赖:
npm install ioredis
接着封装一个简单的活跃标记模块。核心思路是提供一个markActive方法,在用户登录时调用;再提供一个countActive方法,用于统计某一天的活跃用户总数:
const Redis = require('ioredis');
const redis = new Redis({
host: '127.0.0.1',
port: 6379
});
// 获取某一天的位图key
function getDailyKey(date) {
const d = date || new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `active:${y}-${m}-${day}`;
}
// 用户登录时调用,标记为活跃
async function markActive(userId) {
const key = getDailyKey();
await redis.setbit(key, userId, 1);
}
// 统计某天的活跃用户数
async function countActive(date) {
const key = getDailyKey(date);
return await redis.bitcount(key);
}
// 查询某用户在某天是否活跃
async function isActive(userId, date) {
const key = getDailyKey(date);
const bit = await redis.getbit(key, userId);
return bit === 1;
}
module.exports = { markActive, countActive, isActive };代码中有几个细节值得注意。第一,SETBIT的偏移量参数在ioredis中会自动处理,不用手动转换成字节数组。第二,setbit的返回值是原来该位上的值,如果返回1说明该用户今天已经标记过,可以用来做去重判断(虽然重复SETBIT本身也没什么代价)。第三,按天分key的设计让数据天然具备过期条件,可以配合EXPIRE设置35天过期,自动清理历史数据,避免Redis内存无限膨胀。
如果统计的是月活,还可以采用另一种存储方式:一个用户一个月只用一个key,偏移量用天数(1到31),用户某天活跃就SETBIT该月的key、天序号、1。这样31天的数据压缩在一个key里,统计月活只需BITCOUNT一次,代价是查询单日数据要靠位运算拆解,两种方式按查询习惯选择即可。
用BITOP实现连续活跃与留存分析
位图真正强大的地方在于BITOP命令,它可以对多个位图执行AND(与)、OR(或)、XOR(异或)、NOT(非)运算,结果存入目标key。比如想统计连续7天都活跃的用户,把7个日key做AND运算,结果中为1的位就是连续活跃用户;想知道最近7天活跃过的用户总数,做OR运算再BITCOUNT即可。这正是留存分析、流失用户分析的基础。
const Redis = require('ioredis');
const redis = new Redis();
// 获取最近n天的key列表
function getRecentKeys(n) {
const keys = [];
for (let i = 0; i < n; i++) {
const d = new Date(Date.now() - i * 24 * 3600 * 1000);
keys.push(getDailyKey(d));
}
return keys;
}
// 连续n天都活跃的用户数
async function countContinuousActive(n) {
const keys = getRecentKeys(n);
const dest = `tmp:and:${Date.now()}`;
await redis.bitop('AND', dest, ...keys);
const count = await redis.bitcount(dest);
await redis.del(dest); // 及时清理临时key
return count;
}
// 最近n天活跃过的用户数(并集)
async function countAnyActive(n) {
const keys = getRecentKeys(n);
const dest = `tmp:or:${Date.now()}`;
await redis.bitop('OR', dest, ...keys);
const count = await redis.bitcount(dest);
await redis.del(dest);
return count;
}
// 次日留存:今天活跃 且 昨天也活跃
async function countRetention() {
const today = getDailyKey(new Date());
const yesterday = getDailyKey(new Date(Date.now() - 24 * 3600 * 1000));
const dest = `tmp:ret:${Date.now()}`;
await redis.bitop('AND', dest, today, yesterday);
const count = await redis.bitcount(dest);
await redis.del(dest);
return count;
}这里有一个容易踩的坑:BITOP是O(N)操作,N是参与运算的最长key的字节长度。如果用户ID分布很稀疏(比如最大ID是100亿,但实际用户只有100万),每个key的字节长度会非常巨大,BITOP和BITCOUNT都会变慢,内存也被大量0占据。解决办法就是前面提到的,用连续自增序号代替原始用户ID作为偏移量,让位图保持紧凑。
另外,BITOP的结果key需要手动清理。如果统计任务是定时跑的,建议给临时key加上EXPIRE,比如10分钟过期,即使程序异常退出也不会留下垃圾数据。
分布式场景下的注意事项与性能优化
在多台Node.js服务器同时写位图的场景下,不需要担心并发问题,因为SETBIT对单个位的操作是原子的,两个进程同时标记不同用户或者同一用户都不会出错。真正的挑战在于Redis本身的吞吐和单点问题。如果日活量级很高,所有登录请求都直接打到一个Redis实例上,网络IO可能成为瓶颈。可以在Node.js侧做本地聚合:短时间内同一用户的重复SETBIT在内存里去重,再批量下发,或者用pipeline一次性提交多个SETBIT命令,减少网络往返次数。
// 使用pipeline批量标记,减少网络往返
async function markActiveBatch(userIds) {
const key = getDailyKey();
const pipeline = redis.pipeline();
const unique = [...new Set(userIds)];
for (const uid of unique) {
pipeline.setbit(key, uid, 1);
}
await pipeline.exec();
}如果单个Redis实例内存或QPS扛不住,还可以利用Redis Cluster的数据分片特性,但要注意BITOP不支持跨槽位的key运算。解决方案是按用户ID取模把位图拆成多个分片key,运算时在客户端对各分片分别做BITOP,最后把各分片的BITCOUNT结果累加。AND运算稍微特殊一点,需要对相同序号的分片做AND,再统计各分片结果之和。
最后说说数据准确性。位图统计是精确计数,不是近似算法,这一点比HyperLogLog更有优势,代价是内存占用更高。如果只需要知道活跃用户的大概规模、能接受0.81%的误差,HyperLogLog每个key只需12KB,两者可以结合使用:位图用于精确的留存分析,HyperLogLog用于粗略的大盘监控。同时记得给日key设置合理的过期时间,并对统计结果做持久化(写入数据库或生成报表),这样即使Redis数据被清理,历史报表也不会丢失。
总结一下,Node.js配合Redis位图实现活跃用户统计,核心就是按天分key、用户序号做偏移、BITCOUNT计数、BITOP做交叉分析。方案实现简单、内存占用可控、查询性能极高,是中小型到大型系统都适用的经典做法。