☰
Redis亿级用户多状态统计:Bitmap替代与建模实践
2026/10/9 9:02:17 网站建设 项目流程

最近又被问到一个挺典型的问题:系统里已经积累了几亿用户,现在要做各种状态统计,比如“今天活跃过的人”“订阅过某条消息的人”“领过某个奖励的人”,网上一搜方案全在讲 Bitmap,但真到了多状态、多枚举、不同维度组合统计的时候,Bitmap 该怎么继续用?不少人甚至会直接怀疑——除了 Bitmap,Redis 还有没有别的办法?

这个问题我前后在不同项目里折腾过好几次。作为一个算不上新的方向,Redis 做海量用户状态统计其实早就有一套成熟的建模思路,但网上大多数讨论只停留在“Bitmap 能统计 DAU”这一步,很少继续往深讲:状态不是 0/1 怎么办?多个状态维度怎么组合?内存和写入量怎么估算?会不会把主库拖垮?这篇文章把我自己验证过的方案和踩过的坑整理出来,希望能让正在做这套东西的人少走点弯路。

1. 为什么 Bitmap 不是万能钥匙:先厘清单状态与多状态的区别

1.1 单状态统计里,Bitmap 是最优解的基础盘

先说清楚 Bitmap 为什么在 DAU、登录、签到这类场景里能成为“标准答案”。它的核心逻辑特别简单:把用户 ID 映射成一个 bit 位的位置,状态值只有 0 或 1,1 表示满足条件,0 表示不满足。比如 1 亿个用户,我用一个 string 类型的 key 存stats:active:20260101,内部就是 1 亿个 bit,换算过来大概 12MB 出头。每个用户只是其中一个位,写入用SETBIT,统计用BITCOUNT,组合多个条件用BITOP。

SETBIT stats:active:20260101 10001 1 SETBIT stats:active:20260101 10002 1 BITCOUNT stats:active:20260101

这种方式的优势非常直观:内存极小、写入是 O(1)、统计成本可控,而且一个 key 就能容纳上亿人的状态。它唯一的前提是用户 ID 能落地成一个非负整数,而且位数不能超过 string 的长度上限(Redis string 最大 512MB,也就是最多 43 亿 bit 左右)。

问题正出在“状态值只有 0/1”这个前提上。所谓多状态统计,其实并不是一个简单的事,它至少包含三种情况。

1.2 “多状态”的三个层次:多维度、多枚举、会回退

我在实际业务里遇到的多状态,基本可以拆成这三类:

  • 多维度:一个用户同时有多个相互独立的状态。比如今天活跃过吗、订阅过吗、领取过奖励吗、是否实名认证。这些状态之间不互斥,可以同时为 1。
  • 多枚举:某个状态本身不是布尔值,而是有多个取值。比如用户等级可能是普通、黄金、铂金、钻石四种,那“是钻石用户吗”可以拆成布尔,但“用户处于哪个等级”就没办法只用一个 bit 表达。
  • 会回退:状态不是只增不减。比如订阅后取消订阅,领取奖励后又被运营人工撤销。Bitmap 对“置 1”特别顺手,但“回退”如果处理不好,会出现旧事件重新把状态置回 1 的脏数据问题。

很多人的第一个反应是:既然 Bitmap 只能表达 0/1,那多状态干脆换成 Hash 或者普通 String 不就行了?这句话在小数据量下没错,可一旦用户量上亿,每个用户的状态都单独存一个 key 或者一个 field,内存会立刻爆炸。真正合理的路线,应该是先按维度拆解,再选择一个能压住内存的承载结构。

1.3 网上一搜全是 Bitmap,恰恰说明很多人没继续往下建模

我观察到一个很有趣的现象:搜索“Redis 多状态统计”,出来的答案几乎全是 Bitmap,但仔细看内容,基本都停留在“用 Bitmap 统计登录用户”这个层面。原因不难理解——单状态的日活、在线、签到是最常见的统计需求,一篇帖子讲清楚 SETBIT 和 BITCOUNT 就够了,很少有人专门写“二维状态建模”这种偏工程的东西。

