
线上系统里最磨人的需求往往不是某个函数写不出来而是“看似简单、量一大就崩”的状态判断。比如这么几个我做过或帮人排过的问题今天到底有哪些用户登录过某个用户这个月连续打卡了几天一批商品里哪些已经推送过消息、哪些还没推送再比如缓存击穿场景里怎么在亿级ID里快速判断“一个ID根本不存在”从而挡住对数据库的无谓穿透这些需求有一个惊人的共性本质上都是“海量对象各抒一个布尔值”。用户登录了是1没登录是0今天签到了是1没签到是0已经推送过是1没推送过是0。数据量小的时候数据库一行、Redis一个Set都能扛但一旦对象数量冲到百万、千万、亿级方案选型就直接决定你是加几台机器还是加一个字段就能解决。Redis里的Bitmaps和位操作就是为这种“布尔洪水”设计的最锋利工具。这个系列不是讲SETBIT、GETBIT的API怎么背而是把这类场景背后的数据结构本质、命令设计逻辑、真实的工程代价和坑全部摊开讲。适合已经在用Redis、但是对Bitmaps的认识还停留在“好像能做签到”的开发者和架构师。1. 三个经典需求背后共同的“布尔洪水”模型1.1 用户在线/日活统计的常规做法为什么贵先看看最常见的需求统计日活DAU或者在线状态。一开始数据量小很多人直接在Redis里放一个Setkey叫online_users用户登录了就SADD online_users 10001下线了SREM。要统计今天多少人活跃就SCARD一下。很直观代码也很好写。但如果你的系统有1000万日活这个Set会膨胀到什么程度Redis的Set在元素都是整数且数量不大时会用intset编码每个元素大约十几字节。到了几百万以上或者元素不是紧凑整数时就会退化成hashtable编码每个entry还有额外的指针和内存开销。即便按一个元素16字节的乐观估计1000万用户就是160MB而一个平台往往不止一个“布尔维度”——在线、活跃、留存、推送已发每个维度各来一套内存立刻失控。数据库方案呢一张user_login_log表每天几千万行要算今天的DAU要么COUNT(DISTINCT user_id)跑几分钟要么维护一张“每日活跃用户明细表”写入量大、聚合查询极慢。更致命的是这种需求往往不是只问“今天多少人”还要问“昨天也在、今天也在的人有多少”——也就是留存和交集关系型数据库处理起来越发吃力。1.2 签到记录、过滤判断等需求其实是同一个模型把这类需求抽象一下你会发现它们共享同一个数学模型一个二维布尔矩阵。横轴是用户ID纵轴是日期或维度单元格的值只有0和1。签到场景看起来不一样其实也是这个模型只是坐标轴换了一下每个用户维护一条“时间轴”某天签到就在对应位写1。商品推送标记也类似一亿个商品ID每个ID对应“已推送/未推送”一个布尔位。缓存穿透防护里的布隆过滤器底子更是标准的位数组加多次散列。这就是为什么Redis把位操作设计在字符串类型之上而不是另搞一套“Bitmap类型”。因为几乎所有语言和网络模型里二进制安全的字符串天然就是字节数组位的本质就是字节里的比特。你只需要告诉Redis“我关心第几个bit”它就能帮你读、改那个位上的一点点信息。2. 先看清Bitmaps的真身它就是STRING的位视图2.1 内存占用公式和512MB上限从哪来很多教程开口就是“Redis的Bitmaps数据类型”这个说法不严谨。你在TYPE命令里根本看不到bitmap这个类型Redis里的位图本质是一个字符串键。之所以叫Bitmaps只是它提供了一系列面向“位”操作视角的命令。Redis字符串底层是SDS二进制安全可以存任意字节。当你执行SETBIT key offset value时Redis会先计算这个offset落在字符串的哪个字节byteIndex offset / 8然后看当前字符串长度够不够不够就扩展到这个字节索引为止扩展出来的字节全部填0。反过来GETBIT如果发现offset超出了字符串长度不会分配内存直接返回0。理解了这一点两个之前很多人问我的问题就自然清楚了为什么单key的位偏移上限是2^32 - 1即4294967295 因为Redis单个字符串key的最大长度是512MB512 * 1024 * 1024 * 8 4294967296个bit扣除从0开始最大offset就是4294967295。为什么位图有时候那么占内存 因为一条位图占用的字节数不取决于“你写了几个1”而取决于“你写到的最大的offset”。SETBIT key 100000000 1无论你只写了一个1Redis都会把字符串扩展到第100000001个bit的位置也就是大约12.5MB。这个反直觉的特性后面专章展开。内存账本大家先有个体感1个bit就是1位8个bit是1字节1亿个bit约等于11.92MiB。也就是说用位图记录1亿个用户的任意布尔状态每天只花12.5MB不到。2.2 一个容易弄反的位序问题为什么设了bit 0读出\x80位操作里最容易出师不利的就是位序。Redis规定字符串的bit编号从0开始第0位是字符串第一个字节的最高位然后从高位往低位排。我实际敲一下给你看127.0.0.1:6379 SETBIT sign:user:10001 0 1 (integer) 0 127.0.0.1:6379 GET sign:user:10001 \x80 127.0.0.1:6379 SETBIT sign:user:10001 1 1 (integer) 0 127.0.0.1:6379 GET sign:user:10001 \xc0 127.0.0.1:6379 STRLEN sign:user:10001 (integer) 1\x80是二进制10000000\xc0是11000000。看到没第一个bit是字节的最高位不是最低位。这跟很多从网络协议、底层驱动转过来的开发者的直觉正好相反。如果你要手动拼字节或者把位图里的内容导出给其他系统解析位序搞反了整张位图的数据就全错位了。这个方向要记牢尤其是做签到场景时第0天、第1天的bit在同一个字节里顺序就是由这个位序决定的。3. 七个位操作命令的语义、边界与易错点3.1 SETBIT/GETBIT/BITCOUNT最基础的三板斧这套命令从Redis 2.2开始就有了语法如下SETBIT key offset value把某个bit设为0或1返回该bit的旧值。offset必须是非负整数范围0到2^32-1。注意因为返回旧值你可以在业务层用来做“如果之前没设过就执行某个动作”的原子判断但跨命令组合时不能替代真正的事务或Lua。GETBIT key offset读取某个bit越界返回0。它永远不会分配内存所以也常被用来判断“这个位是不是已经写过了”。BITCOUNT key [start end]统计整串中置为1的bit个数。最经典的是直接当DAU统计用。BITCOUNT有个高频坑start和end的单位是字节不是bit。我第一次用的时候想数“前100个bit里有多少个1”直接BITCOUNT key 0 100然后发现数字明显不对。后来反应过来0 100是统计前101个字节也就是前808个bit。所以要么先把字节区间算清楚要么配合BITPOS、BITFIELD做精细定位。Redis 7.0之后BITPOS命令支持了BYTE|BIT参数来声明start/end的单位但BITCOUNT的参数单位依然是字节。这个不对称文档写得没那么显眼实战里特别容易绊人。3.2 BITPOS/BITOP定位与批量聚合BITPOS key bit [start [end [BYTE|BIT]]]用于查找第一个出现目标bit值的位置。比如查“第一个在线用户是谁”可以BITPOS online:2025-03-01 1返回的就是那个用户的offset。查“第一个空位在哪”就查0。老版本只能按字节区间找Redis 7.0之后可以精确到bit区间灵活很多。BITOP operation destkey key [key ...]支持AND、OR、XOR、NOT四种逻辑运算是位图做“交集、并集、差集”的核心武器。几个使用时的细节对不同长度的字符串做AND/OR/XOR时参与运算结果的长度取决于最短的那个key。因为逻辑上更短的key高位全按0补齐比如短的串和长的串AND结果高位永远是0所以截断到短串长度不会丢有效信息。NOT运算只能传一个源key结果长度等于源的原始长度。所有BITOP都是O(N)复杂度的单线程操作。在几MB的key上求和毫秒级没问题如果某个key到了几百MB、上GB通常是因为offset没控制好一次BITOP会阻塞Redis挺长时间这个后面避坑章节详细讲。3.3 BITFIELD把位图当迷你数组用BITFIELD是Redis 3.2引入的它让你在一条命令里对一串bit做多种“取子字段”式的操作。支持无符号整数类型u1到u63有符号整数类型i1到i64日常做状态位u1到u8基本够用需要小心的是u64在GET时容易出现协议层有符号表示的坑建议不用。核心子命令BITFIELD key GET u8 0从offset 0开始取8个bit当成无符号整数返回。BITFIELD key SET u8 8 255从offset 8开始写8个bit为255。BITFIELD key INCRBY u16 16 3把offset 16开始的16bit整数加3返回增加后的值。OVERFLOW WRAP|SAT|FAIL控制整数溢出时的行为。默认是WRAP回绕想要钳制在最大最小值就SAT想直接报错就FAIL。单条BITFIELD可以带多个子命令一次往返搞定多次读写这在批量场景下能显著减少RTT。比如一次取出一段区间内多位用户的状态127.0.0.1:6379 BITFIELD userflag:2025-03-01 GET u1 100 GET u1 200 GET u1 300 1) (integer) 1 2) (integer) 0 3) (integer) 1另外Redis 6.2推出了只读的BITFIELD_RO key GET ...专门解决在主从架构里想用BITFIELD读、又不想因为BITFIELD的写可能性导致命令无法路由到只读副本的问题。涉及Redis Cluster或读写分离时这是一个容易漏掉的细节。4. 第一个实战用位图把1亿用户的DAU统计成本压到12.5MB/天4.1 在线状态与日活统计的数据模型和key设计到了实战环节。先明确一个前提位图最讨厌“稀疏且离散的巨大ID”。如果用户ID是从1开始的连续自增主键那是理想情况如果是UUID或者带业务前缀的长字符串需要先建一层映射表把用户ID映射成线性整数。这一步必须在设计早期做不然后面迁移的成本很高。key的命名我推荐按“业务维度:时间粒度:日期”来拆。日活场景最简单的拆法是key: dau:2025-03-01 offset: userId value: 1用户登录时写入127.0.0.1:6379 SETBIT dau:2025-03-01 10001 1 (integer) 0判断今天是否活跃127.0.0.1:6379 GETBIT dau:2025-03-01 10001 (integer) 1统计今天的DAU127.0.0.1:6379 BITCOUNT dau:2025-03-01 (integer) 1250000这里有个工程细节1亿用户按天拆出来的key每个大约12.5MB。12.5MB的key不算大Redis完全能存但是如果你把一整年的活跃记录塞进同一个key那就是4.5GB的超级大key任何一次BITOP或BITCOUNT都会把Redis卡住。所以日活类位图一定按“天”拆因为聚合需求也是按天来的签到类位图则按“用户”拆因为要看单人的时间轴。4.2 BITOP做留存与交叉用户的三种聚合姿势日活统计只是开胃菜位图的真正威力在聚合。次留前一天活跃今天也活跃127.0.0.1:6379 BITOP AND r1 dau:2025-02-28 dau:2025-03-01 (integer) 1250000 127.0.0.1:6379 BITCOUNT r1 (integer) 99850r1这个key就是在两天都活跃的用户id集合BITCOUNT一下就是次留人数。7日内活跃用户数把7天的key做OR127.0.0.1:6379 BITOP OR result7 dau:2025-03-01 ... dau:2025-03-07某个功能上线前后的新老用户差异拿到功能开关的“已体验用户”位图A和某天的活跃位图B想要“活跃但没体验过功能”的人127.0.0.1:6379 BITOP NOT tmp A 127.0.0.1:6379 BITOP AND result B tmp这种“集合运算”用传统数据库做要写好几条SQL关联大表、去重统计而位图就是几条命令的事而且都是纯内存按位运算速度极快。我在实际项目里用这套方式做用户分层筛选原先跑十几分钟的Hive任务换成位图预聚合后秒出。4.3 从1.6GB到12.5MB一次直观的内存账本把账算明白选型就清楚了。假设1亿用户每天一个Set记录活跃用户Set 平均每个元素算16字节1亿用户约1.6GB/天。若同时维护“在线”“日活”“推送已发”三个Set就是4.8GB/天。换成位图1亿个bit 100,000,000 / 8 12,500,000字节 ≈ 11.92MiB/天/维度。三个维度加起来不到36MiB/天。差了128倍以上。关键是这个优势会随规模放大。1亿用户已经算大厂体量了但对位图来说1亿和1000万在命令复杂度上几乎没差别BITCOUNT都是O(N)只是N大了10倍。对数据库来说1亿行的表和多表关联那就是完全不同的世界了。当然位图也不是没代价它对“ID连续可映射”要求高而且不能像Set那样直接携带用户属性。所以实践中常见做法是位图只负责“谁在/谁不在”这层布尔逻辑用户的其他画像放Redis Hash或数仓里。分层各干各的职责清晰。5. 第二个实战自建布隆过滤器挡掉缓存穿透5.1 为什么“判断一个key是否可能存在”会打垮后端缓存穿透的典型症状是请求查一个不存在的key缓存里没有数据库里也没有于是每次请求都穿过缓存打到数据库。如果有人用脚本循环请求不存在的ID数据库连接池会被直接打满。常规解法是在缓存前加一个过滤器先判断这个ID是否可能存在不存在就直接返回不查DB。“可能存在”这个场景就是布隆过滤器的标准舞台。布隆过滤器并不会告诉你“一定存在”它只会告诉你“一定不存在”或者“可能存在”。这个性质对缓存穿透防护正好能挡住确定不存在的请求漏过去的一小部分假阳性不过是多查一次DB。5.2 位图版布隆过滤器的容量公式与参数选择布隆过滤器的实现思路本质上就是在位图上做k次散列。假设你要容纳n个元素、期望误判率是p位数组长度m和散列函数个数k由下面两个公式决定m ≈ -n * ln(p) / (ln(2))^2 k ≈ (m / n) * ln(2)以n1000万、p1%为例m ≈ -10000000 * ln(0.01) / (0.48045) ≈ 10000000 * 4.60517 / 0.48045 ≈ 9585万bit ≈ 11.43MB k ≈ (9585万 / 1000万) * 0.693 ≈ 6.64取7也就是说只需要11.43MB内存就能给1000万key做去重误判率控制在1%左右。这个内存花销比存1000万个Set元素省了大几十倍。自建落地时先选一个能产生64位或128位散列的哈希函数MurmurHash、xxHash都行。对每个元素算出一个主散列然后用“双重散列”的技巧派生k个独立散列位置。伪代码思路大概是这样h1 first_hash(item) h2 second_hash(item) for i in range(k): offset (h1 i * h2) mask setbit(offset)写入时逐个SETBIT判断时逐个GETBIT只要有一个位是0就判定为“一定不存在”可以安全拦截。这套逻辑用Redis位图做十几MB的位图加7次GETBIT单次判断耗时极小还能用BITFIELD批量GET一次取多个位性能更稳。5.3 自建过滤器与RedisBloom模块的取舍自建优点是零依赖、逻辑完全可控而且因为底层就是普通字符串你可以对多个过滤器做BITOP AND/OR拿到多个集合的交并集。这个能力是RedisBloom模块不太好给的。但自建也有明显短板布隆过滤器天生不支持删除元素。如果业务里有“从集合中移除”的需求你得选Counting Bloom Filter或者Cuckoo Filter或者直接上RedisBloom模块。RedisBloom提供了BF.RESERVE、BF.ADD、BF.EXISTS等命令还有CF系列支持删除省去你维护散列派生的代码。我的判断标准很简单只需要“一定不存在”判断且位数组要参与自定义聚合运算时自建。功能单一、不想维护散列逻辑、需要删除能力时用模块。数据量小到几万个元素时别折腾布隆过滤器一个Set或数据库索引就够。这里同样适用“先问数据规模再上工具”的原则。6. 位图的暗面六个我在生产环境踩过的坑6.1 一次内存雪崩事故大offset怎么写爆了Redis我印象很深的一次故障是有个团队做“用户首单标记”设计文档里写“用位图记录”。但他们的用户ID不是线性的而是分布式系统里的雪花ID一个id好几千万甚至上亿。开发图省事直接拿原始id当offset去SETBIT。结果就是每写一个用户Redis都要把字符串扩展到这个id对应的字节位置。分布又分散一个key里可能只写了十几个1却占了几十MB内存。跑了半天一台Redis内存直接飙到90%以上OOM告警。这事儿完全验证了前面那个结论位图内存按最大offset分配不按实际置1数量分配。所以要么做ID映射压缩要么在写入前先评估ID的稀疏程度。如果ID本身不是连续整数甚至可以用hash分段但必须严格控制分段后的offset范围避免一个key出现巨大的空洞。另一个常见事故是误把offset当成“第几个字节”来写。SETBIT key 100 1实际是设置第100bit也就是第13字节的最高位而不是“从第100字节开始”。如果本意是按字节维度扩展应该把offset乘8再写。这种误解在实现“按天偏移”的位图时特别容易埋雷。6.2 BITCOUNT的start/end和BITOP的阻塞陷阱BITCOUNT的start/end单位是字节这个坑前面的命令章节提过。我想重点说明它在“按区间统计”时的实务影响假设你要统计“第0到99个bit之间有多少个1”直接BITCOUNT key 0 99是错的它统计的是前100个字节800个bit。正确做法是BITCOUNT key 0 12统计前13个字节104个bit然后接受多统计4个bit的误差或者用BITFIELD把0到99位的值取出来自己在应用层数。误差不可接受时就选后者。BITOP的阻塞问题也要重点警惕。一个500MB的位图key做一次BITOP单线程Redis会被占住几百毫秒甚至更久期间所有读写排队。所以别在用户请求链路上执行大key的BITOP。日活类任务尽量放到离线任务或低峰期。定期检查--bigkeys位图key单key超过100MB就该考虑拆分了。6.3 并发写位图、TTL和大key拆分的边界并发写同一个位图key有一个很多人忽略的坑。SETBIT本身是原子的但如果你先GETBIT判断、再SETBIT修改这两个命令之间发生并发写就会丢更新。比如“用户首次访问时签到”这个动作两个请求同时来都读到旧值0然后都写1实际语义上没问题但如果逻辑是“读旧值、根据旧值累加”就可能丢。需要原子读改写时用BITFIELD INCRBY或者Lua脚本包一层。TTL问题也很隐蔽。给位图key设置了过期时间后SETBIT不会重置TTL。我做过一个“最近30天活跃”的需求设计上想实现“每次活跃刷新过期时间”结果直接SETBIT之后发现30天后key照样过期。正确做法是写完位后单独EXPIRE key 86400或者用SET key value EX变体、Lua保证写位和续期原子完成。之所以不把续期放进SETBIT语义里是因为Redis命令设计原则是“每个命令尽量只做一件事”这个逻辑得由业务层负责。大key拆分方面除了按天拆还可以按“用户段”拆。比如用户ID是1到1亿拆成10个key每个key管1000万用户。这样单个key最大的内存占用约1.2MBBITOP操作的时长显著下降而且可以并行处理。拆分后日常聚合注意在应用层合并多个key的BITCOUNT结果别把key粒度拆没了。7. 两年搞位图之后我的选型清单与几个技巧7.1 什么时候用位图什么时候果断放弃结合我做过的几套统计系统给一张非常个人向的选型表场景是“海量对象 × 有限布尔维度”且需要交集、并集、差集聚合 → 位图这是它最舒服的主场。对象ID连续或者经过映射后线性可用 → 位图直接上。单个对象需要一个以上的状态值比如未读/已读/已删可以考虑BITFIELD搞u2字段状态多起来就换Hash。数据量小几万级别 → 别硬上位图Set用intset压缩后已经很省而且能存更多附属信息。需要精确计数的大规模去重 → 用Set或数据库布隆过滤器的假阳性在这里不可接受。允许一定误差的巨量去重 → 用HyperLogLog或者布隆过滤器但记住HLL是基数估计不是成员判断。有删除元素的布隆需求 → 用RedisBloom的Cuckoo Filter别拿自建布隆硬扛。判断要不要迁移一套老系统到位图上我的建议是先量化当前方案每天消耗多少内存查询到底多慢如果只是几百万元素的Set迁移带来的收益其实不大一旦到了亿级位图带来的百倍内存优势就会让迁移成本变得非常划算。7.2 几个让位图更好用的小技巧最后分享几个日常用得上的细节都是文档里不常写、但实战价值很高的东西批量读状态时用BITFIELD GET一次拉多个用户比循环GETBIT少几十次RTT。你可以写一段生成命令的客户端逻辑而不是手写几百个子命令。key命名别写得太随意。我推荐域:维度:时间粒度:日期的格式比如user:sign:202503这样后期用SCAN匹配、按key做过期清理都很方便。日活场景里按天拆key、按用户段拆分单key最大控制在十几MB以内。配合redis-cli --bigkeys定期体检别让位图偷偷长成super key。清理过期位图时用UNLINK而不是DEL。这个在删除大key时是常识但位图的“大key”有时看起来很小实际底层字符串已经很长了容易疏忽。位图的读性能极高但写性能会随着offset增长而变差。因为SDS扩容时要分配新内存、拷贝旧数据。如果你预先知道要覆盖多大的offset可以先写一次最高位比如SETBIT key maxOffset 0把空间一次性分配好后续写入就没有重复扩容的开销。这是一种“空间换稳定时延”的取舍适合写多读少的批量初始化场景。搞位图这两年我最深的体会是工具本身不复杂真正考验人的是能不能在需求一开始就识别出“这其实是个布尔矩阵”。等数据量打到几千万再迁移迁移的过程远比最初建模多花几十倍精力。所以每当新需求过来我都会先问一句这需求背后的模型是不是一张位图表如果是那Redis位操作应该是你第一个想到的方案而不是最后一个。