Files
cs-note/hhs/Redis/12-Bitmap.md
T
2026-05-28 00:08:17 +08:00

351 lines
14 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)可以用 8 位表示,"用户等级"(0~15)只需要 4 位。
> [!question] 小整数用普通的 String key 不行吗?
> 当然可以,一个 key 存一个值没问题。但如果你要存 **1 万个物品的库存量**,就需要 1 万个 key + 1 万次网络往返。能不能把它们"打包"到一个 key 里?
`BITFIELD` 就是为了解决这个问题——它把一个 String 当作一个**连续的 bit 流**,在任意 bit 偏移处读写指定宽度的整数。
#### 5.1 内存布局:一个 key 怎么存多个整数?
假设我们要在一个 key 里存储 3 个字段:等级(u4)、经验值(u16)、签到天数(u8)。
```mermaid
block-beta
columns 28
block:LVL:4
A["u4 等级"]
end
block:EXP:16
B["u16 经验值"]
end
block:SIGN:8
C["u8 签到天数"]
end
style LVL fill:#f9a825,color:#000
style EXP fill:#42a5f5,color:#fff
style SIGN fill:#66bb6a,color:#fff
```
它们在内存中是**紧挨着排列**的:
```
bit 偏移: 0 4 20 28
┌───┬───────────────────┬───────────┐
│u4 │ u16 │ u8 │
│等级│ 经验值 │ 签到天数 │
└───┴───────────────────┴───────────┘
4 bit 16 bit 8 bit
```
对应 Redis 命令:
```bash
# 一次性读写三个字段,只需一次网络往返
BITFIELD user:1001 SET u4 0 10 SET u16 4 5200 SET u8 20 15
BITFIELD user:1001 GET u4 0 GET u16 4 GET u8 20
```
> [!important] offset 是 **bit 偏移**,不是"第几个字段"
> `SET u16 4` 的意思是"从第 4 个 **bit** 开始写入一个 u16 整数"。字段 1(u4)占了 bit 0~3,所以字段 2 从 bit 4 开始。计算偏移是使用者的职责。
#### 5.2 子指令速查
| 子指令 | 作用 | 示例 |
|--------|------|------|
| `GET type offset` | 从 offset 位读取一个 type 宽度的整数 | `BITFIELD k GET u8 0` → 读取 bit 0~7 |
| `SET type offset value` | 从 offset 位写入一个整数 | `BITFIELD k SET u8 0 255` |
| `INCRBY type offset increment` | 从 offset 位读取整数 + increment 后写回 | `BITFIELD k INCRBY u8 0 1` |
**type 格式**:`u`(无符号)或 `i`(有符号)+ 位宽(1~64)
| type | 范围 | 说明 |
|------|------|------|
| `u8` | 0 ~ 255 | 无符号,适合存储天数、等级 |
| `u16` | 0 ~ 65,535 | 无符号,适合存储经验值、库存量 |
| `i8` | -128 ~ 127 | 有符号,适合存储温度、偏差值 |
| `i32` | -2^31 ~ 2^31-1 | 有符号,适合存储金额(分为单位) |
#### 5.3 溢出控制:OVERFLOW
`INCRBY` 自增时如果超出 type 范围怎么办?Redis 提供了三种策略:
| 策略 | 行为 | 适用场景 |
|------|------|---------|
| `WRAP` | 回绕溢出(默认) | 循环计数器 |
| `SAT` | 饱和到最大/最小值后停止 | 库存量、经验值上限 |
| `FAIL` | 拒绝自增,返回 nil | 需要精确控制的业务 |
```bash
# 经验值上限 65535,满了就饱和不动
BITFIELD user:1001 OVERFLOW SAT INCRBY u16 4 100
# 库存扣减,不足时不扣(返回 nil 表示扣减失败)
BITFIELD stock:item:5001 OVERFLOW FAIL INCRBY i16 0 -1
```
> [!question] 想想看
> 为什么默认是 WRAP 而不是 SAT?因为 `BITFIELD` 的设计初衷是模拟硬件整数行为(CPU 的整数溢出就是回绕的),SAT/FAIL 是后来为业务场景添加的"安全模式"。
#### 5.4 实战:物品背包系统
一个 RPG 游戏的背包,每个物品存数量(u8,最多 255 个)。背包有 50 个槽位,全部打包进一个 key。
```go
// 背包槽位宽度
const slotBits = 8 // u8,每个槽位 8 bit
// 设置某个槽位的物品数量
func SetSlot(ctx context.Context, rdb *redis.Client, bagKey string, slot int, count int) error {
offset := slot * slotBits
// BITFIELD bag:1001 SET u8 0 10
_, err := rdb.BitField(ctx, bagKey, "SET", "u8", strconv.Itoa(offset), strconv.Itoa(count)).Result()
return err
}
// 读取某个槽位的物品数量
func GetSlot(ctx context.Context, rdb *redis.Client, bagKey string, slot int) (int64, error) {
offset := slot * slotBits
vals, err := rdb.BitField(ctx, bagKey, "GET", "u8", strconv.Itoa(offset)).Result()
if err != nil {
return 0, err
}
return vals[0], nil
}
// 增加物品(带溢出保护)
func IncrSlot(ctx context.Context, rdb *redis.Client, bagKey string, slot int, delta int) (int64, error) {
offset := slot * slotBits
// 一次性发送:溢出策略 + 自增,单次网络往返
vals, err := rdb.BitField(ctx, bagKey,
"OVERFLOW", "SAT",
"INCRBY", "u8", strconv.Itoa(offset), strconv.Itoa(delta),
).Result()
if err != nil {
return 0, err
}
return vals[0], nil
}
```
> [!tip] 一次 BITFIELD 调用可以包含多条子指令
> Redis 会把它们当作一个**原子事务**依次执行。背包里的 50 个槽位可以在一次 `BITFIELD` 调用中全部读取,只需 1 次网络往返(而非 50 次)。
#### 5.5 BITFIELD vs 多个 key 怎么选?
```mermaid
flowchart TD
A{"需要存小整数?"} -->|是| B{"数量多且结构相同?"}
B -->|是, 如背包/库存表| C["BITFIELD 打包到一个 key"]
B -->|否, 如用户余额/积分| D["普通 String/Hash key"]
A -->|否, 布尔值| E["SETBIT/GETBIT"]
style C fill:#66bb6a,color:#fff
style D fill:#42a5f5,color:#fff
style E fill:#f9a825,color:#000
```
| 维度 | BITFIELD 单 key | 多个 String key |
|------|----------------|----------------|
| **网络往返** | 1 次读写 N 个字段 | N 次 |
| **过期管理** | 整个 key 统一过期 | 每个 key 独立过期 |
| **单字段更新** | 只影响对应 bit 区间 | 只影响对应 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]]