内存口径:全文统一 1000 进制(1 MB = 10⁶ 字节);String 上限按 512 MiB 标注。
代码可编译:全部 C# 片段在 StackExchange.Redis 2.8.16 + net10 下编译验证通过。
一、导读与边界
Bitmap 是 Redis 里最容易被"讲歪"的话题。它不是独立数据类型 ------TYPE key 返回 string,只是一组定义在 String 上的位操作命令 (SETBIT/GETBIT/BITCOUNT/BITPOS/BITOP),底层 value 与 SET/GET 是同一个 SDS 字节串:
redis
SET mykey "hello"
GETBIT mykey 0 # 取 "h" 第一个 bit → 0
SETBIT mykey 0 1 # 把第一个 bit 设为 1
TYPE mykey # → "string",不是 "bitmap"
OBJECT ENCODING mykey # → "embstr" 或 "raw"
四件事说清它的设计 : ① 没有"删除某 bit"的命令(String 只能覆盖写); ② offset 上限 2^32−1 ,来自 String 512 MiB 上限; ③ 大 offset 瞬间吃内存 (String 按最大 offset 补零字节); ④ BITCOUNT 区间默认按字节切(先是一条 String 命令)。
价值与代价:
- 收益:1000 万用户的单日布尔状态(某天是否活跃/签到)仅约 1.25 MB ;同样数据用 Set 约 400~600 MB ,差 300~500 倍。
- 代价:key 大小由最大 offset 决定,不由"已置 1 的 bit 个数"。雪花 ID 直接当 offset 会直接报错
ERR bit offset is not an integer or out of range;真正危险的是"映射后仍在上限内、但 offset 极大"的写入。
适用前提 :Bitmap 的价值是"用一个 bit 表达一个布尔状态",前提是状态能映射到连续、相对稠密、有界的非负整数(签到=日期、活跃=userId、去重=序号)。ID 若是稀疏雪花 ID、UUID、手机号,Bitmap 优势立刻消失,甚至变成内存炸弹。
二、核心命令全集
2.1 单 bit 读写:SETBIT / GETBIT
redis
SETBIT key offset value # 置位,返回该位原来的值
GETBIT key offset # 读位
语义与边界:
- offset 从 0 起,最大 2^32−1 = 4,294,967,295(对应 String 512 MiB),超过直接报错、不截断。
SETBIT自动扩展 String,中间补\x00------SETBIT key 100000000 1立刻分配约 12.5 MB,这是"稀疏写入爆炸"的根源。SETBIT返回旧值 (0/1),可区分首次/重复,天然幂等(重复签到不重复计数)。GETBIT越界分两种:未超当前长度但在上限内 → 返回 0;超过 2^32−1 → 报错。"读不存在的位"和"读值为 0 的位"结果相同,但越界是错误。SETBIT是 O(1),但首次大跨度分配不是 O(1),会阻塞主线程(量级见 3.2)。- value 只接受 0/1,传 2/-1/true/空串都报错;入口做校验。
csharp
// SETBIT 返回旧值(true = 原来是 1);key 用分钟级,与命名表一致
var minuteKey = $"online:{{hb}}:{_clock.Now:yyyyMMddHHmm}";
bool wasSet = await db.StringSetBitAsync(minuteKey, uid, true);
// GETBIT:上限内越界返回 false
bool online = await db.StringGetBitAsync(minuteKey, uid);
四个高频组合:
redis
# ① 判重:用 SETBIT 返回值(0=首次/1=已存在),别额外 GETBIT,省一次 RTT 也避免竞态
SETBIT seen:order:20261103 1001 1
# ② 位图+过期:位图本身不自动过期,必须手动 EXPIRE
SETBIT active:{dau}:20261103 1001 1
EXPIRE active:{dau}:20261103 2592000
# ③ 开关集合:一位一个开关
SETBIT tags:{1001} 0 1
# ④ 多图聚合(见 2.3)
BITOP OR dest src1 src2 src3
2.2 计数与查找:BITCOUNT / BITPOS
redis
BITCOUNT key [start end [BYTE|BIT]] # 数 1 的个数
BITPOS key bit [start [end [BYTE|BIT]]] # 找第一个为 bit 的位置
BITCOUNT 四个易错点:
- 无参是 O(N),扫全图(底层按字节查表)。
- 带区间时
start/end默认是字节偏移,不是比特。 - 7.0 起才支持
BYTE|BIT;低于 7.0 只能按字节切。 start/end允许负数:-1是最后一个字节。BITCOUNT key 0 7是 8 字节=64 比特,BITCOUNT key 0 7 BIT是 8 比特------只差一个关键字,结果差 8 倍。
量级(自测后替换) :
BITCOUNT是内存带宽受限的 popcount,约 1~2 GB/s。1.25 MB(千万 bit)≈ 1 ms ;12.5 MB(1 亿 bit)≈ 8~10 ms ;BITOP37.5 MB 输入 ≈ 30~50 ms。判断依据:1 ms 可直接调、10 ms 加缓存、50 ms 必须异步。
BITPOS 返回值边界(关键在"有没有给 end"和"key 在不在"):

