Redis Bitmap 签到实现:从命令到字节级原理

一行 SETBIT 吃掉 12 MB,10 个用户 300 MB。从字节级原理拆解 Bitmap 签到,讲透 hash offset 反模式、BITCOUNT 字节偏移坑、大 key 性能边界,附 Go 完整实现和 7 组实测数据。

封面

一行 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 返回 stringOBJECT ENCODINGraw(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 表示"没签到"。

Bitmap 底层是 String 的字节级可视化

理解了这层,后面的命令原理和踩坑就顺理成章了。它们都是对 String 字节的位操作,都有 String 的所有限制。

二、四个命令的字节级原理

签到场景主要用四个命令:SETBITGETBITBITCOUNTBITFIELD。逐个拆开看。

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 反模式,根因就在这里。

offset 与内存占用的量化关系

GETBIT:O(1) 的简单查询

GETBIT key offset 查询指定 offset 的位。底层是 String 的字节读取 + 位运算,复杂度 O(1)。没什么坑,知道存在就行。

BITCOUNT:字节偏移的反直觉设计

BITCOUNT key [start end] 统计置位位数。坑在 startend 参数:它们是字节偏移,不是位偏移

大多数开发者第一次用会以为是位偏移。我构造了一个测试:

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 70 7 实际统计的是第 0-7 字节(8 字节 × 8 位/字节 = 64 位)。

位偏移思维 vs 字节偏移思维的对比

为什么 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)在最右边(最低位)。

另外,BITFIELDOVERFLOW 选项(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 函数的均匀性保证了这一点。

连续 offset vs hash offset 的内存对比

为什么腾讯云那个事故拖了 9 个月

事故复盘里提到一个细节:业务方以为 hash(用户ID) 能让 offset 分布更均匀,避免连续 offset 的"热点"问题。这是对 Bitmap 的根本误解。

连续 offset 不是"热点",是 Bitmap 的正确用法。它让 max offset = 用户数,内存紧凑。hash offset 反而把连续的 offset 打散到整个 2^32 空间,把 Bitmap 的优势变成了劣势。

9 个月才发现,很大程度是因为 Redis 内存增长是渐进的。每天新增几个用户,hash 值又可能命中更大的 offset,内存就涨一点。等到 60 GB 触发告警时,已经积重难返。

hash offset 反模式生活隐喻

正确做法: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

数据说明:

  1. BITCOUNT 确实是 O(N)——从 1 KB 到 512 MB,耗时从 178 µs 增长到 7.16 ms,约 40 倍。
  2. 1 MB 的 avg(114 µs)比 1 KB(178 µs)更快,这是 CPU cache 效应:1 MB 数据能装进 L2/L3 cache,popcount 指令流水线更高效。数据量继续增大后趋于正常增长。
  3. Redis 8.8.0 的 popcount 优化很猛。512 MB 的 Bitmap,P99 只有 8.83 ms。严格意义上这不算"阻塞"(通常 > 50 ms 才算)。
  4. 但这是本地直连、单线程、无并发的理想环境。生产环境有网络往返(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(异步删除)。

BITCOUNT 不同规模 Bitmap 的耗时实测

为什么 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 的优势变成了劣势。

Bitmap vs Set vs Hash 三结构内存对比

选型判断

  • 用户 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。

几个关键点:

  1. uid 必须是连续整数,不能 hash。这是前面所有原理的落脚点
  2. 连续签到天数用 BITFIELD GET,一次命令读取整月位图,避免 N 次 GETBIT
  3. 跨月查询要调用方按月分段查询后拼接,本实现只处理单月内
  4. 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 的坑。剩下的命令细节和实现代码,都在前面的实验里了。

结尾金句:offset 决定内存


附录:实验代码和原始数据

本文 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


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →