Files
cs-note/hhs/Redis/12-Bitmap.md
T
2026-05-25 23:50:33 +08:00

234 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, 缓存, 数据结构, Bitmap]
create time: 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。
```go
// 用户签到(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
}
// 本月累计签到天数(精确版:逐 bit 检查,避免字节边界误差)
func MonthSignCount(ctx context.Context, rdb *redis.Client, userID int64) (int64, error) {
key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
now := time.Now()
// 计算本月第一天和最后一天在年中的 day-of-year(1-based)
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local).YearDay()
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, time.Local).YearDay()
var count int64
for day := firstDay; day <= lastDay; day++ {
val, err := rdb.GetBit(ctx, key, int64(day-1)).Result() // YearDay 1-based -> offset 0-based
if err != nil {
return 0, err
}
count += val
}
return count, nil
}
```
> [!question] 连续签到 7 天如何判断?
> 有两种思路:(1) 用 `BITCOUNT` 统计最近 7 个 bit 是否全为 1;(2) 更高效的做法是用位移掩码——取出 7 位的值,判断是否等于 `0b1111111`(即 127)。
下面是连续签到检测的示例:
```go
// 检测最近 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 位。
```go
// 记录用户今日活跃
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. BITFIELD:任意宽度整数操作
前面的 `SETBIT`/`GETBIT` 只能操作单个 bit(0 或 1)。如果我们需要存储的不是布尔值,而是一个小整数呢?比如"连续签到天数"(0~127)或"用户等级"(0~15)。
这就是 `BITFIELD` 的用武之地——它将一个 String 视为一个**任意宽度整数的数组**,支持原子性的读写和自增。
| 子指令 | 作用 | 示例 |
|--------|------|------|
| `GET type offset` | 读取指定偏移处的整数 | `BITFIELD k GET u8 0` |
| `SET type offset value` | 写入整数 | `BITFIELD k SET u8 0 255` |
| `INCRBY type offset increment` | 原子自增 | `BITFIELD k INCRBY u8 0 1` |
其中 `type` 的格式为 `u`(无符号)或 `i`(有符号)+ 位宽(1~64),例如 `u8` 表示 8 位无符号整数(0~255),`i16` 表示 16 位有符号整数。
> [!tip] 为什么 INCRBY 很重要?
> 它是原子操作。多个客户端可以同时对同一个 offset 做 INCRBY 而不会出现竞态条件,无需额外加锁。
```go
// 用 BITFIELD 存储连续签到天数(u8 宽度,最多 255 天)
func IncrContinuousSign(ctx context.Context, rdb *redis.Client, userID int64) (int64, error) {
key := fmt.Sprintf("streak:%d", userID)
// BITFIELD key INCRBY u8 0 1
vals, err := rdb.BitField(ctx, key, "INCRBY", "u8", "0", "1").Result()
if err != nil {
return 0, err
}
return vals[0], nil // 返回自增后的连续签到天数
}
// 重置连续签到天数(签到中断时调用)
func ResetContinuousSign(ctx context.Context, rdb *redis.Client, userID int64) error {
key := fmt.Sprintf("streak:%d", userID)
return rdb.BitField(ctx, key, "SET", "u8", "0", "0").Err()
}
```
> [!question] BITFIELD vs 多个 key 怎么选?
> 如果每个用户只需要存 1~2 个小整数,用普通的 String/Hash key 就够了。`BITFIELD` 的优势在于:当需要**批量**存储大量同类型小整数时(比如 1 万个物品的库存量),可以把它们紧凑地打包到一个 key 中,大幅减少 key 数量和网络往返。
### 6. 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**
### 7. 签到系统流程
```mermaid
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
```
## 关联笔记
- [[hhs/Redis/02-核心数据类型]]
- [[hhs/Redis/13-HyperLogLog]]
- [[hhs/Redis/README]]