只给
start时 Redis 仍按"右侧补零"返回长度×8 而不是 -1;key 被淘汰后BITPOS key 0返回 0 ,像"没签",会静默走错分支。判断存在性用EXISTS/BITCOUNT,别用BITPOS。
csharp
// BITCOUNT 无参:整月签到次数,O(N)
var count = await db.StringBitCountAsync("sign:{1001}:202611");
// 带区间:默认字节单位,只数第 1 个字节 = 前 8 天
var firstByte = await db.StringBitCountAsync("sign:{1001}:202611", 0, 0);
// 位单位(7.0+):只数前 8 个比特
var firstBits = await db.StringBitCountAsync("sign:{1001}:202611", 0, 7, StringIndexType.Bit);
// 客户端不支持 Bit 时走原始命令(服务端仍须 ≥ 7.0)
var raw = (long)await db.ExecuteAsync("BITCOUNT", "sign:{1001}:202611", 0, 7, "BIT");
// BITPOS:找第一个未签到的位(找 0),返回值显式转 long
long firstMiss = (long)await db.ExecuteAsync("BITPOS", "sign:{1001}:202611", 0);
⚠️
StringIndexType.Bit需 StackExchange.Redis 2.6.x 及以上 (以实测为准)且服务端 ≥ 7.0;服务端低于 7.0 时发BIT会被当语法错误。
2.3 位运算:BITOP
redis
BITOP AND destkey srckey1 srckey2 ... # 交集:同时为 1
BITOP OR destkey srckey1 srckey2 ... # 并集:任一为 1
BITOP XOR destkey srckey1 srckey2 ... # 异或:不同为 1
BITOP NOT destkey srckey # 取反:只接受一个源
关键语义 :结果是写入 destkey(存在会被整体覆盖);不同长度按最长对齐,短的在尾部(高 offset 端)补 0 ,不存在的 key 视为全零;BITOP 是 O(N) (最长输入字节数),单线程同步执行,大输入务必错峰;destkey 必须设 TTL;不要在只读副本上执行(副本写入不回主库,切换后结果丢失)。
运算差别 :OR=并集(7 天合成周活)· AND=交集(7 天每天活跃的核心用户)· XOR=对称差(两天状态不同)。
⚠️
XOR求"只在 A 不在 B"不行(它同时含"在 B 不在 A")。标准版没有ANDNOT,要两跳:
redisBITOP NOT tmp_b b BITOP AND dest a tmp_b UNLINK tmp_b致命前提:两图必须等长 。若
len(b) < len(a),AND给b尾部补 0,a在覆盖范围外的位被0 & 1 = 0清掉,差集偏小且不报错 。长度不等时先对齐或分段BITFIELD。8.2 起新增DIFF/DIFF1/ANDOR/ONE,用前先COMMAND DOCS BITOP确认。
csharp
// 一周内每天都活跃的人数:7 张日活图 AND;key 共用 Hash Tag {dau} 保证同 Slot
string WeekKey(string suffix) => $"active:{{dau}}:{suffix}";
var dayKeys = new RedisKey[] { WeekKey("20261102"), WeekKey("20261103"), /* ...7 个 */ };
// dest 用 weekall,别和 4.2 的周活(OR)key 撞名
await db.StringBitOperationAsync(Bitwise.And, WeekKey("weekall:20261102:job1"), dayKeys);
await db.KeyExpireAsync(WeekKey("weekall:20261102:job1"), TimeSpan.FromHours(2));
long weeklyAllActive = await db.StringBitCountAsync(WeekKey("weekall:20261102:job1"));
2.4 位域读写:BITFIELD / BITFIELD_RO
BITFIELD 把 String 当"按任意位宽/偏移读写的位字段数组"。本篇只用它一次读出整月位模式 ;SET/INCRBY/OVERFLOW 见第 7 篇。
type= 位宽+符号:u8是无符号 8 位(0~255),i8有符号,位宽最大 64。- 一次可执行多个子命令,返回数组、顺序与子命令一致(减少 RTT 的关键)。
BITFIELD_RO(6.2+)只允许GET,可在只读副本上安全用。
csharp
// 一次读出当月前 15 天的位模式(减少 RTT),应用层解析连续签到
var raw = await db.ExecuteAsync("BITFIELD", "sign:{1001}:202611", "GET", "u15", 0);
// RedisResult 没有 AsArray()(StackExchange.Redis 2.8.16 实证),用 Length + 索引器 + 显式转换
long bits = raw.IsNull || raw.Length == 0 || raw[0].IsNull ? 0L : (long)raw[0];
⚠️ 位序 MSB-first:
GET u15 0的最高位(bit14)对应 offset 0/1 号。bits拿到后按 3.5 的BitmapBits.Get解析,别直接当"天数"。
2.5 命令速查表

2.6 版本兼容矩阵

建议把此表写进项目"Redis 版本基线"文档,升级前对照一遍。
2.7 .NET API 对照与三个坑

三个坑 : ① StringBitOperation 是 params RedisKey[],30 个 key 一次传没问题,但集群下必须全部同 Slot ; ② StringBitCount(key, start, end) 的 start/end 默认字节,0,7 是 64 比特,要比特显式传 StringIndexType.Bit(2.6.x+ 且服务端 ≥7.0); ③ StringGetAsync 取整图拿到 byte[],别 ToString() 当文本(位图全是 \0)。
三、底层机制:Bitmap 到底住在哪儿
只讲"它是什么结构、对业务有什么影响";SDS 源码见 S10-2。这是全文理论核心。
3.1 一句话:不是类型,是 String 的位视图
执行 SETBIT mybitmap 7 1:检查是不是 String(无则建空串)→ 扩到能放下第 7 位(1 字节)→ 大端序下第 7 位是该字节最低位。你享受"位级读写",付的是"String 的账":内存按字节分配、受 String 编码约束、集群按 key 分片、持久化按 String 刷盘。
3.2 影响一:内存按最大 offset 分配,不是按 1 的个数
假设只写 3 个位:SETBIT b 0 1 / SETBIT b 100 1 / SETBIT b 1000000 1。你以为很省,实际分配 1,000,001 bit ÷ 8 ≈ 125 KB ------长度由最大 offset 决定,中间全 \0。
csharp
// offset 用到上限:立刻分配 512 MiB,且阻塞主线程
await db.StringSetBitAsync("b", 4294967295L, true);
分配速率约 1.5~1.7 GB/s(示意,非官方基准,须自测):

第一次分配完成后,在已分配长度内继续
SETBIT不再有分配开销。真正危险的是"第一次就跳极大 offset" ------一次几百毫秒阻塞,高并发实例上足以引发雪崩。空洞演示:SETBIT demo 9999999 1→ 占约 1.25 MB;即使SETBIT demo 9999999 0清零,内存不变、max_offset 不回退 ,释放只能DEL/UNLINK整个 key。
3.3 影响二:TYPE 返回 string,编码随长度变化

边界别记错:offset 352 需 353 bit,
ceil(353/8)=45字节,已超 44 字节 embstr 上限,所以分界是 351(=352 bit=44 字节) 。且位图写的是原始字节(如\x01),不满足整数字符串判定,永远进不了int编码。
3.4 影响三:复用全部 String 命令(语义要分清)
redis
EXPIRE sign:{1001}:202611 5184000 # 必做,否则僵尸 key
STRLEN sign:{1001}:202611 # 字节长 = ceil((maxOffset+1)/8)
GET sign:{1001}:202611 # 拿到二进制,非可读文本
SETBIT sign:{1001}:202611 30 0 # ⚠️ 只是清零,String 不缩短
DEL sign:{1001}:202611 # ⚠️ 删大位图阻塞主线程,用 UNLINK
APPEND sign:{1001}:202611 "x" # ⚠️ 破坏位语义
SETRANGE sign:{1001}:202611 0 "ab" # ⚠️ 破坏最彻底
APPEND 只尾部追加(多出的字节被当更高 offset,语义污染);SETRANGE 直接改写已有字节(数据破坏,更严重)。"清零不等于缩容"值得单独记一句 :SETBIT key offset 0 不会让 String 变短。位图长度只由"历史上写过的最大 offset"决定,要释放只有删整个 key。
3.5 影响四:位序是 MSB-first,跨语言读写必翻车
Redis 位序是大端序(MSB-first) :offset 0 对应第一个字节的最高位。把整图 GET 出来拿 byte[] 后,判断第 n 位必须:
csharp
internal static class BitmapBits
{
// 按 Redis 的 MSB-first 位序读取第 offset 位;越界返回 false
public static bool Get(ReadOnlySpan<byte> bytes, long offset)
{
// 第几个字节
long idx = offset >> 3;
if (idx < 0 || idx >= bytes.Length) return false;
// 字节内第几位(0 = 最高位)
int pos = (int)(offset & 7);
return (bytes[(int)idx] & (1 << (7 - pos))) != 0;
}
}
按很多语言直觉用 LSB-first (1 << pos)去读,结果整字节反转 。把 BitmapBits 收进公共库并补首尾位单测,能一劳永逸。
三条铁律(offset 设计) :① 不让 offset 直接等于业务 ID,雪花/UUID/手机号先做映射(uid→seq);② 按时间/空间分片;③ offset 从 0 连续编号。内存公式只有一条:
数据部分 = ceil((max_offset+1)/8) 字节。
四、生产业务场景实战
4.1 场景一:签到系统(完整设计)
需求:今日签到、查本月、连续签到、补签、防重复,跨天/跨月边界按业务时区。
选型:用户维度 vs 日期维度(选错差一个数量级内存):