可现实中,业务一旦复杂起来,单状态只是起点。你要统计“今天登录且领取过奖励的人群”“最近 7 天连续签到的用户”“处于黄金等级以上且未订阅的用户”,这些问题都绕不开建模。Bitmap 不是不能做,而是你需要先回答一个问题:这个多状态,能不能在语义上拆成独立的布尔维度?如果能,Bitmap 依然是首选;如果不能,再去考虑位段、Hash、Set 或者布隆过滤器。千万不要一上来就想着替代 Bitmap,它本身完全可以用得更花一点。

2. 多状态建模:多个 Bitmap、位段编码,还是一张 String 状态表

2.1 第一步永远是拆维度,而不是选数据结构

我自己的习惯是,拿到需求先画一张表:状态名称、取值类型、是否会回退、是否需要按天/按周聚合、是否会和其他状态做交集统计。这个表画完,90% 的方案其实已经定了。

比如“用户活跃”和“用户订阅”是两个独立维度,就分别建两个 Bitmap;如果状态互相独立,硬塞进同一个 key 反而会让后续统计变得很别扭。最简单的反例是,你想统计“既活跃又订阅”的用户,两个独立 Bitmap 一条BITOP AND就出来,但如果你把活跃和订阅塞进同一个位段里,就得先取位段再逐用户判断,聚合成本高出好几个量级。

拆分维度的原则也很朴素:能被业务单独统计的状态,就单独建一个 key;只有那些天然互斥的枚举值,才考虑放到同一个位段里。比如“用户等级”这种枚举状态,普通、黄金、铂金、钻石本身就是互斥的,你不可能让用户同时是黄金和铂金,那就不该拆成四个布尔 Bitmap,而是应该用位段来表达。

2.2 多维度场景:N 个 Bitmap 并行

假设现在有活跃和订阅两个状态,用户量是 1 亿,建模方式非常简单:

状态Key 命名示例统计命令
活跃stats:active:20260101BITCOUNT stats:active:20260101
订阅stats:subscribe:20260101BITCOUNT stats:subscribe:20260101
活跃且订阅临时聚合 keyBITOP AND stats:both:20260101 stats:active:20260101 stats:subscribe:20260101

写入的时候,一条业务事件可能同时会触发多个状态的更新,比如用户注册后既成为“活跃用户”,又自动“订阅了通知”。这种情况下用 Pipeline 一把送过去就行。

SETBIT stats:active:20260101 10001 1 SETBIT stats:subscribe:20260101 10001 1

这里有一个容易忽略的小问题:如果同一个事件里多个 Bitmap 需要保证原子性,Pipeline 并不是事务。好在状态统计场景通常允许“最终一致”,真遇到“必须同时成功或同时失败”的强一致需求,再用 Lua 脚本包一层,我在后面的章节会单独写。

2.3 多枚举场景:用 BITFIELD“位段”代替裸 Bitmap

如果一个状态有 4 种取值,比如用户等级:0 普通,1 黄金,2 铂金,3 钻石,那就需要每个用户至少 2 个 bit。这时候裸SETBIT不够了,因为它只能操作单 bit,而 Redis 的BITFIELD命令正好补齐这个缺口。

BITFIELD可以直接对一个字符串做有符号/无符号整数的位段读写。比如给用户 ID 10001 设置等级为 3,可以这样写:

BITFIELD stats:vip:20260101 SET u4 #10001 3

这里的u4表示用 4 个 bit 存一个无符号整数,#10001的意思是不直接给 bit 偏移量,而是按照“每个元素占 4 bit”来计算起始位置,也就是从 bit 40004 开始。这样读出来:

BITFIELD stats:vip:20260101 GET u4 #10001

一个 100M 用户、每个用户占 4 bit 的状态位段,总量是 400M bits,也就是 50MB 左右,跟同等量级的 4 个独立 Bitmap 几乎一样。也就是说,如果你想把 4 个布尔状态压缩成一个 4-bit 的状态值,内存并没有省,但 key 的数量确实变少了。所以这里真正的取舍是:key 数量和编码复杂度的权衡,不是单纯的内存节省。

