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中间进程崩溃导致锁永远不释放的问题。早期版本不少人还在用SETNX+EXPIRE两步走,见过多次因此踩坑的案例,后来官方文档也明确推荐用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 - 在这个字节内定位具体 bit:
bitOffset = 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.9MB,10 亿 bit 约 119MB,31 天签到位图(每人一个 key)只有 31bit,约 4 字节。这种“位级压缩”在计数和集合统计场景里优势非常明显。
之前参与过一个活动项目,要统计某天所有登录用户中哪些后续 7 天还来过。如果用 String 存每个用户的访问标记再去做交集,逻辑极其复杂,数据量上来内存直接失控。改用 bitmap 方案后,每个用户每天的登录状态是 1 个 bit,7 天就是 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。
如果想知道本周活跃(可能某天上线就算),用 BITOP:
BITOP 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 到 12(100 / 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(host="127.0.0.1", port=6379, decode_responses=False) pipe = r.pipeline(transaction=False) 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 就是一件趁手的小工具;想不清楚,它就会变成内存和阻塞事故的头号嫌疑犯。希望这篇内容能帮你少踩几个我踩过的坑。