Files
cs-note/hhs/Redis/12-Bitmap.md
T
2026-05-24 21:18:14 +08:00

7.2 KiB
Raw Blame History

tags, create time
tags create time
Redis
缓存
数据结构
Bitmap
2026-05-24 10:00

Redis Bitmap 位图

概述

Bitmap 并非 Redis 的独立数据结构,而是基于 String 类型的一套位操作指令。它将一个 String 值视为一个巨大的位数组,每个 bit 只占 1 位,因此在处理大规模布尔型数据(如签到、在线状态、DAU 统计)时,内存消耗极低。一个包含 10 亿用户的 Bitmap 仅需约 125MB,这使其成为高并发场景下的利器。

正文

1. 核心原理

[!question] 为什么 Bitmap 不是一种新数据结构? 因为 Bitmap 本质上就是 String。Redis 的 String 底层是 SDS(Simple Dynamic String),存储的是字节数组。Bitmap 操作只是在这个字节数组上进行位级别的读写,不涉及新的底层结构。

Bitmap 的核心指令只有四个:

指令 作用 时间复杂度
SETBIT key offset value 设置指定位的值(0 或 1) O(1)
GETBIT key offset 获取指定位的值 O(1)
BITCOUNT key [start end] 统计值为 1 的位数 O(N),N 为字节数
BITOP operation destkey key [key ...] 对多个 Bitmap 做位运算 O(N)

其中 BITOP 支持 AND、OR、NOT、XOR 四种位运算,用于多个 Bitmap 之间的聚合分析。

[!tip] offset 是从 0 开始的 SETBIT sign:1001 5 1 表示将 key sign:1001 的第 6 个 bit(offset=5)置为 1。如果 key 不存在,Redis 会自动扩展字符串长度。

2. 用户签到系统

用 Bitmap 实现签到非常直观:每个用户一个 key,每天对应一个 bit 位,签到则置 1。

// 用户签到(offset = 一年中的第几天)
func SignIn(ctx context.Context, rdb *redis.Client, userID int64, dayOfYear int) error {
    key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
    return rdb.SetBit(ctx, key, int64(dayOfYear), 1).Err()
}

// 查询某天是否签到
func IsSigned(ctx context.Context, rdb *redis.Client, userID int64, dayOfYear int) (bool, error) {
    key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
    val, err := rdb.GetBit(ctx, key, int64(dayOfYear)).Result()
    return val == 1, err
}

// 本月累计签到天数
func MonthSignCount(ctx context.Context, rdb *redis.Client, userID int64) (int64, error) {
    key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
    // BITCOUNT 支持按字节范围统计,start/end 是字节索引
    // 需要根据月份计算对应的字节范围
    return rdb.BitCount(ctx, key, &redis.BitCount{
        Start: int64((time.Now().Month() - 1) * 4), // 近似值,实际需精确计算
        End:   int64(time.Now().Month()*4 - 1),
    }).Result()
}

[!question] 连续签到 7 天如何判断? 有两种思路:(1) 用 BITCOUNT 统计最近 7 个 bit 是否全为 1;(2) 更高效的做法是用位移掩码——取出 7 位的值,判断是否等于 0b1111111(即 127)。

下面是连续签到检测的示例:

// 检测最近 N 天是否连续签到
func IsContinuousSign(ctx context.Context, rdb *redis.Client, userID int64, days int) (bool, error) {
    key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
    today := time.Now().YearDay()

    // 逐 bit 检查最近 N 天
    for i := 0; i < days; i++ {
        val, err := rdb.GetBit(ctx, key, int64(today-i)).Result()
        if err != nil {
            return false, err
        }
        if val == 0 {
            return false, nil
        }
    }
    return true, nil
}

3. DAU 统计

DAU(Daily Active Users)是 Bitmap 最经典的应用场景。思路很简单:每天一个 key,每个用户 ID 对应一个 bit 位。

// 记录用户今日活跃
func MarkActive(ctx context.Context, rdb *redis.Client, userID int64) error {
    key := fmt.Sprintf("dau:%s", time.Now().Format("2006-01-02"))
    return rdb.SetBit(ctx, key, userID, 1).Err()
}

// 查询今日 DAU
func GetDAU(ctx context.Context, rdb *redis.Client, date string) (int64, error) {
    key := fmt.Sprintf("dau:%s", date)
    return rdb.BitCount(ctx, key, nil).Result()
}

// 查询本周 UV(去重)
func GetWeeklyUV(ctx context.Context, rdb *redis.Client) (int64, error) {
    destKey := "dau:weekly:tmp"
    var keys []string
    // 收集最近 7 天的 key
    for i := 0; i < 7; i++ {
        day := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
        keys = append(keys, fmt.Sprintf("dau:%s", day))
    }
    // OR 运算:任意一天活跃即算周活
    err := rdb.BitOpOr(ctx, destKey, keys...).Err()
    if err != nil {
        return 0, err
    }
    return rdb.BitCount(ctx, destKey, nil).Result()
}

[!tip] BITOP OR 的去重原理 假设用户 A 在周一和周三都活跃,对应的 bit 位已经是 1。OR 运算后该位仍是 1,最终 BITCOUNT 只统计一次,天然实现了去重。这比在应用层用 Set 去重高效得多。

4. 内存计算

[!tip] Bitmap 到底有多省内存?我们来算一笔账。

假设平台有 10 亿注册用户,用户 ID 从 0 到 999,999,999。

总 bit 数 = 1,000,000,000 bits
总字节数 = 1,000,000,000 / 8 = 125,000,000 bytes ≈ 119.2 MB

对比一下其他方案存储同样 10 亿用户的信息:

方案 数据结构 内存消耗
Bitmap 1 bit / 用户 ~125 MB
Hash(存 boolean) ~50 bytes / 用户(含 key 开销) ~47 GB
Set(存用户 ID) ~16 bytes / 用户 ~15 GB

Bitmap 的内存效率比 Hash 方案低约 380 倍,这就是为什么在大规模布尔型统计场景下,Bitmap 是首选。

[!question] 为什么 Bitmap 这么省? 因为它只用 1 个 bit 来表示一个布尔值,而 Hash/Set 需要存储完整的 key 和 value。Redis String 底层 SDS 本身也有元数据开销,但分摊到数十亿个 bit 上几乎可以忽略。

5. Bitmap vs Set vs HyperLogLog 对比

特性 Bitmap Set HyperLogLog
底层结构 String(位数组) Hashtable / Ziplist 概率算法
单条数据内存 1 bit 16~50 bytes 固定 12 KB
精确度 精确 精确 误差 ~0.81%
支持操作 位运算、计数 集合运算、随机取 仅计数
适用场景 签到、DAU、布隆过滤器 精确集合运算 超大规模基数估算
数据量上限 受 String 最大 512 MB 限制(约 40 亿 bit) 受内存限制 固定 12 KB

[!question] 该选哪个?

  • 需要精确统计 + 数据是布尔型(在线/签到/活跃) -> Bitmap
  • 需要精确集合运算(交集、差集) -> Set
  • 只需要统计基数(UV/PV),允许 0.81% 误差 -> HyperLogLog

6. 签到系统流程

flowchart TD
    A["用户发起签到请求"] --> B["计算当日 offset"]
    B --> C["SETBIT sign:uid:year offset 1"]
    C --> D["签到成功"]
    D --> E{"查询本月签到?"}
    E -->|是| F["BITCOUNT 计算本月签到天数"]
    E -->|否| G{"检查连续签到?"}
    G -->|是| H["逐 bit 检查最近 N 天"]
    G -->|否| I["返回结果"]
    F --> I
    H --> I

关联笔记