还有一点要提醒:BITFIELD对位段做的是“覆盖写”,如果你要更新的位段依赖旧值,比如“等级提升一级”,就必须先读出来再写回去。这个读改写操作在高并发下容易出问题,稳妥做法是丢给 Lua 脚本一次性完成。

下面这个脚本是“读旧值,加增量,再写回”的简化版:

local uid = tonumber(ARGV[1]) local incr = tonumber(ARGV[2]) local old = redis.call('BITFIELD', KEYS[1], 'GET', 'u4', '#' .. uid) local next = (old[1] or 0) + incr redis.call('BITFIELD', KEYS[1], 'SET', 'u4', '#' .. uid, next) return next

调用方式:

EVAL "local uid = tonumber(ARGV[1]); local incr = tonumber(ARGV[2]); local old = redis.call('BITFIELD', KEYS[1], 'GET', 'u4', '#' .. uid); local next = (old[1] or 0) + incr; redis.call('BITFIELD', KEYS[1], 'SET', 'u4', '#' .. uid, next); return next" 1 stats:vip:20260101 10001 1

2.4 key 命名、位偏移映射与桶设计

设计这套东西的时候,最容易被忽略但又最关键的就是“用户 ID 到偏移量的映射”。Bitmap 看起来好用,但它要求用户 ID 必须是一个非负整数,而且最好是接近连续的整数。如果业务主键是 UUID 或其他字符串,直接用 UUID 做 offset 是行不通的,因为 UUID 没法表达成一个紧凑的 bit 位。

常见的解决方案有两类:

  • 给用户维护一张“数字 ID 映射表”,自增序列主键或者专门的短链映射,Redis 位图直接用这个数字 ID 当 offset;
  • 把用户 ID 按某个分桶规则散列成有限范围内的整数,比如uid % 100000000,但这样必然会有碰撞,取舍时要确认能否接受。

另外一个细节是 key 命名。状态统计的 key 一定要包含时间维度,而且要统一格式,否则后面写聚合脚本时非常痛苦。我常用的命名习惯是stats:{维度}:{yyyyMMdd},比如stats:active:20260101、stats:subscribe:20260101。跨天聚合时就拿这些天级 key 做BITOP OR,这样日、周、月都能滚出来。位图滚动本身不复杂,真正的复杂度在于旧数据怎么保留、怎么过期,后面 5.4 节我会展开讲。

3. 放弃 Bitmap 时的替代选型:String、Hash、Set、HyperLogLog、布隆过滤器

3.1 String 直接存状态枚举:小规模下的“偷懒方案”

如果用户量在百万以下,而且状态统计的频率不高,直接用一个 String key 存用户状态也不是不行。比如:

SET user:10001:status 3

它的优点是简单到不需要任何建模,业务代码里想读就读,想写就写。但代价也很明显:一个 key 只服务一个用户的一个状态,1 亿个用户就是 1 亿个 key。Redis 里一个 key 即使 value 只有一个字节,算上 key 字符串、redisObject、哈希表指针等 overhead,平均也要 50 到 100 字节。1 亿用户轻轻松松干到好几个 GB,而且这还只是一个状态。如果你有 4 个状态,内存直接翻四倍。

所以我的结论是:String 方案只适合“用户量小、状态字段多、需要快速迭代”的临时场景,大规模线上直接换位图。

3.2 Hash 做状态字段:读写灵活,但 field 膨胀是硬伤

Hash 的写法是每个用户一个 field,状态值是自定义枚举字符串:

HSET stats:user:20260101 10001 3 HSET stats:user:20260101 10002 5

它比 String 方案更进了一步,至少聚合 key 少了,同一个 key 下能放几十万甚至几百万个用户状态。Redis 内部对小 Hash 有 listpack 压缩编码,字段少的时候内存还能忍;但一旦 Hash 的 field 数超过阈值(默认hash-max-listpack-entries通常是 128),就会升级成真正的 hashtable,每个 field 都会变成独立的 entry,内存开销会迅速抬头。

