Redis BitMaps 原理与实战:签到、活跃统计与内存优化
1. 为什么要在Redis里聊BitMaps这个东西先说结论BitMaps不是Redis的独立数据类型它底层就是String只不过把String当成了一个按位操作的数组来用。这个认知非常关键很多人第一次接触会以为Redis又多了一个新类型其实不是你SET一个key再对它做SETBIT这两个操作作用的是同一块内存只是读法不同。我当年第一次看到SETBIT、GETBIT、BITCOUNT这几个命令的时候也愣了几秒觉得这不就是位运算吗跟Redis有什么关系。后来在真实项目里用了一次签到统计才发现这东西在特定场景下能把内存占用压到令人发指的程度。先把最核心的一句话放这儿BitMaps适合处理“海量整数ID的二值状态”问题比如某用户今天有没有签到、某活动某天有没有被某个用户访问过、某篇文章被哪些用户点过赞。这类问题的共同特征是你要记录的是“是/否”两个状态而对象本身可以用一个连续或近似连续的整数来标识。只要满足这两点BitMaps几乎就是内存效率最高的方案没有之一。为什么这么说我们算一笔账。假设你要记录100万个用户某天的签到状态最朴素的方案是用一个Set把签到用户的ID塞进去。假设用户ID是10位以内的整数Redis的Set底层是哈希表或整数集合在元素数量超过一定阈值后会用哈希表存每个元素加上指针开销保守估计一个元素占几十个字节100万个就是几十MB。如果换成BitMaps100万个bit就是1000000/8125000字节也就是约122KB。差了两个数量级。这个差距在大规模场景下是决定性的不是“优化一点”的问题而是“能不能扛住”的问题。但BitMaps也不是银弹它的限制同样明显。第一它依赖ID的连续性如果你的用户ID是雪花算法生成的19位长整型最大值可能到几十亿甚至更大那你为了记录这100万个用户得开一个能覆盖最大ID的bit数组中间的稀疏空洞全是浪费。第二BitMaps的操作是位级别的单个bit没有过期时间你不能给某一位单独设置TTL只能对整个key设置。第三BITCOUNT这类统计命令在数据量大时虽然快但仍然是O(N)级别不能无脑乱用。这几点后面会展开讲先把场景和选型的逻辑理清楚再进实操。这篇文章面向的读者主要是已经用过Redis基本类型、想搞清楚BitMaps到底怎么落地的人。如果你连SET和GET都没敲过建议先把String、Hash、List这几个类型过一遍再回来不然看位操作会有点懵。如果你已经在生产环境用过Set做统计正被内存压得喘不过气那这篇基本就是给你写的。2. BitMaps的核心原理与关键命令拆解2.1 底层存储结构String为什么能当位数组用Redis的String底层是SDS也就是简单动态字符串。SDS本身是按字节管理的但在BitMaps的语境下Redis把这个字节数组当成了一个可以按位寻址的大数组。你执行SETBIT key offset valueRedis内部做的事情是找到offset对应的字节位置offset除以8取整再找到这个字节里的位位置offset对8取模然后通过位运算把这个bit设成0或1。如果offset超出了当前字符串长度Redis会自动扩展字符串扩展的部分用0填充。这个自动扩展机制很重要它意味着你不需要预先声明“我要一个多大的位数组”直接按最大的offset去设就行Redis会帮你补齐中间的0。这里有个细节值得说自动扩展是按字节还是按位Redis扩展的时候会保证字符串长度足够容纳到offset所在的字节也就是说假如你设SETBIT k 100 1offset 100对应的字节索引是12100/812那么字符串长度会被扩展到至少13字节。100之后的101到103这几个bit同一个字节内的剩余位会自动是0。这个行为在大多数场景下没问题但如果你是从一个很大的offset开始写中间的0也是要占内存的只不过占的是0字节不是bit。内存是按字节分配的稀疏到极致也是按字节浪费。理解了这个结构很多看似奇怪的行为就说得通了。比如BITCOUNT为什么能统计整个位数组里1的个数因为它可以按字节做查表或者用更高效的算法逐字节统计popcount。再比如GETBIT为什么对不存在的offset返回0因为超出字符串长度的位置逻辑上就是0。这些东西不是特例都是String位视图这个本质决定的。2.2 命令家族从单bit操作到跨key位运算BitMaps的命令可以分成四类我按使用频率从高到低排一下。第一类是单bit读写SETBIT和GETBIT。SETBIT key offset value里value只能是0或1返回的是这个位置原来的值。这个返回值有时候很有用比如你想知道“这个人是不是第一次签到”就可以看SETBIT返回的是0还是1是0说明之前没设过是首次。GETBIT key offset就简单了返回0或1。第二类是批量统计BITCOUNT和BITPOS。BITCOUNT key [start end [BYTE|BIT]]统计指定范围内1的个数不传范围就统计整个key。注意老版本Redis的范围默认是字节Redis 7之后可以用BIT参数按位指定这个坑很多人踩过明明想看前100个bit结果传了0 100实际统计的是前101个字节。用的时候一定看清楚版本和参数单位。BITPOS key bit [start end]是找第一个0或1的位置用来判断“这批用户里有没有谁还没签到”这种问题很方便。第三类是跨key位运算BITOP operation destkey key [key ...]。支持的operation有AND、OR、XOR、NOT。这个命令在“多天签到结果合并”“多个活动人群求交集”这类场景里特别好用。注意NOT只接受一个key因为按位取反就是单目运算。BITOP的结果会存到一个destkey里结果最长按最长的输入key对齐短的key在高位补0。这个对齐规则在做交集的时候要留意别以为短key就自动忽略。第四类是位置查询BITFIELD。这个严格说是个单独的命令支持在String上做任意位宽、任意偏移的整数读写有点像结构体。它跟BitMaps关系密切但用法更复杂后面单独开一节说。2.3 内存模型为什么它能省到极致再回到内存这个话题因为它才是BitMaps真正的卖点。Redis的String有一个特性叫共享整数对象但BitMaps用不上因为位数组通常比较大。不过BitMaps有另一个隐含优势它对小位数组有压缩效果不明显但对大位数组是线性的而且常数极小。你存100万个bit就是125KB存1亿个bit就是12.5MB存10亿个bit就是125MB。这个线性关系意味着你可以精确预估内存不像Hash或ZSet那样随着元素数量增长有各种内部编码切换带来的非线性跳跃。但要注意一点Redis的key本身有开销。一个key在Redis里除了value还有dictEntry、redisObject等结构几十到上百字节不等。如果你为了省内存把用户按天拆成很多个小key比如signin:20240101、signin:20240102每个key只有很少的有效bit那key的开销可能反而超过value。这种情况下更合理的做法是把时间维度编码进offset用一个key存一整年甚至更久让位数组尽量填满把key的开销摊薄。这个权衡在实操部分会用具体数字算给你看。还有个小细节Redis在字符串长度小于等于44字节的时候会用embstr编码一次分配读写都省一次内存寻址。但BitMaps一旦offset超过35144*8-1就落到raw编码了。这在实际场景里几乎必然发生所以不用太纠结这个分界点知道有这回事就行。3. 典型应用场景与选型逻辑3.1 签到打卡从Set迁移到BitMaps的真实收益签到是最经典的BitMaps场景没有之一。假设你要做一个“连续签到7天送奖励”的功能用户量100万每天记录谁签到了。用Set的话每个用户一个Setkey是signin:user:{uid}:202401value是签到的日期集合。100万用户就是100万个Set key每个Set里最多31个元素。光key的开销100万乘以大概100字节就是100MB再加上Set内部结构整体奔着200MB去了。换成BitMaps思路正好反过来以天为单位建keykey是signin:20240101offset是用户ID。一个key的位数组带上100万个用户就是125KB一个月31天就是31个key约3.9MB。从200MB到4MB这个量级的差距让运维半夜不用起来扩容。而且查“某人某月哪天签到了”也很直接就是GETBIT signin:20240101 uid查连续签到就是连续几次GETBIT或者用BITFIELD一次性读出来。当然BitMaps方案也有代价。你要查“某个用户这个月签到哪几天”得循环31次GETBIT或者用BITFIELD一次读31个bit。如果这种查询特别频繁可以再加一个缓存层把用户的签到日期缓存成一个小字符串或JSON。但大多数签到场景查询是按用户维度的低频操作签到动作本身是高频的所以把高频写做成位操作、低频读稍微绕一点这个权衡是划算的。3.2 活跃用户统计与留存分析DAU、WAU、MAU这类统计本质上是“某时间段内活跃过的用户”集合运算。用BitMaps可以这样设计每天一个keyoffset是用户ID用户当天活跃就SETBIT设1。算DAU就是BITCOUNT daily:20240101。算一周的WAU可以用BITOP OR destkey daily:20240101 daily:20240102 ...把7天的位或到destkey再BITCOUNT。算某个用户是不是周活跃就是判断这7天里至少有一天是1可以循环GETBIT也可以先BITOP OR到一个临时key再GETBIT。留存分析稍微复杂一点比如“昨天活跃且今天也活跃”的用户数。这个正好是AND运算BITOP AND destkey daily:yesterday daily:today然后BITCOUNT destkey。得到的就是连续两天活跃的人数。要算7日留存就是把第1天和第7天的key做AND。这种集合运算用BitMaps做比用Set做SINTER要快得多因为位运算是CPU原生的而Set求交集要遍历哈希表。在大数据量下位运算的常数优势非常明显。这里有个坑要注意BITOP的结果key不会自动过期。如果你频繁做OR或AND生成临时key一定要记得给这些临时key设过期时间或者用完就DEL不然内存会被这些中间结果慢慢吃掉。我在一个项目里就因为漏了这一步跑了两周后发现内存里多了一堆bitop:xxx的key排查了半天。后来养成习惯所有BITOP的结果key一律SETEX式的思路处理或者用BITOP之后立刻EXPIRE。3.3 布隆过滤器与去重判断布隆过滤器是BitMaps的一个高级用法虽然Redis官方有RedisBloom模块但自己用BitMaps手搓一个也完全可行而且理解原理后更有掌控感。布隆过滤器的核心是用多个哈希函数把元素映射到bit数组的多个位置查询时如果所有位置都是1则元素可能存在如果有任何一位是0则一定不存在。它没有假阴性只有假阳性。用BitMaps实现布隆过滤器的关键参数是位数组大小和哈希函数个数。位数组太小假阳性率飙升太大内存优势就没了。计算公式大概是位数组大小m -n * ln(p) / (ln2)^2哈希个数k (m/n) * ln2。其中n是预期元素数量p是可接受的假阳性率。举个例子n100万p1%算下来m约958万bitk约7。也就是约1.2MB内存用7个哈希函数。这个配置能挡住99%的无效查询代价是1.2MB非常划算。实操中SETBIT和GETBIT就是布隆过滤器的基元你要做的事就是自己实现多个哈希函数把元素的哈希值分散到多个offset上。哈希函数可以用MurmurHash的不同种子也可以简单点用MD5取前几段。注意Redis的offset是unsigned long类型取值范围很大实际用的时候对m取模即可。3.4 什么时候不该用BitMaps说了这么多好处也得说说什么时候别用。第一如果你的对象ID不连续且范围极大比如UUID做标识那BitMaps就不合适因为offset没法用UUID表示。第二如果每个对象的状态不是二值而是多值比如一个用户有三种状态而不是“是/否”那BitMaps需要编码复杂度上来了不如直接用Hash。第三如果数据量本身很小比如只有几千个用户那BitMaps省的那点内存对你毫无意义反而增加了代码复杂度得不偿失。第四如果需要对单个元素设置过期时间BitMaps做不到因为过期是key级别的。我的经验是判断要不要用BitMaps就问自己三个问题标识能不能映射成整数偏移状态是不是二值量级是不是足够大以致于内存成了问题三个都是“是”那就上BitMaps有一个“否”就再考虑考虑。这个判断比背命令有用得多。4. 从零落地一套签到系统的完整实操4.1 环境准备与key设计先假设一个具体场景100万用户要记录每个人2024年全年的签到状态支持查某天是否签到、查某月签到多少天、算某天总签到人数。环境就是我本地的Redis版本7.xdocker起的单机不是主从也不是集群先验证逻辑。key的设计我选了一个方案signin:2024:{uid_bucket}其中uid_bucket是把用户ID按高位分桶。为什么要分桶因为如果100万用户ID集中在1到100万单一keysignin:2024就是100万bit约122KB完全没问题。但如果用户ID分布稀疏最大到1亿那单key就是12.5MB虽然也不算大但每次BITCOUNT要扫全量而且位数组太稀疏浪费严重。分桶可以缓解这个问题比如按uid/1000000分桶每个桶覆盖100万个ID桶内偏移是uid%1000000这样每个桶的位数组都很紧凑。不过为了简化这个实操我用最朴素的单key方案signin:2024假设用户ID就是1到100万的连续整数。offset直接就是uid。全年按天编码offset (月份-1)*31 (日期-1)这样每个月的位段是31位一年12个月共372位留点冗余无所谓。其实更精确的编码是按当年第几天算offset这样一年365位刚好但月初月末算起来麻烦我选31的倍数图个直观。写签到就是SETBIT signin:2024 offset 1。查签到就是GETBIT signin:2024 offset。某天总签到人数要换个思路因为位数组是按用户排的同一天的签到分散在不同用户的位段里没法直接BITCOUNT。所以这里得再加一个按天维度的keysignin:daily:20240101offset是uidBITCOUNT这个key就是当天签到人数。也就是说写一次签到要写两个地方这是用空间换查询便利两台key加起来也就几百KB可以接受。注意双写意味着两次网络往返如果签到量特别大可以用Lua脚本把两个SETBIT打包成原子操作减少往返同时避免只写了一个的中间状态。4.2 核心命令的实操记录与参数验证先在redis-cli里验证基础行为。我设几个bit看看。127.0.0.1:6379 SETBIT signin:2024 0 1 (integer) 0 127.0.0.1:6379 SETBIT signin:2024 7 1 (integer) 0 127.0.0.1:6379 GETBIT signin:2024 0 (integer) 1 127.0.0.1:6379 GETBIT signin:2024 1 (integer) 0 127.0.0.1:6379 BITCOUNT signin:2024 (integer) 2SETBIT返回0说明这两个位置之前都是0是首次设置。BITCOUNT返回2符合预期。这时候key的长度是1字节因为最高offset是7正好一个字节。再验证自动扩展和BITPOS127.0.0.1:6379 SETBIT signin:2024 100 1 (integer) 0 127.0.0.1:6379 STRLEN signin:2024 (integer) 13 127.0.0.1:6379 BITPOS signin:2024 0 (integer) 1 127.0.0.1:6379 BITCOUNT signin:2024 (integer) 3offset 100对应第12个字节从0开始数所以字符串长度是13字节验证了前面的推断。BITPOS signin:2024 0返回1说明从0开始第一个为0的bit在位置1也符合预期因为位置0是1位置1是0。再验证BITCOUNT的范围参数这个是踩坑重灾区127.0.0.1:6379 BITCOUNT signin:2024 0 0 (integer) 2 127.0.0.1:6379 BITCOUNT signin:2024 0 0 BIT (integer) 1同样写0 0不带BIT参数时统计的是第0个字节也就是bit 0到bit 7里面有bit 0和bit 7两个1所以返回2。带上BIT参数后统计的是bit 0这一个位值是1所以返回1。这个差异如果不注意统计结果能错得离谱。我用7.2版本的Redis跑这个低版本可能不支持BIT参数用的时候要确认版本。4.3 用BITFIELD做批量读写BITFIELD是个被低估的命令。它的语法是BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL]。type可以是u8、i8、u16、i16等u表示无符号i表示有符号数字是位宽。它能在一次命令里对多个位段做读写这对批量操作特别友好。比如我要一次读出某个用户1月1日到1月7日的签到状态用一个7位的位段就行。但BITFIELD的type位宽是有限制的支持u1吗实测支持u1就是1位无符号数取值0或1。所以可以这样127.0.0.1:6379 BITFIELD signin:2024 GET u1 0 GET u1 1 GET u1 2 1) (integer) 1 2) (integer) 0 3) (integer) 0一次命令读出三个bit返回1、0、0。这样比三次GETBIT省两次往返。如果要读更多可以继续往后加GET子命令。要注意BITFIELD里GET的offset是按位算的不是按字节别跟BITCOUNT的范围参数搞混。INCRBY用来做计数器BITFIELD counter INCRBY u8 0 1每次给从0位开始的8位无符号整数加1最大到255后默认WRAP回绕。如果你要做“每个用户每天的签到次数”这种可能超过255的得用更大的位宽比如u32。但签到一般是每天一次用u1就是SET语义不需要INCRBY。INCRBY更适合“某接口今天被调用了多少次”这种计数场景。注意BITFIELD在同一个命令里如果有多个写操作它们不是原子的跟MULTI不一样。Redis保证单条命令原子但BITFIELD内部多个子操作之间Redis的文档说它是原子执行的这一点在官方命令页有说明可以放心用。不过跨命令的原子性还是要靠Lua或MULTI。4.4 跨key位运算实现留存与交集分析现在验证BITOP。假设我造了三天的活跃数据127.0.0.1:6379 SETBIT daily:20240101 0 1 127.0.0.1:6379 SETBIT daily:20240101 1 1 127.0.0.1:6379 SETBIT daily:20240102 1 1 127.0.0.1:6379 SETBIT daily:20240102 2 1 127.0.0.1:6379 SETBIT daily:20240103 1 1 127.0.0.1:6379 SETBIT daily:20240103 3 1然后求1月1日和1月2日的共同活跃用户127.0.0.1:6379 BITOP AND retention:0102 daily:20240101 daily:20240102 (integer) 1 127.0.0.1:6379 BITCOUNT retention:0102 (integer) 1返回1说明用户1两天都活跃。再求三天并集127.0.0.1:6379 BITOP OR active:week daily:20240101 daily:20240102 daily:20240103 (integer) 1 127.0.0.1:6379 BITCOUNT active:week (integer) 4并集里有4个不同的用户0、1、2、3正确。这里BITOP的返回值是结果字符串的长度单位是字节我刚才的结果key长度1字节因为最大的offset是3落在一个字节内。如果要做7日留存就是第1天和第7天的AND。这个操作在用户量大的时候BITOP的性能主要花在拷贝和位运算上100万bit就是125KB的量级几毫秒就完事比Set求交集快一个档。但要注意BITOP生成的结果key如果不删会一直占内存操作用完记得DEL或设过期。4.5 用Lua脚本保证签到双写原子性前面提到签到要写两个key一个按用户维度一个按天维度。用Lua包一下-- KEYS[1] signin:2024 -- KEYS[2] signin:daily:20240101 -- ARGV[1] user_offset_in_year -- ARGV[2] user_offset_in_day local r1 redis.call(SETBIT, KEYS[1], ARGV[1], 1) local r2 redis.call(SETBIT, KEYS[2], ARGV[2], 1) return {r1, r2}返回值里r1、r2分别是两个SETBIT的原值。如果r1是0说明这个用户这一年还没签过这个位置可以判断为首次签到如果r2是0说明这个用户今天没在当天key里出现过。用EVAL执行127.0.0.1:6379 EVAL local r1 redis.call(SETBIT, KEYS[1], ARGV[1], 1) local r2 redis.call(SETBIT, KEYS[2], ARGV[2], 1) return {r1, r2} 2 signin:2024 signin:daily:20240101 5 5 1) (integer) 0 2) (integer) 0这样一次调用完成两个写减少往返也保证原子。如果并发签到量特别大Lua脚本的阻塞时间也要考虑脚本尽量短只做必要操作。5. 常见问题与排查实录5.1 BITCOUNT范围参数为什么总是对不上这个问题我遇到太多次了。BITCOUNT key start end在Redis 6及之前start和end的单位是字节而且包含两端。你想统计前100个bit写了BITCOUNT k 0 100实际统计的是前101个字节也就是808个bit结果自然对不上。正确做法是Redis 7用BITCOUNT k 0 99 BIT老版本要么自己算字节范围比如前100bit是前13个字节100/8向上取整那就BITCOUNT k 0 12但这样会多统计几个bit精度有损。所以凡是按bit范围统计优先升级到7.x用BIT参数或者改用BITFIELD按位读出来自己数。5.2 BITOP结果key越来越多怎么治前面提过BITOP的结果key不会自动过期。排查方法很简单KEYS bitop:*或者SCAN一下看有多少结果keyMEMORY USAGE看每个占多少。治本的方法是在代码里所有BITOP调用之后都跟一个EXPIRE比如设24小时过期。或者封装一个函数统一处理BITOPEXPIRE。另外如果一个临时结果只用一次可以BITOP到一个固定key名上反复复用而不是每次生成新key名这样key的数量就是常数。这两种思路结合着用最好。5.3 offset怎么算才不会错offset的映射是BitMaps实操里最容易出错的地方因为它不直观。我的经验是把offset的计算封装成一个纯函数单元测试覆盖绝不在业务代码里散落地写uid * 31 day这种表达式。比如年度签到的offset函数offset(uid, month, day) (uid - 1) * 372 (month - 1) * 31 (day - 1)。注意这里我先预留了uid的位段再在段内放月份和日期这样“某用户全年”的位是连续的。如果反过来先放时间再放用户位就碎片化了。封装函数之后改编码方式只改一处不容易错。5.4 大key导致的阻塞问题一个位数组如果特别大比如单key几百万bit甚至上千万bitBITCOUNT扫全量在Redis单线程模型下会阻塞其他请求。实测1000万bit的BITCOUNT大概几毫秒到十几毫秒不算灾难但也要警惕。如果真有这么大的统计需求两个办法一是分桶把大key拆成多个小key统计时分别BITCOUNT再求和让单次操作的时间可控二是把统计结果异步化别在请求路径上做用定时任务预计算好放进另一个key。分桶还能顺便解决稀疏浪费的问题一举两得。5.5 常见问题速查表现象可能原因排查与解决BITCOUNT结果远大于预期范围参数单位是字节不是bitRedis 7加BIT参数或按字节重算范围内存莫名增长BITOP结果key未清理SCAN排查bitop前缀key加EXPIRE或复用key名offset对不上编码公式散落在业务代码封装纯函数统一计算offset单次统计卡顿大key全量BITCOUNT分桶拆分或异步预计算SETBIT没生效offset超出预期写入位置错误用GETBIT回读验证核对offset公式位数组稀疏浪费ID范围大且分布稀疏按高位分桶压缩桶内ID范围6. 经验总结与几个容易忽略的细节BitMaps这个东西用好了是神器用错了是灾难。我最后再补几个实操里总结出来的点。第一永远先算内存再动手。在写代码之前拿纸笔或计算器算一下n和bit数看看单key多大、多少个key、总内存多少。我见过有人一上来就设计了一个上亿bit的单key结果每次操作都卡回头一看是设计阶段没算。算内存的时候别忘了把key本身的几十到上百字节开销也算进去尤其是key数量多的时候。第二offset的编码方式决定了查询模式。你在设计offset的时候其实就是在决定“哪些查询是O(1)的哪些是要扫描的”。按用户排、时间放段内那么查用户的时间范围是连续的BITFIELD一把读查某天的所有用户就是分散的得靠额外的按天key。反过来按时间排、用户放段内结论就相反。所以offset设计不是技术问题是需求问题先想清楚最频繁的查询是什么再定编码。第三BITOP的临时key一定要管理。这个是血泪教训前面说了两次这里再说一次是因为它真的太容易被忽略。你可以写个脚本定期扫描并清理没有TTL的中间key把这个当成运维例行工作。第四版本差异要留意。BITCOUNT的BIT参数、BITFIELD的OVERFLOW行为、BITPOS的参数解析在不同Redis版本里有细微差别。生产环境用之前在测试环境把目标版本跑一遍别拿文档当圣旨文档有时也滞后。我用7.x的BITCOUNT ... BIT用得很顺换到6.x的老集群就得改写法这种事撞一次就记住了。第五别把BitMaps当唯一方案。有些场景Set反而更合适比如元素少、需要存额外属性、需要单独过期的时候。工具是为场景服务的不是反过来。我个人的判断标准还是那三条ID能映射成整数、状态是二值、量大到内存敏感。这三条同时满足我才用BitMaps不然优先考虑其他结构。这套判断帮我在几个项目里避免了过度设计代码简单了不少维护的人也少踩坑。最后分享一个我常用的调试技巧当你怀疑offset写错了别急着看代码先在redis-cli里用GETBIT把你预期的那几个位置读出来跟预期值一一比对。位操作的对错是视觉上最不直观的肉眼验证比逻辑推理快多了。我排查位相关bug的时候基本都是先用GETBIT定位到具体哪个bit错了再反推offset公式哪里有问题比看半天代码有效。