From 1e8dbf06b8049e853a11a23c8d8075562459491b Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Mon, 25 May 2026 23:50:33 +0800 Subject: [PATCH] vault backup: 2026-05-25 23:50:33 --- hhs/Redis/09-高级特性.md | 40 +++++------ hhs/Redis/10-缓存架构模式.md | 118 +++++++++++++++++++++++++++------ hhs/Redis/11-运维与性能调优.md | 117 ++++++++++++++++++++++---------- hhs/Redis/12-Bitmap.md | 65 +++++++++++++++--- hhs/Redis/14-BloomFilter.md | 50 +++++++++++++- 5 files changed, 304 insertions(+), 86 deletions(-) diff --git a/hhs/Redis/09-高级特性.md b/hhs/Redis/09-高级特性.md index 6ae2680..e6c9db0 100644 --- a/hhs/Redis/09-高级特性.md +++ b/hhs/Redis/09-高级特性.md @@ -27,8 +27,8 @@ pipe.Set(ctx, "key", "value", 0) results, _ := pipe.Exec(ctx) ``` -> [!TIP] Go 的 TxPipeline vs MULTI -> `rdb.TxPipeline()` 在 go-redis 中**并不发送 MULTI/EXEC**——它只是普通 pipeline。这是为了兼容 Cluster 环境(集群不支持事务)。两者区别: +> [!TIP] Go 的 Pipeline vs TxPipeline +> `rdb.Pipeline()` 只批量发送命令,不加事务包裹;`rdb.TxPipeline()` 会在批量命令前后自动加上 `MULTI` / `EXEC`,等价于上文的事务语义。在 Cluster 模式下,go-redis 会按 slot 分组,对每个节点单独发送 MULTI/EXEC(因此同一 pipeline 内的 key 必须在相同 hash slot)。两者区别: | 维度 | MULTI/EXEC | Pipeline | |------|-----------|----------| @@ -206,8 +206,8 @@ os.date() -- 依赖系统时间 -- redis.call('set', KEYS[1], ARGV[1]) -- 带 token 写入 ``` -> [!NOTE] Redis 7.2+ 更新 -> Redis 7.2 起开放了部分确定性数学函数:`math.random`(需自行 set seed)、`math.max`、`math.min`、`math.log`。但不建议在状态机类脚本中使用随机性。 +> [!NOTE] 关于 Lua 沙箱中的数学函数 +> `math.max`、`math.min`、`math.log`、`math.abs` 等纯函数在沙箱中可用——它们的输出完全由输入决定,属于确定性函数。但 `math.random` **始终不可用**:即使设置相同 seed,伪随机序列也属于非确定性行为,会破坏主从一致性和 AOF 重放。随机值必须由客户端生成后通过 ARGV 传入。 > [!QUESTION] 为什么 Lua 脚本不能有随机函数? > Lua 脚本在主线程中同步执行,如果引入随机性或时间依赖,会导致两个严重问题: @@ -244,25 +244,27 @@ pipe.HSet(ctx, "user:1:profile", "email", "a@x.com") results, _ := pipe.Exec(ctx) // 返回 []redis.Result _ = results // 实际使用中检查 error -// 方式二:Watch CAS(乐观锁 —— 读-改-写原子的核心模式) -var balance int64 +// 方式二:Watch + TxPipeline(乐观锁 —— 读-改-写原子的核心模式) +// 核心思路:WATCH 监听 key → 在回调内读-判断-写 → 如果 key 被别人改了则整个回调失败重试 err := rdb.Watch(ctx, func(tx *redis.Tx) error { + // 1. 读:获取当前余额 acc, err := tx.Get(ctx, "account:alice").Result() if err != nil { return err } - balance, _ = strconv.ParseInt(acc, 10, 64) - return nil // 释放 Watch 锁 + balance, _ := strconv.ParseInt(acc, 10, 64) + if balance < 100 { + return fmt.Errorf("余额不足") + } + // 2. 改-写:在 Watch 回调内用 TxPipeline 保证原子性 + pipe := tx.TxPipeline() + pipe.DecrBy(ctx, "account:alice", 100) + pipe.IncrBy(ctx, "account:bob", 100) + _, err = pipe.Exec(ctx) + return err }, "account:alice") -if err != nil { /* NOSCRIPT / watch abort */ return } - -if balance < 100 { - log.Fatal("余额不足") +if err == redis.TxFailedErr { + // 被 Watch 的 key 被其他客户端修改了,需要重试整个操作 + log.Println("并发冲突,重试...") } - -_, err = rdb.ExecFunc(ctx, func(tx *redis.Tx) error { - tx.DecrBy(ctx, "account:alice", 100) - tx.IncrBy(ctx, "account:bob", 100) - return nil -}).Result() ``` > [!WARNING] Pipeline 的注意事项 @@ -456,7 +458,7 @@ flowchart TD | 实时通知、WebSocket 广播 | Pub/Sub | 无状态、低延迟 | > [!TIP] 一句话总结 -> - **Pipeline**:省网络,不保证顺序 +> - **Pipeline**:省网络,不保证原子性 > - **MULTI**:保证顺序,不保证逻辑原子 > - **Lua**:真正的原子性,但阻塞主线程(脚本要短小精悍) > - **Stream**:需要消息可靠投递时的首选 diff --git a/hhs/Redis/10-缓存架构模式.md b/hhs/Redis/10-缓存架构模式.md index c9f4bbf..dec570f 100644 --- a/hhs/Redis/10-缓存架构模式.md +++ b/hhs/Redis/10-缓存架构模式.md @@ -74,10 +74,12 @@ func UpdateUser(ctx context.Context, u *User) error { } ``` -> [!WARNING] Cache-Aside 的最终一致性 -> 写操作后立刻读,可能读到旧值(因为删缓存比下一个读请求先执行)。对于强一致场景,可以: -> - 读操作加一把短锁(避免并发刷缓存覆盖新值) -> - 或者直接用 DB 作为唯一真相源(放弃缓存一致性依赖) +> [!WARNING] Cache-Aside 的竞态窗口 +> 典型竞态:读请求 A 先 miss 缓存并开始查 DB,此时写请求 B 更新 DB 并删缓存;A 从 DB 取到**旧值**后回填缓存,覆盖了 B 的删除操作。后续读请求将持续拿到旧数据。 +> 解决方案: +> - **延迟双删**:写后删一次缓存,延迟几百毫秒再删一次(覆盖并发读的回填) +> - **读操作加短锁**:回填缓存前加锁,避免并发读覆盖新值 +> - **版本号 / 时间戳**:回填时比对版本,旧版本不写入缓存 ### Read/Write Through(读写穿透) @@ -96,6 +98,26 @@ flowchart LR > [!NOTE] 实际中较少纯原生实现 > Redisson(Java)、Spring Cache 提供此抽象;Go 生态没有标准库,通常自行封装。核心思路是 **用 AOP / Middleware 把缓存逻辑从业务代码中剥离**。 +### Write-Behind(异步写回)—— 高写入吞吐场景 + +与 Read/Write Through 不同,Write-Behind 将写操作**先落到缓存**,再**异步批量刷回 DB**,适合写密集型场景(如计数器、日志采集)。 + +```mermaid +flowchart LR + App["写请求"] --> Cache["Redis
立即返回 ✅"] + Cache -->|"异步队列
批量合并"| Writer["写回 Worker"] + Writer --> DB["MySQL"] + + style App fill:#efe,stroke:#090 + style Cache fill:#fda,stroke:#da0 + style Writer fill:#ddf,stroke:#66c +``` + +> [!WARNING] Write-Behind 的风险 +> - **数据丢失风险**:缓存宕机时,尚未刷回 DB 的数据会丢失 +> - **实现复杂度高**:需要可靠的异步队列 + 重试机制 + 幂等保证 +> - **适用场景有限**:适合"可容忍少量丢失"的计数、埋点类业务,**不适合核心交易数据** + ## 二、三大经典问题 ### 缓存穿透(Penetration)—— 不存在的数据一直打到 DB @@ -192,32 +214,47 @@ sequenceDiagram ```go func GetProduct(ctx context.Context, id int64) (*Product, error) { - data, _ := rdb.Get(ctx, "product:"+strconv.FormatInt(id, 10)).Bytes() + cacheKey := "product:" + strconv.FormatInt(id, 10) + + // 先查缓存 + data, _ := rdb.Get(ctx, cacheKey).Bytes() if data != nil { var p Product json.Unmarshal(data, &p) return &p, nil } - + // 拿分布式锁,只有一个 goroutine 去查 DB + 重建缓存 - lockKey := "lock:product:" + strconv.FormatInt(id, 10) - locked, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result() - if err != nil || !locked { - time.Sleep(50 * time.Millisecond) // 等别人重建完 - return GetProduct(ctx, id) // 递归重试(有最大深度保护) + lockKey := "lock:" + cacheKey + const maxRetries = 3 + for i := 0; i < maxRetries; i++ { + locked, _ := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result() + if locked { + defer rdb.Del(ctx, lockKey) + break + } + // 未拿到锁 → 等别人重建完后再查缓存 + time.Sleep(50 * time.Millisecond) + if v, _ := rdb.Get(ctx, cacheKey).Bytes(); v != nil { + var p Product + json.Unmarshal(v, &p) + return &p, nil + } + if i == maxRetries-1 { + return nil, errors.New("获取锁超时,降级处理") + } } - defer rdb.Del(ctx, lockKey) - + // 双重检查——防止自己等太久,另一个已经重建了 - data, _ = rdb.Get(ctx, "product:"+strconv.FormatInt(id, 10)).Bytes() + data, _ = rdb.Get(ctx, cacheKey).Bytes() if data != nil { var p Product json.Unmarshal(data, &p) return &p, nil } - + p, _ := db.GetProduct(id) - rdb.Set(ctx, "product:"+strconv.FormatInt(id, 10), toJSON(p), 30*time.Minute) + rdb.Set(ctx, cacheKey, toJSON(p), 30*time.Minute) return p, nil } ``` @@ -257,10 +294,22 @@ if time.Now().Unix() < item.ExpireAt { } // 异步重建缓存(不阻塞当前请求) +// ⚠️ 需配合 singleflight 或分布式锁,防止并发触发大量重建 go rebuildCacheAsync(key, item.ExpireAt) return item.Content // 返回旧值,后台静默刷新 ``` +#### 如何发现热点 Key? + +击穿的前提是"热点",但如果不知道哪些 Key 是热点,防护就无从谈起: + +| 方案 | 原理 | 适用场景 | +|------|------|---------| +| **Redis `MONITOR` / `redis-cli --hotkeys`** | Redis 4.0+ 的 LFU 采样,直接输出高频 Key | 开发调试、小规模集群 | +| **客户端代理统计** | 在 SDK 层埋点,按 Key 维度统计 QPS | 生产环境精确统计 | +| **Redis Cluster `CLUSTER COUNTKEYSINSLOT`** | 统计 slot 级别的 Key 分布 | 定位数据倾斜 | +| **离线分析慢查询日志** | `SLOWLOG GET` + 日志聚合 | 事后分析 | + ### 缓存雪崩(Avalanche)—— 大量 key 同时过期 > [!QUESTION] 穿透/击穿/雪崩 的区别是什么? @@ -368,10 +417,37 @@ flowchart LR | 4 | 大 Key 拆小 | 单个 Key ≤ 5KB,防止网络阻塞 | | 5 | Pipeline 批量操作 | 减少 RTT,提升吞吐 | | 6 | 监控命中率 | 低于 80% 考虑调整策略 | -| 7 | 不要缓存不可变数据 | 配置类可存本地内存 | -| 8 | 敏感数据不入库 | 密码、手机号要脱敏或加密 | +| 7 | 静态数据不必放 Redis | 配置、字典等低频变化数据用本地缓存即可,节省网络开销 | +| 8 | 敏感数据不缓存明文 | 密码、手机号等脱敏或加密后再写入缓存 | -## 四、常见架构模式速览 +## 四、进阶:基于 Binlog 的缓存一致性 + +Cache-Aside 的"先删缓存再更新 DB"存在竞态窗口。生产中更可靠的方案是**监听 DB 变更日志(Binlog),异步驱动缓存失效**。 + +```mermaid +flowchart LR + App["业务写请求"] --> DB["MySQL"] + DB -->|"Binlog"| Canal["Canal / Debezium"] + Canal --> MQ["Kafka / RocketMQ"] + MQ --> Consumer["缓存失效 Worker"] + Consumer -->|"DEL key"| Redis["Redis"] + + style DB fill:#ddf,stroke:#66c + style Canal fill:#fda,stroke:#da0 + style Redis fill:#fcc,stroke:#c00 +``` + +> [!QUESTION] 这种方案解决了什么问题? +> - **解除业务代码耦合**:写操作不需要关心缓存失效,由 Binlog 订阅统一处理 +> - **减少竞态窗口**:Binlog 保证有序,可以按变更顺序精确删除缓存 +> - **适合多数据源**:一次 DB 变更可以同时驱动多个缓存实例失效 + +> [!NOTE] 实际落地注意点 +> - **延迟**:Binlog → MQ → Consumer 链路通常有 **100ms~1s** 延迟,仍属于最终一致性 +> - **幂等**:Consumer 必须保证幂等(重复消费同一 Binlog 不产生副作用) +> - **顺序性**:同一行的多次变更需要保证消费顺序,通常按 `ROW_ID` 分区 + +## 五、常见架构模式速览 ```mermaid flowchart TD @@ -395,7 +471,7 @@ flowchart TD style DB fill:#ddf,stroke:#66c ``` -## 五、缓存选型与演进路径 +## 六、缓存选型与演进路径 > [!QUESTION] 项目初期应该从哪一层开始做缓存? > **答案:先做 L2(Redis),稳定后再加 L1(本地缓存)。** 原因如下: @@ -433,7 +509,7 @@ flowchart TD | **V3 大规模** | 极致性能和稳定性 | 多级缓存 + 逻辑过期 + 降级策略 | 一致性 vs 可用性的平衡取舍 | > [!TIP] 一句话总结 -> **好的缓存架构不是设计出来的,是演进出來的**。先保证正确性,再优化性能,最后打磨可用性。 +> **好的缓存架构不是设计出来的,是演进出来的**。先保证正确性,再优化性能,最后打磨可用性。 ## 关联笔记 diff --git a/hhs/Redis/11-运维与性能调优.md b/hhs/Redis/11-运维与性能调优.md index 5cc1c40..8f074d0 100644 --- a/hhs/Redis/11-运维与性能调优.md +++ b/hhs/Redis/11-运维与性能调优.md @@ -126,15 +126,56 @@ SLOWLOG RESET | `LRANGE 0 -1` (百万条 List) | 全列表拉取 | LRANGE 指定范围 | | 无索引的 DB 查询(在 Lua 中) | DB 层问题 | 加 DB 索引 | +```bash +# SLOWLOG GET 输出字段说明 +1) (integer) 14 # 慢查询 ID(递增) +2) (integer) 1716620400 # 发生时间(Unix 时间戳) +3) (integer) 13456 # 耗时(微秒) +4) 1) "KEYS" # 命令及参数 + 2) "*" +5) "127.0.0.1:53412" # 客户端地址 +6) "" # 客户端名称 +``` + ```go // go-redis 中的 SlowLog 接口 -entries := rdb.SlowLogGet(ctx, 10).Val() +entries, err := rdb.SlowLogGet(ctx, 10).Result() +if err != nil { + log.Fatal(err) +} for _, e := range entries { fmt.Printf("ID: %d, Time: %s, Cmd: %s, Duration: %v\n", - e.ID, e.Time, e.Cmd, e.Duration) + e.ID, e.Time.Format(time.RFC3339), e.Args, e.Duration) } ``` +### 延迟诊断工具 + +Redis 提供了内建的延迟诊断能力,定位"客户端感知慢"但服务端 CPU 并不繁忙的场景: + +```bash +# ──── 实时延迟采样(排查首选) ──── +redis-cli --latency # 持续输出 min/avg/max(ms) +redis-cli --latency-history -i 5 # 每 5 秒输出一次统计 + +# ──── 延迟分布直方图 ──── +redis-cli --latency-dist # 交互式彩色直方图,快速定位延迟区间 + +# ──── 事件驱动延迟监控(需服务端配置) ──── +# 在 redis.conf 中配置延迟阈值(微秒) +# latency-monitor-threshold 10 + +# 之后查看: +LATENCY LATEST # 最近的延迟事件 +LATENCY HISTORY command # 某命令的延迟历史 +LATENCY RESET # 重置统计数据 +``` + +> [!TIP] 延迟排查三板斧 +> 1. `redis-cli --latency` — 先看基线是否正常(正常 P99 < 1ms) +> 2. `LATENCY LATEST` — 看是否有异常事件(fork、大 Key 删除、AOF fsync) +> 3. `INFO commandstats` — 按命令耗时排序,定位慢命令 + ## 三、大 Key 治理 ### 什么是大 Key? @@ -202,52 +243,59 @@ UNLINK big_key # 异步删除(Redis 4.0+),立即返回 ```go rdb := redis.NewClient(&redis.Options{ - Addr: "localhost:6379", - Password: "", - DB: 0, - - // ──── 连接池参数 ──── - MaxIdle: 10, // 空闲连接上限 - MaxActive: 100, // 最大活跃连接数 - IdleTimeout: 5 * time.Minute, // 空闲超时关闭 - + Addr: "localhost:6379", + Password: "", + DB: 0, + + // ──── 连接池参数(go-redis v9) ──── + PoolSize: 100, // 连接池最大连接数(= 旧 MaxActive) + MinIdleConns: 10, // 最小空闲连接数,启动时预热 + ConnMaxIdleTime: 5 * time.Minute, // 空闲连接存活上限,超时关闭 + ConnMaxLifetime: 0, // 连接最大存活时长,0 = 不限制 + // ──── 超时参数 ──── - DialTimeout: 5 * time.Second, // 建立 TCP 连接超时 - ReadTimeout: 3 * time.Second, // 读超时 - WriteTimeout: 3 * time.Second, // 写超时 - PoolTimeout: 1 * time.Second, // 获取连接的等待超时 - - // ──── 心跳保活 ──── - PingPeriod: 60 * time.Second, // PING/PONG 保活周期,防止连接被防火墙断开 - MaxRetries: 3, - RetryDelay: 200 * time.Millisecond, + DialTimeout: 5 * time.Second, // 建立 TCP 连接超时 + ReadTimeout: 3 * time.Second, // 读超时 + WriteTimeout: 3 * time.Second, // 写超时 + PoolTimeout: 1 * time.Second, // 获取连接的等待超时 + + // ──── 重试策略 ──── + MaxRetries: 3, + MinRetryBackoff: 200 * time.Millisecond, + MaxRetryBackoff: 1 * time.Second, }) ``` +> [!NOTE] go-redis v8 → v9 参数变化 +> - `MaxIdle` / `MaxActive` → 合并为 `PoolSize`,`MinIdleConns` 控制预热 +> - `IdleTimeout` → `ConnMaxIdleTime` +> - `PingPeriod` 无对应字段,go-redis 内部通过健康检查自动保活 +> - `RetryDelay` → `MinRetryBackoff` + `MaxRetryBackoff`(指数退避) + ### 连接池选型决策树 ```mermaid graph TD - A["预估 QPS"] -->|"< 500"| S["MaxActive: 20
MaxIdle: 5"] - A -->|"500 ~ 5000"| M["MaxActive: 50
MaxIdle: 20"] - A -->|"> 5000"| L["MaxActive: 100~200
MaxIdle: 30~50"] - + A["预估 QPS"] -->|"< 500"| S["PoolSize: 20, MinIdleConns: 5"] + A -->|"500 ~ 5000"| M["PoolSize: 50, MinIdleConns: 20"] + A -->|"> 5000"| L["PoolSize: 100~200, MinIdleConns: 30~50"] + B{"有无 Pipeline?"} - B -->|"是"| C["↑ 增加 30% 连接数"] + B -->|"是"| C["增加 30% 连接数"] B -->|"否"| D["保持上述配置"] - + style L fill:#fdd,stroke:#900 ``` -> [!TIP] MaxActive 计算公式 +> [!TIP] PoolSize 计算公式 > ``` -> MaxActive = N × (1 + CPU等待时间/CPU计算时间) +> PoolSize = N × (1 + CPU等待时间/CPU计算时间) > ``` > 其中 N 为 Goroutine 数量。对于大多数 Web 应用,**50~100** 已经足够——如果频繁出现 PoolTimeout,优先考虑排查慢查询或大 Key 问题。 > [!WARNING] 连接池常见问题 -> - **MaxActive 太小**:`context deadline exceeded`(PoolTimeout)频繁出现 -> - **MaxActive 太大**:创建过多 TCP 连接,文件描述符耗尽 +> - **PoolSize 太小**:`context deadline exceeded`(PoolTimeout)频繁出现 +> - **PoolSize 太大**:创建过多 TCP 连接,文件描述符耗尽 > - **ReadTimeout 太短**:大数据量操作(如 HGETALL 大 hash)被误杀 > - **不设 DialTimeout**:Redis 宕机时请求永远挂起 @@ -268,8 +316,9 @@ maxmemory-policy allkeys-lru | 策略 | 作用范围 | 淘汰算法 | 适用场景 | |------|---------|---------|---------| | **allkeys-lru** | 所有 key | LRU(近似) | 通用缓存 ✅ 最常用 | -| allkeys-lfu | 所有 key | LFU(频次统计) | Redis 6.0+,热点数据语义更强的场景 | +| allkeys-lfu | 所有 key | LFU(频次统计) | Redis 4.0+,热点数据语义更强的场景 | | volatile-lru | 仅设 TTL 的 key | LRU(近似) | 混合存储:永不过期的配置 + 可淘汰的缓存 | +| volatile-lfu | 仅设 TTL 的 key | LFU(频次统计) | 混合存储 + 频次敏感淘汰 | | volatile-ttl | 仅设 TTL 的 key | TTL 最短优先 | 让数据自然死亡的场景 | | noeviction | — | — | 数据不可丢失,超限返回错误(如配置中心) | @@ -336,7 +385,7 @@ Crontab: ```mermaid flowchart TD - A["⚠️ 数据异常或丢失"] --> B{"可用最近的备份?"} + A["数据异常或丢失"] --> B{"可用最近的备份?"} B -->|"是"| C["停止写入,防止覆盖"] C --> D["恢复 RDB/AOF 备份"] D --> E["检查数据完整性"] @@ -377,8 +426,8 @@ flowchart TD ```bash # ACL 示例(Redis 6.0+) -ACL SETUSER readonly on ">readOnlyPass123" ~cached:* get mget scan -ACL SETUSER writer on ">writePass123" ~*:set* ~*:add* ~*:incr* set add incr +ACL SETUSER readonly on >readOnlyPass123 ~cached:* &* +get +mget +scan +ping +ACL SETUSER writer on >writePass123 ~* &* +set +add +incr +del +ping ACL LIST # 查看所有用户 ACL WHOAMI # 当前用户 ``` diff --git a/hhs/Redis/12-Bitmap.md b/hhs/Redis/12-Bitmap.md index 626190a..f393da0 100644 --- a/hhs/Redis/12-Bitmap.md +++ b/hhs/Redis/12-Bitmap.md @@ -48,15 +48,23 @@ func IsSigned(ctx context.Context, rdb *redis.Client, userID int64, dayOfYear in 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()) - // 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() + 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 } ``` @@ -147,7 +155,46 @@ Bitmap 的内存效率比 Hash 方案低约 **380 倍**,这就是为什么在 > [!question] 为什么 Bitmap 这么省? > 因为它只用 1 个 bit 来表示一个布尔值,而 Hash/Set 需要存储完整的 key 和 value。Redis String 底层 SDS 本身也有元数据开销,但分摊到数十亿个 bit 上几乎可以忽略。 -### 5. Bitmap vs Set vs HyperLogLog 对比 +### 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 | |------|--------|-----|-------------| @@ -163,7 +210,7 @@ Bitmap 的内存效率比 Hash 方案低约 **380 倍**,这就是为什么在 > - 需要精确集合运算(交集、差集) -> **Set** > - 只需要统计基数(UV/PV),允许 0.81% 误差 -> **HyperLogLog** -### 6. 签到系统流程 +### 7. 签到系统流程 ```mermaid flowchart TD diff --git a/hhs/Redis/14-BloomFilter.md b/hhs/Redis/14-BloomFilter.md index b233c56..23b2021 100644 --- a/hhs/Redis/14-BloomFilter.md +++ b/hhs/Redis/14-BloomFilter.md @@ -42,7 +42,25 @@ graph LR ### 方案 A:RedisBloom 模块(服务端) -RedisBloom 是 Redis 官方模块,通过 `BF.ADD` / `BF.EXISTS` 命令直接操作。 +RedisBloom 是 Redis 官方模块,**默认不随 Redis 一起安装**,需要单独编译或通过 Docker 加载: + +```bash +# Docker 方式(推荐) +docker run -p 6379:6379 redis/redis-stack-server # 内置 RedisBloom +``` + +核心命令: + +| 命令 | 说明 | +|------|------| +| `BF.RESERVE key error_rate capacity` | 创建自定义布隆过滤器(生产环境推荐) | +| `BF.ADD key item` | 添加元素(自动创建默认参数的过滤器) | +| `BF.EXISTS key item` | 查询元素,返回 1=可能存在 / 0=一定不存在 | +| `BF.MADD key item1 item2 ...` | 批量添加 | +| `BF.MEXISTS key item1 item2 ...` | 批量查询 | + +> [!tip] 生产环境务必用 `BF.RESERVE` 预创建 +> `BF.ADD` 首次调用时会以默认参数自动创建过滤器(容量约 100,误判率 0.01),数据量稍大就会急剧膨胀误判率。正确做法是先 `BF.RESERVE user_filter 0.0001 1000000` 显式声明参数。 ```go // go-redis 调用 RedisBloom @@ -138,7 +156,9 @@ func (s *BloomCacheService) GetUser(ctx context.Context, userID string) (*User, return nil, nil // 缓存的空值 } var user User - json.Unmarshal(data, &user) + if err := json.Unmarshal(data, &user); err != nil { + return nil, err // JSON 反序列化失败 + } return &user, nil } @@ -220,7 +240,24 @@ func calcBloomParams(n int, p float64) (m int, k int) { ### 6.1 不支持删除 -标准布隆过滤器不支持删除——将某位从 1 置为 0 可能影响其他元素判断。解决方案是**计数布隆过滤器(Counting Bloom Filter)**,用计数器替代 1 bit,删除时计数器减 1。RedisBloom 的 Cuckoo Filter(`CF.ADD` / `CF.EXISTS`)也原生支持删除。 +标准布隆过滤器不支持删除——将某位从 1 置为 0 可能影响其他元素判断。两种解决方案: + +| 方案 | 原理 | 优点 | 缺点 | +|------|------|------|------| +| **计数布隆过滤器** | 用计数器替代 1 bit,删除时减 1 | 兼容标准布隆过滤器思路 | 内存开销增大 3-4 倍 | +| **Cuckoo Filter** | 基于 cuckoo hashing,直接存储元素指纹 | 支持删除、查询更快、空间更优 | 容量接近满时插入可能失败 | + +RedisBloom 中 Cuckoo Filter 的命令: + +```bash +CF.RESERVE key capacity # 创建 +CF.ADD key item # 添加 +CF.EXISTS key item # 查询 +CF.DEL key item # 删除(布隆过滤器做不到) +``` + +> [!tip] 何时用 Cuckoo Filter? +> 如果你的场景**需要删除过期元素**(比如用户注销后移除 ID),优先用 Cuckoo Filter。如果只做"只增不查删"的穿透防护,标准布隆过滤器更简单高效。 ### 6.2 需要预加载 @@ -229,6 +266,13 @@ func calcBloomParams(n int, p float64) (m int, k int) { > [!question] 如果新增了数据但忘记更新布隆过滤器会怎样? > 新增的 key 在布隆过滤器中查询会返回"不存在",导致合法请求被误拦截。这比缓存穿透更严重——用户明明有数据却查不到。所以务必确保新增数据时同步 `BF.ADD` 或 `filter.AddString()`。 +> [!warning] Go-side 过滤器的重启问题 +> Go 内存中的布隆过滤器在进程重启后**完全丢失**。两种应对策略: +> 1. **启动时重建**:从 DB 全量加载 ID 重新构建(数据量大时启动慢,可用 goroutine 并行加载) +> 2. **持久化 bit 数组**:将 bit 数组序列化到 Redis 或文件,启动时反序列化恢复(快但需要额外维护一致性) +> +> 如果重建成本不可接受,优先考虑 RedisBloom——数据随 Redis 持久化,应用重启无感知。 + ### 6.3 误判率随使用上升 实际元素数量远超预估值 `n` 时,误判率会显著上升。建议预留 20%-30% 余量,监控实际元素数量,必要时重建过滤器。