海量用户场景下,Hash 最大的问题还不是内存,而是聚合能力很弱。你要统计“等级大于等于 2 的用户有多少”,Bitmap 一个BITCOUNT配合位段读就能算,Hash 只能HSCAN全量扫描后逐条判断,这在亿级规模下完全不现实。所以说 Hash 只适合“字段要频繁单点修改、状态量不大、不需要全量聚合”的场景。

3.3 Set 做状态集合:集合运算灵活,但别在亿级里硬扛

Set 的思路和 Bitmap 不太一样:它不强调“用户 ID 是第几个位”,而是把每个满足条件的用户 ID 作为 member 塞进集合里。

SADD active:20260101 10001 10002 10003 SINTER active:20260101 subscribe:20260101 SCARD active:20260101

Set 的集合运算能力很强,交集、并集、差集一条命令就能出结果,而且语义非常直观。问题是内存和 Bitmap 完全不在一个量级。Bitmap 里 1 亿个用户就是 12MB 一个状态;Set 里放 1 亿个用户 ID,即使每个 ID 按整数紧凑存储,加上哈希表指针和键对象,少说 2 到 4 个 GB,多状态直接爆炸。

所以我一般把 Set 用在“用户量在百万级以内、需要频繁做集合运算”的场景,比如黑名单、白名单、指定活动人群包。亿级状态统计优先还是 Bitmap。

3.4 HyperLogLog:只能回答“有多少”,不能回答“是哪几个”

HyperLogLog 在 Redis 里被很多人拿来统计 UV,它最大的特点是内存恒定:一个 key 大约 12KB,不管塞进去多少用户,内存都不随用户量增长。

PFADD uv:20260101 user1 user2 user3 PFCOUNT uv:20260101

但它的能力也止步于“去重后的基数估计”,而且是有误差的,默认标准误差约 0.81%。它不能枚举具体用户,不能判断某个用户是否已被统计,更不能做交集、差集之外的精确分析。所以 HyperLogLog 只适合回答“今天有多少独立访客”“这个活动有多少人参与”这类规模类问题。它和 Bitmap 并不是同一类工具,两者完全可以组合使用——UV 用 HyperLogLog,具体每个用户的状态细节用 Bitmap。

3.5 布隆过滤器:存在性判断可以,状态统计不太行

Redis Stack 里提供了布隆过滤器模块,命令是BF.ADD、BF.EXISTS。它比 Bitmap 更省空间的典型场景是“判断某个用户是否出现过”,比如活动防重复参与、消息去重。它允许一定的误判率——可能把没出现过的人误判为出现过,但不会漏判,这就是它以精度换空间的原理。

BF.ADD active:20260101 user1 BF.EXISTS active:20260101 user1

但布隆过滤器有两个和状态统计不太兼容的硬伤:第一,它只能表达“存在/不存在”,没法表达 0/1 之外的枚举状态;第二,它默认不支持删除,而业务状态往往要支持回退。所以布隆过滤器不太适合做正式的“用户状态存储”,更适合做状态系统外围的“快速判断前置”,比如检查一个用户 ID 是否合法、是否曾经参与过某项活动。

3.6 各选型对比表

到这里,我把几种方案的特性做一个汇总,方便实际做技术选型时快速对标。

方案表达能力亿级用户内存聚合能力典型适用场景
单个 Bitmap单布尔状态约 12MB/亿人BITCOUNT / BITOP 很强活跃、登录、签到
多个 Bitmap / 位段可支持多枚举和多维度按维度数线性增长BITOP 组合统计方便多维度状态、枚举等级
String(每用户一 key)强,任意状态极高,数个 GB 起步弱小用户量、快速开发
Hash(每用户一 field)强,任意状态高,field 膨胀明显弱,需全量 HSCAN小到中等用户量、单点修改多
Set集合语义强高,2~4GB 起步交并差很方便黑名单、活动人群包、百万级
HyperLogLog只有基数恒定约 12KB弱,不能枚举UV 类规模统计
布隆过滤器只有存在性可调,较低弱,不能删除防重、资格预检

