Files
cs-note/hhs/Redis/10-缓存架构模式.md
T
2026-05-25 23:50:33 +08:00

520 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, 缓存, 架构]
create time: 2026-05-15 18:15
---
# Redis 缓存架构模式
## 概述
在实际生产系统中,Redis 很少直接裸用——它需要与业务数据库、应用层紧密配合。本节介绍最常见的**缓存设计模式**以及经典的三大问题(穿透/击穿/雪崩)的解决方案。
> [!QUESTION] 为什么有了 DB 还需要缓存?
> - **性能**:内存读写 ≈ 1μs,磁盘 IO ≈ 1ms,差距三个数量级
> - **容量**:DB 扛不住瞬时峰值,缓存可以做"削峰填谷"
> - **成本**:一台 Redis 集群的成本远低于扩容 MySQL
**核心矛盾**:缓存带来了性能提升,也引入了**一致性**和**可用性**的新风险。下文所有模式都是围绕这个矛盾在做取舍。
## 一、基础缓存策略
### Cache-Aside Pattern(旁路缓存)—— 最常用
```mermaid
flowchart TD
Req["请求到达"] --> Hit{"读缓存命中?"}
Hit -->|是| Data["返回缓存数据"]
Hit -->|否| DB["查询数据库"]
DB --> WriteCache["写入缓存<br/>set key val EX 3600"]
WriteCache --> Data
UpReq["写请求到达"] --> DelCache["先更新 DB"]
DelCache --> DelKV["再删除缓存 Key"]
DelKV --> Done["结束"]
style WriteCache fill:#dfd,stroke:#090
style DelKV fill:#fdd,stroke:#900
```
> [!QUESTION] 写操作为什么"删缓存"而不是"更新缓存"?
> - **删缓存**:简单可靠,下次读时自然回源 DB 拿到最新数据
> - **更新缓存**:需要构造完整值(可能跨表 JOIN),复杂且容易不一致
> - **关键原则**:读时保证一致性,写后不保证实时性(延迟满足)
#### Go 示例
```go
// 读操作
func GetUser(ctx context.Context, id int64) (*User, error) {
data, err := rdb.Get(ctx, "user:"+strconv.FormatInt(id, 10)).Bytes()
if err == redis.Nil {
// 缓存未命中 → 查 DB
u, err := db.GetUser(id)
if err != nil {
return nil, err
}
// 回填缓存
ttl := 30*time.Minute + time.Duration(rand.Intn(300))*time.Second
rdb.Set(ctx, "user:"+strconv.FormatInt(id, 10), toJSON(u), ttl)
return u, nil
}
var u User
json.Unmarshal(data, &u)
return &u, nil
}
// 写操作 —— 先更 DB,再删缓存
func UpdateUser(ctx context.Context, u *User) error {
if err := db.UpdateUser(u); err != nil {
return err
}
rdb.Del(ctx, "user:"+strconv.FormatInt(u.ID, 10))
return nil
}
```
> [!WARNING] Cache-Aside 的竞态窗口
> 典型竞态:读请求 A 先 miss 缓存并开始查 DB,此时写请求 B 更新 DB 并删缓存;A 从 DB 取到**旧值**后回填缓存,覆盖了 B 的删除操作。后续读请求将持续拿到旧数据。
> 解决方案:
> - **延迟双删**:写后删一次缓存,延迟几百毫秒再删一次(覆盖并发读的回填)
> - **读操作加短锁**:回填缓存前加锁,避免并发读覆盖新值
> - **版本号 / 时间戳**:回填时比对版本,旧版本不写入缓存
### Read/Write Through(读写穿透)
通过中间件层自动处理缓存读写,业务代码只跟中间件交互:
```mermaid
flowchart LR
App["App Code<br/>业务层"] --> Cache["Cache Layer<br/>中间件透明封装"]
Cache --> DB["Database<br/>持久层"]
style App fill:#efe,stroke:#090
style Cache fill:#fda,stroke:#da0
style DB fill:#ddf,stroke:#66c
```
> [!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
> [!QUESTION] 为什么是"不存在的数据"而不是"过期数据"?
> - **过期数据**:过段时间就自然恢复了,是 TTL 管理的范畴
> - **不存在的数据**:DB 和缓存都没有,请求每次都打到 DB,属于**恶意攻击或 Bug**
```mermaid
sequenceDiagram
participant C as 客户端
participant R as Redis
participant D as MySQL
C->>R: GET user:999999999
R-->>C: nil (未命中)
C->>D: SELECT * FROM users WHERE id=999999999
D-->>C: empty result
Note over C,D: 每次非法请求都走完整链路<br/>QPS = N × 非法流量
```
#### 方案 1:布隆过滤器(Bloom Filter)
```mermaid
flowchart LR
Req["请求"] --> BF{"布隆过滤器"}
BF -->|不存在<br/>直接拒绝| Reject["返回空 / 错误"]
BF -->|可能存在| Cache["查 Redis"]
Cache -->|"nil"| DB["查 DB"]
Cache -->|"命中"| Return["返回缓存值"]
DB -->|"有数据"| StoreCache["写入缓存 + 回刷 BF"]
DB -->|"无数据"| EmptyCache["空值缓存"]
style Reject fill:#fcc,stroke:#c00
style Return fill:#cfc,stroke:#090
style StoreCache fill:#dfd,stroke:#090
```
```go
// Package main — 布隆过滤器预加载示例
import "github.com/bits-and-blooms/bloom/v3"
bf := bloom.NewWithEstimates(1_000_000, 0.01) // 百万级元素,错误率 1%
bf.Add([]byte("user:1"))
bf.Add([]byte("user:2"))
if !bf.Test([]byte("user:999999")) {
return nil, fmt.Errorf("key does not exist") // 直接拒绝,不查 DB
}
```
> [!TIP] 布隆过滤器的误判
> `false positive` 会发生(判断存在但实际不存在),但不会 `false negative`。所以把拦截器放在 DB 前面是安全的。
#### 方案 2:空值缓存
```go
// 缓存一个短过期时间的空值(如 30s ~ 5min)
rdb.Set(ctx, "user:999999", "", 5*time.Minute)
// TTL 不宜过长,否则业务数据新增后仍然拦截
```
> [!QUESTION] 两种方案怎么选?
> - 全量已知数据集(如商品 SKU ID 列表)→ **布隆过滤器**,精确高效
> - 动态未知数据集(如任意用户 ID)→ **空值缓存**,实现简单
### 缓存击穿(Breakdown)—— 热点 key 过期瞬间 QPS 洪峰
```mermaid
sequenceDiagram
participant K as Key: hot_product:42
participant R as Redis
participant D as DB
time t1
K->>R: GET key
R-->>K: ✅ 命中,正常返回
time t2 (TTL 到期)
K->>R: GET key → nil!
time t3 — "击穿瞬间"
rect rgba(255, 0, 0, 0.1)
loop 1000 个并发请求
R-->>Req: nil (未命中)
Req->>D: SELECT ... (全部打到 DB!)
end
end
Note over D: ⚠️ DB QPS 瞬时暴增 → 可能崩溃
```
#### 方案 1:互斥锁(Mutex Lock)
```go
func GetProduct(ctx context.Context, id int64) (*Product, error) {
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:" + 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("获取锁超时,降级处理")
}
}
// 双重检查——防止自己等太久,另一个已经重建了
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, cacheKey, toJSON(p), 30*time.Minute)
return p, nil
}
```
#### 方案 2:逻辑过期(Logical Expiration)
```mermaid
flowchart LR
Read["读请求"] --> Check{"检查 expire_at"}
Check -->|"未过期"| Return["直接返回旧值 ✅"]
Check -->|"已过期"| Async["启动 goroutine 重建缓存"]
Async --> ReturnOld["返回旧值(不阻塞)"]
subgraph "后台重建"
Rebuild["查 DB 最新数据"] --> SetNew["覆盖 Redis value"]
end
style Return fill:#cfc,stroke:#090
style ReturnOld fill:#ffd,stroke:#da0
```
> [!TIP] 两种方案的本质区别
> - **Mutex Lock**:保证返回的一定是**最新数据**,但会短暂阻塞
> - **Logical Expiration**:保证**永远不阻塞用户**,但可能返回略旧的数据
```go
// 读取时不检查物理 TTL,只检查逻辑过期时间
v := rdb.Get(ctx, key).Val()
var item struct {
Content string
ExpireAt int64
}
json.Unmarshal([]byte(v), &item)
if time.Now().Unix() < item.ExpireAt {
return item.Content // 未过期,直接返回
}
// 异步重建缓存(不阻塞当前请求)
// ⚠️ 需配合 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] 穿透/击穿/雪崩 的区别是什么?
> - **穿透**:数据**永远不存在**,请求直达 DB → 关注"过滤非法请求"
> - **击穿**:单个**热点 key** 过期,瞬时洪峰 → 关注"防并发重建"
> - **雪崩**:大批量 / **全部 key** 同时过期 → 关注"错峰 + 兜底"
```mermaid
flowchart LR
subgraph "❌ 无抖动"
T["TTL=30min"] -->|"t=30:00"| AllExpire["100% keys expire simultaneously"]
AllExpire --> Crash["DB overload → crash"]
style Crash fill:#fcc,stroke:#c00
end
subgraph "✅ 加随机抖动"
T2["TTL=30min ±5min"] -->|"t=30:00"| P1["20% expire"]
T2 -->|"t=31:00"| P2["20% expire"]
T2 -->|"t=32:00"| P3["20% expire"]
T2 -->|"t=33:00"| P4["20% expire"]
T2 -->|"t=34:00"| P5["20% expire"]
P1 -->|"DB 平稳承受"| Safe
P2 --> Safe
P3 --> Safe
P4 --> Safe
P5 --> Safe
style Safe fill:#cfc,stroke:#090
end
```
#### 解决方案
**① TTL 加随机偏移(最常用)**
```go
// ✅ 给 TTL 加随机偏移(抖动)
baseTTL := 30 * time.Minute
jitter := time.Duration(rand.Intn(300)) * time.Second // 0~5min 随机
rdb.Set(ctx, key, value, baseTTL+jitter)
```
> [!NOTE] 抖动范围怎么定?
> 建议设为基础 TTL 的 **10% ~ 20%**。太小效果不明显,太大则可能导致冷数据过早失效。
**② 多级缓存**
```go
// L1 本地缓存(进程内)→ L2 Redis(分布式)→ DB
func GetValue(key string) ([]byte, error) {
if v := localCache.Get(key); v != nil { // ~1μs,L1 hit
return v, nil
}
rdbKey := "cache:" + key
if v, err := rdb.Get(ctx, rdbKey).Bytes(); err == nil { // ~1ms,L2 hit
localCache.Set(key, v) // L1 回填
return v, nil
}
// L2 miss → 查 DB → 回填两级缓存
data := queryDB(key)
l1TTL, l2TTL := 5*time.Minute, 30*time.Minute
rdb.Set(ctx, rdbKey, data, l2TTL)
localCache.Set(key, data)
return data, nil
}
```
**③ 限流降级(兜底策略)**
```go
// 当缓存命中率骤降时,触发熔断
if cacheMissRate > 0.5 {
// 返回默认响应 / 排队等待 / 直接限流
return fallbackResponse()
}
```
> [!WARNING] 多级缓存的一致性挑战
> 本地缓存和 Redis 之间天然存在延迟不一致。多节点部署下,A 节点的本地缓存和 B 节点的本地缓存可能看到不同值。**适合读多写少、允许短暂不一致的场景**。
```mermaid
flowchart LR
subgraph "应用进程内"
L1["L1 本地缓存<br/>sync.Map / bigcache<br/>⚡ ~1μs"]
end
subgraph "分布式层"
L2["L2 Redis Cluster<br/>⚡ ~1ms"]
end
L1 -->|"miss"| L2
L2 -->|"miss"| DB["MySQL / 外部 API<br/>⚡ ~5~50ms"]
DB -->|"回填"| L2
L2 -->|"广播失效"| L1
```
## 三、缓存设计原则总结
| # | 原则 | 说明 |
|---|------|------|
| 1 | 永远不信任单一数据源 | 缓存和 DB 保持最终一致 |
| 2 | 设 TTL 是必须的 | 即使内存够也要设,防止永久占用 |
| 3 | TTL 加随机抖动 | 避免大规模同时过期 |
| 4 | 大 Key 拆小 | 单个 Key ≤ 5KB,防止网络阻塞 |
| 5 | Pipeline 批量操作 | 减少 RTT,提升吞吐 |
| 6 | 监控命中率 | 低于 80% 考虑调整策略 |
| 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
Client["客户端请求"]
Client --> LocalCache{"本地缓存?"}
LocalCache -->|hit| Return["返回"]
LocalCache -->|miss| Redis["Redis"]
Redis -->|hit| Return
Redis -->|miss| DB["数据库"]
DB --> Redis
Redis --> LocalCache
Writer["写操作"] -->|"先更 DB"| DB
DB -->|"异步/同步刷缓存"| Redis
style Redis fill:#fda,stroke:#da0
style LocalCache fill:#dfd,stroke:#090
style DB fill:#ddf,stroke:#66c
```
## 六、缓存选型与演进路径
> [!QUESTION] 项目初期应该从哪一层开始做缓存?
> **答案:先做 L2(Redis),稳定后再加 L1(本地缓存)。** 原因如下:
### 决策树
```mermaid
flowchart TD
Start["有新需求要加缓存"] --> Q1{"数据是否频繁变化?"}
Q1 -->|"否 — 配置/字典"| Config["直接放应用内存<br/>不经过 Redis"]
Q1 -->|"是"| Q2{"QPS > 5000?"}
Q2 -->|"否 — 低频场景"| Simple["L2 only: Cache-Aside<br/>+ Redis"]
Q2 -->|"是 — 中频场景"| Medium["L2 + ReadThrough<br/>引入布隆过滤器防穿透"]
Q2 -->|"是 — 高频热点"| HotKey["L1 + L2 多级缓存<br/>+ 逻辑过期防击穿"]
Config --> End["完成 ✅"]
Simple --> End
Medium --> End
HotKey --> End
style Config fill:#ffd,stroke:#da0
style Simple fill:#dfd,stroke:#090
style Medium fill:#fdd,stroke:#900
style HotKey fill:#fcc,stroke:#c00
```
### 不同阶段的关键关注点
| 阶段 | 核心目标 | 推荐模式 | 避坑指南 |
|------|---------|---------|---------|
| **V1 起步期** | 快速上线,能跑就行 | Cache-Aside + Redis | 不要过度设计,先解决有无问题 |
| **V2 增长期** | 扛住流量高峰 | 布隆过滤器 + 互斥锁 | 关注监控指标,建立告警机制 |
| **V3 大规模** | 极致性能和稳定性 | 多级缓存 + 逻辑过期 + 降级策略 | 一致性 vs 可用性的平衡取舍 |
> [!TIP] 一句话总结
> **好的缓存架构不是设计出来的,是演进出来的**。先保证正确性,再优化性能,最后打磨可用性。
## 关联笔记
- [[hhs/Redis/09-高级特性]] — Lua 脚本 + 分布式锁
- [[hhs/Redis/07-集群方案]] — Cluster 下的一致性挑战
- [[hhs/Redis/README]] — 知识索引总览
- [[hhs/GORM/09-钩子函数]] — GORM 生命周期中同步缓存的模式