351 lines
14 KiB
Markdown
351 lines
14 KiB
Markdown
---
|
||
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]]
|