1000 万用户一个月的账单:用户维度 value-only 320 MB/月 (1000 万×32 B),日期维度 37.5 MB/月 ;含 key 元数据 用户维度 1.12 GB/月 、日期维度 ~40 MB,真实差距 ~28 倍 。只算 value 会低估近 4 倍。
结论 :只做聚合指标 → 日期维度;要查"某用户本月哪几天/连续几天" → 用户维度;大系统两个都存。注意日期维度 offset 是 userId,要求 ID 稠密可控,雪花 ID 要先建映射------映射本身可能比位图贵(见 4.3)。
按月分 key 的原因 :单 key 最多 31 位(4 字节),BITCOUNT/BITFIELD 开销恒定,过期/归档灵活;年图 offset=年积日(1~366),适合"全勤/年度成就",value 仅 46 字节但 key 少、连续签到一次 GET 算全年,内存还省(1000 万用户年图 800 MB/年 vs 月图 3.84 GB/年)。
Redis 命令:
redis
SETBIT sign:{1001}:202611 2 1 # 签到:11-03(offset=当月第几天−1)
EXPIRE sign:{1001}:202611 5184000 # 60 天,留跨月余量
GETBIT sign:{1001}:202611 2 # 今天是否已签
BITCOUNT sign:{1001}:202611 # 本月签到天数
BITFIELD sign:{1001}:202611 GET u15 0 # 读前 15 天位模式,算连续签到
C# 实现(含连续签到):
csharp
public sealed class SignService
{
private readonly IDatabase _db;
private readonly IBizClock _clock;
public SignService(IDatabase db, IBizClock clock) { _db = db; _clock = clock; }
// 花括号是 Hash Tag:同用户位图与 streak 计数同 Slot
private static RedisKey BitmapKey(long uid, DateOnly day) => $"sign:{{{uid}}}:{day:yyyyMM}";
// streak 用 String 整数(单值别用 Hash,省一份容器开销)
private static RedisKey StreakKey(long uid) => $"sign:{{{uid}}}:streak";
// 本月签到天数
public Task<long> MonthCountAsync(long uid, DateOnly day)
=> _db.StringBitCountAsync(BitmapKey(uid, day));
// 从今天往前算连续签到(仅本月;跨月见 4.1.3)
public async Task<int> CurrentStreakAsync(long uid, DateOnly day)
{
byte[] bytes = (byte[]?)await _db.StringGetAsync(BitmapKey(uid, day)) ?? Array.Empty<byte>();
int streak = 0;
for (int d = day.Day; d >= 1; d--)
{
if (!BitmapBits.Get(bytes, d - 1)) break; // 复用 3.5 的工具方法
streak++;
}
return streak;
}
}
跨月续签与年图取舍 :CurrentStreakAsync 只算本月。要"本月 1 号签了、上月 30 号也签了"能连起来,必须同时 做两件事,缺一不可:① 另存 sign:{uid}:streak 计数器(O(1) 读);② 写路径每月 1 号读上月图判定"昨天在不在"。只做 ① 是最常见错误 :每月 1 号把连续天数重置成 1,100 天连签在第 31 天断掉且不报错。计数器必须与位图同 TTL,否则位图过期计数还在,成僵尸 key。
补签规则 :补签 = SETBIT key 历史offset 1,但规则应用层定死------可补范围(仅本月/近 7 天)、补签次数(另用 INCR 计数)、是否计入连续(通常不计)、是否展示(位图只有 0/1 无法区分,需两张图)。两个最易漏 :① 位图无法区分"真实签到/补签",要区分就两张图;② 补签走同一 Lua 会让 streak 错误 +1,要么单独命令、要么脚本加 is_makeup 参数只置位不动计数。
一次 Lua 调用完成:置位、设 TTL、幂等、跨月续签:
lua
-- KEYS[1]=本月位图 KEYS[2]=streak 计数 KEYS[3]=上月位图(仅 1 号读)
-- 三 key 共用 Hash Tag {uid},同 Slot 才能进同一脚本
-- ARGV[1]=今天 offset ARGV[2]=TTL ARGV[3]=昨天 offset(-1=1号) ARGV[4]=1=补签 ARGV[5]=上月最后一天 offset
-- 读计数;-1 表示 key 不存在(被淘汰/过期/首次)
local function cur() return tonumber(redis.call('GET', KEYS[2]) or '-1') end
-- 从昨天往回数,返回含今天在内的连续天数;昨天没签返回 nil;最多 31 次 GETBIT
local function rebuild()
local y = tonumber(ARGV[3]); local n = 1; local off
if y >= 0 then
if redis.call('GETBIT', KEYS[1], y) == 0 then return nil end
off = y - 1
while off >= 0 and redis.call('GETBIT', KEYS[1], off) == 1 do n = n + 1; off = off - 1 end
else
if redis.call('GETBIT', KEYS[3], tonumber(ARGV[5])) == 0 then return nil end
off = tonumber(ARGV[5]) - 1
while off >= 0 and redis.call('GETBIT', KEYS[3], off) == 1 do n = n + 1; off = off - 1 end
end
return n
end
-- 1) 置位,位图+计数同一 TTL
local oldBit = redis.call('SETBIT', KEYS[1], ARGV[1], 1)
redis.call('EXPIRE', KEYS[1], ARGV[2]); redis.call('EXPIRE', KEYS[2], ARGV[2])
-- 2) 幂等:oldBit==1 表示已签过,不动计数
if oldBit == 1 then
local n = cur(); if n < 0 then n = rebuild() or 0; if n > 0 then redis.call('SET', KEYS[2], n) end end
return { 0, n }
end
-- 3) 补签:只置位,不动连续天数
if ARGV[4] == '1' then
local n = cur(); if n < 0 then n = rebuild() or 0; if n > 0 then redis.call('SET', KEYS[2], n) end end
return { 1, n }
end
-- 4) 正常签到:计数缺失就重建;昨天没签就清零;最后 +1
local n = cur()
if n < 0 then n = rebuild() or 0
else
local y = tonumber(ARGV[3]); local prevSigned
if y >= 0 then prevSigned = redis.call('GETBIT', KEYS[1], y) == 1
else prevSigned = redis.call('GETBIT', KEYS[3], tonumber(ARGV[5])) == 1 end
if not prevSigned then n = 0 end
end
n = n + 1
redis.call('SET', KEYS[2], n)
return { 1, n }
csharp
// 以下加在 SignService 里(_db/_clock/BitmapKey/StreakKey 已定义)
private const string SignScript = /* 上面那段 Lua */;
private static readonly int BitmapTtl = (int)TimeSpan.FromDays(60).TotalSeconds;
// 签到(幂等、跨月续签)。返回 (本次是否生效, 当前连续天数)
public async Task<(bool Created, int Streak)> SignAsync(long uid, DateOnly? day = null, bool isMakeup = false)
{
// 业务时区的"今天",不是 UtcNow
var d = day ?? _clock.Today;
var prevMonth = d.AddMonths(-1);
int prevLastOffset = DateTime.DaysInMonth(prevMonth.Year, prevMonth.Month) - 1;
var raw = await _db.ScriptEvaluateAsync(
SignScript,
new RedisKey[]
{
BitmapKey(uid, d), // KEYS[1] 本月
StreakKey(uid), // KEYS[2] 计数
BitmapKey(uid, prevMonth), // KEYS[3] 上月
},
new RedisValue[]
{
d.Day - 1, // ARGV[1] 今天 offset
BitmapTtl, // ARGV[2]
d.Day == 1 ? -1 : d.Day - 2, // ARGV[3] 昨天;1 号给 -1
isMakeup ? 1 : 0, // ARGV[4] 补签标记
prevLastOffset, // ARGV[5]
});
// RedisResult 无 AsArray()(2.8.16 实证),用 Length + 索引器 + 显式转换
bool created = !raw.IsNull && raw.Length >= 1 && (long)raw[0] == 1;
int streak = !raw.IsNull && raw.Length >= 2 ? (int)(long)raw[1] : 0;
return (created, streak);
}
脚本解决了什么 :原子性(一步执行完);幂等(oldBit==1 已签过);少一次 RTT;集群安全(三 key 同 Slot);跨月续签 (1 号读上月);计数与位图同寿命;计数丢失自愈 (-1 时回扫重建)。没解决:重建只回溯单月(更长历史交离线任务);不做限流;补签连续口径需产品定义。
4.2 场景二:在线状态与日活 / 周活 / 月活
时区 :位图场景最容易埋雷。直接用 UtcNow 拼 key,对 UTC+8 意味着日活图在北京 8 点才翻页 ,错位 8 小时。规则:所有"哪一天/哪月"的判定走同一个业务时钟,绝不直接用 UtcNow。
csharp
public interface IBizClock
{
DateOnly Today { get; } // 日期维度:签到、日活、月活
DateTime Now { get; } // 分钟维度:实时在线打点
}
public sealed class BizClock : IBizClock
{
// .NET 6+ 认 IANA 名(ICU);精简容器报找不到时区就换 "China Standard Time"
private static readonly TimeZoneInfo Tz =
TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
public DateTime Now => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Tz);
public DateOnly Today => DateOnly.FromDateTime(Now);
}
实时在线 key 是分钟级,只给
Today会逼调用方拼DateTime.Now------那是时区 bug 的入口。时钟接口要么给全,要么别给。
日活/周活/月活(日期维度位图最合适,offset=userId):
redis
SETBIT active:{dau}:20261103 1001 1 # 上线打点
EXPIRE active:{dau}:20261103 2592000 # 保留 30 天
BITCOUNT active:{dau}:20261103 # 日活
# 周活:7 天 OR 起来(共同 Hash Tag 保证同 Slot)
BITOP OR active:{dau}:week:20261102:job1 active:{dau}:20261102 ... active:{dau}:20261108
BITCOUNT active:{dau}:week:20261102:job1
EXPIRE active:{dau}:week:20261102:job1 7200 # 聚合结果临时
# 月活同理:OR 当月 30 天
为什么用"周起始日"20261102 而非
2026w45:ISO 周号跨年处会错位,用周起始日既不依赖 ISO 也不跨年错位。
csharp
// 业务时区,不是 UtcNow
var today = _clock.Today;
string DayKey(DateOnly d) => $"active:{{dau}}:{d:yyyyMMdd}";
await _db.StringSetBitAsync(DayKey(today), uid, true);
await _db.KeyExpireAsync(DayKey(today), TimeSpan.FromDays(30));
// 自然周聚合:以本周一为起点,与上方 redis 例子的"周起始日"口径一致
DateOnly monday = today.AddDays(-(((int)today.DayOfWeek + 6) % 7));
var dayKeys = Enumerable.Range(0, 7)
.Select(i => (RedisKey)DayKey(monday.AddDays(i)))
.ToArray();
var dest = (RedisKey)$"active:{{dau}}:week:{monday:yyyyMMdd}:{jobId}";
await _db.StringBitOperationAsync(Bitwise.Or, dest, dayKeys);
await _db.KeyExpireAsync(dest, TimeSpan.FromHours(2));
long wau = await _db.StringBitCountAsync(dest);
边界 :BITOP 输出 key 新建常驻,务必 TTL;生产不要用固定 ...:week:cur 并发写同一 dest,要带 bucket+任务后缀;Hash Tag 是权衡 :{dau} 让所有天同 Slot 保证跨天 BITOP,但该 Slot 承担全部日活写入、其余节点分不到------出路①按口径拆(治不了热点)、②片号进 Tag ({dau:s0},见 4.5.2)、③客户端聚合(拉回本地按位 OR)。用户 ID 必须稠密可控;12.5 MB 图的 BITCOUNT 是 O(N),别在高峰期每秒调。
"日活"与"实时在线"是两回事:日活=今天来过(日期位图+BITCOUNT);实时在线=刚刚有心跳,状态要自动消失,位图没有会自动过期的位,只能靠 key TTL 或窗口。

redis
SETBIT online:{hb}:202611031830 1001 1 # 打点
EXPIRE online:{hb}:202611031830 600 # 10 分钟后清理
# 实时在线(近 5 分钟):OR 后 BITCOUNT
BITOP OR online:{hb}:now:job1 online:{hb}:202611031826 ... online:{hb}:202611031830
BITCOUNT online:{hb}:now:job1
EXPIRE online:{hb}:now:job1 60
结论:要"今天来过"用日位图;要"此刻在线多少"用分钟位图+短 TTL+OR 窗口;要"还能推给这个人"用心跳 key(要拿到具体是谁)。三者目的不同,别一套结构硬扛。
4.3 场景三:去重------稠密整数 ID 的精确布尔判断
redis
SETBIT seen:order:20261103 1001 1 # 标记出现过
GETBIT seen:order:20261103 1001 # 判断是否存在
BITCOUNT seen:order:20261103 # 当天不同 ID 数
ID 从 0 开始、相对稠密时,"1 bit 一个 ID"最省内存的精确去重------100 万连续 ID 约 125 KB。关键前提:ID 必须映射到有限稠密 offset。而这一层映射往往是整个方案最贵的一块,先算清账再动手。
映射放哪:

最易翻车处 :为了跑 12.5 MB 位图引入 10 GB 映射表 ,省下的内存被映射吃掉 800 倍。先把映射放数据库是这条路上最省事的选择。
若确实放 Redis,用 Lua 保证"首次分配、之后复用"原子性:
lua
-- KEYS[1]=uid:{map}:hash(业务ID→序号) KEYS[2]=uid:{map}:seq(自增序号)
-- 两 key 共用 Hash Tag {map},同 Slot;但这意味全部映射压在一个节点
local seq = redis.call('HGET', KEYS[1], ARGV[1])
if seq then return tonumber(seq) end
local newSeq = redis.call('INCR', KEYS[2])
redis.call('HSET', KEYS[1], ARGV[1], newSeq)
return newSeq
csharp
var seq = (long)await _db.ScriptEvaluateAsync(
MapScript, new RedisKey[] { "uid:{map}:hash", "uid:{map}:seq" }, new RedisValue[] { userId });
// 映射序号是 1-based,Redis offset 是 0-based
await _db.StringSetBitAsync("seen:order:20261103", seq - 1, true);
三件事: ① 两 key 必须同 Tag,否则
CROSSSLOT; ②{map}让全部映射成一个 Slot,是全局单点,所以建议放数据库; ③ 映射 key 绝不能设 TTL ------一过期INCR从头发号,新用户拿旧序号,两人映到同一位,是最隐蔽的串数据且不报错。映射要备份,配"序号续发"兜底。
什么时候不该用 Bitmap 去重:ID 是字符串(UUID/手机号)→ Set 或 HLL;ID 稀疏大整数(雪花)→ 超上限直接报错、未超但稀疏则膨胀,换 Set 或稠密映射;只要"多少个不同值"→ HLL(固定 ~12 KB、约 0.81% 误差);删除高频 → 位图只能清零不释放,Set 更合适。
4.4 场景四:布尔画像标签
redis
SETBIT tags:{1001} 3 1 # 用户 1001 打上 3 号标签
BITCOUNT tags:{1001} # 该用户共有几个标签
一位一个标签,比 Set 省内存,但只适合标签固定且编号连续;标签动态增删、还要枚举,就用 Set。
4.5 场景五:冷热归档、分片与数据生命周期
冷热归档 :Redis 只存热数据(近 2~3 个月),超期导出 MySQL/数仓后 UNLINK 删。不要无限累积------1000 万用户×12 个月=1.2 亿 key ,元数据与 SCAN 巡检都是负担。归档注意:位图先转成行 再落库;大批量 UNLINK 分批错峰防删除风暴。
分片设计 :单 key SDS 超 1~2 MB 就该分片(1 亿 ID 的日活图 12.5 MB 必须分)。按 offset 区间切,step 取 800 万~1600 万 bit(1~2 MB),别取 100 万(125 KB,片数爆炸、元数据反超数据)。
关键:片号必须进 Hash Tag(本文最容易写错的一处):
text
✗ 错误:active:{dau}:20261103:s0 / :s1 ← Tag 仍是 dau,CRC16 相同,13 片挤同 Slot
✓ 正确:active:{dau:s0}:20261103 ← 13 个不同 Slot,真正分散;片内跨天仍同 Slot
规则一句话:Tag 里放"一次聚合的最小单元" ------不分片是口径(dau),分片是"口径+片号"(dau:s0)。分片后两种运算:单片内跨天聚合(7 个同 Slot key 一次 BITOP);全站周活/月活 = 每片各做 BITOP+BITCOUNT,应用层把各片数字相加。
csharp
// 分片后的全站周活:每片独立聚合,应用层求和(不要在 Redis 跨片合并)
// ceil(1 亿 / 800 万)
const int ShardCount = 13;
var days = Enumerable.Range(0, 7).Select(i => today.AddDays(-i)).ToArray();
long wau = 0;
foreach (var n in Enumerable.Range(0, ShardCount))
{
var keys = days.Select(d => (RedisKey)$"active:{{dau:s{n}}}:{d:yyyyMMdd}").ToArray();
var dest = (RedisKey)$"active:{{dau:s{n}}}:week:{today.AddDays(-6):yyyyMMdd}:{jobId}";
await _db.StringBitOperationAsync(Bitwise.Or, dest, keys);
await _db.KeyExpireAsync(dest, TimeSpan.FromHours(2));
wau += await _db.StringBitCountAsync(dest);
}
优势:单 key 体积可控,迁移/RDB fork/删除阻塞压力大降。代价:要封装 SDK 屏蔽分片;聚合变 N 次往返(用 pipeline 收敛);片数一旦定下别改 ------改
step等于全量重映射、等于重建。
用户注销与生命周期 :日期维度注销只能"置 0",位图大小不变;用户维度可以整月整月删(一个用户一个月一个 key),才是删除上的正确用法。UNLINK sign:{uid}:* 是错的 ------Redis 没有通配删除,* 会被当 key 名一部分,返回 0 既不报错也不删,是最坏的失败。正确:key 名可推导就直接列(用户维度最多 13 个 key,根本不需要通配);不确定则 SCAN(集群要按每个 master 扫)再分批 UNLINK。精确删单成员是核心需求,选 Set。
4.6 场景建模速查

一句话 :Bitmap 的场景设计 = "offset 语义的映射" ------映射成日期→签到、userId→日活、序号→去重、标签编号→画像。设计 offset,就是设计业务。前提永远是:offset 稠密、有界、可控。
五、内存占用估算
5.1 公式与对照
text
数据部分 = ceil((max_offset + 1) / 8) 字节
value 占用 ≈ 最近 jemalloc 桶(数据部分 + 约 19~25 字节固定开销) # SDS头3~9 + robj16
真实占用 ≈ value 占用 + key 名 SDS + dictEntry(~24) + 过期字典条目(~24)
常用桶:8/16/32/48/64/80/96/112/128...... 内存取决于 max_offset,与"有多少 bit 为 1"无关,空洞也占字节。

直接背:每 1000 万 bit ≈ 1.25 MB,翻 10 倍用户翻 10 倍内存。表里是单 key value,不含 key 名与 dictEntry------key 数大时(用户维度签到)按 5.3 另算。
5.2 千万级日活演算
maxOffset=10,000,000 → 数据 ceil(10,000,001/8) ≈ 1.25 MB,30 天 → 37.5 MB。对比 Set 同规模:Bitmap 1.25 MB vs Set 400600 MB,省约 300500 倍。
与行存比(同规模 1000 万用户 30 天):行存
0.20.4 GB、位图 value-only 0.32 GB(含元数据 1.1 GB);查询行存 COUNT 10~100 ms、位图 BITCOUNT ~1 ms。这张表天然对行存不公平 (行存能查明细/事务/JOIN),用途是说明量级差;具体数字用自己业务压测。口径必须对齐:行宽×行数 vs 单 key×key 数,两边都要算索引/元数据。
5.3 用户维度签到图(小图)账单
maxOffset=30 → 数据 4 字节 → 对齐 32 字节桶。1000 万用户 320 MB/月(value-only) ,但这是下限:每个 key 还要 key 名 SDS(sign:{1001}:202611 18 B+头 3 B → 32 B)、dictEntry 24 B、过期字典 24 B。
text
32(value桶,已含 sdshdr8+robj) + 32(key名) + 24(dictEntry) + 24(过期字典) ≈ 112 B/key
10,000,000 × 112 B ≈ 1.12 GB/月
⚠️ 别把
robj加两遍 :32 B 桶已由 4B 数据+3B sdshdr8+16B robj 取整而来,robj 已在里面;再加一次就写成 128 B/key,账就错了。对照日期维度 30×1.25 MB≈37.5 MB+元数据约 40 MB,真实差距约 28~30 倍 。 一句话:位图性价比 = "总位数 ÷ 单 key 固定开销",位数越多越划算;当单 key 只有几字节,你付的钱几乎全是"key 的存在成本"。
5.4 亿级与"密度无关"
ID 上界 1 亿 → 数据 100,000,000/8 = 12.5 MB。无论当天 10 万人活跃还是 9000 万,都是 12.5 MB------内存与实际活跃人数无关,只与 ID 上界有关。

反过来说 :活跃人数很少时位图反而"浪费"(10 万活跃但上界 1 亿,Set 只 5.6 MB、位图 12.5 MB)。选型看稀疏度,不能一概而论。上界到几十亿即使稠密,单图也要几百 MB------要么压缩 ID 空间(映射/分片),要么转 HLL。
在线测真实占用 :MEMORY USAGE key(单 key 近似)、INFO memory 的 used_memory 增量(总量)、DBSIZE(key 数)。注意 key 名/dictEntry/过期字典等全局开销,不同版本统计口径可能不同。
六、与竞争方案的选型对比
6.1 多维对比矩阵

一句话:Bitmap 比 Set 省约两个数量级;HLL 又比 Bitmap 省约三个数量级。但精度和功能逐级递减。
6.2 决策路径
text
需要记录布尔二值状态?
├─ ✅ 是
│ ├─ ID 可映射成连续紧凑整数 offset?→ ✅ 优先 Bitmap(内存极致、支持交并集)
│ │ └─ ❌ 雪花/UUID/极稀疏 → 要精确+明细→Set;只要估算→HLL
│ └─ ❌ 还要存时间/数值/附加属性 → 不用 Bitmap,选 Hash/String/Bitfield
└─ ❌ 不需要布尔二值标记 → 不选 Bitmap
口诀:稠密整数要精确,Bitmap 位图最省内存;需要枚举和删除,Set 集合是首选;只要基数不要人,HLL 十二 KB 搞定;多个小整数要打包,Bitfield 来帮忙。
6.3 组合与 Bitfield 定位
真实系统常组合:Bitmap+HLL (位图管精确当日人数+单用户查询,HLL 管跨月近似 UV);Bitmap+Set (位图亿级标记,Set 小名单枚举);Bitmap+布隆(稠密 ID 精确判存,稀疏 ID 快速预筛)。
Bitfield 定位 :
BITFIELD不是 Bitmap 替代品。它解决"一个 String 里存多个小整数计数器"(打包问题);Bitmap 解决"一个 bit 一个布尔状态"(判存/计数问题)。两者正交。布隆像位数组但多一层哈希:k 个哈希映射到 k 位置,查询 k 位全 1 才"可能存在";一个位被多元素共享,清 0 会误伤他人,所以标准版不支持删除------Bitmap 是精确位数组,布隆是带哈希的位数组。
七、高频坑点、故障复盘与上生产清单
22 条坑收敛成三张表 + 10 条红线;7.4 给故障复盘。完整展开见原版。
7.1 命令坑

7.2 编码 / 数据坑

7.3 架构 / 运维坑

坑 20 细说(最危险) :淘汰候选按 idle(空闲时间) 采样,不是按体积------allkeys-lru 找 idle 最大的。位图"一天写一次、平时不读",idle 天然长,于是被采中。别以为"位图大所以被优先淘汰"去拆小,拆小不改变 idle,照样被淘汰 。对策:noeviction(写入报错比静默丢好)、独立实例/DB 隔离、监控 evicted_keys、把位图当缓存层事实落库。
坑 21 的对账怎么做 (三条,成本从低到高):① 总量比对 (每日):BITCOUNT vs SELECT count(distinct user_id),偏差>0.1% 告警;② 抽样比对 (每小时):随机 1000 用户 GETBIT 拼成整数与明细表位掩码逐位比;③ 重建回补 (按需):按天扫明细 SETBIT 重刷,pipeline 每批 1000 条限速;重建前先 RENAME 旧 key 为 .bak 设 24h TTL,出错可回滚。
7.4 故障复盘(精简)
严重度:致命 服务不可用 · 内存 内存失控 · 性能 延迟抖动 · 数据 数据错误/丢失。

