Redis 数据类型全解 - Bitmap 生产实战:把位视图讲准、用对、守住红线(六)

内存口径:全文统一 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 字节串:

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

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/空串都报错;入口做校验。
// 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);

四个高频组合:

# ① 判重:用 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

BITCOUNT key [start end [BYTE|BIT]]    # 数 1 的个数
BITPOS key bit [start [end [BYTE|BIT]]] # 找第一个为 bit 的位置

BITCOUNT 四个易错点:

  1. 无参是 O(N),扫全图(底层按字节查表)。
  2. 带区间时 start/end 默认是字节偏移,不是比特。
  3. 7.0 起才支持 BYTE|BIT;低于 7.0 只能按字节切。
  4. 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;BITOP 37.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。

// 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

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,要两跳:

BITOP 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 确认。

// 一周内每天都活跃的人数: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,可在只读副本上安全用。
// 一次读出当月前 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。

// 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 命令(语义要分清)

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 位必须:

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 命令:

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# 实现(含连续签到):

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、幂等、跨月续签:

-- 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 }
// 以下加在 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。

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):

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 也不跨年错位。

// 业务时区,不是 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 或窗口。

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 的精确布尔判断

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 保证“首次分配、之后复用”原子性:

-- 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
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 场景四:布尔画像标签

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(本文最容易写错的一处):

✗ 错误: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,应用层把各片数字相加。

// 分片后的全站周活:每片独立聚合,应用层求和(不要在 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 公式与对照

数据部分 = 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 400~600 MB,省约 300~500 倍。

与行存比(同规模 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。

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 决策路径

需要记录布尔二值状态?
├─ ✅ 是
│   ├─ 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 生产红线与上生产清单

可打勾清单:

八、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 的位操作视图。

  1. 身份:TYPE 返回 string,底层 SDS 字节串;offset 上限 2^32−1 来自 String 512 MiB;稍大的位图基本 raw。
  2. 内存:key_size = ceil((max_offset+1)/8),由最大 offset 决定、不由 1 的个数。1000 万 bit≈1.25 MB,1 亿≈12.5 MB;比 Set 省 300~500 倍,前提是 offset 稠密有界。
  3. 代价:一次大跨度分配阻塞主线程(512 MiB≈300 ms);ID 必须映射,绝不用雪花/UUID/手机号当 offset;映射本身可能比位图贵一个数量级以上,优先放数据库。
  4. 位序:MSB-first。跨语言读字节必须 1 << (7 - pos),否则整字节反转。
  5. 命令:SETBIT/GETBIT O(1),BITCOUNT/BITPOS/BITOP O(N)。BITCOUNT 区间默认字节、7.0+ 才能 BIT;BITOP 是同步 CPU 密集运算,dest 务必 TTL,且不能在副本上跑。
  6. 场景与红线:签到用用户维度或日期维度;日活用日期维度+Hash Tag,Tag 放"一次聚合的最小单元"(不分片 {dau}、分片 {dau:s0});去重用稠密序号映射。清零不释放内存;位图与 streak 计数器同 TTL;删大 key 用 UNLINK,没有通配删除(UNLINK key* 静默失败);日期只走业务时钟。
  7. 可靠性:位图会因淘汰(idle 长,不是体积大)、异步复制、误删静默归零。它只能是查询层,事实必须落库——对账要落到三条动作(总量比对、抽样比对、重建回补),不能只写口号。这一条比任何命令细节都更接近真实事故。
  8. 选型:精确+ID 稠密→Bitmap;集合运算/需枚举→Set;UV 统计→HLL;多计数器打包→Bitfield;海量判存→布隆。真实系统常组合使用。

记住一句话:Bitmap 的威力来自 offset 的连续性,毁灭也来自 offset 的稀疏性;而它的可靠性,从来不在 offset 上。

posted @ 2026-11-05 07:16  IT规划师  阅读(22)  评论(0)    收藏  举报