4. 海量用户场景下的内存账本与成本测算

4.1 用 1 亿用户、4 个状态维度算一笔账

选型不能靠感觉,我习惯直接按“亿级用户、4 个状态维度”这个标准模型去估算。这里先说一个关键数据:100,000,000 个 bit 等于 12.5MB,约 11.92MiB。这个数字需要刻在脑子里,所有位图方案的内存评估都从这里出发。

  • 4 个独立 Bitmap:4 × 12.5MB = 50MB,加上少量 key 自身的开销,完全可以接受。
  • 4-bit 复合位段:1 亿用户 × 4 bit = 400M bits = 50MB。和 4 个独立 Bitmap 一模一样。区别只是 key 少了,但访问复杂度原来用 4 条SETBIT,现在变成 1 条BITFIELD。
  • Hash 方案:如果 1 亿个 field 全部打到一个 Hash key 里,内存会非常难看。假设每个 field 平均占用 60 到 80 字节,1 亿用户就是 6~8GB,而这还只是一个状态维度。做 4 个维度,内存直接上不封顶。
  • String 方案:1 亿个 key,每个 key 平均 70~100 字节,单维度就得 7~10GB,同样不可承受。
  • Set 方案:1 亿个纯整数 member,内存大概 2~4GB,维度一多照样崩。

所以结论非常清晰:在亿级用户多状态统计面前,Bitmap 系列方案的内存优势不是“稍微好一点”,而是“差好几个数量级”。这也是为什么我始终坚持,只要状态能建模成布尔维度或者有限枚举,就别轻易放弃位图。

4.2 位段方案的枚举上限和内存公式

位段方案经常被误解,以为它能无限扩展枚举数。实际上它的公式很简单:

总内存(字节)= 用户数 × 每位段 bit 数 / 8。

  • 1 bit:1 亿用户 = 12.5MB;
  • 2 bit:1 亿用户 = 25MB,最多表达 4 个状态;
  • 4 bit:1 亿用户 = 50MB,最多表达 16 个状态;
  • 8 bit:1 亿用户 = 100MB,最多表达 256 个状态。

一般来说,单状态枚举超过 8 bit 就不要再用位段死扛了。一方面是业务复杂度上来了,另一方面是内存优势正在消失。一个 8-bit 位段和一个存储 0~255 整数的紧凑 Hash 相比,内存差的没有想象中那么大,但 Hash 的数据建模要灵活得多。

4.3 别忽略 key 数量、过期和持久化开销

内存账本最容易漏掉的部分是 key 数量。比如每天一个维度一个 key,维度很多、保留周期又长,一年下来可能会积累几千个 key。每个 key 作为独立的 Redis 对象,键名本身、过期索引、字典指针都需要额外内存。虽然不至于像“每用户一个 key”那么夸张,但建议还是定期清理旧 key,或者直接用 TTL 让 Redis 自动过期。

还有一个经常被忽略的点是 RDB 持久化和主从复制的开销。一个 50MB 的大位图在 RDB fork 或重写时,如果量特别大,内存会短暂翻倍,主从实例之间同步这个大 key 也会产生明显带宽消耗。所以状态统计这种大 key 集合,最好放在独立实例,或者至少和业务的高频热 key 分隔开。真到特别大规模的时候,主节点只负责写入,聚合统计放到从节点跑,是性价比很高的做法。

5. 写入、聚合与滚动统计的工程实现细节

5.1 批量写入:Pipeline 比逐条 SETBIT 快得多

状态统计系统最典型的写入场景是:一个用户发生一次行为,同时更新多个状态。如果每次行为都发 N 条 Redis 命令,网络 RTT 会吃掉大量时间。正确做法是聚合一批请求后走 Pipeline,或者直接使用redis-cli --pipe一次导入。

伪代码层面:

