
一行 SETBIT sign:1001 100000000 1,Redis 直接吃掉 12 MB 内存。
12 MB 给一个 key,就为了标记一个用户在某天签了到。10 个这样的用户,120 MB 就没了。1000 个,12 GB。
你可能会想:“这不对吧,Bitmap 不是最省内存的签到方案吗?” Bitmap 在连续用户 ID 场景下确实省内存,100 万用户签到一个月只要 0.13 MB。
但前提是 offset 的设计要正确。把用户 ID hash 一下当 offset 用(后面会详细讲为什么这会出问题),类似腾讯云那个 9 个月吃掉 60 GB 内存的真事故。
这篇讲三件事:Bitmap 每个命令的字节级原理、hash offset 为什么是反模式、怎么正确实现签到。不讲选型决策框架,只讲实现和踩坑。那些"怎么用"的教程已经够多了,我写这篇是因为缺一个把原理和踩坑讲透的版本。
所有数据都是我在 Redis 8.8.0 上实测的,本地直连,带版本标注。
一、Bitmap 的底层真相:它不是独立类型
很多人以为 Bitmap 是 Redis 的一种数据类型,和 String、Hash、Set 平级。不是。
Redis 官方文档原话:“Bitmaps are not an actual data type, but a set of bit-oriented operations defined on the String type.” Bitmap 不是独立类型,是 String 上的一组位操作。
我跑了一组实测验证这件事:
SETBIT sign:demo 7 1
TYPE sign:demo # → string
OBJECT ENCODING sign:demo # → raw
STRLEN sign:demo # → 1(1 字节)
TYPE 返回 string,OBJECT ENCODING 是 raw(String 的原始编码),STRLEN 显示 1 字节。Bitmap 就是 String,只是被当作位向量来操作。
再设置第 100 位:
SETBIT sign:demo 100 1
STRLEN sign:demo # → 13(ceil(101/8) = 13 字节)
GET sign:demo # → [1 0 0 0 0 0 0 0 0 0 0 0 8]
GET 能直接拿到字节序列(以下是各字节的十进制值)[1 0 0 0 0 0 0 0 0 0 0 0 8]。第 0 字节是 1(二进制 00000001,第 7 位被设置),第 12 字节是 8(二进制 00001000,第 100 位 = 第 12 字节的第 4 位被设置)。
这就是 Bitmap 的全部底层:一个 String,每个字节的每一位对应一个布尔状态。签到场景下,1 表示"签到了",0 表示"没签到"。

理解了这层,后面的命令原理和踩坑就顺理成章了。它们都是对 String 字节的位操作,都有 String 的所有限制。
二、四个命令的字节级原理
签到场景主要用四个命令:SETBIT、GETBIT、BITCOUNT、BITFIELD。逐个拆开看。
SETBIT:offset 决定内存
SETBIT key offset value 把指定 offset 的位设为 value。看起来很简单,但有个关键设计:offset 直接决定这个 key 占多少内存。
我实测了不同 offset 下的内存占用(Redis 8.8.0 本地直连):
| SETBIT offset | STRLEN | MEMORY USAGE | 等价大小 |
|---|---|---|---|
| 7 | 1 B | 35 B | - |
| 1,000 | 126 B | 208 B | - |
| 10,000 | 1,251 B | 1,328 B | ~1.3 KB |
| 1,000,000 | 125,001 B | 131,120 B | ~128 KB |
| 10,000,000 | 1,250,001 B | 1,261,616 B | ~1.2 MB |
| 100,000,000 | 12,500,001 B | 12,501,040 B | ~12 MB |
规律很清楚:STRLEN = ceil((offset + 1) / 8)。即使只设置 1 个位,offset 多大,Redis 就得分配多大内存。这是 String 类型的本性——你要写到第 100 个字节,前面 99 个字节必须存在。
MEMORY USAGE 略大于 STRLEN,多出的是 Redis 内部元数据(redisObject 头、SDS 头,约 16-64 字节)。
记住这个规律:offset 决定内存。下一章的 hash offset 反模式,根因就在这里。

