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
+97 -21
View File
@@ -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<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
@@ -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] 一句话总结
> **好的缓存架构不是设计出来的,是演进出來的**。先保证正确性,再优化性能,最后打磨可用性。
> **好的缓存架构不是设计出来的,是演进出来的**。先保证正确性,再优化性能,最后打磨可用性。
## 关联笔记