611 lines
22 KiB
Markdown
611 lines
22 KiB
Markdown
---
|
||
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["Request"] --> Hit{"Cache Hit?"}
|
||
Hit -->|Yes| Data["Return Cache Data"]
|
||
Hit -->|No| DB["Query DB"]
|
||
DB --> WriteCache["Write Cache set key val EX 3600"]
|
||
WriteCache --> Data
|
||
|
||
UpReq["Write Request"] --> DelCache["Update DB First"]
|
||
DelCache --> DelKV["Delete Cache Key"]
|
||
DelKV --> Done["Done"]
|
||
|
||
WriteCache:::success
|
||
DelKV:::danger
|
||
```
|
||
|
||
> [!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"] --> Cache["Cache Layer"]
|
||
Cache --> DB["Database"]
|
||
|
||
App:::success
|
||
Cache:::warning
|
||
DB:::info
|
||
```
|
||
|
||
> [!NOTE] 实际中较少纯原生实现
|
||
> Redisson(Java)、Spring Cache 提供此抽象;Go 生态没有标准库,通常自行封装。核心思路是 **用 AOP / Middleware 把缓存逻辑从业务代码中剥离**。
|
||
|
||
### Write-Behind(异步写回)—— 高写入吞吐场景
|
||
|
||
与 Read/Write Through 不同,Write-Behind 将写操作**先落到缓存**,再**异步批量刷回 DB**,适合写密集型场景(如计数器、日志采集)。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
App["Write Request"] --> Cache["Redis, Return OK"]
|
||
Cache -->|"Async Queue, Batch Merge"| Writer["Write-Back Worker"]
|
||
Writer --> DB["MySQL"]
|
||
|
||
App:::success
|
||
Cache:::warning
|
||
Writer:::info
|
||
```
|
||
|
||
> [!WARNING] Write-Behind 的风险
|
||
> - **数据丢失风险**:缓存宕机时,尚未刷回 DB 的数据会丢失
|
||
> - **实现复杂度高**:需要可靠的异步队列 + 重试机制 + 幂等保证
|
||
> - **适用场景有限**:适合"可容忍少量丢失"的计数、埋点类业务,**不适合核心交易数据**
|
||
|
||
## 二、三大经典问题
|
||
|
||
### 缓存穿透(Penetration)—— 不存在的数据一直打到 DB
|
||
|
||
缓存穿透是指查询的数据**在缓存和数据库中都不存在**。由于缓存永远无法命中,每次请求都会穿透缓存直接打到数据库。在正常业务中这种情况偶尔发生无伤大雅,但如果攻击者利用大量不存在的 ID(如随机负数、极端大的随机数)频繁请求,流量会全部落到 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: "每次非法请求都走完整链路, QPS = N * 非法流量"
|
||
```
|
||
|
||
#### 方案 1:布隆过滤器(Bloom Filter)
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Req["Request"] --> BF{"Bloom Filter"}
|
||
BF -->|"Not Exist, Reject"| Reject["Return Empty"]
|
||
BF -->|"Maybe Exist"| Cache["Query Redis"]
|
||
Cache -->|"nil"| DB["Query DB"]
|
||
Cache -->|"Hit"| Return["Return Cached"]
|
||
DB -->|"Has Data"| StoreCache["Write Cache + Update BF"]
|
||
DB -->|"No Data"| EmptyCache["Cache Empty Value"]
|
||
|
||
Reject:::danger
|
||
Return:::success
|
||
StoreCache:::success
|
||
```
|
||
|
||
```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 前面是安全的。
|
||
|
||
> [!NOTE] Go 进程内 BF vs Redis 原生 BF
|
||
> - **Go 进程内**(如 `bloom/v3`):零网络开销,但多实例间数据不共享,适合单体或实例数少的场景
|
||
> - **Redis 原生**(`BF.RESERVE` / `BF.ADD` / `BF.EXISTS`,需加载 RedisBloom 模块):多实例共享同一份过滤器,适合分布式部署,但每次判断需一次网络 RTT
|
||
> - 生产推荐:用 Redis 原生 BF 做全局拦截 + 本地缓存做热点加速,两者配合效果最佳
|
||
|
||
#### 方案 2:空值缓存
|
||
|
||
```go
|
||
// 缓存一个短过期时间的空值(如 30s ~ 5min)
|
||
rdb.Set(ctx, "user:999999", "", 5*time.Minute)
|
||
// TTL 不宜过长,否则业务数据新增后仍然拦截
|
||
```
|
||
|
||
> [!QUESTION] 两种方案怎么选?
|
||
> - 全量已知数据集(如商品 SKU ID 列表)→ **布隆过滤器**,精确高效
|
||
> - 动态未知数据集(如任意用户 ID)→ **空值缓存**,实现简单
|
||
|
||
### 缓存击穿(Breakdown)—— 热点 key 过期瞬间 QPS 洪峰
|
||
|
||
#### 什么是热点 Key?
|
||
|
||
热点 Key 是指在**短时间内被极高频访问**的少数 Key,它们通常承载了系统中最核心、最高频的读取数据。与其他 Key 相比,热点 Key 的特征是:**访问量极度集中**——可能只占全部 Key 的 1%,却承载了 90% 以上的读请求。
|
||
|
||
| 场景 | 热点 Key 示例 | 访问特征 |
|
||
|------|-------------|---------|
|
||
| 电商秒杀 | `product:1001:detail` | 秒杀开始瞬间,万级 QPS 集中打同一个商品 |
|
||
| 社交热搜 | `hot:topic:某明星` | 热搜爆发期,读写比可达 100:1 |
|
||
| 配置中心 | `config:global` | 几乎所有请求都读取,读远大于写 |
|
||
| 排行榜 | `rank:daily:top10` | 定时更新,查询窗口内被密集读取 |
|
||
|
||
> [!QUESTION] 怎么判断某个 Key 是不是"热点"?
|
||
> 核心指标是 **QPS 集中度**:如果单个 Key 的 QPS 远超平均值(例如平均 QPS=10,但某个 Key 达到 5000+),它就是热点。具体发现手段见下方「如何发现热点 Key」小节。
|
||
|
||
理解了热点 Key 的概念后,缓存击穿的问题就好理解了——
|
||
|
||
#### 击穿是如何发生的?
|
||
|
||
缓存击穿针对的就是**单个热点 Key**。当某个热点 Key 恰好过期的瞬间,成百上千个并发请求同时发现缓存 miss,转而并发打到数据库,造成瞬时 QPS 洪峰。这与雪崩不同——它不需要大量 Key 同时过期,**一个热点 Key 就够了**。
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant C as Client
|
||
participant R as Redis
|
||
participant D as DB
|
||
|
||
Note over C,R: t1 — 正常期
|
||
C->>R: GET key
|
||
R-->>C: ✅ 命中,正常返回
|
||
|
||
Note over C,R: t2 — TTL 到期
|
||
C->>R: GET key → nil!
|
||
|
||
Note over C,D: t3 — 击穿瞬间
|
||
rect rgba(255, 0, 0, 0.1)
|
||
loop 1000 concurrent requests
|
||
C->>R: GET key
|
||
R-->>C: nil (未命中)
|
||
C->>D: SELECT ...
|
||
end
|
||
end
|
||
|
||
Note over D: DB QPS spike, may crash
|
||
```
|
||
|
||
#### 方案 1:互斥锁(Mutex Lock)
|
||
|
||
核心思路很简单:**热点 key 过期后,只允许一个请求去查 DB 重建缓存,其余请求排队等待,等缓存重建完成后再读缓存。** 就像只有一个窗口办理业务,其他人后面排队。
|
||
|
||
具体流程:
|
||
1. 缓存 miss 时,先尝试用 `SETNX` 抢一把分布式锁
|
||
2. 抢到锁的请求 → 查 DB → 回写缓存 → 释放锁
|
||
3. 没抢到锁的请求 → 短暂 sleep → 重试读缓存(大概率已被重建好了)
|
||
4. 带**双重检查**:拿到锁后再读一次缓存,防止等待期间别人已经重建完毕
|
||
|
||
> [!QUESTION] 如果不用分布式锁会怎样?
|
||
> 假设 1000 个请求同时 miss 缓存,它们会并发打到 DB,相当于没有缓存保护。加锁后只有 1 次 DB 查询,其余 999 次读缓存即可命中。
|
||
|
||
```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
|
||
locked := false
|
||
for i := 0; i < maxRetries; i++ {
|
||
locked, _ = rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
|
||
if locked {
|
||
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("获取锁超时,降级处理")
|
||
}
|
||
}
|
||
// 确保锁在函数退出时释放(无论成功或 panic)
|
||
if locked {
|
||
defer rdb.Del(ctx, lockKey)
|
||
}
|
||
|
||
// 双重检查——防止自己等太久,另一个已经重建了
|
||
data, _ = rdb.Get(ctx, cacheKey).Bytes()
|
||
if data != nil {
|
||
var p Product
|
||
json.Unmarshal(data, &p)
|
||
return &p, nil
|
||
}
|
||
|
||
p, err := db.GetProduct(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rdb.Set(ctx, cacheKey, toJSON(p), 30*time.Minute)
|
||
return p, nil
|
||
}
|
||
```
|
||
|
||
#### 方案 2:逻辑过期(Logical Expiration)
|
||
|
||
互斥锁虽然解决了并发问题,但拿到锁的请求还是要等 DB 查询完成——这段时间用户是被**阻塞**的。逻辑过期的思路是:**永远不让用户等。**
|
||
|
||
做法:Redis 的 key **不设物理 TTL**(永远不过期),而是在 value 里额外存一个 `expire_at` 字段表示"逻辑过期时间"。每次读取时:
|
||
1. 检查 `expire_at`:没过期 → 直接返回,零开销
|
||
2. 已过期 → **先返回旧数据**(用户体验不断裂),同时**异步触发后台重建**
|
||
|
||
这就把"同步等 DB"变成了"异步静默刷新",代价是:过期瞬间的短暂时间窗口内,用户拿到的是**旧数据**。
|
||
|
||
> [!WARNING] 为什么需要 singleflight?
|
||
> 逻辑过期的判断发生在每个请求上,如果一秒内 1000 个请求都发现 key 过期,它们会各自触发重建。`singleflight` 保证同一个 key 的多次并发重建合并为一次 DB 查询,避免"重建风暴"。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Read["Read Request"] --> Check{"Check expire_at"}
|
||
Check -->|"Not Expired"| Return["Return Old Value OK"]
|
||
Check -->|"Expired"| Async["Async Rebuild Cache"]
|
||
Async --> ReturnOld["Return Old Value, No Block"]
|
||
|
||
subgraph "Background Rebuild"
|
||
Rebuild["Query DB"] --> SetNew["Overwrite Redis Value"]
|
||
end
|
||
|
||
Return:::success
|
||
ReturnOld:::warning
|
||
```
|
||
|
||
> [!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 // 返回旧值,后台静默刷新
|
||
```
|
||
|
||
> [!TIP] singleflight 防止缓存重建风暴
|
||
> 当多个 goroutine 同时发现 key 过期并尝试重建时,`singleflight` 保证**同一个 key 只触发一次 DB 查询**,其余 goroutine 等待并共享结果。
|
||
|
||
```go
|
||
import "golang.org/x/sync/singleflight"
|
||
|
||
var rebuildGroup singleflight.Group
|
||
|
||
func rebuildCacheAsync(key string, oldExpireAt int64) {
|
||
// singleflight 保证同一 key 只有一个 goroutine 真正执行
|
||
_, _, _ = rebuildGroup.Do(key, func() (interface{}, error) {
|
||
// 再次检查:可能别的 goroutine 已经重建完了
|
||
v := rdb.Get(ctx, key).Val()
|
||
var item struct{ ExpireAt int64 }
|
||
json.Unmarshal([]byte(v), &item)
|
||
if item.ExpireAt > oldExpireAt {
|
||
return nil, nil // 已被别人刷新,跳过
|
||
}
|
||
|
||
// 查 DB → 回写缓存(设置新的逻辑过期时间)
|
||
data := queryDB(key)
|
||
newExpireAt := time.Now().Add(30 * time.Minute).Unix()
|
||
payload, _ := json.Marshal(map[string]interface{}{
|
||
"content": data,
|
||
"expire_at": newExpireAt,
|
||
})
|
||
rdb.Set(ctx, key, payload, 0) // 不设物理 TTL,靠逻辑过期
|
||
return nil, nil
|
||
})
|
||
}
|
||
```
|
||
|
||
#### 如何发现热点 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 同时过期
|
||
|
||
缓存雪崩是三种问题中影响面最大的。它发生在**大面积缓存同时失效**的时刻——通常是因为大量 Key 设置了相同的 TTL,或者 Redis 节点整体宕机。失效瞬间,海量请求集体涌向数据库,DB 瞬时压力暴增,可能直接被打垮甚至引发级联故障,导致整个服务不可用。雪崩的破坏力在于"雪球效应":DB 慢查询堆积 → 连接池耗尽 → 上游超时 → 用户重试 → 流量进一步放大。
|
||
|
||
> [!QUESTION] 穿透/击穿/雪崩 的区别是什么?
|
||
> - **穿透**:数据**永远不存在**,请求直达 DB → 关注"过滤非法请求"
|
||
> - **击穿**:单个**热点 key** 过期,瞬时洪峰 → 关注"防并发重建"
|
||
> - **雪崩**:大批量 / **全部 key** 同时过期 → 关注"错峰 + 兜底"
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph "No Jitter"
|
||
T["TTL=30min"] -->|"t=30:00"| AllExpire["100% keys expire simultaneously"]
|
||
AllExpire --> Crash["DB overload crash"]
|
||
Crash:::danger
|
||
end
|
||
|
||
subgraph "With Jitter"
|
||
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 handles smoothly"| Safe
|
||
P2 --> Safe
|
||
P3 --> Safe
|
||
P4 --> Safe
|
||
P5 --> Safe
|
||
Safe:::success
|
||
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 "In-Process"
|
||
L1["L1 Local Cache, ~1us"]
|
||
end
|
||
|
||
subgraph "Distributed"
|
||
L2["L2 Redis Cluster, ~1ms"]
|
||
end
|
||
|
||
L1 -->|"miss"| L2
|
||
L2 -->|"miss"| DB["MySQL, ~5-50ms"]
|
||
DB -->|"Backfill"| L2
|
||
L2 -->|"Broadcast Invalidation"| 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["Business Write"] --> DB["MySQL"]
|
||
DB -->|"Binlog"| Canal["Canal or Debezium"]
|
||
Canal --> MQ["Kafka or RocketMQ"]
|
||
MQ --> Consumer["Cache Invalidation Worker"]
|
||
Consumer -->|"DEL key"| Redis["Redis"]
|
||
|
||
DB:::info
|
||
Canal:::warning
|
||
Redis:::danger
|
||
```
|
||
|
||
> [!QUESTION] 这种方案解决了什么问题?
|
||
> - **解除业务代码耦合**:写操作不需要关心缓存失效,由 Binlog 订阅统一处理
|
||
> - **减少竞态窗口**:Binlog 保证有序,可以按变更顺序精确删除缓存
|
||
> - **适合多数据源**:一次 DB 变更可以同时驱动多个缓存实例失效
|
||
|
||
> [!NOTE] 实际落地注意点
|
||
> - **延迟**:Binlog → MQ → Consumer 链路通常有 **100ms~1s** 延迟,仍属于最终一致性
|
||
> - **幂等**:Consumer 必须保证幂等(重复消费同一 Binlog 不产生副作用)
|
||
> - **顺序性**:同一行的多次变更需要保证消费顺序,通常按 `ROW_ID` 分区
|
||
|
||
## 五、常见架构模式速览
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Client["Client Request"]
|
||
|
||
Client --> LocalCache{"Local Cache?"}
|
||
LocalCache -->|hit| Return["Return"]
|
||
LocalCache -->|miss| Redis["Redis"]
|
||
|
||
Redis -->|hit| Return
|
||
Redis -->|miss| DB["Database"]
|
||
|
||
DB --> Redis
|
||
Redis --> LocalCache
|
||
|
||
Writer["Write"] -->|"Update DB First"| DB
|
||
DB -->|"Async Sync Cache"| Redis
|
||
|
||
Redis:::warning
|
||
LocalCache:::success
|
||
DB:::info
|
||
```
|
||
|
||
## 六、缓存选型与演进路径
|
||
|
||
> [!QUESTION] 项目初期应该从哪一层开始做缓存?
|
||
> **答案:先做 L2(Redis),稳定后再加 L1(本地缓存)。** 原因如下:
|
||
|
||
### 决策树
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Start["有新需求要加缓存"] --> Q1{"数据是否频繁变化?"}
|
||
|
||
Q1 -->|"否 — 配置/字典"| Config["直接放应用内存<br/>不经过 Redis"]
|
||
Q1 -->|"Yes"| Q2{"QPS > 5000?"}
|
||
|
||
Q2 -->|"No - Low QPS"| Simple["L2 only Cache-Aside + Redis"]
|
||
Q2 -->|"Yes - Mid QPS"| Medium["L2 + ReadThrough + Bloom Filter"]
|
||
Q2 -->|"Yes - High QPS"| HotKey["L1 + L2 Multi-Level Cache"]
|
||
|
||
Config --> End["Done ✅"]
|
||
Simple --> End
|
||
Medium --> End
|
||
HotKey --> End
|
||
|
||
Config:::warning
|
||
Simple:::success
|
||
Medium:::danger
|
||
HotKey:::danger
|
||
```
|
||
|
||
### 不同阶段的关键关注点
|
||
|
||
| 阶段 | 核心目标 | 推荐模式 | 避坑指南 |
|
||
|------|---------|---------|---------|
|
||
| **V1 起步期** | 快速上线,能跑就行 | Cache-Aside + Redis | 不要过度设计,先解决有无问题 |
|
||
| **V2 增长期** | 扛住流量高峰 | 布隆过滤器 + 互斥锁 | 关注监控指标,建立告警机制 |
|
||
| **V3 大规模** | 极致性能和稳定性 | 多级缓存 + 逻辑过期 + 降级策略 | 一致性 vs 可用性的平衡取舍 |
|
||
|
||
> [!TIP] 一句话总结
|
||
> **好的缓存架构不是设计出来的,是演进出来的**。先保证正确性,再优化性能,最后打磨可用性。
|
||
|
||
## 关联笔记
|
||
|
||
- [[hhs/Redis/09-高级特性]] — Lua 脚本 + 分布式锁
|
||
- [[hhs/Redis/07-集群方案]] — Cluster 下的一致性挑战
|
||
- [[hhs/Redis/README]] — 知识索引总览
|
||
- [[hhs/GORM/09-钩子函数]] — GORM 生命周期中同步缓存的模式
|