Redis Bitmaps实战:从位图存储原理到亿级签到系统的工程设计与踩坑指南
Redis的Bitmaps类型说简单也简单说深也深。很多人面试时都能背出“用位存储节省内存”可真到生产环境里设计签到、活跃统计这类功能时却容易踩到offset设计不合理、BITCOUNT参数传错、集群环境下key散列导致原子性丧失这些坑。这篇文章不打算只讲命令而是把它当一门“位图工程”来拆解从底层存储结构、命令组合用法到亿级用户场景下的容量计算、分段方案、连续签到算法再到我实际工作中踩过的问题。内容偏实战适合正在用Redis做统计类功能的后端开发也适合准备深入理解Redis数据类型的进阶者。1. 先搞明白Bitmaps在Redis里到底存的是什么1.1 它本质上是String类型不是独立数据结构这是很多人第一个误解。在Redis里执行TYPE命令查看一个用SETBIT写入的key返回结果是string而不是什么bitmap。Redis官方文档中“Bitmaps”被归为“bit array”这类扩展类型但底层依然是SDSSimple Dynamic String字符串。为什么Redis不单独设计一个bitmap结构两个原因字符串本身就是二进制安全的天然可以存任意字节序列位操作只是在这之上加了一层读写接口。复用现有字符串的内存管理、过期策略、持久化机制不需要引入新的底层结构实现成本低也便于和GET、SET等命令混用。我遇到过有同事误以为Bitmaps是类似Set的结构试图在一个key上直接做添加成员操作结果自然是命令报错。这个概念搞不清楚后续所有设计都会跑偏。1.2 位数组的读写定位逻辑要真正用好Bitmaps必须理解一个核心计算公式一个bit在字符串中对应的字节位置是offset 3在该字节内的第几位是offset 0x7。换句话说offset 0到7落在第0个字节offset 8到15落在第1个字节依此类推。举个例子127.0.0.1:6379 SETBIT sign:10001 0 1 (integer) 0 127.0.0.1:6379 SETBIT sign:10001 1 1 (integer) 0 127.0.0.1:6379 GET sign:10001 \xc0\x00\x000xc0换成二进制就是11000000正好表示前两个bit为1。也就是说当你对offset 8设置值时Redis会先把字符串扩展到至少2个字节。如果offset很大而中间从未写入值中间部分全部填充为0字节而不是“不存在”。这里有一个隐蔽的性能和内存陷阱SETBIT user:10001 8388608 1会将key直接扩展到1MB。如果你拿用户ID直接当offset而用户ID是雪花ID比如19位数字offset动辄几十亿一个key就会拉爆内存。所以用Bitmaps时offset的设计比value的设计重要得多。这也是我后面会用一整节讲offset规划的原因。2. 命令组合拳从SETBIT到BITFIELD2.1 基础命令的逐条拆解先看最常用的四个命令。SETBIT key offset value将offset位置的bit设为0或1返回该位置原来的值。注意offset从0开始value只能是0或1。GETBIT key offset返回offset位置的bit值。即使offset超出当前字符串长度也不会报错而是返回0。BITCOUNT key [start end]统计整串比特中值为1的数量。这个命令的两个可选参数特别坑——它们不是bit偏移而是字节偏移。例如BITCOUNT sign:10001 0 1统计的是第0和第1个字节共16个bit中的1的数量。如果你以为传的是bit区间统计结果必然错误。BITPOS key bit [start end]返回第一个值为bit0或1的位置。这里有个容易忽略的细节如果key中找不到值为1的bit返回-1如果找不到值为0的bit返回的是字符串长度乘以8——也就是说它认为0可以出现在当前字符串末尾之后。这个边界行为在做连续签到判断时非常有用。再看一个能体现Redis设计巧妙的命令组合# 用户10001在2024年6月14日签到offset13因为日期从1号开始算且offset从0开始 127.0.0.1:6379 SETBIT sign:202406:10001 13 1 (integer) 0 # 统计这个用户6月总共签到了多少天 127.0.0.1:6379 BITCOUNT sign:202406:10001 (integer) 12.2 BITFIELD一次原子操作替代多次命令Redis 3.2引入了BITFIELD命令它可以在一句话里执行多个子操作而这些子操作是在同一个key上原子执行的。常用子命令包括GET type offset读取指定类型和偏移的值。SET type offset value设置值。INCRBY type offset increment对指定范围的整数做自增默认溢出行为是WRAP回绕。OVERFLOW WRAP|SAT|FAIL设置后续子命令的溢出策略。type的格式是i或u加位数比如u8表示无符号8位整数i16表示有符号16位整数。来看一个实际例子。假设一个key存储了用户的连续签到信息每天占用1个bit我想一次性把第5天的bit设为1同时读取前8天作为一个字节127.0.0.1:6379 BITFIELD sign:202406:10001 SET u1 4 1 GET u8 0 1) (integer) 0 2) (integer) 16返回结果第一个是SET操作前的值0第二个是第0到第7位组成的无符号8位整数16即00010000。这个命令的好处是省去了多次RTT而且多个子操作原子生效在需要“先读后写”的复杂逻辑里特别好用。2.3 BITOP位运算才是统计类功能的核心武器BITOP AND|OR|XOR|NOT destkey key [key...]可以把多个bitmap按位运算后写入目标key。它最典型的使用场景是做交集和并集计算。举个例子统计6月1日和6月2日都活跃的用户。127.0.0.1:6379 BITOP AND active:0601:0602 active:20240601 active:20240602 (integer) 1250000 127.0.0.1:6379 BITCOUNT active:0601:0602 (integer) 823012这里active:20240601和active:20240602是日期维度的活跃位图key偏移对应userId映射后的编号。AND之后得到的新位图中值为1的位置就是两天都活跃的用户。BITOP的计算复杂度是O(N)N为参与运算的最长key的字节数。如果位图很长这是有阻塞风险的。关于这一点后面第6节我会详细说我的处理方式。3. 实战场景拆解一个签到功能的设计与演进3.1 需求定义与方案选型先描述一个我做过的真实需求。某App有约1亿注册用户需要上线每日签到功能产品提了几个明确指标用户每天能签到一次用户可以查看自己某月哪些天签到了需要统计“连续签到7天”的用户数量运营后台要能看到每天的活跃签到人数数据要保留至少3个月。一开始团队里的同学提了三版方案。第一版用Setkey是sign:{userId}:{yyyyMM}value是当天日期字符串比如2024-06-14。这个方案直观但1亿用户一年下来光Redis里的key数量就爆炸每个set存几十个字符串的内存开销完全不可控。第二版用Hashkey相同field是日期value是1。相比Set内存略省一些但字段数量和总内存依然是线性增长。第三版就是Bitmapskey是sign:{userId}:{yyyyMM}offset等于“日期 - 当月1号”value为1。三版的内存对比如下按每月签到10次估算方案单用户月内存约1亿用户月内存约查询某天是否签到统计月签到天数Set字符串约500字节约500GBO(1)需要遍历成员O(n)Hash整数约300字节约300GBHGET O(1)HGETALL后遍历O(n)Bitmaps4字节约400MBGETBIT O(1)BITCOUNT O(1)差距巨大最终选型没有任何悬念。3.2 key设计用户维度还是日期维度这里有一个必须想清楚的问题sign:{userId}:{yyyyMM}这样以用户为维度的key适合做“我的签到记录”类查询但要想统计“某天全站有多少人签到”就需要扫描全量用户key成本极高。所以我在同一套系统里用了两套位图用户维度sign:user:{userId}:{yyyyMM}offset 日期 - 当月1号。日期维度sign:day:{yyyyMMdd}offset userId映射后的编号。日期维度key的一个优势是运营查询非常快BITCOUNT sign:day:20240614直接得到当天签到人数。劣势是当用户量级达到亿级时这个key的字符串长度会到1亿bit约12.5MB一个key承担所有写入和读取热key问题明显。缓解热key的做法是分段。把用户ID映射到0到9999的区间key拆成sign:day:20240614:0000到sign:day:20240614:9999每个key只承担1万用户的bit操作。统计全站人数时对1万个key做BITCOUNT再汇总。这里需要的不是Redis端的原子操作本来汇总就允许一定延迟而是应用侧的多线程并发统计。3.3 连续签到判断的算法细节判断“截止到今天连续签到了几天”看起来只要从今天往回数1的个数就行但在位图上要小心处理几个边界。最朴素的写法是从今天的offset开始往前逐个GETBIT遇到0就停止。假设用户连续签到7天那就查7次性能没问题。但有一种优化方式更优雅先找到最近的0的位置然后用当前offset - 最近0的offset - 1得到连续签到天数。用BITPOS找第一个0然后和当前日期offset做差值import redis r redis.Redis(hostlocalhost, port6379, db0) def continuous_sign_days(user_id: int, year_month: str, current_day: int) - int: key fsign:user:{user_id}:{year_month} # current_day表示今天是当月第几天offset current_day - 1 today_offset current_day - 1 # 寻找当前位置之前第一个0。BITPOS支持字节范围但这里直接用全key查找 first_zero r.bitpos(key, 0) if first_zero -1: # 整个位图没有0说明这个月每天都在签到 return today_offset 1 if first_zero today_offset: return 0 return today_offset - first_zero这个算法有几个隐含前提需要确认未来日期的bit不能提前置1——如果用户某天误操作把明天也签了连续计算会出问题。所以写入时应用侧必须做日期校验。当月1号之前没有历史bit残留——如果用月份作为key上个月的bit不会出现在当前key这个天然规避了跨月干扰。时区问题。签到日期到底按用户本地时间还是服务器时间算如果是全球产品服务器是UTC用户在东八区凌晨0点到8点之间会出现“日期不一致”。我这里踩过一次最后统一要求所有签到请求的日期由客户端上送服务器只做合法性校验位图写入就用这个日期。这样也避免了Redis服务器时区配置不一致引发的脏数据。3.4 日历翻页的性能优化用户打开签到日历页时产品要显示这个月哪些天签到了并且最好连上个月这几天一起显示。逐日GETBIT 30次其实很快但为了减少RTT我直接用BITFIELD一次把整个月的bit读出来# 读取从offset 0开始的31个bit作为一个无符号31位整数 value r.bitfield(key).get(fu31, 0).execute()[0] # 把整数转成每天的签到状态 days [(value (30 - i)) 1 for i in range(31)]这里一个小坑是BITFIELD的u31读取31位如果用户这个月的字符串长度不足31位缺失部分会按0处理这正好符合“未签到”语义不用额外判断。4. 更多实战形态在线状态、UV去重与布隆过滤4.1 在线状态统计与热key分流在线状态是Bitmaps的另一个经典场景。思路很简单key是online:{yyyyMMddHHmm}offset是用户ID的映射编号用户上线就SETBIT 1离线就SETBIT 0。统计当前在线人数时直接BITCOUNT。但在高并发场景这个方案有一个必然要面对的问题所有人的上线操作都落在同一个key上写入热点集中单key的CPU和内存访问都会成为瓶颈。我当时的处理方式是按用户ID后三位分片key变成online:{time}:{shard}shard范围000到999。每个分片大约管理千分之一用户量应用层维护一个分片映射表统计时并发对全部分片做BITCOUNT再累加。这个方案的代价是统计时要查询1000个key但实际耗时反而比单key要快因为Redis处理小key更快且应用侧是并发的。分片数不是越大越好1000个key在几十毫秒内能统计完这是IO密集操作再大就会因为TCP连接数量和命令往返增多而收益下降。4.2 用Bitmaps做小型布隆过滤器Bitmaps还有一个很实用的变体自己实现一个简化版布隆过滤器。原理不复杂——布隆过滤器本质上就是用一个位数组和若干个哈希函数每个元素经过多个哈希函数后映射到多个bit位全部置为1。查询时同样计算哈希位置只要有一个bit为0就说明元素肯定不存在全部为1才说可能不存在有误判概率。Redis里布隆过滤器有专门的moduleRedisBloom但有些环境不允许装module这时用Bitmaps手写一个也能顶住不少场景比如URL去重、短链防重复、推荐流去重。我做过一个爬虫URL去重的例子。每次抓到一个URL先计算Hash1和Hash2可以再用Hash1 i * Hash2扩展出k个哈希位置然后检查全部k个bit位。如果全为1认为URL可能已抓取这里允许少量误判导致URL重复代价是浪费一次下载但不影响正确性如果有一个位置为0则确认未抓取并把全部k个位置置为1。def bloom_check_and_add(redis, key, url, k6, width10**7): h1 xxhash.xxh32(url, seed1).intdigest() % width h2 xxhash.xxh32(url, seed2).intdigest() % width positions [(h1 i * h2) % width for i in range(k)] exists True for pos in positions: if not redis.getbit(key, pos): exists False break if not exists: pipe redis.pipeline() for pos in positions: pipe.setbit(key, pos, 1) pipe.execute() return exists这里面k哈希函数个数和width位数组长度直接影响误判率。10万元素、1千万bit位、6个哈希函数时误判率大约在1.6%左右。这是我用简化公式算出来的实际可以接受。自己实现的缺点也很明显无法删除元素除非用counting Bloom Filter即每个位不是0/1而是一个计数器而且不能像RedisBloom那样自动扩张容量。所以它适合一次性定容、误判率可接受的场景。5. 内存账本扩容到1亿用户时怎么算5.1 理论内存计算很多人只知道“Bitmaps省内存”但从不说具体省多少。我们来算一笔实账。1亿用户每个用户占1个bit总bit数 100,000,000换算成字节 12,500,000字节 ≈ 12.5MB。如果每天一个bitmap key保留90天总内存 12.5MB × 90 ≈ 1.125GB。对比用Set存活跃用户每个userId如果是一个自增ID在Set中每存一个成员除了成员本身的字符串开销还有dictEntry、robj、SDS等结构粗略估算一个成员至少30字节。1亿个成员就是3GB。当然真实场景中Set还要考虑rehash过程中的内存峰值这个差距只大不小。存储方案1亿用户的内存占用写入耗时模拟查询某用户是否在线Set≥3GB约2.3秒SISMEMBERO(1)StringJSON约1.5GB压缩前更大全量覆盖才有意义解析JSONO(n)Bitmaps12.5MB约0.8秒GETBITO(1)这里有个前提用户ID必须是连续的或经过映射的。如果直接用分布式ID当offsetID分散在超大范围里位图会被拉得极长内存优势荡然无存。5.2 offset的稀疏陷阱与映射对策我自己踩过一次很大的坑。当时有一个活动系统用户ID是雪花算法生成的19位数字我图省事直接把userId当作offset写入位图。一个ID可能在2^60这个数量级SETBIT瞬间让Redis分配了上百MB内存直接导致线上Redis内存报警最后回滚了版本。解决思路有几种自增映射表。维护一个uid - seq的映射用Redis Hash或MySQL存。用户第一次进入时分配一个自增ID之后所有bitmap操作都用这个seq。缺点是要引入一次查映射的IO。分段哈希。用userId % 1亿作为offset碰撞概率是有的但可以通过多级位图降低。这个方法适合对精确性要求不高的统计场景。只用于短期维度。如果位图key按天切分用户当天活跃就置为所在分区编号不追求全局唯一跨天统计交给别的系统。我的建议是凡是要求精确统计比如签到、交易用户数的场景一定要有uid到seq的映射凡是允许少量误差比如活跃统计、在线人数的场景可以用哈希分段。5.3 maxmemory与碎片管理用位图最怕的是内存碎片。频繁SETBIT分散扩展字符串底层SDS会不断realloc长期运行后碎片率可能升高。我在维护的Redis实例上看到过mem_fragmentation_ratio超过2的情况。应对办法尽量一次性把位图初始化到位比如每月用户签到key月初时用SETRANGE或者直接SET一个长度为天数*字节数的0字节串提前占位避免后续反复扩容。开启activedefrag yes需要Redis 4.0以上并且jemalloc编译让Redis在后台自动整理碎片。关注INFO memory里的mem_fragmentation_ratio持续偏高时考虑重启迁移或换主从节点。6. 实战中踩过的坑与规避手段6.1 BITCOUNT的start/end参数是字节不是bit这是我见过最多人踩的坑。文档里写的是[start end]很多人默认以为单位是bit实际上单位是字节。比如我统计6月前10天的签到情况会写127.0.0.1:6379 BITCOUNT sign:202406:10001 0 9如果误以为0和9是bit偏移实际统计出来的结果是完全错误的。这里有一个小技巧如果真的要按bit区间统计可以先用BITFIELD把对应的字节段取出来再交给应用层计算。或者干脆用多个GETBIT。6.2 BITOP在大key上的阻塞问题BITOP是O(N)操作其中N是所有输入key中最长字符串的字节数。一个1亿用户的位图就是12.5MB两个做AND理论上最坏需要遍历12.5MB数据。在普通机器上纯内存遍历12.5MB大概需要10ms左右看起来不大。但如果同时有多个BITOP请求或者key更大比如一个位图因为offset设置过大变成了500MBRedis主线程就会被卡住几百毫秒甚至更久。线上Redis是单线程处理命令主线程卡住意味着所有读写请求都会排队这就是阻塞事故。我规避的方案是三层控制单个key大小。位图相关的key我尽量控制在10MB以内超过就做分片拆分。用异步任务做BITOP。把BITOP的操作放到独立的Redis实例或从节点上执行只把结果同步回主实例。尽量用增量位图。比如统计“今天和昨天都活跃的用户”不用每天做全量AND而是昨天活跃的用户在今天活跃位图里挨个GETBIT只查询存量用户避免全量运算。6.3 集群模式下的散列问题Redis Cluster下不同的key会按照CRC16算法散列到不同的slot而BITOP要求所有参与运算的key在同一个slot。如果两个key不在同一个slot命令直接报错CROSSSLOT。解决办法是使用hash tag。key里加一对花括号比如{active}:20240601和{active}:20240602Redis会只对花括号内的字符串计算hash slot这样两个key必然落在同一个slot上。但hash tag不能滥用。如果所有key都用一个tag会导致数据全部堆积在同一个节点上节点负载失衡。我的建议是需要做位运算的key才用统一的tag其他业务key保持自然散列。6.4 分散SETBIT导致的SDS扩容性能问题如果每个月签到key在月初是一个空字符串然后用户每天来SETBIT一次这个key的SDS就会从1字节、2字节、4字节……逐步扩容到月末的31字节如果用月维度key。每次扩容可能触发内存拷贝虽然单次耗时不大但量大之后会产生大批小对象碎片。我在一个高并发签到活动里遇到过大量用户同时首次签到Redis的写QPS非常高但CPU时间大量耗在SDS扩容和内存分配上SETBIT操作的P99延迟一度到了30ms正常应该在1ms以内。后来改成了用户首次签到时用SETRANGE直接把整个月的位图初始化好# 初始化31天31bit ≈ 4字节的位图 127.0.0.1:6379 SETBIT sign:202406:10001 0 1 127.0.0.1:6379 SETRANGE sign:202406:10001 0 \x00\x00\x00\x00先SETBIT触发字符串创建然后用SETRANGE一次性分配4个字节避免后续逐日扩容。这个优化把SETBIT操作的P99从30ms降到了3ms左右。6.5 过期策略与归档Bitmaps的key如果不设置过期时间内存会无限增长。但签到、活跃这类数据往往需要保留一段时间。我的原则是日期维度key设置保留期比如活跃位图保留90天用EXPIRE sign:day:20240614 7776000。用户维度key按业务需求设置比如签到年榜需要保留到年底就给sign:user:{userId}:202412设置到次年1月3日过期留出统计余量。归档逻辑用定时任务扫描过期key或者直接依赖Redis的惰性删除主动淘汰机制。一个容易忽略的点给大量key设置相同的过期时间会在同一时刻触发Redis的批量过期删除导致瞬时CPU抖动。我的做法是给过期时间加上一个随机偏移比如基础90天加0到86400秒的随机值避免所有key在同一秒到期。最后补充一个实用技巧我最后再分享一个小技巧在做Bitmaps批量初始化时其实可以更省事用SET key \x00\x00...直接写入一个预先生成的字节串就完成了占位比逐位SETBIT快得多。字符串本身就是二进制安全的写多少字节都行。比如要初始化一个长度为1MB的位图可以用SET bitmap:20240614 \x00 * 1048576这里在Redis命令行可以用SETRANGE配合长度参数也能做到。这一招在应对大流量营销活动时非常管用活动开始前把位图预分配出来活动期间的SETBIT就完全避免了扩容开销。Bitmaps这个东西原理并不复杂真正拉开差距的是能不能在真实场景里把offset规划、key设计、分片策略、过期管理和异常边界都想清楚。如果你正在做签到、活跃、在线这类统计需求建议先把上文的命令和内存计算手推一遍再结合自己的用户模型选好映射方案能省下很多后面排障的时间。