GETBIT:O(1) 的简单查询
GETBIT key offset 查询指定 offset 的位。底层是 String 的字节读取 + 位运算,复杂度 O(1)。没什么坑,知道存在就行。
BITCOUNT:字节偏移的反直觉设计
BITCOUNT key [start end] 统计置位位数。坑在 start 和 end 参数:它们是字节偏移,不是位偏移。
大多数开发者第一次用会以为是位偏移。我构造了一个测试:
SETBIT sign:202606 0 1 # 设置第 0 位
SETBIT sign:202606 8 1 # 设置第 8 位
STRLEN sign:202606 # → 2 字节(第 0 位在第 0 字节,第 8 位在第 1 字节)
然后分别用不同 start/end 调用 BITCOUNT:
BITCOUNT sign:202606 0 10 # → 2(以为是 0-10 位,实际是 0-10 字节)
BITCOUNT sign:202606 0 0 # → 1(统计第 0 字节,含第 0 位)
BITCOUNT sign:202606 0 1 # → 2(统计第 0-1 字节,含第 0 和第 8 位)
BITCOUNT sign:202606 1 1 # → 1(统计第 1 字节,含第 8 位)
反直觉点:想统计第 0-7 位,应该用 BITCOUNT key 0 0(第 0 字节),不是 BITCOUNT key 0 7。0 7 实际统计的是第 0-7 字节(8 字节 × 8 位/字节 = 64 位)。

为什么 Redis 这么设计?因为 Bitmap 底层是 String,BITCOUNT 本质上是遍历 String 的字节做 popcount。按字节范围切片对实现最自然,对用户却反直觉。这是"实现友好"和"用户友好"的权衡,Redis 选了前者。
这个坑在 Redis 官方文档里有说明,但中文教程很少提及。Redis 7.0 起增加了 BIT/BYTE 修饰符,想按位范围统计可以用 BITCOUNT key 0 7 BIT。如果你的 Redis 版本 ≥ 7.0,这个坑能直接绕过。
BITFIELD:一次读取整月签到
BITFIELD 是更强大的位运算命令,可以一次读取/设置多位。签到场景用它计算连续签到天数,一次命令读取整个月的位图,避免 N 次 GETBIT 的网络往返。
BITFIELD sign:1001:202606 GET u28 0
这里有个细节坑过我:BITFIELD 的位序是大端序:offset 0 对应返回值的最高位。所以当天(offset = day-1)对应返回值的最低位。写连续签到天数的逻辑时,要从最低位开始往前数。
想象成一排位从左到右排列,offset 0 在最左边(最高位),当天(offset = day-1)在最右边(最低位)。
另外,BITFIELD 的 OVERFLOW 选项(WRAP/SAT/FAIL)在位宽较大时需要注意溢出行为。单月签到(u31)不会触发,但跨月或更大位宽场景要留意。
三、hash offset 反模式:10 个用户吃掉 300 MB
前面讲了 offset 决定内存。那如果用 hash(userID) 作为 offset 会怎样?
这是腾讯云那篇《慎用 BitMap, 小心玩爆你的内存》记录的真实事故:某业务用 hash(用户ID) 作为 SETBIT 的 offset,9 个月内 Bitmap 吃掉了 60 GB 内存。我复现了这个反模式。
实验设计
用两组方案存储相同的 10 个用户签到状态:
- 方案 A(正确):连续 offset,用户 i 用 offset = i
- 方案 B(反模式):FNV1a hash(用户ID) 对 2^32 取模作为 offset
测试用用户 ID 为 ‘user_001’ 到 ‘user_010’ 的字符串。实测环境:Redis 8.8.0 / Go 1.26.4 / 本地直连。
结果
| 用户数 | 连续 offset(正确) | FNV1a hash(反模式) | 倍数差异 |
|---|---|---|---|
| 10 | 48 B | 306,823,200 B(~293 MB) | 6,392,150 倍 |
| 100 | 64 B | 306,823,200 B(~293 MB) | 4,794,112 倍 |
| 1,000 | 288 B | 442,564,640 B(~422 MB) | 1,536,682 倍 |
| 10,000 | 2,096 B | 534,298,672 B(~510 MB) | 254,913 倍 |
10 个用户,连续 offset 只要 48 字节,hash offset 要 293 MB。差 600 多万倍。
换 CRC32 hash 结果类似,10 个用户 467 MB。
根因
hash 函数的设计目标是均匀分布。FNV1a 和 CRC32 都会把输入字符串映射到 0-2^32 区间的任意位置。10 个用户 hash 后的 offset 分散在 0 到 24 亿之间(实测最大值,接近 2^32 = 42.9 亿的上限)。
Bitmap 的内存由 max offset 决定:STRLEN = ceil((max_offset + 1) / 8)。max offset = 2.4 亿,STRLEN 就是 30 MB;max offset = 42 亿(2^32 上限),STRLEN 就是 512 MB。
所以 hash offset 不是"可能爆炸",是"必然爆炸"。hash 函数的均匀性保证了这一点。