7.5 生产红线与上生产清单

可打勾清单:
- ① ID 必做映射:雪花/手机号/UUID 不当 offset
- ② 映射预算先算:优先数据库
- ③ offset 上限防呆 + value 只 0/1
- ④ 日期只走业务时钟
- ⑤ 位图与 streak 同 TTL、映射 key 永不过期
- ⑥ 区间单位写清楚
- ⑦ 大位运算错峰 + 独立实例
- ⑧ 统一
offset=编号−1 - ⑨ 位序固定 MSB-first
- ⑩ 删大 key 用
UNLINK(禁DEL/APPEND/SETRANGE) - ⑪ 集群分片 + Tag 规则 (不分片
{dau}、分片{dau:s0},跨片应用层求和) - ⑫ 删除不用通配 (
UNLINK key*静默失败) - ⑬ 淘汰策略护栏 (
noeviction) - ⑭ 位图不作唯一存储(事实落库 + 具体对账算法)
- ⑮ 冷热归档 (转行存落库,
UNLINK分批) - ⑯ 监控到位 (key 数/
MEMORY USAGE/TTL==-1/evicted_keys/慢日志) - ⑰ 上线前压测 (
SETBIT/BITCOUNT/BITOP目标规模量化 P99) - ⑱ 纳入持久化备份(评估 RDB 体积/fork/AOF 放大)
八、FAQ 附录
Q1:Bitmap 是数据类型吗? 不是。TYPE 返回 string,是 String 上的位操作命令(3.1)。
Q2:只写 1 个 bit 却占 125 KB? 长度由最大 offset 决定,不由 1 的个数;中间补 \0(3.2)。
Q3:BITCOUNT key 0 7 不是 8 比特? start/end 默认字节,0 7 是 8 字节=64 比特;要比特用 7.0+ 的 BIT(2.2)。
Q4:怎么算连续签到 N 天? 逐位回看(GET+BitmapBits.Get)或独立 streak 计数器;计数器必须处理跨月 ------1 号读上月最后一天,否则 100 天连签第 31 天归零(4.1.3 的 KEYS[3])。
Q5:Bitmap 和 HLL 选谁? 要精确、查"某人在不在"→Bitmap;只要大概数、规模极大、省内存→HLL(6.1)。
Q6:为什么应用层读位图全反? Redis MSB-first,很多语言位数组 LSB-first,1 << pos 整字节反转(3.5)。
Q7:SETBIT offset 0 能释放内存吗? 不能,只是清零;要释放只能 DEL/UNLINK 整 key(3.4)。
Q8:BITOP 原子吗?多进程写同一 dest? 单条原子;但"读-改-写"不是。dest 要唯一或只用单条 BITOP(7.3 坑 19)。
Q9:Bitmap 适合多状态吗? 不适合,只有 0/1;多状态用 BITFIELD/Hash;区分真实/补签用两张图(4.1.4/7.2 坑 12)。
Q10:位图能当签到唯一存储吗? 不能。淘汰/异步复制/误删都让它静默归零 且 BITCOUNT 不报错。位图只做查询层,事实落库(7.3 坑 20/21)。
Q11:日活为什么北京 8 点才翻页? 因为用 UtcNow 拼 key,日期必须来自业务时钟(4.2.1)。
Q12:BITOP 能扔到只读副本吗? 不能。副本拒写 destkey;设可写写入也不回主库,切换后丢失。用独立离线实例(2.3)。
九、本篇小结
Bitmap 最容易被"讲歪"也最容易被"用歪"。它的全部能力来自一个事实:不是独立数据类型,而是 String 的位操作视图。
- 身份 :
TYPE返回string,底层 SDS 字节串;offset 上限 2^32−1 来自 String 512 MiB;稍大的位图基本raw。 - 内存 :
key_size = ceil((max_offset+1)/8),由最大 offset 决定、不由 1 的个数。1000 万 bit≈1.25 MB,1 亿≈12.5 MB;比 Set 省 300~500 倍,前提是 offset 稠密有界。 - 代价 :一次大跨度分配阻塞主线程(512 MiB≈300 ms);ID 必须映射,绝不用雪花/UUID/手机号当 offset;映射本身可能比位图贵一个数量级以上,优先放数据库。
- 位序 :MSB-first。跨语言读字节必须
1 << (7 - pos),否则整字节反转。 - 命令 :
SETBIT/GETBITO(1),BITCOUNT/BITPOS/BITOPO(N)。BITCOUNT区间默认字节、7.0+ 才能BIT;BITOP是同步 CPU 密集运算,dest 务必 TTL,且不能在副本上跑。 - 场景与红线 :签到用用户维度或日期维度;日活用日期维度+Hash Tag,Tag 放"一次聚合的最小单元" (不分片
{dau}、分片{dau:s0});去重用稠密序号映射。清零不释放内存;位图与 streak 计数器同 TTL ;删大 key 用UNLINK,没有通配删除 (UNLINK key*静默失败);日期只走业务时钟。 - 可靠性 :位图会因淘汰(idle 长,不是体积大)、异步复制、误删静默归零。它只能是查询层,事实必须落库------对账要落到三条动作(总量比对、抽样比对、重建回补),不能只写口号。这一条比任何命令细节都更接近真实事故。
- 选型:精确+ID 稠密→Bitmap;集合运算/需枚举→Set;UV 统计→HLL;多计数器打包→Bitfield;海量判存→布隆。真实系统常组合使用。
记住一句话 :Bitmap 的威力来自 offset 的连续性,毁灭也来自 offset 的稀疏性;而它的可靠性,从来不在 offset 上。