ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis Bitmap 实战:从存储原理到高并发统计的完整指南

Redis Bitmap 实战:从存储原理到高并发统计的完整指南 开篇先讲个真实经历。之前有个社区类产品上线签到功能运营要的报表很直接本月每一天有多少用户签到、连续签到多少人、某个用户这个月签了几天。需求看着简单但日活大概 20 万一个月下来就是 600 万条记录用关系型数据库做计数还得按天按人去重统计一个报表查询能把从库 CPU 打到 100%。后来我换成了 Redis 的 Bitmap 结构内存占用低到可以忽略不计统计却从秒级降到了毫秒级。从那次之后凡是涉及状态型海量数据标记的需求我第一反应基本都是 Bitmap。这篇文章就围绕 Redis 的 Bitmap 数据结构展开从存储原理、核心命令、典型应用到真实项目里的坑一次讲透。适合两类人看一是刚开始接触 Redis 数据结构的开发者二是已经在用 Bitmap 但只停留在 SETBIT/GETBIT 层面、想深入理解底层逻辑和进阶用法的同学。1. 剥开 Bitmap 的底层它就是个会位运算的字符串1.1 为什么说 Bitmap 不是一种独立的数据类型很多初学者看 Redis 文档看到 Bitmap 单独列了一章就误以为它是一种独立的数据类型。实际完全不是。在 Redis 的 type 命令面前Bitmap 的真身是String 类型底层就是动态字符串 SDS。Redis 的 String 本身就是二进制安全的意味着你可以往里头存任意字节序列包括乱七八糟的图片内容、序列化对象也包括一张比特表。Bitmap 只是站在 String 的二进制视图上提供了一套新的读写 API让你能把字符串的每一个 bit 当作一个独立的状态位来操作。理解这一点非常关键因为它直接决定了 Bitmap 的内存模型既然是字符串它就遵循字符串的空间分配规则最大的 key 可以到 512MB也就是能承载 2^32 个 bit约 42.9 亿个位。在实际工程中绝大多数场景根本用不满这个上限。1.2 位偏移与字节偏移的换算逻辑Bitmap 的每个 bit 都有一个位偏移量offset范围从 0 开始。第 0 位到第 7 位构成第一个字节第 8 位到第 15 位构成第二个字节依此类推。换算公式很简单字节偏移 bit 偏移 / 8向下取整字节内位置 bit 偏移 % 8举个例子。SETBIT key 10 1 这句话是把整个字符串的第 10 个 bit 置为 1。这发生在第 1 个字节10/81余 2这个字节的二进制位被改成00000100对应十进制是 4。如果你去 GET key拿到的字符串长度是 2 字节第二字节的值是 4。很多人踩坑的地方也在这Bitmap 的位顺序是 MSB 在前即每个字节内部最左边是最高位bit 7最右边是最低位bit 0。这与某些语言里习惯的 LSB 排列相反后面做 BITFIELD 是会遇到的。1.3 惰性创建机制带来的假象还有一个隐藏特性值得知道Redis 对 SETBIT 操作是惰性分配空间的。当你 SETBIT key 1000000 1 时Redis 系统会自动把字符串扩展到能容纳第 1000000 个 bit 的长度也就是 125000 字节同时把中间所有位清零。这个扩展是即时发生的不会有二阶段的异步过程。所以要对大偏移量写入格外谨慎一次操作就可能让一个 key 占据上百 KB 内存这在后面第 4 节会专门展开。从存储本质来看Bitmap 适合的是一类稀疏状态问题大量标识位、只有少量为 1。它最忌讳的用法是把 bitmap 当数组用乱序写随机偏移马上会把内存空间撑爆。2. 核心命令逐个拆解从基础读写到 BITFIELD 进阶2.1 SETBIT 与 GETBIT最基础但必须养成的习惯在 Redis 命令行里操作SETBIT 的格式是SETBIT key offset valuevalue 只能是 0 或 1offset 必须是大于等于 0 的整数。如果 offset 超过了当前字符串长度Redis 自动扩展字符串并在中间补零。GETBIT 则是读取指定偏移的位值GETBIT key offset关键要注意即使 offset 远超当前 key 的实际长度GETBIT 也不会报错而是一律返回 0。因为逻辑上超出字符串范围的位都视为 0。但对当前 key 来说任何超出范围的 GETBIT 都会返回 0 这一点引出了一个很实用的技巧判断某个位是否被写入过不能只看 GETBIT 结果为 0 就断言它不存在需要用 bitpos 或字符串长度来判断数据区域的范围。另外一个容易被忽略的是 SETBIT 的返回值它是这个位之前的值而不是新值。这个特性偶尔能用在原地做去重判断的场景里省一次查询——先 SETBIT 再判断返回值如果之前是 1说明重复标记。动手验证的话可以打开 redis-cli 跑一段127.0.0.1:6379 SETBIT user:login:20240101 100 1 (integer) 0 127.0.0.1:6379 GETBIT user:login:20240101 100 (integer) 1 127.0.0.1:6379 GETBIT user:login:20240101 101 (integer) 02.2 BITCOUNT 与 BITPOS统计和定位的核心组合BITCOUNT 用来统计整个 key或指定字节区间内值为 1 的 bit 数量BITCOUNT key [start end [BYTE|BIT]]默认统计整个字符串。这里有一个大坑是 start 和 end 的单位默认是字节不是 bit。如果你以为传 0 到 10 是统计前 10 个位那就完全错了实际统计的是前 10 个字节也就是前 80 个位。Redis 7.0 之后可以通过BIT参数改成按位区间统计旧版本没有这个参数这是我的一个踩坑点。BITPOS 则返回第一个被置为 1或 0的位偏移BITPOS key bit [start [end [BYTE|BIT]]]它常被用来找出第一个有签到记录的人之类的问题但实际价值更大的是配合判断一个稀疏位图的大致密度。比如判断一个位图里的最低位和最高位就能估算这个键的有效数据范围。举个例子要判断用户 100000 到 100999 这 1000 个用户中最早活跃的是哪个用户 ID就能用 BITPOS# 假设 user:active:20240101 这个 key 里每个 bit 对应一个用户 ID BITPOS user:active:20240101 1返回的偏移量加上用户基数 100000就是最早活跃的用户 ID。BITPOS 内部实现用到了一些优化策略在稀疏位图上的性能非常优秀。2.3 BITOP跨位图运算的杀伤力BITOP 支持四个操作符AND、OR、XOR、NOT。它是把多个 key或者同一个 key 做 NOT按位做逻辑运算把结果写入目标 keyBITOP operation destkey srckey [srckey ...]例如运营想看连续 7 天都活跃的用户就可以先把每一天活跃用户写入独立的 bitmap然后把这 7 个 bitmap 做 AND结果位为 1 的位置对应的用户就是全勤用户。BITOP AND dest:7days_active user:active:20240101 user:active:20240102 ... user:active:20240107这比用程序循环 getbit 再与操作要高效得多因为整个运算都在 Redis 服务端完成而且底层按字节做位运算速度极快。还需要注意一个机制BITOP 的结果长度等于参与运算的所有 key 中最长 key 的长度。如果其中一个 key 比较短Redis 会把缺失的 bit 视为 0。这推理上很自然但实际操作时容易被遗忘尤其是 AND 运算想得到全部满足的位图结果集的长度被某个短 key 限制住时后面做统计就会失真。2.4 BITFIELD把 Bitmap 用得更高阶的武器如果只是在某个偏移量上置 1/0SETBIT 足够。但现实需求往往更复杂比如要存储一个计数器组每个用户每周有 7 天的状态位或者要一次性设置一段连续位的值。这时 BITFIELD 就派上用场了。BITFIELD 支持在一个命令里对任意位偏移做多个子操作比如BITFIELD key SET u8 100 1这表示从偏移量 100 开始把连续 8 个 bit 当作无符号整数u8设置为 1。类似的格式还有GET u8 100、INCRBY u5 100 1从偏移 100 开始的无符号 5 位整数加 1。这里就遇到之前说的字节序问题。BITFIELD 里按位定位时偏移量是纯粹的 bit 偏移不关心字节边界。比如要操作 offset 从 3 到 10 共 8 个 bit它跨了两个字节第 0 字节和第 1 字节BITFIELD 都能正确处理。它本质是在一个连续的比特流上切片段操作这是它比 SETBIT 更灵活的地方。一个实际用法是做计数器桶。比如计数器是 2 个 bit 一组可表示 0 到 3 四种状态这样一个字节能存 4 个用户的签到等级。用 BITFIELD 批量读写# 假设 key 存储了 100 个用户的状态每个用户 2 bit BITFIELD key SET u2 0 1 BITFIELD key SET u2 2 2这只是入门BITFIELD 还支持原子性的 INCRBY一个命令完成读取-修改-写回配合溢出控制WRAP/SAT/FAIL可以做出防溢出的计数器。不过它的坑也不少比如 GET 的溢出处理、不同版本对 bit 参数支持的差异后面会专门讲。3. 典型应用场景的落地设计与选型理由3.1 场景一亿级用户在线状态追踪在线状态是一个非常典型的布尔状态场景。用户数千万甚至上亿每秒都有大量状态变更。如果用 Redis String 单独存一个 key 给每个用户比如online:10001 - 1那么一亿用户就需要一亿个 key光 key 的元数据开销就很大。而 Bitmap 用 1 个 bit 代表一个人的状态内存占用直接缩小了几个数量级。设计很简单online这个 keybit 偏移量就是 user_id用户上线SETBIT online user_id 1用户下线SETBIT online user_id 0判断用户是否在线GETBIT online user_id统计在线总数BITCOUNT online这样的一种方案一亿用户全部标记也只需要约 12.5MB 内存。相比之下纯字符串方案光 key 的空间就要上百 MB且 GC 压力和命令往返次数都更多。选型说明是只要用户 ID 是整型且相对连续有空洞不要紧稀疏也 OK就用 Bitmap如果用户 ID 是 UUID 之类不可映射为整型的就需要维护一个映射表或者另选方案。3.2 场景二签到日历与连续签到判断签到是 Bitmap 的教科书应用因为它天然按天为维度每个用户一个月只有 30 个左右的 bit。我一般这么设计每个用户每月一个 key命名sign:202401:10001位偏移就是当月第几天减 1第 1 天签到SETBIT sign:202401:10001 0 1第 15 天签到SETBIT sign:202401:10001 14 1查当月签到天数BITCOUNT sign:202401:10001查今天是否已签GETBIT sign:202401:10001 14连续签到数怎么判断可以直接取当月所有位从当天往前逐位检查import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) key sign:202401:10001 byte_len r.strlen(key) or 0 if byte_len 0: print(本月未签到) else: bits r.getrange(key, 0, byte_len-1) val int.from_bytes(bits, byteorderbig) today 20 streak 0 for day in range(today, 0, -1): if (val (day-1)) 1: streak 1 else: break print(f连续签到{streak}天)这种一次性取回的方案比循环执行 20 次 GETBIT 快得多减少了 RTT性能优势非常明显。要注意还原时使用大端序big endian因为 Redis 的位序是 MSB 优先。3.3 场景二扩展用 BITFIELD 存储多种状态签到场景只会用一位但有些业务希望对每天记录多个状态比如签到 是否补签 是否请假。这时可以把每天扩展成几位用 BITFIELD 操作更灵活。假设每天 4 个 bit第 0 位正常签到第 1 位补签第 2 位请假第 3 位保留那么第 n 天的状态块偏移是(n-1)*4# 第 5 天正常签到并补签 BITFIELD signext:202401:10001 SET u4 16 3读出来直接是一个 4-bit 整数程序里用位掩码拆解状态。这种设计比维护多个 bitmap 更省内存也方便按天扩展字段。但要注意BITFIELD 的偏移量计算容易算错特别是跨越多天、需要连续读整个月数据时我建议先在纸上画好位表再写命令。3.4 场景三UV 去重统计的位图方案UVUnique Visitor去重是另一个常见需求。传统做法是用 Set 类型每个用户 ID 作为集合成员统计 SCARD。但用户量很大时Set 的内存开销又上来了而且存储的成员是字符串形式额外携带字符串长度和指针。用 Bitmap 做 UV 统计的思路为每个活动或者每个页面每天建一个 bitmap用户 ID 作为偏移访问则置为 1。要统计任意日期区间的去重 UV先把区间内每天的 bitmap 做 OR 运算结果写入临时 key再 BITCOUNT。BITOP OR temp:uv:20240101_07 user:uv:20240101 ... user:uv:20240107 BITCOUNT temp:uv:20240101_07这样做的优点是去重逻辑天然由位运算保证一个用户访问多天在 OR 结果里还是只有 1 位为 1。对比 Set 方案在内存上的优势10 亿级别的 UV 去重Bitmap 大约需要 125MBSet 存 10 亿个 10 字节 ID假设 int64 的话再加上指针内存要翻 5-10 倍并不在同一量级。但是这个方案有个致命前置条件用户 ID 要能转成数字偏移。如果用户 ID 是 UUID需要维护一个 UUID 到整型的映射这个映射本身也有成本。所以我在实际项目中通常用它做 B 端匿名用户统计这类统计不依赖登录态用自增 ID 就可以。4. 真实场景中容易踩的坑与排查过程4.1 大偏移量写入导致内存暴涨我记得很清楚有次把一个活动的奖品投放系统接到 Bitmap 上同事写完代码后 Redis 内存以肉眼可见的速度往上涨。探查半天发现原因他用用户手机号哈希后的值当作偏移量。手机号 13 位哈希后得到一个很大的整数比如 4 亿多。SETBIT 这个偏移量的瞬间Redis 为了容纳这个位直接把字符串扩展到 4 亿/8 5 千万字节也就是约 50MB。Redis 一次性分配了这个连续空间key 数量一多内存直接打爆。排查链路是这样的先执行redis-cli --bigkeys看到了几个巨型 string key然后STRLEN确认数据长度再结合业务日志确认写入偏移量来源。最后修复方案是改用自增用户 ID 作为偏移原来哈希映射到 0-9999 的桶改成溢出位图加二级索引。这段经历说明一个铁律用 Bitmap 之前先看你的偏移量映射是不是可控且尽量紧凑。宁可稀疏一点点也不能随机大跨度。4.2 字节序问题导致跨语言读取错乱BITFIELD 和 GETRANGE 返回的数据在跨语言解析时字节序是个大坑。Redis 的字符串是字节数组各语言读取出来之后按自己的字节序解释结果就完全乱了。我踩过用 Java 读取一个由 Python 写入的 Bitmap做签到连续判断时Java 的 BitSet.valueOf(byte[]) 默认按 LITTLE_ENDIAN 解释字节数组而 Redis 的 bit 顺序是 BIG_ENDIANMSB first。结果导致的二进制位顺序整个反转连续签到判断错得离谱。解决办法有三个统一用 BITFIELD / GETBIT 这类 Redis 命令读写不做整段字节转换。如果需要整体读取必须明确指定按大端序解析。写入和读取都用同一套语言时也要小心 SDK 自带的位操作函数是不是符合 Redis 语义。Java 里正确解析示例byte[] data redis.get(key.getBytes()); java.util.BitSet bits java.util.BitSet.valueOf(reverseBytes(data)); // reverseBytes 需要把字节数组反转成 Redis 位序但 reverseBytes 的实现一定要测试到位尤其是位跨字节边界时。4.3 BITCOUNT 的 start/end 单位歧义使用 BITCOUNT 带区间统计时start 和 end 默认单位是字节。网上很多旧资料也直接用字节但如果有同学想当然地传了 bit 偏移统计就会出现数量级错误。假设要统计第 100 个用户到第 200 个用户bit 偏移 100 到 200之间有多少活跃BITCOUNT user:active:20240101 100 200实际上这统计的是字节偏移100 到 200 的数据即 bit 800 到 1600 的范围完全不是想要的结果。你需要在命令中显式指定按 bit 统计BITCOUNT user:active:20240101 100 200 BIT这个BIT参数是在 Redis 7.0.0 引入的如果公司用的 Redis 版本偏低就得自己换算成字节区间还要注意字节边界不对齐的问题非常容易出错。4.4 BITFIELD 溢出处理与子命令的版本差异BITFIELD 的 INCRBY 子命令有溢出控制默认是 WRAP回绕。比如 u2 最大是 3再加 1 就回到 0。如果不希望溢出需要显式指定溢出策略BITFIELD key OVERFLOW SAT INCRBY u2 0 1SAT 模式会让值停在最大值或最小值不会回绕。这在实现计数器和限流状态机时非常有用但很多人不知道还有这个选项导致数据被回绕覆盖。另外BITFIELD 在不同 Redis 版本对部分子命令的支持有差异包括命令行为版本要求BITFIELD GET/SET/INCRBY基础读写Redis 3.2BITFIELD_RO只读操作Redis 6.0BITFIELD 的 overflow 参数溢出示教Redis 3.2项目里如果用了老版本 Redis 或者 Redis Cluster 的某些代理层要注意命令兼容问题最好在预发环境跑一把全量回归。4.5 key 数量膨胀与集群分片问题Bitmap 按天、按人拆分后key 的数量会快速膨胀。一天一个 key、每用户每月一个 key业务跑一年下来会有几十万个 key管理成本和内存碎片都不容小觑。在 Redis Cluster 下还有一个容易被忽视的问题Bitmap 的 BITOP 操作涉及多个 key集群模式下这些 key 必须分布在同一个哈希槽或者使用 Redis Cluster 的跨槽聚合能力一般不具备否则 BITOP 会直接报错 CROSSSLOT。所以在设计 key 时可能需要用{prefix}花括号方式强制同一批 key 落在同一槽。例如BITOP AND {user:active}:20240101_07 {user:active}:20240101 {user:active}:20240102花括号里的字符串参与哈希计算所以{user:active}:20240101和{user:active}:20240102会落在同一个槽。这个技巧对跨 key 运算很重要但知道的人不多。5. 内存估算与 Redis 版本演进中的细节5.1 用公式提前评估内存占用Bitmap 的内存占用可以精确预估内存字节数 ≈ ceil(max_offset / 8)也就是说它只和最大的那个偏移量有关和设置了多少个 1 无关。举个例子存储 1000 万个用户状态偏移量最大 9999999内存约 1.25MB存储 1 亿个用户状态内存约 12.5MB存储 10 亿个用户状态内存约 125MB这个公式也解释了为什么偏移量必须紧凑最大偏移 4 亿时一个 key 就要 50MB和多少个位置 1 完全无关。另一个容易被低估的是 key 的元数据和碎片成本虽然字符串空间本身按公式算但每个 key 在 Redis 里还有约几十字节的字典项开销key 数量很大时这部分也不容小觑。5.2 数据过期策略与 Bitmap 的冲突使用 Bitmap 时最怕遇到单个 key 需要部分过期的需求。比如在线状态要求 3 天无活跃自动下线如果整体 EXPIRE 会让所有人都下线而 Bitmap 本身不支持针对单个 bit 设 TTL。我一般结合业务定时任务解决每天凌晨在低峰期对 key 做整体重建或者每个用户状态独立 key这时就不是 bitmap 了。另外一个思路是维护一个辅助的时间戳位图但复杂度并不值得。还有一点执行 SETBIT 对已存在的 key 做写操作不会重置这个 key 的 TTL。如果 key 本身设了过期时间要小心过期后所有数据丢失建议对长期统计类数据不要设短期 TTL而是写定时压缩归档任务把旧数据转储到别的存储。5.3 Redis 高版本对 Bitmap 的增强Redis 7.0 之后的 BITCOUNT 和 BITPOS 都支持按 bit 偏移区间统计这是最直观的增强。以前按字节区间还得精确对齐字节边界现在直接按 bit 传参做第 100 到 200 个用户之间有多少活跃这类细粒度统计方便多了。BITFIELD_RO 命令则是只读版本在 Redis 6.0 引入。它专门用于只读场景下的负载均衡因为只读操作可以被发送到从节点减少主节点压力。这个命令在做大量统计报表时非常有用。我测过 Redis 7.0 的 BITCOUNT 带 BIT 参数与不带参数性能差异数据量在百万字节级别时差别不大主要差别在于传参少了换算的麻烦更不容易出错。5.4 位图拆分策略单 key 太大时的应对前面提到单个 key 最大 512MB但实际建议单 key 不要超过几百 MB否则持久化、同步、复制的开销都会变大。常用的策略是横向拆分按用户段拆user:active:segment:0、user:active:segment:1每段管 1000 万个 ID。按时间拆user:active:20240101、user:active:20240102统计跨天时做 BITOP。拆完之后统计总活跃数时就是多个 BITCOUNT 求和或者多个 BITOP 汇总后再 BITCOUNT。后者更优因为一次输出一个临时 key 方便后面继续处理。拆分之后要注意一个副作用原来一个 key 上可以直接用 BITPOS 定位最小活跃用户拆完就得逐段查逻辑稍复杂。我个人建议若数据总量在 1 亿以内单 key 完全够用不用过度设计超过 1 亿或单 key 超过 200MB 再考虑切分。6. 从一个生产事故看 Bitmap 的边界与运维要点6.1 事故回顾预热缓存把主从同步拖垮有次线上做活动预期用户量很大我提前用脚本把几万个用户的签到数据全部写入 bitmap key。脚本逻辑用了很多大偏移量的 SETBIT单个 key 被拉到 10MB 以上然后并发写了几百个这样的 key。结果 Redis 主从复制延迟飙升原因是每次 SETBIT 造成的字符串扩容都要整体复制新数据网络带宽被占满从库同步一度落后几十秒。这里的关键原理是字符串扩容不是原地扩Redis 会分配新空间再拷贝数据。频繁对大字符串做 SETBIT 操作会反复触发拷贝这是和普通小字符串操作最大的不同。以后的改进措施预热阶段使用 Redis pipeline 批量写入减少 RTT 次数。尽量避免对大字符串反复 SETBIT改为一次性构造字节数组再 SET 整个字符串。大 key 拆分避免主从同步时传输超大对象。6.2 日常监控与容量规划用 Bitmap 的项目一定要针对性加监控单个 key 的 STRLEN 变化趋势能反映出是否有偏移量失控。key 数量增长状况防止产生过多小 key。主从复制延迟大字符串操作会导致复制风暴。压测时则一定要按最大偏移量而非存在的 1 的数量来设计测试数据两者在 Redis 内存占用上完全不同。很多人只压了几个 SETBIT内存看着很稳真到了大批量用户上线时偏移量变大内存增长就刹不住车了。6.3 什么时候毅然放弃 BitmapBitmap 虽然强但不是万能的。如果遇到下面这几类情况我建议放弃它换用其他结构或系统需要存储的值不止布尔状态而是要携带多维度的属性。ID 是碎片化字符串且无法映射成连续整数维护映射表成本过高。需要对单个位做带 TTL 的自动过期Bitmap 做不到。单个 key 预期超过几百 MB拆 key 又导致跨键运算需求膨胀复杂度失控。在这些场景下用 Redis Set、Hash、甚至专门的位图存储系统比如基于 RocksDB 的方案更合适。技术选型没有银弹Bitmap 的天花板就是能做的特别快、特别省但一旦超出它的模型硬用反而更糟。实际上我接触过的生产系统里Bitmap 用得最好的两个场景依然是签到统计和海量设备在线状态。这两个场景共同点是ID 天然整型化、状态天然布尔化、数据天然稀疏化三个条件齐备Bitmap 几乎是毫无争议的最佳选择。回到最开始那个签到需求最终方案就是用 Bitmap 按用户按月存储配合 BITCOUNT 做统计BITOP 做跨月汇总再给每个 key 设置足够长的过期时间同时在每天凌晨跑一次定时任务压缩归档上月的 key。整个功能上线后内存占用不足预期的十分之一报表查询耗时从原来的几十秒降到几十毫秒。这套设计在之后几个项目里一直沿用基本没出过幺蛾子。如果你正打算在一个需要高频标记、海量统计的模块里引入 Bitmap建议先把上面这些原理和坑走一遍尤其是偏移量规划——想清楚偏移映射、想清楚 key 的拆分边界、想清楚版本兼容。把这几个问题解决Bitmap 才会真正变成顺手利器。
返回列表