为什么腾讯云那个事故拖了 9 个月
事故复盘里提到一个细节:业务方以为 hash(用户ID) 能让 offset 分布更均匀,避免连续 offset 的"热点"问题。这是对 Bitmap 的根本误解。
连续 offset 不是"热点",是 Bitmap 的正确用法。它让 max offset = 用户数,内存紧凑。hash offset 反而把连续的 offset 打散到整个 2^32 空间,把 Bitmap 的优势变成了劣势。
9 个月才发现,很大程度是因为 Redis 内存增长是渐进的。每天新增几个用户,hash 值又可能命中更大的 offset,内存就涨一点。等到 60 GB 触发告警时,已经积重难返。

正确做法:ID 映射
如果你的用户 ID 不是连续整数(比如是字符串 UUID),又想用 Bitmap,正确做法是做一层 ID 映射:
// getOrCreateOffset 用自增计数器给每个用户分配连续 offset
// 注意:INCR + HSET 非原子,并发场景下会 offset 泄漏
// 生产环境应使用 Lua 脚本:先 HGET 判断存在,不存在才 INCR + HSET
func (s *SigninService) getOrCreateOffset(ctx context.Context, rawUserID string) (int64, error) {
// INCR sign:uid:next → 拿到一个自增整数
offset, err := s.rdb.Incr(ctx, "sign:uid:next").Result()
if err != nil {
return 0, err
}
// 记录映射关系
s.rdb.HSet(ctx, "sign:uid:mapping", rawUserID, offset)
return offset - 1, nil // offset 从 0 开始
}
这样 max offset = 用户总数,内存紧凑。代价是多一次 HGET 查询映射,但相比 60 GB 内存,这点开销可以忽略。
另一个选择是直接用自增主键作为 offset。如果业务侧的用户表有自增 ID,直接拿来用,连映射都省了。
四、BITCOUNT 大 key:不阻塞,但是性能关注点
前面讲的坑都在内存分配。BITCOUNT 的坑不一样——它在执行耗时上。官方复杂度是 O(N),很多教程会告诉你"大 Bitmap 上 BITCOUNT 会阻塞"。我实测了一下,这个说法需要修正。
实测环境:Redis 8.8.0 / Go 1.26.4 / 本地直连,每个规模跑 200 次取平均:
| Bitmap 规模 | STRLEN | BITCOUNT avg | P50 | P99 | max |
|---|---|---|---|---|---|
| 1 KB | 1,024 B | 178 µs | 172 µs | 326 µs | 529 µs |
| 1 MB | 1 MB | 114 µs | 111 µs | 144 µs | 160 µs |
| 10 MB | 10 MB | 206 µs | 202 µs | 239 µs | 245 µs |
| 100 MB | 100 MB | 1.41 ms | 1.40 ms | 1.60 ms | 1.61 ms |
| 512 MB(上限) | 512 MB | 7.16 ms | 7.07 ms | 8.83 ms | 12.24 ms |
数据说明:
BITCOUNT确实是 O(N)——从 1 KB 到 512 MB,耗时从 178 µs 增长到 7.16 ms,约 40 倍。- 1 MB 的 avg(114 µs)比 1 KB(178 µs)更快,这是 CPU cache 效应:1 MB 数据能装进 L2/L3 cache,popcount 指令流水线更高效。数据量继续增大后趋于正常增长。
- Redis 8.8.0 的 popcount 优化很猛。512 MB 的 Bitmap,P99 只有 8.83 ms。严格意义上这不算"阻塞"(通常 > 50 ms 才算)。
- 但这是本地直连、单线程、无并发的理想环境。生产环境有网络往返(0.5-2 ms)和并发负载,按 2-5 倍放大估算,512 MB Bitmap 在生产环境可能 14-36 ms。对延迟敏感的业务(< 10 ms SLA),这就是性能瓶颈。
所以正确的说法是:BITCOUNT 在大 Bitmap 上是性能关注点,不是必然阻塞。真正的阻塞风险在 DEL 大 key。DEL 是同步删除,512 MB 的 key 会阻塞主线程几百毫秒到几秒。生产环境删大 key 要用 UNLINK(异步删除)。

