Redis String 不只是字符串:拆解 bitmap 位图的底层原理与实战 String 类型大概是 Redis 里最被低估的一个。很多人第一天学 Redis 就是SET/GET存个字符串读出来完事。直到业务里真正碰到几个硬需求——比如统计 10 亿用户的在线状态、做连续签到、给千万级号码去重——才会发现原来 Redis 里根本没有“bitmap 这个类型”bitmap 只是架在 String 字节数组上的一组位操作视角。这篇文章我想把这层关系彻底拆开讲清楚同时把 String 本身容易忽略的细节也一并补上。内容面向两类读者刚学完 Redis 基础、想知道 bitmap 怎么用的人以及工作中已经在用 String 但一直没想明白底层是大桶存储还是字节数组的人。我用的是实战视角不会堆概念尽量让你看完能直接照搬到自己的业务里。1. String 在 Redis 里的真实定位别只把它当“一根字符串”1.1 SET 命令身上被忽略的原子选项日常用SET key value取存读只是 String 的冰山一角。SET命令后面那几个参数在真实业务里比很多人想象的重要。NX只有 key 不存在时才设置成功用来做“首次写入”XX只有 key 存在时才设置成功用来做“条件更新”EX/PX设置过期时间精确到秒 / 毫秒KEEPTTL覆盖 value 时保留原有过期时间一个经典场景是分布式锁SET lock:order:1001 123456 NX EX 10这条命令在一个原子操作里完成了“加锁 设置持有者 过期兜底”避免你先SET再EXPIRE中间进程崩溃导致锁永远不释放的问题。早期版本不少人还在用SETNXEXPIRE两步走见过多次因此踩坑的案例后来官方文档也明确推荐用SET的扩展参数替代。用途不止锁。做缓存时防止并发穿透可以用NX只有 key 不存在才回源查库写入否则直接跳过做“只在没设置过时才写入”的幂等标记也会用NX。原子性是这里的关键词Redis 单线程执行命令一条命令内部不会被打断所以这类“检查再写入”的逻辑只要交给单命令做就不会有竞态问题。1.2 计数器场景为什么数值型 String 是天然选择很多人觉得计数就该用专门的数值类型但 Redis 里没有单独的 int 类型INCR/DECR操作的就是 String 里存的整数。INCR article:view:1024 DECR stock:sku:2048这两个命令是原子的意味着你不需要“读出来 - 加一 - 写回去”三步不用担心并发下计数错乱。商品库存扣减、点赞数、访问量、验证码发送次数限制都用这套机制。构建一个滚动窗口限流器也很顺手每次请求INCR rate:{userId}:{当前分钟}第一次INCR的同时带上后续的EXPIRE等值超过阈值就拒绝请求。注意这里要保证“自增”和“设置过期时间”的原子性实际中有人用 Lua 脚本把两步包起来或者让过期时间由后续惰性清理承担。缓存数据在过期后重新计数天然实现了“每分钟最多 N 次”的效果。1.3 String 能存的不只是文本二进制安全与序列化对象String 的 value 本质是一个字节数组不是“文本”所以它可以存任意二进制数据。图片、压缩包、序列化对象都能放进去这就是“二进制安全”的意思。对象缓存是另一个高频用法。最常见的做法是把对象序列化成 JSON 字符串塞进去读取时反序列化SET user:info:12345 {name:某开发者,level:5}优点是直观、可调试出问题能直接看到内容缺点是 JSON 体积大序列化开销高。高吞吐场景下改用紧凑的二进制序列化方案能省不少内存和 CPU。我当时做过一个用户画像读取的优化同一份数据 JSON 大约 1.2KB换成二进制序列化之后降到 400 字节左右热点接口的整体响应时间直接掉了 15%。存储对象时的经验是单个 value 不要无脑塞大对象。String 一个大 key 保存几十 MB读取和写入都会拖慢其他命令DBA 清理起来也麻烦。按经验单个 String value 最好控制在几十 KB 以内超过这个量级就该拆分 key 或者迁移到其他存储方案。2. 从 String 到 bitmap位图其实就是一套针对字节数组的位开关2.1 setbit 到底在那块内存上做了什么先明确一个并不复杂但关键的概念String 的 value 既然是字节数组那么每个字节有 8 个 bit。bitmap 不创造新存储结构只是把 Key 对应的 value 当作一片连续的位来处理。执行SETBIT online:2024-06-01 12345 1时Redis 做的事是找到 online:2024-06-01 这个 key 对应的 String value如果当前 value 长度不够容纳第 12345 位会自动用零字节扩展定位到第 12345 个 bit 所在的字节byteIndex 12345 / 8 1543在这个字节内定位具体 bitbitOffset 12345 % 8 1把该位设为 1定位好字节后Redis 内部是按位序从最高位开始的所以在第 1543 个字节里的逻辑是把相应位置的 bit 置 1。理解了这个过程就明白为什么 bitmap 不支持“负数偏移”也不支持超过内存大小的偏移——它操作的是真实的字节数组下标不是抽象概念。[ setbit key offset 1 ]每执行一次Redis 返回该位置原来的值这在某些场景可以用来判断“这次是不是第一次设置”。2.2 内存幻觉为什么一亿状态最占 12.5MB位图最吸引人的地方是它的空间占用。统计 1 亿个用户的在线状态如果每个用户用一个 key 存1或0那光 key 查询开销和内存就够喝一壶的。但如果用一个 bitmap逻辑上只需要 1 亿个 bit。1 亿 bit 是多少内存计算一下[ 100000000 / 8 12500000 \text{ 字节} 11.92 \text{ MB} ]顺便算几个常见规模1 亿 bit 约 11.9MB10 亿 bit 约 119MB31 天签到位图每人一个 key只有 31bit约 4 字节。这种“位级压缩”在计数和集合统计场景里优势非常明显。之前参与过一个活动项目要统计某天所有登录用户中哪些后续 7 天还来过。如果用 String 存每个用户的访问标记再去做交集逻辑极其复杂数据量上来内存直接失控。改用 bitmap 方案后每个用户每天的登录状态是 1 个 bit7 天就是 7 个 bitmap key最终做交集时跑一下 BITOP 就出来了内存小到可以忽略。注意一个细节bitmap 对稀疏数据并不友好如果一个 bitmap 只记录 10 个用户但用户 ID 最大到 1 亿那依然要分配 12.5MB。位图内存与“最大偏移量”强相关和“实际有多少位是 1”关系不大。这一点后面会专门讲也是多数人踩的第一个大坑。2.3 位运算命令全家桶BITCOUNT、BITPOS、BITOP、BITFIELD除 setbit / getbit 之外还有几个命令必须掌握。BITCOUNT key [start end]统计位图里值为 1 的个数常用于统计“有多少人签到”“有多少人上线”。注意 start 和 end 是字节偏移而不是 bit 偏移这个极其容易搞错后面避坑部分详细讲。BITPOS key bit [start]找到第一个值为 0 或 1 的位置。可以用于查询“从哪天开始没有签到”之类的问题。BITOP op destkey key [key ...]对多个 bitmap 做位运算AND OR XOR NOT结果存入目标 key。这是做“周活跃 7 天 OR”、“留存用户 首日 AND 次日”这类统计的核心工具。BITOP AND dest:retain user:2024-04-01 user:2024-04-08 BITCOUNT dest:retain这条命令先求两个日期的交集再统计交集里有多少用户一条龙完成“首日活跃且 8 天后仍活跃的用户数”。BITFIELD是进阶版本的瑞士军刀可以在一条命令里对位图做多个子操作读取、设置、自增支持有符号和无符号整数。签名统计场景里一次性拿到一个月的签到位就是靠它BITFIELD sign:user:1024:2024-04 GET u31 0这条命令以无符号 31 位整数从 offset 0 开始读取一下子把 4 月 1 日到 4 月 30 日的签到记录读出来业务侧再做位运算判断连续天数。相比循环 31 次GETBIT管道化优势非常明显。3. 真实业务里 bitmap 能扛的三类硬需求3.1 连续签到统计从每天一个 key 到每月一个 key签到是 bitmap 教学里最经典的例子实际落地过程中有一些容易被忽略的细节。key 设计上常见推荐格式是sign:{userId}:{yyyy-MM}一个用户一个月用一个 key一个 bit 代表一天。用户在第 15 天签到SETBIT sign:1024:2024-04 14 1这里 offset 从 0 开始所以第 15 天对应 offset 14。查询用户 4 月的总签到天数BITCOUNT sign:1024:2024-04查询是否连续签到 7 天从昨天开始往前逐天GETBIT检查或者干脆用BITFIELD GET u31 0一次性读出 31 位整数然后循环 7 次检查位。后面这种方案把 31 次网络往返压缩成一次代码也更紧凑。按月拆 key 的最大好处是配合EXPIRE sign:{userId}:2024-04 30 86400之类的过期时间自动淘汰历史数据不需要手动清理。跨年统计时会同时访问 2 个 key业务代码多写一层查询逻辑即可不复杂。3.2 亿万级用户在线状态125MB 换一张实时状态表在线状态类的需求通常要回答两个问题某用户当前是否在线某时间点一共有多少用户在线。前者用GETBIT就能回答后者用BITCOUNT。在实际项目中我见过一个做法按天生成 bitmap key用户登录时SETBIT online:2024-06-01 {userId} 1。这里提前设定好 userId 是自增数值 ID可以当成 offset 使用。某天在线用户总数BITCOUNT online:2024-06-01。如果想知道本周活跃可能某天上线就算用 BITOPBITOP OR dest:week:2024-06 online:2024-06-01 online:2024-06-02 ... online:2024-06-07 BITCOUNT dest:week:2024-06这种“把若干天合并成一批”的思路就是位图的核心威力——集合运算天然支持交集、并集、差集报表系统里做留存、回访、活跃统计特别顺手。需要注意在线状态类数据每天生成一个 key时间长了会积累大量过期 key规划过期时间时要考虑统计周期。还有每天一个 key 如果覆盖一个 10 亿用户平台单个 key 约 120MB 内存多个 key 加起来不可忽略但相比每个用户一条记录的存储方案仍然划算。3.3 手写简化布隆过滤器三个 hash 函数加一个位图布隆过滤器在 Redis 里可以用 bitmap 手搓初始化一个大数组添加元素时用 k 个哈希函数算出 k 个位置并置 1判断元素是否存在时检查这 k 个位置是否都是 1。举个例子要判断用户 ID 是否在某个黑名单集合里假设集合有 10 万个 ID布隆过滤器开 10 万 bit 显然不够——要有一定冗余。一个直观但准确的估算公式是[ m -\frac{n \ln p}{(\ln 2)^2} ]其中 n 是预期元素数量p 是允许的误判率。1000 万个元素、误判率 1% 时[ m \approx -\frac{10000000 \times \ln 0.01}{0.48} \approx 9.6 \times 10^7 \text{ bit} \approx 11.4 \text{ MB} ]hash 函数个数取 7内存 11.4MB在 1000 万数据量下还是很可观的。实现时可以用 Redis 的 Lua 脚本保证多 hash 原子执行或者用管道批量发送 setbit 命令。检测时如果 k 个位有一个是 0一定不在集合中如果全是 1可能在集合中。这就是布隆过滤器的“假阳性”本质。这种方案的适用场景是“宁可误判不可漏判”例如防缓存穿透时判断某个 key 是否可能存在于数据库误判只是多查一次库不影响正确性。4. 实战里最容易踩的坑4.1 最大 offset 与内存炸弹sparse bitmap 的代价bitmap 有一个硬限制单个 String key 最大允许 512MB换算下来最大 offset 是[ 512 \times 1024 \times 1024 \times 8 - 1 4294967295 ]也就是 (2^{32}-1)大约 42 亿。这本身不是问题问题出在业务侧直接拿一个很大且很分散的 ID 当 offset。比如用户编号是分布式雪花 ID动辄几十亿但平台上实际只有几百万用户用雪花 ID 直接 SETBIT 会瞬间分配几百 MB 的内存甚至 OOM。处理办法通常有三种用户 ID 映射接入层维护一个紧凑的自增序号把大数据 ID 映射到 0 开始的连续编号再 SETBIT。映射关系可以用另一套 String 维护代价是增加一步查询。分段 bitmap把用户 ID 按区间拆到不同 key例如 ID 前三位作为 key 的一部分每个 key 只覆盖一个区间避免单个大 key。只用在自增 ID 场景如果业务天然是自增主键比如签到用户是某张表的自增 ID直接拿主键当 offset 没问题。首次 SETBIT 一个超大 offset 时Redis 需要一次性分配那么多字节肉眼可见地卡顿。我当时在某活动里测试过第一次 SETBIT 一个亿的 offset单次命令耗时从微秒级跳到几十毫秒而且内存突增。这启发我不要把不连续的稀疏大 ID 直接塞给 bitmap否则它只是个伪装成省内存的“内存炸弹”。4.2 BITCOUNT 的 start/end 是字节偏移不是位偏移BITCOUNT key 0 10统计的并不是“前 10 个 bit 里有多少 1”而是“从第 0 字节到第 10 字节一共 11 个字节 88 个 bit 里有多少 1”。很多新手说“统计前 10 位”代码直接写BITCOUNT key 0 10结果比预期多了 8 倍左右。正确做法是想统计位而不是字节时先把字节区间换算好。比如统计前 100 个 bit就该查字节区间 0 到 12100 / 8 12.5取 0 到 12并接受末尾多出来的 4 bit 误差如果必须精确直接读出来业务侧统计或者用 BITFIELD 取整数。BITPOS也同理start 参数是字节偏移不是位偏移。这类坑最容易出现在“从第 N 天开始找第一个断签”这类场景算错单位结果会完全不对。4.3 BITOP 在大 key 上的阻塞问题BITOP的时间复杂度是 O(N)N 是被操作 bitmap 的总长度。如果几个 key 都比较大这条命令会长时间占用 Redis 主线程其他命令全部排队。我见到过一个例子统计百万日活用户的 30 天留存把 30 个每天约 12MB 的 bitmap 做 OR单次 BITOP 耗时飙到数百毫秒线上接口感受到明显延迟。优化手段不要在业务高峰期跑大 BITOP丢到定时任务里低峰期执行。尽量用小粒度 key 预先聚合。按周/月提前合并好避免每次临时 OR 30 天。数据量实在大时分段 BITOP 然后汇总结果避免一次命令内部做全部运算。考虑直接在主从的从节点执行统计类命令减少对主节点的压力。4.4 中文与字节偏移的编码陷阱String 是字节数组SETRANGE、GETRANGE、STRLEN操作的偏移和长度都是字节维度。中文一个字符在 UTF-8 下占 3 字节如果你按“字符数”去推偏移很容易错位。缓存一个用户昵称如果某个逻辑要用SETRANGE修改部分内容先算清楚字节偏移。比如abc你好的字节长度是 3 6 9而不是 5。用STRLEN验证一下避免写脏数据。大多数业务场景我们不会对中文文本做SETRANGE但序列化对象里如果包含中文涉及范围读取时同样要按字节理解。5. 生产环境里必须做好的三件杂事5.1 key 的规划从命名到维度拆解bitmap 的 key 设计直接影响后续维护成本。常见格式是业务域:对象:时间维度比如签到sign:user:{userId}:{yyyy-MM}在线online:{yyyy-MM-dd}活动参与activity:{活动ID}:{yyyy-MM-dd}一个反直觉的经验按时间拆 key 比按业务拆 key 更常见因为过期时间可以直接挂在每个时间分片 key 上。如果只有一个永无止境的大 bitmap删除旧数据会非常麻烦。还要防止 key 之间相互干扰。不同业务千万别共用一个 bitmap比如签到和登录状态挤在一个 key 里不仅语义混乱BITOP 统计也会被混入脏数据。5.2 过期时间、持久化与主从环境下的注意事项给时间维度 bitmap 设置过期时间是最省心的自动清理方式。每日在线 key 设置 30 天过期签到 key 设置 2 年过期过期后 Redis 自动释放内存。持久化方面bitmap 在 RDB 落盘时按普通 String 处理大 bitmap 会导致 RDB 文件偏大、fork 时内存开销上升。AOF 重写时也有类似问题。所以“大 key 要拆分”不只是内存问题也是持久化和主从复制问题。主从环境下有一个小坑如果主节点执行了一次大 offset 的 SETBIT 导致瞬间分配大量内存从节点同步这条命令时也会做同样的事可能同时 OOM。这类操作最好提前分配好内存或者分批次写入避免一次性命令带来全链路冲击。5.3 批量写入与管道的配合小数据量无所谓数据量一大就要考虑批量。比如初始化一个千万级用户的一周活跃位图逐条 SETBIT 需要千万次网络往返效率极低。用管道把命令批量发给 Redis吞吐量提升非常明显。我用 Python 客户端演示过这类操作的写法import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesFalse) pipe r.pipeline(transactionFalse) for user_id in range(1, 100000): pipe.setbit(online:2024-06-01, user_id, 1) pipe.execute()非事务管道把命令批量发送一次往返处理成百上千条命令。实测在一个 5 万用户规模的批量初始化的场景下比逐条 SETBIT 快了几十倍。这里也提醒一下管道批量命令虽然快但不要在一条管道里塞几十万条命令容易把网络缓冲打爆建议分批例如每批一万条循环执行。6. 结尾先想清楚字节数组再想清楚业务维度把 bitmap 用到生产环境之后我越来越觉得很多团队对 bitmap 的困惑不是因为位运算难而是因为没把 String 的字节数组本质想清楚。一旦理解了 String 存的就是一段连续字节setbit 无非就是在这段字节上按位打标记后面 BITCOUNT、BITOP、BITFIELD 都是水到渠成的工具不存在“换了一个新类型”的认知负担。最后再分享一个我个人的实操体会动手用 bitmap 之前一定先确认好业务 ID 与 offset 的映射关系回答清楚“这个 key 会覆盖到的最大偏移是多少、过期时间要设多长、会不会和其他业务共用 key”。把这几个问题想清楚了bitmap 就是一件趁手的小工具想不清楚它就会变成内存和阻塞事故的头号嫌疑犯。希望这篇内容能帮你少踩几个我踩过的坑。