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% 余量,监控实际元素数量,必要时重建过滤器。