导读:本期聚焦于苏锦程创作的《如何用Node.js和Redis分布式位图高效统计日活用户?》,敬请观看详情。统计日活跃用户时,如果直接用数据库记录每个用户的登录行为,数据量一大查询就会变得极慢。Redis提供的位图数据结构恰好能解决这个问题:一个用户对应一个比特位,一亿用户的活跃状态只需要十几MB内存。本文围绕Node.js环境讲解BITSET、BITCOUNT、BITOP等核心命令的用法,演示如何设置和查询用户活跃标记,再进一步给出按天分片存储、跨天做AND与OR运算得出连续活跃用户和任意时段活跃用户的完整实现思路,最后补充位图偏移量的选取策略与内存优化技巧,帮助你用极低的成本搭建一套可扩展的用户活跃度统计方案。

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

如何用Node.js和Redis分布式位图高效统计日活用户?

位图统计活跃用户的核心原理

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做交叉分析。方案实现简单、内存占用可控、查询性能极高,是中小型到大型系统都适用的经典做法。

Node.jsRedis位图活跃用户统计修改时间:2026-09-07 20:20:49

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260907/52423.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。