pipeline = redis.pipeline() pipeline.setbit("stats:active:20260101", user_id, 1) pipeline.setbit("stats:subscribe:20260101", user_id, 1) pipeline.bitfield("stats:level:20260101", "SET", "u4", "#" + user_id, level) pipeline.execute()

实测下来的经验是:批量越大,Pipeline 的收益越明显,但也不能一次性塞几十万条命令,建议一批几千条左右,否则单次命令缓冲占用过大反而会引起延迟抖动。如果系统要求高,还可以把事件先打进消息队列,由一个消费任务批量刷 Redis,这样既能削峰,又能减少 Redis 连接数。

5.2 用 Lua 保证多个状态更新的原子性

Pipeline 能减少 RTT,但它并不保证原子性。比如一个事件既要“设置活跃状态”,又要“提升用户等级”,如果两条命令之间实例宕机,可能只执行了前一条。对于大部分统计场景,这问题不大;但如果是等级提升依赖旧等级值,或者“订阅状态回退”需要防止并发覆盖,就必须用 Lua 脚本把读改写包在一起。

一个典型场景:用户等级从当前值增加一级。如果不用 Lua,你需要先GET,再计算,再SET,中间任何并发事件都可能导致脏写。前面 2.3 节给的脚本就是标准解法,实际生产环境中,你可以把状态变更逻辑都写成 Lua 脚本,然后通过EVAL或EVALSHA执行。这样既省网络开销,又保证了一段逻辑的原子性。

5.3 聚合统计:BITOP + BITCOUNT 的正确打开方式

多状态组合统计的核心命令是BITOP,它能对多个字符串做 AND、OR、XOR、NOT,结果写到新 key 里,然后BITCOUNT统计结果里有多少个 1。

比如要统计“今天活跃过且订阅过通知的用户数”:

BITOP AND stats:active_and_subscribe:20260101 stats:active:20260101 stats:subscribe:20260101 BITCOUNT stats:active_and_subscribe:20260101

要注意BITOP的时间复杂度是 O(n),n 是位图长度。如果位图很大,并且每次查询都现场做交并运算,会比较吃 CPU。高频统计场景建议把聚合结果缓存起来,或者定时刷新。比如每分钟执行一次聚合,结果输出到一个小 key 里,页面查询只读这个结果值,避免每次都触发全量 BITOP。

再就是统计任务的下沉:如果主库写入压力已经很高,聚合统计这类“批量读 + 算”的任务尽量放到从库执行。主库只需要保证写入,从库只读模式天然适合这种离线聚合。注意BITOP会写入目标 key,所以执行时要选对节点,要么从库允许写,要么把聚合结果直接通过REPLICAOF的方式再回传,不过一般建议直接对统计实例操作。

5.4 位图滚动与历史数据保留

“位图旋转”这个概念在日活统计里很常见,本质就是按时间周期滚动生成新 key。每天的统计用独立的 day key,跨天的统计则通过多 key 组合计算。

比如要统计“本周活跃用户”,把周一到周日的活跃 Bitmap 做 OR 合并:

BITOP OR stats:active:week:20260103 stats:active:20260101 stats:active:20260102 stats:active:20260103 BITCOUNT stats:active:week:20260103

这里有一个常见误区:周统计不能只把 7 天数据累加,因为同一个用户可能多天活跃,只有 OR 合并才能保证去重。同理月度数据就用当月所有 day key 做 OR。

旧数据怎么保留,我推荐的做法是“热数据留 Redis,冷数据落地数据仓库”。Redis 里主要保留最近 N 天的 day key、最近几周的 week key,以及临时的聚合结果 key。过期的位图要么靠 TTL 自动淘汰,要么定期写一个清理任务删除。不要把位图在 Redis 里囤一整年,不然再省内存也会变成负担。真到需要长周期分析的时候,直接从数仓回放原始事件更划算。

5.5 状态统计 key 与业务缓存的隔离