为什么 Redis 8.8.0 的 BITCOUNT 这么快
512 MB 的 Bitmap,BITCOUNT 只要 7 ms,这得益于现代 CPU 的 popcount 指令。Redis 在编译时检测 CPU 是否支持 __builtin_popcount(GCC/Clang 内置函数),如果支持就用硬件指令,一次处理 64 位而不是逐位遍历。
ARM64(M1/M2)有 cnt 指令,x86_64 有 POPCNT 指令。这些指令把 8 字节的 popcount 压到个位数时钟周期。512 MB ÷ 8 字节/块 ≈ 6700 万个块,实测 7 ms 处理完毕,对应吞吐约 613 Gbps(512 MB 含约 43 亿位 ÷ 7 ms)。实测环境是 M1(ARM64),popcount 走 cnt 指令路径;x86 平台走 POPCNT 或 AVX2 路径,具体吞吐取决于 CPU 型号和频率,但数量级一致。
BITCOUNT 命令从 Redis 2.6.0 引入时就使用了 popcount 优化路径(根据 Redis 源码 src/bitops.c)。后续版本逐步追加了 AVX2、AVX-512、AArch64 NEON 等 SIMD 优化路径,但源码注释中未标注各优化的具体引入版本,这里不逐一断言。如果你用的是 Redis 2.6 之前的老版本,BITCOUNT 是纯软件实现,会慢得多。生产环境务必确认 Redis 版本。
五、Bitmap vs Set vs Hash:什么时候该用 Bitmap
讲了这么多坑,Bitmap 到底什么时候值得用?我实测了三种数据结构存储相同用户签到状态的内存占用。
连续 ID 场景:Bitmap 完胜
| 用户数 | Bitmap | Set | Hash | Bitmap/Set |
|---|---|---|---|---|
| 1 万 | 2,112 B | 430,082 B | 480,082 B | 0.5% |
| 10 万 | 16,448 B | 4,137,586 B | 4,637,586 B | 0.4% |
| 100 万 | 131 KB | 38.4 MB | 43.2 MB | 0.3% |
100 万用户,Bitmap 只要 0.13 MB,Set/Hash 要 38-43 MB。差 300 倍。这是 Bitmap 的主战场。
稀疏 ID 场景:Bitmap 反而更费
| 场景 | Bitmap | Set | Hash | Bitmap/Set |
|---|---|---|---|---|
| 1 万用户(步长 1000,max ID ≈ 10^7) | 1.23 MB | 0.44 MB | 0.49 MB | 281% |
稀疏 ID 场景下,Bitmap 反而比 Set/Hash 大 2.8 倍。原因还是 offset 决定内存。max ID = 10^7 意味着 Bitmap 要分配 1.25 MB,而 Set/Hash 只存实际的 1 万个元素。
这就是 hash offset 反模式的本质:hash 把连续的用户 ID 打散成稀疏 offset,把 Bitmap 的优势变成了劣势。

