vault backup: 2026-06-08 23:08:57

This commit is contained in:
hhs
2026-06-08 23:08:57 +08:00
parent d67831afe0
commit 51de196713
136 changed files with 26069 additions and 344 deletions
+204 -113
View File
@@ -22,18 +22,18 @@ create time: 2026-05-15 18:15
```mermaid
flowchart TD
Req["请求到达"] --> Hit{"读缓存命中?"}
Hit -->|是| Data["返回缓存数据"]
Hit -->|否| DB["查询数据库"]
DB --> WriteCache["写入缓存<br/>set key val EX 3600"]
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["写请求到达"] --> DelCache["先更新 DB"]
DelCache --> DelKV["再删除缓存 Key"]
DelKV --> Done["结束"]
style WriteCache fill:#dfd,stroke:#090
style DelKV fill:#fdd,stroke:#900
UpReq["Write Request"] --> DelCache["Update DB First"]
DelCache --> DelKV["Delete Cache Key"]
DelKV --> Done["Done"]
WriteCache:::success
DelKV:::danger
```
> [!QUESTION] 写操作为什么"删缓存"而不是"更新缓存"?
@@ -87,12 +87,12 @@ func UpdateUser(ctx context.Context, u *User) error {
```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
App["App Code"] --> Cache["Cache Layer"]
Cache --> DB["Database"]
App:::success
Cache:::warning
DB:::info
```
> [!NOTE] 实际中较少纯原生实现
@@ -104,13 +104,13 @@ flowchart LR
```mermaid
flowchart LR
App["写请求"] --> Cache["Redis<br/>立即返回 ✅"]
Cache -->|"异步队列<br/>批量合并"| Writer["写回 Worker"]
App["Write Request"] --> Cache["Redis, Return OK"]
Cache -->|"Async Queue, Batch Merge"| Writer["Write-Back Worker"]
Writer --> DB["MySQL"]
style App fill:#efe,stroke:#090
style Cache fill:#fda,stroke:#da0
style Writer fill:#ddf,stroke:#66c
App:::success
Cache:::warning
Writer:::info
```
> [!WARNING] Write-Behind 的风险
@@ -122,38 +122,40 @@ flowchart LR
### 缓存穿透(Penetration)—— 不存在的数据一直打到 DB
缓存穿透是指查询的数据**在缓存和数据库中都不存在**。由于缓存永远无法命中,每次请求都会穿透缓存直接打到数据库。在正常业务中这种情况偶尔发生无伤大雅,但如果攻击者利用大量不存在的 ID(如随机负数、极端大的随机数)频繁请求,流量会全部落到 DB 上,可能直接打垮数据库。
> [!QUESTION] 为什么是"不存在的数据"而不是"过期数据"?
> - **过期数据**:过段时间就自然恢复了,是 TTL 管理的范畴
> - **不存在的数据**:DB 和缓存都没有,请求每次都打到 DB,属于**恶意攻击或 Bug**
```mermaid
sequenceDiagram
participant C as 客户端
participant R as Redis
participant D as MySQL
participant C as "客户端"
participant R as "Redis"
participant D as "MySQL"
C->>R: GET user:999999999
R-->>C: nil (未命中)
R-->>C: nil
C->>D: SELECT * FROM users WHERE id=999999999
D-->>C: empty result
Note over C,D: 每次非法请求都走完整链路<br/>QPS = N × 非法流量
Note over C,D: "每次非法请求都走完整链路, 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
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
@@ -172,6 +174,11 @@ if !bf.Test([]byte("user:999999")) {
> [!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
@@ -186,32 +193,64 @@ rdb.Set(ctx, "user:999999", "", 5*time.Minute)
### 缓存击穿(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 K as Key: hot_product:42
participant C as Client
participant R as Redis
participant D as DB
time t1
K->>R: GET key
R-->>K: ✅ 命中,正常返回
Note over C,R: t1 — 正常期
C->>R: GET key
R-->>C: ✅ 命中,正常返回
time t2 (TTL 到期)
K->>R: GET key → nil!
Note over C,R: t2 — TTL 到期
C->>R: GET key → nil!
time t3 — "击穿瞬间"
Note over C,D: t3 — 击穿瞬间
rect rgba(255, 0, 0, 0.1)
loop 1000 个并发请求
R-->>Req: nil (未命中)
Req->>D: SELECT ... (全部打到 DB!)
loop 1000 concurrent requests
C->>R: GET key
R-->>C: nil (未命中)
C->>D: SELECT ...
end
end
Note over D: ⚠️ DB QPS 瞬时暴增 → 可能崩溃
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)
@@ -227,10 +266,10 @@ func GetProduct(ctx context.Context, id int64) (*Product, error) {
// 拿分布式锁,只有一个 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()
locked, _ = rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
if locked {
defer rdb.Del(ctx, lockKey)
break
}
// 未拿到锁 → 等别人重建完后再查缓存
@@ -244,6 +283,10 @@ func GetProduct(ctx context.Context, id int64) (*Product, error) {
return nil, errors.New("获取锁超时,降级处理")
}
}
// 确保锁在函数退出时释放(无论成功或 panic)
if locked {
defer rdb.Del(ctx, lockKey)
}
// 双重检查——防止自己等太久,另一个已经重建了
data, _ = rdb.Get(ctx, cacheKey).Bytes()
@@ -253,7 +296,10 @@ func GetProduct(ctx context.Context, id int64) (*Product, error) {
return &p, nil
}
p, _ := db.GetProduct(id)
p, err := db.GetProduct(id)
if err != nil {
return nil, err
}
rdb.Set(ctx, cacheKey, toJSON(p), 30*time.Minute)
return p, nil
}
@@ -261,19 +307,30 @@ func GetProduct(ctx context.Context, id int64) (*Product, error) {
#### 方案 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["读请求"] --> Check{"检查 expire_at"}
Check -->|"未过期"| Return["直接返回旧值 ✅"]
Check -->|"已过期"| Async["启动 goroutine 重建缓存"]
Async --> ReturnOld["返回旧值(不阻塞)"]
subgraph "后台重建"
Rebuild["查 DB 最新数据"] --> SetNew["覆盖 Redis value"]
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
style Return fill:#cfc,stroke:#090
style ReturnOld fill:#ffd,stroke:#da0
Return:::success
ReturnOld:::warning
```
> [!TIP] 两种方案的本质区别
@@ -299,6 +356,38 @@ 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 是热点,防护就无从谈起:
@@ -312,6 +401,8 @@ return item.Content // 返回旧值,后台静默刷新
### 缓存雪崩(Avalanche)—— 大量 key 同时过期
缓存雪崩是三种问题中影响面最大的。它发生在**大面积缓存同时失效**的时刻——通常是因为大量 Key 设置了相同的 TTL,或者 Redis 节点整体宕机。失效瞬间,海量请求集体涌向数据库,DB 瞬时压力暴增,可能直接被打垮甚至引发级联故障,导致整个服务不可用。雪崩的破坏力在于"雪球效应":DB 慢查询堆积 → 连接池耗尽 → 上游超时 → 用户重试 → 流量进一步放大。
> [!QUESTION] 穿透/击穿/雪崩 的区别是什么?
> - **穿透**:数据**永远不存在**,请求直达 DB → 关注"过滤非法请求"
> - **击穿**:单个**热点 key** 过期,瞬时洪峰 → 关注"防并发重建"
@@ -319,24 +410,24 @@ return item.Content // 返回旧值,后台静默刷新
```mermaid
flowchart LR
subgraph "❌ 无抖动"
subgraph "No Jitter"
T["TTL=30min"] -->|"t=30:00"| AllExpire["100% keys expire simultaneously"]
AllExpire --> Crash["DB overload → crash"]
style Crash fill:#fcc,stroke:#c00
AllExpire --> Crash["DB overload crash"]
Crash:::danger
end
subgraph "✅ 加随机抖动"
T2["TTL=30min ±5min"] -->|"t=30:00"| P1["20% expire"]
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 平稳承受"| Safe
P1 -->|"DB handles smoothly"| Safe
P2 --> Safe
P3 --> Safe
P4 --> Safe
P5 --> Safe
style Safe fill:#cfc,stroke:#090
Safe:::success
end
```
@@ -393,18 +484,18 @@ if cacheMissRate > 0.5 {
```mermaid
flowchart LR
subgraph "应用进程内"
L1["L1 本地缓存<br/>sync.Map / bigcache<br/>⚡ ~1μs"]
subgraph "In-Process"
L1["L1 Local Cache, ~1us"]
end
subgraph "分布式层"
L2["L2 Redis Cluster<br/>⚡ ~1ms"]
subgraph "Distributed"
L2["L2 Redis Cluster, ~1ms"]
end
L1 -->|"miss"| L2
L2 -->|"miss"| DB["MySQL / 外部 API<br/>⚡ ~5~50ms"]
DB -->|"回填"| L2
L2 -->|"广播失效"| L1
L2 -->|"miss"| DB["MySQL, ~5-50ms"]
DB -->|"Backfill"| L2
L2 -->|"Broadcast Invalidation"| L1
```
## 三、缓存设计原则总结
@@ -426,15 +517,15 @@ Cache-Aside 的"先删缓存再更新 DB"存在竞态窗口。生产中更可靠
```mermaid
flowchart LR
App["业务写请求"] --> DB["MySQL"]
DB -->|"Binlog"| Canal["Canal / Debezium"]
Canal --> MQ["Kafka / RocketMQ"]
MQ --> Consumer["缓存失效 Worker"]
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"]
style DB fill:#ddf,stroke:#66c
style Canal fill:#fda,stroke:#da0
style Redis fill:#fcc,stroke:#c00
DB:::info
Canal:::warning
Redis:::danger
```
> [!QUESTION] 这种方案解决了什么问题?
@@ -451,24 +542,24 @@ flowchart LR
```mermaid
flowchart TD
Client["客户端请求"]
Client --> LocalCache{"本地缓存?"}
LocalCache -->|hit| Return["返回"]
Client["Client Request"]
Client --> LocalCache{"Local Cache?"}
LocalCache -->|hit| Return["Return"]
LocalCache -->|miss| Redis["Redis"]
Redis -->|hit| Return
Redis -->|miss| DB["数据库"]
Redis -->|miss| DB["Database"]
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
Writer["Write"] -->|"Update DB First"| DB
DB -->|"Async Sync Cache"| Redis
Redis:::warning
LocalCache:::success
DB:::info
```
## 六、缓存选型与演进路径
@@ -483,21 +574,21 @@ 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["完成 ✅"]
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
style Config fill:#ffd,stroke:#da0
style Simple fill:#dfd,stroke:#090
style Medium fill:#fdd,stroke:#900
style HotKey fill:#fcc,stroke:#c00
Config:::warning
Simple:::success
Medium:::danger
HotKey:::danger
```
### 不同阶段的关键关注点