vault backup: 2026-05-25 23:50:33

This commit is contained in:
hhs
2026-05-25 23:50:33 +08:00
parent 268ee58de6
commit 1e8dbf06b8
5 changed files with 304 additions and 86 deletions
+21 -19
View File
@@ -27,8 +27,8 @@ pipe.Set(ctx, "key", "value", 0)
results, _ := pipe.Exec(ctx) results, _ := pipe.Exec(ctx)
``` ```
> [!TIP] Go 的 TxPipeline vs MULTI > [!TIP] Go 的 Pipeline vs TxPipeline
> `rdb.TxPipeline()` 在 go-redis 中**并不发送 MULTI/EXEC**——它只是普通 pipeline。这是为了兼容 Cluster 环境(集群不支持事务)。两者区别: > `rdb.Pipeline()` 只批量发送命令,不加事务包裹;`rdb.TxPipeline()` 会在批量命令前后自动加上 `MULTI` / `EXEC`,等价于上文的事务语义。在 Cluster 模式下,go-redis 会按 slot 分组,对每个节点单独发送 MULTI/EXEC(因此同一 pipeline 内的 key 必须在相同 hash slot)。两者区别:
| 维度 | MULTI/EXEC | Pipeline | | 维度 | MULTI/EXEC | Pipeline |
|------|-----------|----------| |------|-----------|----------|
@@ -206,8 +206,8 @@ os.date() -- 依赖系统时间
-- redis.call('set', KEYS[1], ARGV[1]) -- 带 token 写入 -- redis.call('set', KEYS[1], ARGV[1]) -- 带 token 写入
``` ```
> [!NOTE] Redis 7.2+ 更新 > [!NOTE] 关于 Lua 沙箱中的数学函数
> Redis 7.2 起开放了部分确定性数学函数:`math.random`(需自行 set seed)、`math.max`、`math.min`、`math.log`。但不建议在状态机类脚本中使用随机性。 > `math.max`、`math.min`、`math.log`、`math.abs` 等纯函数在沙箱中可用——它们的输出完全由输入决定,属于确定性函数。但 `math.random` **始终不可用**:即使设置相同 seed,伪随机序列也属于非确定性行为,会破坏主从一致性和 AOF 重放。随机值必须由客户端生成后通过 ARGV 传入。
> [!QUESTION] 为什么 Lua 脚本不能有随机函数? > [!QUESTION] 为什么 Lua 脚本不能有随机函数?
> Lua 脚本在主线程中同步执行,如果引入随机性或时间依赖,会导致两个严重问题: > Lua 脚本在主线程中同步执行,如果引入随机性或时间依赖,会导致两个严重问题:
@@ -244,25 +244,27 @@ pipe.HSet(ctx, "user:1:profile", "email", "a@x.com")
results, _ := pipe.Exec(ctx) // 返回 []redis.Result results, _ := pipe.Exec(ctx) // 返回 []redis.Result
_ = results // 实际使用中检查 error _ = results // 实际使用中检查 error
// 方式二:Watch CAS(乐观锁 —— 读-改-写原子的核心模式) // 方式二:Watch + TxPipeline(乐观锁 —— 读-改-写原子的核心模式)
var balance int64 // 核心思路:WATCH 监听 key → 在回调内读-判断-写 → 如果 key 被别人改了则整个回调失败重试
err := rdb.Watch(ctx, func(tx *redis.Tx) error { err := rdb.Watch(ctx, func(tx *redis.Tx) error {
// 1. 读:获取当前余额
acc, err := tx.Get(ctx, "account:alice").Result() acc, err := tx.Get(ctx, "account:alice").Result()
if err != nil { return err } if err != nil { return err }
balance, _ = strconv.ParseInt(acc, 10, 64) balance, _ := strconv.ParseInt(acc, 10, 64)
return nil // 释放 Watch 锁 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") }, "account:alice")
if err != nil { /* NOSCRIPT / watch abort */ return } if err == redis.TxFailedErr {
// 被 Watch 的 key 被其他客户端修改了,需要重试整个操作
if balance < 100 { log.Println("并发冲突,重试...")
log.Fatal("余额不足")
} }
_, 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 的注意事项 > [!WARNING] Pipeline 的注意事项
@@ -456,7 +458,7 @@ flowchart TD
| 实时通知、WebSocket 广播 | Pub/Sub | 无状态、低延迟 | | 实时通知、WebSocket 广播 | Pub/Sub | 无状态、低延迟 |
> [!TIP] 一句话总结 > [!TIP] 一句话总结
> - **Pipeline**:省网络,不保证顺序 > - **Pipeline**:省网络,不保证原子性
> - **MULTI**:保证顺序,不保证逻辑原子 > - **MULTI**:保证顺序,不保证逻辑原子
> - **Lua**:真正的原子性,但阻塞主线程(脚本要短小精悍) > - **Lua**:真正的原子性,但阻塞主线程(脚本要短小精悍)
> - **Stream**:需要消息可靠投递时的首选 > - **Stream**:需要消息可靠投递时的首选
+94 -18
View File
@@ -74,10 +74,12 @@ func UpdateUser(ctx context.Context, u *User) error {
} }
``` ```
> [!WARNING] Cache-Aside 的最终一致性 > [!WARNING] Cache-Aside 的竞态窗口
> 写操作后立刻读,可能读到旧值(因为删缓存比下一个读请求先执行)。对于强一致场景,可以: > 典型竞态:读请求 A 先 miss 缓存并开始查 DB,此时写请求 B 更新 DB 并删缓存;A 从 DB 取到**旧值**后回填缓存,覆盖了 B 的删除操作。后续读请求将持续拿到旧数据。
> - 读操作加一把短锁(避免并发刷缓存覆盖新值) > 解决方案:
> - 或者直接用 DB 作为唯一真相源(放弃缓存一致性依赖) > - **延迟双删**:写后删一次缓存,延迟几百毫秒再删一次(覆盖并发读的回填)
> - **读操作加短锁**:回填缓存前加锁,避免并发读覆盖新值
> - **版本号 / 时间戳**:回填时比对版本,旧版本不写入缓存
### Read/Write Through(读写穿透) ### Read/Write Through(读写穿透)
@@ -96,6 +98,26 @@ flowchart LR
> [!NOTE] 实际中较少纯原生实现 > [!NOTE] 实际中较少纯原生实现
> Redisson(Java)、Spring Cache 提供此抽象;Go 生态没有标准库,通常自行封装。核心思路是 **用 AOP / Middleware 把缓存逻辑从业务代码中剥离**。 > Redisson(Java)、Spring Cache 提供此抽象;Go 生态没有标准库,通常自行封装。核心思路是 **用 AOP / Middleware 把缓存逻辑从业务代码中剥离**。
### Write-Behind(异步写回)—— 高写入吞吐场景
与 Read/Write Through 不同,Write-Behind 将写操作**先落到缓存**,再**异步批量刷回 DB**,适合写密集型场景(如计数器、日志采集)。
```mermaid
flowchart LR
App["写请求"] --> Cache["Redis<br/>立即返回 ✅"]
Cache -->|"异步队列<br/>批量合并"| 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 ### 缓存穿透(Penetration)—— 不存在的数据一直打到 DB
@@ -192,7 +214,10 @@ sequenceDiagram
```go ```go
func GetProduct(ctx context.Context, id int64) (*Product, error) { 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 { if data != nil {
var p Product var p Product
json.Unmarshal(data, &p) json.Unmarshal(data, &p)
@@ -200,16 +225,28 @@ func GetProduct(ctx context.Context, id int64) (*Product, error) {
} }
// 拿分布式锁,只有一个 goroutine 去查 DB + 重建缓存 // 拿分布式锁,只有一个 goroutine 去查 DB + 重建缓存
lockKey := "lock:product:" + strconv.FormatInt(id, 10) lockKey := "lock:" + cacheKey
locked, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result() const maxRetries = 3
if err != nil || !locked { for i := 0; i < maxRetries; i++ {
time.Sleep(50 * time.Millisecond) // 等别人重建完 locked, _ := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
return GetProduct(ctx, id) // 递归重试(有最大深度保护) if locked {
}
defer rdb.Del(ctx, lockKey) 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("获取锁超时,降级处理")
}
}
// 双重检查——防止自己等太久,另一个已经重建了 // 双重检查——防止自己等太久,另一个已经重建了
data, _ = rdb.Get(ctx, "product:"+strconv.FormatInt(id, 10)).Bytes() data, _ = rdb.Get(ctx, cacheKey).Bytes()
if data != nil { if data != nil {
var p Product var p Product
json.Unmarshal(data, &p) json.Unmarshal(data, &p)
@@ -217,7 +254,7 @@ func GetProduct(ctx context.Context, id int64) (*Product, error) {
} }
p, _ := db.GetProduct(id) 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 return p, nil
} }
``` ```
@@ -257,10 +294,22 @@ if time.Now().Unix() < item.ExpireAt {
} }
// 异步重建缓存(不阻塞当前请求) // 异步重建缓存(不阻塞当前请求)
// ⚠️ 需配合 singleflight 或分布式锁,防止并发触发大量重建
go rebuildCacheAsync(key, item.ExpireAt) go rebuildCacheAsync(key, item.ExpireAt)
return item.Content // 返回旧值,后台静默刷新 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 同时过期 ### 缓存雪崩(Avalanche)—— 大量 key 同时过期
> [!QUESTION] 穿透/击穿/雪崩 的区别是什么? > [!QUESTION] 穿透/击穿/雪崩 的区别是什么?
@@ -368,10 +417,37 @@ flowchart LR
| 4 | 大 Key 拆小 | 单个 Key ≤ 5KB,防止网络阻塞 | | 4 | 大 Key 拆小 | 单个 Key ≤ 5KB,防止网络阻塞 |
| 5 | Pipeline 批量操作 | 减少 RTT,提升吞吐 | | 5 | Pipeline 批量操作 | 减少 RTT,提升吞吐 |
| 6 | 监控命中率 | 低于 80% 考虑调整策略 | | 6 | 监控命中率 | 低于 80% 考虑调整策略 |
| 7 | 不要缓存不可变数据 | 配置类可存本地内存 | | 7 | 静态数据不必放 Redis | 配置、字典等低频变化数据用本地缓存即可,节省网络开销 |
| 8 | 敏感数据不入库 | 密码、手机号要脱敏或加密 | | 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 ```mermaid
flowchart TD flowchart TD
@@ -395,7 +471,7 @@ flowchart TD
style DB fill:#ddf,stroke:#66c style DB fill:#ddf,stroke:#66c
``` ```
## 五、缓存选型与演进路径 ## 六、缓存选型与演进路径
> [!QUESTION] 项目初期应该从哪一层开始做缓存? > [!QUESTION] 项目初期应该从哪一层开始做缓存?
> **答案:先做 L2(Redis),稳定后再加 L1(本地缓存)。** 原因如下: > **答案:先做 L2(Redis),稳定后再加 L1(本地缓存)。** 原因如下:
@@ -433,7 +509,7 @@ flowchart TD
| **V3 大规模** | 极致性能和稳定性 | 多级缓存 + 逻辑过期 + 降级策略 | 一致性 vs 可用性的平衡取舍 | | **V3 大规模** | 极致性能和稳定性 | 多级缓存 + 逻辑过期 + 降级策略 | 一致性 vs 可用性的平衡取舍 |
> [!TIP] 一句话总结 > [!TIP] 一句话总结
> **好的缓存架构不是设计出来的,是演进出來的**。先保证正确性,再优化性能,最后打磨可用性。 > **好的缓存架构不是设计出来的,是演进出来的**。先保证正确性,再优化性能,最后打磨可用性。
## 关联笔记 ## 关联笔记
+70 -21
View File
@@ -126,15 +126,56 @@ SLOWLOG RESET
| `LRANGE 0 -1` (百万条 List) | 全列表拉取 | LRANGE 指定范围 | | `LRANGE 0 -1` (百万条 List) | 全列表拉取 | LRANGE 指定范围 |
| 无索引的 DB 查询(在 Lua 中) | DB 层问题 | 加 DB 索引 | | 无索引的 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
// go-redis 中的 SlowLog 接口 // 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 { for _, e := range entries {
fmt.Printf("ID: %d, Time: %s, Cmd: %s, Duration: %v\n", 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 治理
### 什么是大 Key? ### 什么是大 Key?
@@ -206,10 +247,11 @@ rdb := redis.NewClient(&redis.Options{
Password: "", Password: "",
DB: 0, DB: 0,
// ──── 连接池参数 ──── // ──── 连接池参数(go-redis v9) ────
MaxIdle: 10, // 空闲连接上限 PoolSize: 100, // 连接池最大连接数(= 旧 MaxActive)
MaxActive: 100, // 最大活跃连接数 MinIdleConns: 10, // 最小空闲连接数,启动时预热
IdleTimeout: 5 * time.Minute, // 空闲超时关闭 ConnMaxIdleTime: 5 * time.Minute, // 空闲连接存活上限,超时关闭
ConnMaxLifetime: 0, // 连接最大存活时长,0 = 不限制
// ──── 超时参数 ──── // ──── 超时参数 ────
DialTimeout: 5 * time.Second, // 建立 TCP 连接超时 DialTimeout: 5 * time.Second, // 建立 TCP 连接超时
@@ -217,37 +259,43 @@ rdb := redis.NewClient(&redis.Options{
WriteTimeout: 3 * time.Second, // 写超时 WriteTimeout: 3 * time.Second, // 写超时
PoolTimeout: 1 * time.Second, // 获取连接的等待超时 PoolTimeout: 1 * time.Second, // 获取连接的等待超时
// ──── 心跳保活 ──── // ──── 重试策略 ────
PingPeriod: 60 * time.Second, // PING/PONG 保活周期,防止连接被防火墙断开
MaxRetries: 3, MaxRetries: 3,
RetryDelay: 200 * time.Millisecond, 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 ```mermaid
graph TD graph TD
A["预估 QPS"] -->|"&lt; 500"| S["MaxActive: 20<br/>MaxIdle: 5"] A["预估 QPS"] -->|"&lt; 500"| S["PoolSize: 20, MinIdleConns: 5"]
A -->|"500 ~ 5000"| M["MaxActive: 50<br/>MaxIdle: 20"] A -->|"500 ~ 5000"| M["PoolSize: 50, MinIdleConns: 20"]
A -->|"> 5000"| L["MaxActive: 100~200<br/>MaxIdle: 30~50"] A -->|"> 5000"| L["PoolSize: 100~200, MinIdleConns: 30~50"]
B{"有无 Pipeline?"} B{"有无 Pipeline?"}
B -->|"是"| C["↑ 增加 30% 连接数"] B -->|"是"| C["增加 30% 连接数"]
B -->|"否"| D["保持上述配置"] B -->|"否"| D["保持上述配置"]
style L fill:#fdd,stroke:#900 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 问题。 > 其中 N 为 Goroutine 数量。对于大多数 Web 应用,**50~100** 已经足够——如果频繁出现 PoolTimeout,优先考虑排查慢查询或大 Key 问题。
> [!WARNING] 连接池常见问题 > [!WARNING] 连接池常见问题
> - **MaxActive 太小**:`context deadline exceeded`(PoolTimeout)频繁出现 > - **PoolSize 太小**:`context deadline exceeded`(PoolTimeout)频繁出现
> - **MaxActive 太大**:创建过多 TCP 连接,文件描述符耗尽 > - **PoolSize 太大**:创建过多 TCP 连接,文件描述符耗尽
> - **ReadTimeout 太短**:大数据量操作(如 HGETALL 大 hash)被误杀 > - **ReadTimeout 太短**:大数据量操作(如 HGETALL 大 hash)被误杀
> - **不设 DialTimeout**:Redis 宕机时请求永远挂起 > - **不设 DialTimeout**:Redis 宕机时请求永远挂起
@@ -268,8 +316,9 @@ maxmemory-policy allkeys-lru
| 策略 | 作用范围 | 淘汰算法 | 适用场景 | | 策略 | 作用范围 | 淘汰算法 | 适用场景 |
|------|---------|---------|---------| |------|---------|---------|---------|
| **allkeys-lru** | 所有 key | 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-lru | 仅设 TTL 的 key | LRU(近似) | 混合存储:永不过期的配置 + 可淘汰的缓存 |
| volatile-lfu | 仅设 TTL 的 key | LFU(频次统计) | 混合存储 + 频次敏感淘汰 |
| volatile-ttl | 仅设 TTL 的 key | TTL 最短优先 | 让数据自然死亡的场景 | | volatile-ttl | 仅设 TTL 的 key | TTL 最短优先 | 让数据自然死亡的场景 |
| noeviction | — | — | 数据不可丢失,超限返回错误(如配置中心) | | noeviction | — | — | 数据不可丢失,超限返回错误(如配置中心) |
@@ -336,7 +385,7 @@ Crontab:
```mermaid ```mermaid
flowchart TD flowchart TD
A["⚠️ 数据异常或丢失"] --> B{"可用最近的备份?"} A["数据异常或丢失"] --> B{"可用最近的备份?"}
B -->|"是"| C["停止写入,防止覆盖"] B -->|"是"| C["停止写入,防止覆盖"]
C --> D["恢复 RDB/AOF 备份"] C --> D["恢复 RDB/AOF 备份"]
D --> E["检查数据完整性"] D --> E["检查数据完整性"]
@@ -377,8 +426,8 @@ flowchart TD
```bash ```bash
# ACL 示例(Redis 6.0+) # ACL 示例(Redis 6.0+)
ACL SETUSER readonly on ">readOnlyPass123" ~cached:* get mget scan ACL SETUSER readonly on >readOnlyPass123 ~cached:* &* +get +mget +scan +ping
ACL SETUSER writer on ">writePass123" ~*:set* ~*:add* ~*:incr* set add incr ACL SETUSER writer on >writePass123 ~* &* +set +add +incr +del +ping
ACL LIST # 查看所有用户 ACL LIST # 查看所有用户
ACL WHOAMI # 当前用户 ACL WHOAMI # 当前用户
``` ```
+56 -9
View File
@@ -48,15 +48,23 @@ func IsSigned(ctx context.Context, rdb *redis.Client, userID int64, dayOfYear in
return val == 1, err return val == 1, err
} }
// 本月累计签到天数 // 本月累计签到天数(精确版:逐 bit 检查,避免字节边界误差)
func MonthSignCount(ctx context.Context, rdb *redis.Client, userID int64) (int64, error) { func MonthSignCount(ctx context.Context, rdb *redis.Client, userID int64) (int64, error) {
key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year()) key := fmt.Sprintf("sign:%d:%d", userID, time.Now().Year())
// BITCOUNT 支持按字节范围统计,start/end 是字节索引 now := time.Now()
// 需要根据月份计算对应的字节范围 // 计算本月第一天和最后一天在年中的 day-of-year(1-based)
return rdb.BitCount(ctx, key, &redis.BitCount{ firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local).YearDay()
Start: int64((time.Now().Month() - 1) * 4), // 近似值,实际需精确计算 lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, time.Local).YearDay()
End: int64(time.Now().Month()*4 - 1),
}).Result() 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 这么省? > [!question] 为什么 Bitmap 这么省?
> 因为它只用 1 个 bit 来表示一个布尔值,而 Hash/Set 需要存储完整的 key 和 value。Redis String 底层 SDS 本身也有元数据开销,但分摊到数十亿个 bit 上几乎可以忽略。 > 因为它只用 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 | | 特性 | Bitmap | Set | HyperLogLog |
|------|--------|-----|-------------| |------|--------|-----|-------------|
@@ -163,7 +210,7 @@ Bitmap 的内存效率比 Hash 方案低约 **380 倍**,这就是为什么在
> - 需要精确集合运算(交集、差集) -> **Set** > - 需要精确集合运算(交集、差集) -> **Set**
> - 只需要统计基数(UV/PV),允许 0.81% 误差 -> **HyperLogLog** > - 只需要统计基数(UV/PV),允许 0.81% 误差 -> **HyperLogLog**
### 6. 签到系统流程 ### 7. 签到系统流程
```mermaid ```mermaid
flowchart TD flowchart TD
+47 -3
View File
@@ -42,7 +42,25 @@ graph LR
### 方案 A:RedisBloom 模块(服务端) ### 方案 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
// go-redis 调用 RedisBloom // go-redis 调用 RedisBloom
@@ -138,7 +156,9 @@ func (s *BloomCacheService) GetUser(ctx context.Context, userID string) (*User,
return nil, nil // 缓存的空值 return nil, nil // 缓存的空值
} }
var user User var user User
json.Unmarshal(data, &user) if err := json.Unmarshal(data, &user); err != nil {
return nil, err // JSON 反序列化失败
}
return &user, nil return &user, nil
} }
@@ -220,7 +240,24 @@ func calcBloomParams(n int, p float64) (m int, k int) {
### 6.1 不支持删除 ### 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 需要预加载 ### 6.2 需要预加载
@@ -229,6 +266,13 @@ func calcBloomParams(n int, p float64) (m int, k int) {
> [!question] 如果新增了数据但忘记更新布隆过滤器会怎样? > [!question] 如果新增了数据但忘记更新布隆过滤器会怎样?
> 新增的 key 在布隆过滤器中查询会返回"不存在",导致合法请求被误拦截。这比缓存穿透更严重——用户明明有数据却查不到。所以务必确保新增数据时同步 `BF.ADD` 或 `filter.AddString()`。 > 新增的 key 在布隆过滤器中查询会返回"不存在",导致合法请求被误拦截。这比缓存穿透更严重——用户明明有数据却查不到。所以务必确保新增数据时同步 `BF.ADD` 或 `filter.AddString()`。
> [!warning] Go-side 过滤器的重启问题
> Go 内存中的布隆过滤器在进程重启后**完全丢失**。两种应对策略:
> 1. **启动时重建**:从 DB 全量加载 ID 重新构建(数据量大时启动慢,可用 goroutine 并行加载)
> 2. **持久化 bit 数组**:将 bit 数组序列化到 Redis 或文件,启动时反序列化恢复(快但需要额外维护一致性)
>
> 如果重建成本不可接受,优先考虑 RedisBloom——数据随 Redis 持久化,应用重启无感知。
### 6.3 误判率随使用上升 ### 6.3 误判率随使用上升
实际元素数量远超预估值 `n` 时,误判率会显著上升。建议预留 20%-30% 余量,监控实际元素数量,必要时重建过滤器。 实际元素数量远超预估值 `n` 时,误判率会显著上升。建议预留 20%-30% 余量,监控实际元素数量,必要时重建过滤器。