diff --git a/hhs/Redis/12-Bitmap.md b/hhs/Redis/12-Bitmap.md index f393da0..2172ab6 100644 --- a/hhs/Redis/12-Bitmap.md +++ b/hhs/Redis/12-Bitmap.md @@ -157,42 +157,159 @@ Bitmap 的内存效率比 Hash 方案低约 **380 倍**,这就是为什么在 ### 5. BITFIELD:任意宽度整数操作 -前面的 `SETBIT`/`GETBIT` 只能操作单个 bit(0 或 1)。如果我们需要存储的不是布尔值,而是一个小整数呢?比如"连续签到天数"(0~127)或"用户等级"(0~15)。 +前面的 `SETBIT`/`GETBIT` 只能操作单个 bit(0 或 1)。但现实场景经常需要存**小整数**——"连续签到天数"(0~127)可以用 8 位表示,"用户等级"(0~15)只需要 4 位。 -这就是 `BITFIELD` 的用武之地——它将一个 String 视为一个**任意宽度整数的数组**,支持原子性的读写和自增。 +> [!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` | 读取指定偏移处的整数 | `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` | +| `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),例如 `u8` 表示 8 位无符号整数(0~255),`i16` 表示 16 位有符号整数。 +**type 格式**:`u`(无符号)或 `i`(有符号)+ 位宽(1~64) -> [!tip] 为什么 INCRBY 很重要? -> 它是原子操作。多个客户端可以同时对同一个 offset 做 INCRBY 而不会出现竞态条件,无需额外加锁。 +| 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 -// 用 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() +// 背包槽位宽度 +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 // 返回自增后的连续签到天数 + 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() +// 增加物品(带溢出保护) +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 } ``` -> [!question] BITFIELD vs 多个 key 怎么选? -> 如果每个用户只需要存 1~2 个小整数,用普通的 String/Hash key 就够了。`BITFIELD` 的优势在于:当需要**批量**存储大量同类型小整数时(比如 1 万个物品的库存量),可以把它们紧凑地打包到一个 key 中,大幅减少 key 数量和网络往返。 +> [!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 对比