选型判断
- 用户 ID 连续或可映射到连续 offset(如自增主键)→ Bitmap
- 用户 ID 稀疏且无法映射 → Set 或 Hash
- 用户 ID 是字符串/UUID → 不建议用 Bitmap,也别 hash 后用 Bitmap(用户量极小时影响不大,但缺乏扩展性)
六、完整签到实现
讲完原理和踩坑,给你一份能跑的代码。用 Go + go-redis 实现,包含签到、查询、连续天数、月度统计。
key 设计:sign:{uid}:{yyyyMM},每月一个 Bitmap,offset = day - 1(第 1 天对应 offset 0)。
// SigninService 签到服务
// key 设计:sign:{uid}:{yyyyMM},每月一个 Bitmap
type SigninService struct {
rdb *redis.Client
}
// Signin 用户签到(指定日期)
// uid 必须是连续整数,不能是 hash
func (s *SigninService) Signin(ctx context.Context, uid int64, date time.Time) error {
key := s.key(uid, date)
day := int64(date.Day() - 1) // 第 1 天对应 offset 0
return s.rdb.SetBit(ctx, key, day, 1).Err()
}
// IsSigned 查询某天是否签到
func (s *SigninService) IsSigned(ctx context.Context, uid int64, date time.Time) (bool, error) {
key := s.key(uid, date)
day := int64(date.Day() - 1)
bit, err := s.rdb.GetBit(ctx, key, day).Result()
if err != nil {
return false, err
}
return bit == 1, nil
}
// MonthlyCount 本月签到次数
func (s *SigninService) MonthlyCount(ctx context.Context, uid int64, date time.Time) (int64, error) {
key := s.key(uid, date)
return s.rdb.BitCount(ctx, key, nil).Result()
}
// ContinuousDays 连续签到天数(从指定日期往前数)
// 用 BITFIELD 一次读取整月位图,避免 N 次 GETBIT 网络往返
func (s *SigninService) ContinuousDays(ctx context.Context, uid int64, date time.Time) (int64, error) {
key := s.key(uid, date)
day := int64(date.Day())
// BITFIELD GET u{day} 0:读取从 offset 0 开始的 day 位
// 注意:BITFIELD 位序是大端序,offset 0 = 最高位
// 当天(offset=day-1)对应返回值的最低位
res, err := s.rdb.BitField(ctx, key, "GET", fmt.Sprintf("u%d", day), 0).Result()
if err != nil || len(res) == 0 {
return 0, err
}
bits := uint64(res[0])
// 从当天(最低位)向过去(高位方向)数连续的 1
count := int64(0)
for i := uint(0); i < uint(day); i++ {
if (bits>>i)&1 == 1 {
count++
} else {
break
}
}
return count, nil
}
func (s *SigninService) key(uid int64, date time.Time) string {
return fmt.Sprintf("sign:%d:%s", uid, date.Format("200601"))
}
跑一下验证:
用户 1001 今天(2026-06-28)是否签到: true
用户 1001 本月签到次数: 4
用户 1001 连续签到天数: 4
用户 1001 在 2026-06-26 是否签到: true
用户 1001 在 2026-06-24 是否签到: false
key: sign:1001:202606
TYPE: string, STRLEN: 4 字节, MEMORY: 54 字节
4 字节存一个月的签到状态,54 字节含 Redis 元数据。100 万用户一个月也就 100 多 MB。
几个关键点:
- uid 必须是连续整数,不能 hash。这是前面所有原理的落脚点
- 连续签到天数用
BITFIELD GET,一次命令读取整月位图,避免 N 次GETBIT - 跨月查询要调用方按月分段查询后拼接,本实现只处理单月内
- key 设计按月分,方便过期和统计。
DEL sign:1001:202606删一个月数据,配合UNLINK异步删除更稳
生产环境补充
这段代码是核心逻辑的最小实现,生产环境还需要补几层。
过期策略方面,每月初用 EXPIRE 给上个月的 key 设置 TTL(如 90 天),让 Redis 自动清理。如果要做"补签"功能,用 pipeline 一次发多个 SETBIT,减少网络往返。
集群分片方面,用户量到千万级时单 key 会成热点。按 uid 取模分片到多个 key,key 设计为 sign:{shard}:{uid}:{month}(shard = uid % 100),用 hash tag 把同一分片的用户聚到同一 slot,牺牲全局 BITCOUNT 的能力换吞吐(分片后全局统计需要聚合多个 key 的结果)。注意:集群模式下签到和连续天数查询的 SETBIT/BITFIELD 必须在同一 slot,hash tag 要覆盖这一约束——{shard} 是 hash tag,{uid} 和 {month} 不在 {} 内,不会被当作 hash tag。
监控方面,MEMORY USAGE sign:*:202606 定期跑,发现异常增长立即告警。腾讯云那个事故 9 个月才发现,就是因为没监控。
七、踩坑速查
把前面讲的坑汇总成一张速查表,遇到问题快速定位:
| 症状 | 根因 | 解决方案 |
|---|---|---|
| Redis 内存异常增长,单个 key 几百 MB | 用了 hash(userID) 作为 offset | 改用连续 offset 或 ID 映射 |
| BITCOUNT 结果和预期不符 | 以为 start/end 是位偏移,实际是字节偏移 | 按字节范围传参,想统计第 N 位用 BITCOUNT key floor(N/8) floor(N/8)(整数除法) |
| DEL 大 key 阻塞主线程 | DEL 是同步删除,大 key 释放内存慢 | 用 UNLINK 异步删除 |
| 连续签到天数算不对 | BITFIELD 位序是大端序,offset 0 = 最高位 | 从最低位开始往前数连续的 1 |
| offset 超 2^32-1 溢出 | Bitmap offset 上限是 2^32-1 | 做ID映射或改用 Set/Hash |
| 集群下单 key 热点 | Bitmap 是单 key,无法分摊负载 | 按 uid 分片到多个 key |
这张表建议截图存档,下次踩坑时对照排查。