这个词在 Redis 缓存治理里经常被提起,放到状态统计场景同样适用。很多系统的问题是,状态统计 key 和业务缓存 key 混在同一个实例里,结果一个热点状态 key 打满 CPU,把整个业务缓存的命中率拖崩。

我现在的做法是至少做两层隔离:第一,状态统计用专门的前缀命名空间,比如统一以stats:开头;第二,如果用户量和写入频率真的很高,直接建独立 Redis 实例,物理隔离。状态统计对数据一致性的要求通常是“最终一致”,所以它和业务缓存放在一起,反而容易被业务缓存淘汰策略误伤,分开之后两边的生命周期管理都清爽很多。

6. 我在状态统计项目里踩过的坑与最终选型经验

6.1 别把“用户属性”和“用户状态”混在一个 key 里

有段时间我为了图方便,把所有用户相关的数据全部塞进一个 Hash,field 就放各种业务状态。结果单点读写确实方便了,但一旦要做全量统计,HSCAN扫出来的数据量大得离谱,Redis 主线程直接卡了好几次,最终不得不连夜重构。

现在的原则非常明确:用户属性用 String 或 Hash,用户状态用位图,两者永远不混存。属性是低频修改、高频读取的东西,状态是高频写入、需要聚合分析的东西,放在一起只会互相拖累。

6.2 状态回退必须做幂等设计

Bitmap 最顺手的是“置 1”,最容易被坑的是“置 0”。最常见的问题是这样的:用户取消了订阅,业务层发出一条SETBIT stats:subscribe:xxx uid 0,但网络层重试机制导致这条命令被发送了两次,这没事;可如果取消之前有一条“订阅成功”的事件在消息队列里还没消费完,补消费时又会把状态设置回 1,用户明明已经取消订阅,统计里却还是订阅状态。

解决思路有两个方向:一是事件幂等,为每条事件生成全局唯一 ID,消费前判断这条事件是否已经被处理过;二是不支持直接回退,而是用“当前状态 + 事件时间戳”的方式决定最终结果,比如只有比旧事件时间更新的“置 0”才允许生效。不管哪种方案,都不能只依赖一个裸的SETBIT,必须在外层叠加逻辑。

6.3 BITCOUNT 不是免费的,高频聚合要缓存结果

BITCOUNT的实现效率很高,Redis 内部按 4 位一组做了查表优化,但它仍然是 O(n) 操作。一个 50MB 的大位图,聚合一次就是几十 MB 的扫描,如果前端每秒钟刷新一次,Redis 的 CPU 很快就顶不住。

我踩过这个坑后的做法是:写一个内部聚合任务,每 30 秒或 1 分钟执行一次BITOP和BITCOUNT,把结果写成一个普通计数 key,外部的报表接口全部只读这个计数 key。如果想拿到“实时”一点的数据,可以在这个计数 key 之外叠加一个小的“当日增量计数器”,用INCR维护,页面先显示增量值再异步刷新精确值。

6.4 从纯工程视角给一份选型建议

如果我现在重新搭一套亿级用户的多状态统计系统,我会按下面的方式选型:

  • 每个独立布尔状态,建一个独立 Bitmap,key 带日期后缀;
  • 互斥枚举状态,用BITFIELD位段表达,位段宽度按枚举数量决定;
  • 只关心规模不关心个体的统计,比如日活 UV、独立访客,用 HyperLogLog;
  • 需要快速判断“某个用户是否曾经满足某个条件”,但不要求精确回退,用布隆过滤器做前置过滤;
  • 任何状态都不建议直接用每用户一个 key 的 String 或 Hash 扛大流量。

这套组合在内存、写入速度、聚合能力上已经是被反复验证过的方案。真到了单实例连写入都扛不住的时候,再考虑分片或者把状态统计下沉到数据仓库,Redis 负责热数据窗口内的实时状态,历史分析交给离线平台。这恐怕才是标题里“除了 Bitmap”背后真正想找的答案——不是不用 Bitmap,而是围绕 Bitmap 建立一套能够承载多状态、多维度、跨周期聚合的完整建模体系。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询