写在最后:offset 决定内存
回到开头那个 12 MB 的 SETBIT。现在你知道为什么了。offset = 10^8,Redis 必须分配 ceil(10^8 / 8) = 12.5 MB 内存,不管你只设置了 1 个位还是 100 万个位。
Bitmap 的省内存是有条件的:连续 offset 场景下,100 万用户只要 0.13 MB;稀疏 offset 或 hash offset 场景下,10 个用户就能吃掉 300 MB。
记住这条规则,你就能避开腾讯云那个 60 GB 的坑。剩下的命令细节和实现代码,都在前面的实验里了。

附录:实验代码和原始数据
本文 7 组实验的代码和原始输出已开源:
GitHub:zhiyulab-evidence/redis-bitmap-signin
| 子目录 | 对应章节 | 实验内容 |
|---|---|---|
code/bitmap-is-string/ |
一、Bitmap 底层真相 | 验证 Bitmap 是 String 的 redis-cli 实操 |
code/bitcount-byte-vs-bit/ |
二、BITCOUNT 字节偏移 | 字节偏移 vs 位偏移对比实验 |
code/hash-offset-antipattern/ |
三、hash offset 反模式 | 复现腾讯云 60 GB 事故根因,10 用户 vs 300 MB |
code/bitcount-latency/ |
四、BITCOUNT 大 key 耗时 | 1 KB 到 512 MB 五档 Bitmap 耗时实测 |
code/storage-compare/ |
五、Bitmap vs Set vs Hash | 连续 ID + 稀疏 ID 两场景内存对比 |
code/signin-implementation/ |
六、完整签到实现 | Go + go-redis 完整签到服务代码 |
data/offset-memory-relationship.md |
二、SETBIT offset 决定内存 | 不同 offset 下的 STRLEN/MEMORY USAGE 数据表 |
实测环境:Redis 8.8.0 / Go 1.26.4 / darwin/arm64 (M1)。每个子目录都有独立 README,说明如何复现。二进制编译产物不入库,跑实验前自己 go build。
原文发布于 止语Lab