This commit is contained in:
+217
@@ -0,0 +1,217 @@
|
||||
# 缓存雪崩
|
||||
|
||||
!!! note "💡 一句话概述"
|
||||
缓存雪崩是大面积 key 同时失效或缓存节点宕机,导致请求全部涌向 DB 持续高压——防护核心是分散失效时间和保障高可用。
|
||||
|
||||
---
|
||||
|
||||
## 🔑 核心概念
|
||||
|
||||
1. **集中失效**:大量 key 在同一时间点过期,或缓存节点故障导致缓存集体不可用。
|
||||
2. **持续高压**:与击穿(瞬时尖峰)不同,雪崩是持续的 DB 过载。
|
||||
3. **级联故障**:DB 压垮后,依赖 DB 的其他服务也开始超时,故障蔓延。
|
||||
|
||||
---
|
||||
|
||||
## 📝 详细说明
|
||||
|
||||
### 发生机制
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 雪崩["❄️ 缓存雪崩 — 大面积 key 集中失效"]
|
||||
A1["大量 key 同时刻过期<br/>或缓存节点宕机"] --> A2["请求大面积 miss"]
|
||||
A2 --> A3["全部涌向 DB"]
|
||||
A3 --> A4["DB 持续高压 💥"]
|
||||
A4 --> A5["级联故障<br/>依赖服务超时"]
|
||||
end
|
||||
```
|
||||
|
||||
**两种触发方式**:
|
||||
|
||||
| 触发原因 | 场景 |
|
||||
|---------|------|
|
||||
| 大量 key 同时过期 | 批量导入数据时设了相同 TTL,凌晨 0 点大批 key 同时到期 |
|
||||
| 缓存节点宕机 | Redis 主节点故障,未及时切换,所有请求穿透到 DB |
|
||||
|
||||
### 与击穿、穿透的区别
|
||||
|
||||
| | 缓存击穿 | 缓存雪崩 | 缓存穿透 |
|
||||
|---|---------|---------|---------|
|
||||
| **根因** | 热 key 过期 | 大面积 key 同时过期 / 缓存宕机 | 查询不存在的数据 |
|
||||
| **特征** | 单 key、高并发 | 多 key、集中失效 | 缓存永远 miss |
|
||||
| **危害** | DB 瞬时尖峰 | DB 持续高压 + 级联故障 | DB 持续高压 |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 解决方案
|
||||
|
||||
### 方案 1:随机失效(TTL Jitter)
|
||||
|
||||
给 TTL 加随机偏移量,避免大量 key 在同一时刻集中过期——**最简单有效,批量设置 TTL 时必备**。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph 无 Jitter["❌ 无随机偏移"]
|
||||
T1["key_a TTL=30min"] --> E1["同时过期"]
|
||||
T2["key_b TTL=30min"] --> E1
|
||||
T3["key_c TTL=30min"] --> E1
|
||||
E1 --> CRASH["DB 雪崩 💥"]
|
||||
end
|
||||
|
||||
subgraph 有 Jitter["✅ 有随机偏移"]
|
||||
J1["key_a TTL=30m+2m"] --> D1["32min 过期"]
|
||||
J2["key_b TTL=30m+7m"] --> D2["37min 过期"]
|
||||
J3["key_c TTL=30m+4m"] --> D3["34min 过期"]
|
||||
D1 --> SAFE["DB 压力分散 ✅"]
|
||||
D2 --> SAFE
|
||||
D3 --> SAFE
|
||||
end
|
||||
|
||||
无 Jitter -.->|加 jitter| 有 Jitter
|
||||
```
|
||||
|
||||
```go
|
||||
func (s *Service) SetWithJitter(ctx context.Context, key string, data any, baseTTL time.Duration) {
|
||||
jitter := time.Duration(rand.Intn(300)) * time.Second // 0~300s 随机偏移
|
||||
s.rdb.Set(ctx, key, data, baseTTL+jitter)
|
||||
}
|
||||
|
||||
// 批量设置时
|
||||
for _, item := range items {
|
||||
base := 30 * time.Minute
|
||||
jitter := time.Duration(rand.Intn(600)) * time.Second
|
||||
s.rdb.Set(ctx, item.Key, item.Data, base+jitter)
|
||||
}
|
||||
```
|
||||
|
||||
### 方案 2:加锁/限流
|
||||
|
||||
限制并发回源数量,避免 DB 过载。
|
||||
|
||||
```go
|
||||
// 使用带容量限制的令牌桶
|
||||
var limiter = rate.NewLimiter(rate.Limit(100), 50)
|
||||
|
||||
func (s *Service) GetWithRateLimit(ctx context.Context, key string) (any, error) {
|
||||
val, err := s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 限流:超出速率直接拒绝或排队
|
||||
if err := limiter.Wait(ctx); err != nil {
|
||||
return nil, fmt.Errorf("service busy, try later")
|
||||
}
|
||||
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 10*time.Minute)
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 方案 3:Redis 高可用 + 多级缓存
|
||||
|
||||
缓存节点宕机 = 所有 key 同时"失效",必须依赖高可用架构:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 多级缓存架构
|
||||
Client["客户端请求"]
|
||||
L1["L1 本地缓存<br/>bigcache / ristretto"]
|
||||
L2["L2 Redis<br/>Sentinel / Cluster"]
|
||||
L3["L3 DB"]
|
||||
|
||||
Client --> L1
|
||||
L1 -->|miss| L2
|
||||
L2 -->|miss| L3
|
||||
L3 --> L2
|
||||
L2 --> L1
|
||||
|
||||
L1 -.->|Redis 不可用时<br/>降级到本地| Client
|
||||
end
|
||||
```
|
||||
|
||||
| 方案 | 说明 |
|
||||
|------|------|
|
||||
| **Redis Sentinel** | 自动故障转移,主节点挂掉时从节点升主 |
|
||||
| **Redis Cluster** | 数据分片 + 自动故障转移,生产环境推荐 |
|
||||
| **多级缓存** | 本地缓存(如 `bigcache`/`ristretto`)+ Redis,Redis 不可用时降级到本地 |
|
||||
| **熔断降级** | DB 压力过大时直接返回兜底数据或错误页 |
|
||||
|
||||
```go
|
||||
// 多级缓存示例:本地 + Redis
|
||||
func (s *Service) GetMultiLevel(ctx context.Context, key string) (any, error) {
|
||||
// L1: 本地缓存
|
||||
if v, ok := s.localCache.Get(key); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// L2: Redis
|
||||
val, err := s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
s.localCache.Set(key, val, 5*time.Minute)
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// L3: DB
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 30*time.Minute)
|
||||
s.localCache.Set(key, data, 5*time.Minute)
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 方案对比
|
||||
|
||||
| 方案 | 防护方向 | 复杂度 | 适用场景 |
|
||||
|------|---------|:---:|---------|
|
||||
| 随机失效 | 集中过期 | ★ | **最简单有效,批量设 TTL 必备** |
|
||||
| 加锁/限流 | DB 过载 | ★★★ | 应急限流,配合其他方案使用 |
|
||||
| Redis 高可用 | 缓存宕机 | ★★★★ | 生产环境必备 |
|
||||
| 多级缓存 | 缓存宕机 + DB 降级 | ★★★★ | 对可用性要求极高的核心链路 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见陷阱
|
||||
|
||||
!!! warning "批量导入数据时忘记加 jitter"
|
||||
最常见的雪崩根因。批量设缓存时统一用 `SET key value EX 3600`,3600 秒后所有 key 同时过期。
|
||||
|
||||
!!! warning "本地缓存与 Redis 缓存的一致性"
|
||||
多级缓存中,本地缓存 TTL 应远小于 Redis TTL,否则 Redis 更新后本地仍返回旧值。
|
||||
|
||||
!!! warning "Redis Cluster 全量迁移导致集中过期"
|
||||
扩容/缩容时 slot 迁移可能触发 key 重新设置 TTL,需要关注迁移过程中的 TTL 分布。
|
||||
|
||||
---
|
||||
|
||||
## 🏋️ 练习题
|
||||
|
||||
??? question "练习 1:为什么随机失效能防雪崩但不能防击穿?"
|
||||
随机失效分散的是**不同 key 的过期时间**。击穿针对的是**单个热点 key**,它只管一个 key 的过期,加不加 jitter 对单个 key 无意义。
|
||||
|
||||
??? success "答案"
|
||||
随机失效解决的是"多个 key 同时到期"的问题——让过期时间错开,避免集中回源。但击穿是单个 key 过期引发的并发回源,jitter 无法让同一个 key 的过期时间"分散"。防击穿需要加锁或逻辑过期。
|
||||
|
||||
??? question "练习 2:多级缓存中,如果本地缓存和 Redis 都 miss,大量请求同时打到 DB,怎么办?"
|
||||
本地缓存 + Redis 都 miss 说明是冷启动或 Redis 宕机场景。需要加限流或 singleflight 来控制回源并发。
|
||||
|
||||
??? success "答案"
|
||||
多级缓存只解决了"缓存层可用性"问题,没有解决"回源并发控制"。应该在 L3 回源前加一层限流(令牌桶)或 singleflight,确保 DB 不被瞬时请求压垮。生产环境通常是 **多级缓存 + 限流 + 熔断** 的组合。
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [缓存击穿](cache-breakdown.md) — 单热点 key 过期的并发涌入
|
||||
- [缓存穿透](cache-penetration.md) — 查不存在的数据绕过缓存
|
||||
- [Redis 官方文档 — Caching](https://redis.io/docs/manual/patterns/caching/) — Redis 缓存模式
|
||||
@@ -1,493 +0,0 @@
|
||||
# 缓存击穿 & 缓存雪崩 & 缓存穿透
|
||||
|
||||
!!! note "💡 一句话概述"
|
||||
缓存击穿是单 key 过期瞬间的并发涌入,雪崩是大面积 key 同时失效,穿透是根本不存在的数据绕过缓存直击 DB——三者解决思路各不同,但经常一起考察。
|
||||
|
||||
---
|
||||
|
||||
## 🔑 核心概念
|
||||
|
||||
1. **缓存击穿(Breakdown)**:某个热点 key 过期的瞬间,大量并发请求同时穿透到 DB,压垮数据库。
|
||||
2. **缓存雪崩(Avalanche)**:大量 key 在同一时间集中过期,或缓存节点宕机,导致请求全部涌向 DB。
|
||||
3. **缓存穿透(Penetration)**:请求查询的数据在 DB 中根本不存在,缓存永远无法命中,每次请求都直达 DB。
|
||||
|
||||
---
|
||||
|
||||
## 📊 三者发生机制
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 击穿["🔓 缓存击穿 — 单热点 key 过期"]
|
||||
B1["热 key 过期"] --> B2["大量并发同时 miss"]
|
||||
B2 --> B3["同时回源 DB"]
|
||||
B3 --> B4["DB 瞬时尖峰 💥"]
|
||||
end
|
||||
|
||||
subgraph 雪崩["❄️ 缓存雪崩 — 大面积 key 集中失效"]
|
||||
A1["大量 key 同时刻过期<br/>或缓存节点宕机"] --> A2["请求大面积 miss"]
|
||||
A2 --> A3["全部涌向 DB"]
|
||||
A3 --> A4["DB 持续高压 💥"]
|
||||
end
|
||||
|
||||
subgraph 穿透["🕳️ 缓存穿透 — 查不存在的数据"]
|
||||
C1["请求查询不存在的 key"] --> C2["缓存永远 miss"]
|
||||
C2 --> C3["每次直达 DB"]
|
||||
C3 --> C4["DB 持续高压 💥"]
|
||||
end
|
||||
```
|
||||
|
||||
## 📝 三者对比
|
||||
|
||||
| | 缓存击穿 | 缓存雪崩 | 缓存穿透 |
|
||||
|---|---------|---------|---------|
|
||||
| **根因** | 热 key 过期 | 大面积 key 同时过期 / 缓存宕机 | 查询不存在的数据 |
|
||||
| **特征** | 单 key、高并发 | 多 key、集中失效 | 缓存永远 miss |
|
||||
| **触发方式** | 自然过期 | 自然过期 / 节点故障 | 恶意攻击 / 业务异常 |
|
||||
| **危害** | DB 瞬时尖峰 | DB 持续高压 | DB 持续高压 |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 解决方案总览
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph 击穿["击穿"]
|
||||
A1["A1 永不过期"]
|
||||
A2["A2 加锁排队<br/>singleflight"]
|
||||
end
|
||||
|
||||
subgraph 雪崩["雪崩"]
|
||||
B1["B1 加锁/限流"]
|
||||
B2["B2 随机失效"]
|
||||
B3["B3 Redis 高可用<br/>多级缓存"]
|
||||
end
|
||||
|
||||
subgraph 穿透["穿透"]
|
||||
C1["C1 参数校验"]
|
||||
C2["C2 缓存空对象"]
|
||||
C3["C3 布隆过滤器"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 解决方案
|
||||
|
||||
### A. 缓存击穿
|
||||
|
||||
#### A1 永不过期
|
||||
|
||||
逻辑过期而非 TTL 过期:缓存中存一个过期时间字段,由后台异步刷新,请求始终命中缓存。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as 客户端
|
||||
participant R as Redis
|
||||
participant W as 后台 Worker
|
||||
participant DB as DB
|
||||
|
||||
C->>R: GET key
|
||||
R-->>C: 返回数据 + expireAt
|
||||
alt 未过期
|
||||
C-->>C: 直接使用 ✅
|
||||
else 已过期(逻辑过期)
|
||||
C-->>C: 仍返回旧数据 ✅(可用性优先)
|
||||
C->>W: 触发异步刷新
|
||||
W->>DB: 查询最新数据
|
||||
DB-->>W: 返回新数据
|
||||
W->>R: SET key + 新 expireAt
|
||||
end
|
||||
```
|
||||
|
||||
```go
|
||||
type CacheItem struct {
|
||||
Data any
|
||||
ExpireAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, key string) (any, error) {
|
||||
raw, err := s.rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return s.loadAndSet(ctx, key)
|
||||
}
|
||||
|
||||
var item CacheItem
|
||||
json.Unmarshal(raw, &item)
|
||||
|
||||
// 逻辑过期:数据仍可返回,触发异步刷新
|
||||
if time.Now().After(item.ExpireAt) {
|
||||
go s.asyncRefresh(ctx, key)
|
||||
}
|
||||
return item.Data, nil
|
||||
}
|
||||
```
|
||||
|
||||
!!! tip "适用场景"
|
||||
对一致性要求不高、对可用性要求极高的热点数据(如首页推荐)。
|
||||
|
||||
#### A2 加锁排队
|
||||
|
||||
只放一个请求去 DB 加载,其余请求等锁释放后读缓存。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C1 as 请求1
|
||||
participant C2 as 请求2
|
||||
participant C3 as 请求3
|
||||
participant R as Redis
|
||||
participant Lock as 分布式锁
|
||||
participant DB as DB
|
||||
|
||||
C1->>R: GET key → miss
|
||||
C2->>R: GET key → miss
|
||||
C3->>R: GET key → miss
|
||||
|
||||
C1->>Lock: 尝试获锁 ✅
|
||||
C2->>Lock: 尝试获锁 ❌ 等待
|
||||
C3->>Lock: 尝试获锁 ❌ 等待
|
||||
|
||||
C1->>DB: SELECT ...
|
||||
DB-->>C1: 返回数据
|
||||
C1->>R: SET key + TTL
|
||||
C1->>Lock: 释放锁
|
||||
|
||||
C2->>R: GET key → hit ✅
|
||||
C3->>R: GET key → hit ✅
|
||||
```
|
||||
|
||||
```go
|
||||
var mu sync.Mutex
|
||||
|
||||
func (s *Service) GetWithLock(ctx context.Context, key string) (any, error) {
|
||||
// 先查缓存
|
||||
val, err := s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 缓存 miss,加锁
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// double-check:可能其他协程已加载
|
||||
val, err = s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 查 DB 并写缓存
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 10*time.Minute)
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "分布式环境用分布式锁"
|
||||
单机 `sync.Mutex` 只在单进程内有效,多实例部署时需要用 Redis 分布式锁(如 `redsync`)或 `singleflight`。
|
||||
|
||||
**更优雅的方式——`singleflight`**:
|
||||
|
||||
```go
|
||||
var g singleflight.Group
|
||||
|
||||
func (s *Service) GetWithSingleFlight(ctx context.Context, key string) (any, error) {
|
||||
v, err, _ := g.Do(key, func() (any, error) {
|
||||
// 只有一个协程执行查询
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 10*time.Minute)
|
||||
return data, nil
|
||||
})
|
||||
return v, err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B. 缓存雪崩
|
||||
|
||||
#### B1 加锁排队
|
||||
|
||||
与击穿的加锁思路相同,限制并发回源数量,避免 DB 瞬时过载。
|
||||
|
||||
```go
|
||||
// 使用带容量限制的令牌桶
|
||||
var limiter = rate.NewLimiter(rate.Limit(100), 50)
|
||||
|
||||
func (s *Service) GetWithRateLimit(ctx context.Context, key string) (any, error) {
|
||||
val, err := s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 限流:超出速率直接拒绝或排队
|
||||
if err := limiter.Wait(ctx); err != nil {
|
||||
return nil, fmt.Errorf("service busy, try later")
|
||||
}
|
||||
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 10*time.Minute)
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
#### B2 随机失效
|
||||
|
||||
给 TTL 加随机偏移量,避免大量 key 在同一时刻集中过期。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph 无 Jitter["❌ 无随机偏移"]
|
||||
T1["key_a TTL=30min"] --> E1["同时过期"]
|
||||
T2["key_b TTL=30min"] --> E1
|
||||
T3["key_c TTL=30min"] --> E1
|
||||
E1 --> CRASH["DB 雪崩 💥"]
|
||||
end
|
||||
|
||||
subgraph 有 Jitter["✅ 有随机偏移"]
|
||||
J1["key_a TTL=30m+2m"] --> D1["32min 过期"]
|
||||
J2["key_b TTL=30m+7m"] --> D2["37min 过期"]
|
||||
J3["key_c TTL=30m+4m"] --> D3["34min 过期"]
|
||||
D1 --> SAFE["DB 压力分散 ✅"]
|
||||
D2 --> SAFE
|
||||
D3 --> SAFE
|
||||
end
|
||||
|
||||
无 Jitter -.->|加 jitter| 有 Jitter
|
||||
```
|
||||
|
||||
```go
|
||||
func (s *Service) SetWithJitter(ctx context.Context, key string, data any, baseTTL time.Duration) {
|
||||
jitter := time.Duration(rand.Intn(300)) * time.Second // 0~300s 随机偏移
|
||||
s.rdb.Set(ctx, key, data, baseTTL+jitter)
|
||||
}
|
||||
|
||||
// 批量设置时
|
||||
for _, item := range items {
|
||||
base := 30 * time.Minute
|
||||
jitter := time.Duration(rand.Intn(600)) * time.Second
|
||||
s.rdb.Set(ctx, item.Key, item.Data, base+jitter)
|
||||
}
|
||||
```
|
||||
|
||||
#### B3 Redis 高可用
|
||||
|
||||
缓存节点宕机 = 所有 key 同时"失效",必须依赖高可用架构:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 多级缓存架构
|
||||
Client["客户端请求"]
|
||||
L1["L1 本地缓存<br/>bigcache / ristretto"]
|
||||
L2["L2 Redis<br/>Sentinel / Cluster"]
|
||||
L3["L3 DB"]
|
||||
|
||||
Client --> L1
|
||||
L1 -->|miss| L2
|
||||
L2 -->|miss| L3
|
||||
L3 --> L2
|
||||
L2 --> L1
|
||||
|
||||
L1 -.->|Redis 不可用时<br/>降级到本地| Client
|
||||
end
|
||||
```
|
||||
|
||||
| 方案 | 说明 |
|
||||
|------|------|
|
||||
| **Redis Sentinel** | 自动故障转移,主节点挂掉时从节点升主 |
|
||||
| **Redis Cluster** | 数据分片 + 自动故障转移,生产环境推荐 |
|
||||
| **多级缓存** | 本地缓存(如 `bigcache`/`ristretto`)+ Redis,Redis 不可用时降级到本地 |
|
||||
| **熔断降级** | DB 压力过大时直接返回兜底数据或错误页 |
|
||||
|
||||
```go
|
||||
// 多级缓存示例:本地 + Redis
|
||||
func (s *Service) GetMultiLevel(ctx context.Context, key string) (any, error) {
|
||||
// L1: 本地缓存
|
||||
if v, ok := s.localCache.Get(key); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// L2: Redis
|
||||
val, err := s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
s.localCache.Set(key, val, 5*time.Minute)
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// L3: DB
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 30*time.Minute)
|
||||
s.localCache.Set(key, data, 5*time.Minute)
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. 缓存穿透
|
||||
|
||||
#### C1 参数校验
|
||||
|
||||
在入口层拦截非法请求,根本不让它进入缓存/DB 查询流程。
|
||||
|
||||
```go
|
||||
func (s *Service) GetUser(ctx context.Context, id int64) (any, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("invalid user id: %d", id)
|
||||
}
|
||||
// 继续正常查询流程...
|
||||
}
|
||||
```
|
||||
|
||||
#### C2 缓存空对象
|
||||
|
||||
当 DB 查不到时,缓存一个空值(带短 TTL),避免同一 key 反复穿透。
|
||||
|
||||
```go
|
||||
const emptyTTL = 5 * time.Minute
|
||||
|
||||
func (s *Service) GetWithNullCache(ctx context.Context, key string) (any, error) {
|
||||
val, err := s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
if val == "NULL" {
|
||||
return nil, nil // 命中空值缓存
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
// DB 也没有,缓存空值
|
||||
s.rdb.Set(ctx, key, "NULL", emptyTTL)
|
||||
return nil, nil
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 30*time.Minute)
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "空值缓存的风险"
|
||||
- 短 TTL 是必须的,否则真正写入数据后缓存中的空值会遮挡新数据
|
||||
- 大量不同 key 穿透时,会缓存大量空值,占用内存
|
||||
|
||||
#### C3 布隆过滤器
|
||||
|
||||
在缓存之前加一层布隆过滤器,不存在的 key 直接拦截。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Req["请求"] --> BF{"布隆过滤器"}
|
||||
BF -->|"一定不存在 ✗"| Reject["直接拒绝<br/>不查缓存/DB"]
|
||||
BF -->|"可能存在 ✓"| Cache{"Redis 缓存"}
|
||||
Cache -->|hit| Return["返回数据"]
|
||||
Cache -->|miss| DB["查询 DB"]
|
||||
DB -->|有数据| Cache
|
||||
DB -->|无数据| Null["缓存空对象<br/>短 TTL"]
|
||||
```
|
||||
|
||||
```go
|
||||
import "github.com/bits-and-blooms/bloom/v3"
|
||||
|
||||
var userFilter = bloom.NewWithEstimates(1_000_000, 0.01) // 100万条,1%误判率
|
||||
|
||||
// 服务启动时加载数据
|
||||
func InitFilter(ctx context.Context) {
|
||||
ids, _ := db.GetAllUserIDs(ctx)
|
||||
for _, id := range ids {
|
||||
userFilter.AddString(strconv.FormatInt(id, 10))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetWithBloom(ctx context.Context, id int64) (any, error) {
|
||||
key := strconv.FormatInt(id, 10)
|
||||
|
||||
// 布隆过滤器判断:如果一定不存在,直接返回
|
||||
if !userFilter.TestString(key) {
|
||||
return nil, fmt.Errorf("user %d does not exist", id)
|
||||
}
|
||||
|
||||
// 可能存在,走正常缓存 → DB 流程
|
||||
return s.GetWithNullCache(ctx, fmt.Sprintf("user:%d", id))
|
||||
}
|
||||
```
|
||||
|
||||
!!! info "布隆过滤器的特点"
|
||||
- **不存在 → 一定不存在**(无假阴性)
|
||||
- **存在 → 可能存在**(有假阳性,约 1% 误判率可接受)
|
||||
- 不支持删除,数据变更时需重建
|
||||
- 适合数据集相对稳定、查询频繁的场景
|
||||
|
||||
---
|
||||
|
||||
## 🔀 方案速查表
|
||||
|
||||
| 问题 | 方案 | 复杂度 | 适用场景 |
|
||||
|------|------|--------|---------|
|
||||
| 击穿 | A1 永不过期 | ★★ | 可接受短暂不一致的热点数据 |
|
||||
| 击穿 | A2 加锁排队 | ★★★ | 数据一致性要求高 |
|
||||
| 雪崩 | B1 加锁排队 | ★★★ | 应急限流,配合其他方案使用 |
|
||||
| 雪崩 | B2 随机失效 | ★ | **最简单有效,批量设置 TTL 时必备** |
|
||||
| 雪崩 | B3 Redis 高可用 | ★★★★ | 生产环境必备 |
|
||||
| 穿透 | C1 参数校验 | ★ | 入口层拦截,第一道防线 |
|
||||
| 穿透 | C2 缓存空对象 | ★★ | 穿透 key 种类不多时 |
|
||||
| 穿透 | C3 布隆过滤器 | ★★★ | 数据量大、穿透 key 随机时 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见陷阱
|
||||
|
||||
!!! warning "加锁 ≠ 互斥锁就够了"
|
||||
单机 `sync.Mutex` 在多实例部署下无效,要么用分布式锁,要么用 `singleflight`。
|
||||
|
||||
!!! warning "空值缓存 TTL 太长"
|
||||
TTL 太长会遮挡后续写入的真实数据;太短则穿透频繁。一般 1~5 分钟,视业务容忍度调整。
|
||||
|
||||
!!! warning "布隆过滤器不支持删除"
|
||||
数据被删后布隆过滤不会更新,可能误判"存在"。解决方案:使用 Counting Bloom Filter 或定期全量重建。
|
||||
|
||||
---
|
||||
|
||||
## 🏋️ 练习题
|
||||
|
||||
??? question "练习 1:为什么 singleflight 能缓解击穿但不能解决雪崩?"
|
||||
singleflight 合并的是 **同一个 key** 的并发请求。雪崩是 **多个不同 key** 同时失效,每个 key 形成独立的回源请求,singleflight 无法跨 key 合并。
|
||||
|
||||
??? success "答案"
|
||||
singleflight 按 key 做请求合并,只能保证同一个 key 只有一个回源请求。雪崩时大量不同 key 同时失效,各自独立回源,singleflight 无法跨 key 去重。解决雪崩需从 TTL 分散和高可用入手。
|
||||
|
||||
??? question "练习 2:布隆过滤器说「存在 → 可能存在」,那假阳性时会发生什么?"
|
||||
当布隆过滤器误判某个不存在的 key 为"存在"时,请求会正常走缓存 → DB 流程,缓存 miss 后查 DB 也没数据,此时可降级到缓存空对象方案兜底。
|
||||
|
||||
??? success "答案"
|
||||
假阳性不会导致错误结果——只是让本可拦截的请求穿透到了缓存/DB 层。配合缓存空对象(C2),在 DB 查空后缓存一个短 TTL 的空值,后续同样的 key 就不会重复穿透了。实际中 1% 的误判率完全可控。
|
||||
|
||||
??? question "练习 3:永不过期方案中,如果后台异步刷新 DB 查询失败怎么办?"
|
||||
此时缓存返回的是旧数据,一致性要求不高的场景可以接受;如果一致性要求高,应在刷新失败时记录日志/告警,并设置一个最大容忍时间,超过后主动置为失效状态。
|
||||
|
||||
??? success "答案"
|
||||
1. 返回旧数据,保证可用性(AP 优先)
|
||||
2. 刷新失败时记录日志并告警
|
||||
3. 设置 `MaxStale` 上限,超过后返回错误或降级
|
||||
4. 关键业务不适合单独使用永不过期方案,需配合其他手段
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [Redis 官方文档 — Caching](https://redis.io/docs/manual/patterns/caching/) — Redis 缓存模式
|
||||
- [Go singleflight 文档](https://pkg.go.dev/golang.org/x/sync/singleflight) — 请求合并
|
||||
- [bloom 库](https://github.com/bits-and-blooms/bloom) — Go 布隆过滤器实现
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# 缓存击穿
|
||||
|
||||
!!! note "💡 一句话概述"
|
||||
缓存击穿是某个热点 key 过期的瞬间,大量并发请求同时穿透到 DB 导致数据库瞬时尖峰——防护核心是控制回源并发数。
|
||||
|
||||
---
|
||||
|
||||
## 🔑 核心概念
|
||||
|
||||
1. **热点 key 过期**:某个被高频访问的 key 在 TTL 到期后,缓存中消失。
|
||||
2. **并发回源**:大量请求同时发现缓存 miss,同时去 DB 查询。
|
||||
3. **瞬时尖峰**:DB 承受短时间大量相同查询,压力可能压垮数据库。
|
||||
|
||||
---
|
||||
|
||||
## 📝 详细说明
|
||||
|
||||
### 发生机制
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 击穿["🔓 缓存击穿 — 单热点 key 过期"]
|
||||
B1["热 key 过期"] --> B2["大量并发同时 miss"]
|
||||
B2 --> B3["同时回源 DB"]
|
||||
B3 --> B4["DB 瞬时尖峰 💥"]
|
||||
end
|
||||
```
|
||||
|
||||
**典型场景**:电商秒杀商品详情、热门文章、排行榜——这类 key 访问量极高,一旦过期瞬间就有成百上千请求涌入 DB。
|
||||
|
||||
### 与雪崩、穿透的区别
|
||||
|
||||
| | 缓存击穿 | 缓存雪崩 | 缓存穿透 |
|
||||
|---|---------|---------|---------|
|
||||
| **根因** | 热 key 过期 | 大面积 key 同时过期 / 缓存宕机 | 查询不存在的数据 |
|
||||
| **特征** | 单 key、高并发 | 多 key、集中失效 | 缓存永远 miss |
|
||||
| **危害** | DB 瞬时尖峰 | DB 持续高压 | DB 持续高压 |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 解决方案
|
||||
|
||||
### 方案 1:逻辑过期(永不过期)
|
||||
|
||||
逻辑过期而非 TTL 过期:缓存中存一个过期时间字段,由后台异步刷新,请求始终命中缓存。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as 客户端
|
||||
participant R as Redis
|
||||
participant W as 后台 Worker
|
||||
participant DB as DB
|
||||
|
||||
C->>R: GET key
|
||||
R-->>C: 返回数据 + expireAt
|
||||
alt 未过期
|
||||
C-->>C: 直接使用 ✅
|
||||
else 已过期(逻辑过期)
|
||||
C-->>C: 仍返回旧数据 ✅(可用性优先)
|
||||
C->>W: 触发异步刷新
|
||||
W->>DB: 查询最新数据
|
||||
DB-->>W: 返回新数据
|
||||
W->>R: SET key + 新 expireAt
|
||||
end
|
||||
```
|
||||
|
||||
```go
|
||||
type CacheItem struct {
|
||||
Data any
|
||||
ExpireAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, key string) (any, error) {
|
||||
raw, err := s.rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return s.loadAndSet(ctx, key)
|
||||
}
|
||||
|
||||
var item CacheItem
|
||||
json.Unmarshal(raw, &item)
|
||||
|
||||
// 逻辑过期:数据仍可返回,触发异步刷新
|
||||
if time.Now().After(item.ExpireAt) {
|
||||
go s.asyncRefresh(ctx, key)
|
||||
}
|
||||
return item.Data, nil
|
||||
}
|
||||
```
|
||||
|
||||
!!! tip "适用场景"
|
||||
对一致性要求不高、对可用性要求极高的热点数据(如首页推荐)。
|
||||
|
||||
### 方案 2:加锁排队
|
||||
|
||||
只放一个请求去 DB 加载,其余请求等锁释放后读缓存。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C1 as 请求1
|
||||
participant C2 as 请求2
|
||||
participant C3 as 请求3
|
||||
participant R as Redis
|
||||
participant Lock as 分布式锁
|
||||
participant DB as DB
|
||||
|
||||
C1->>R: GET key → miss
|
||||
C2->>R: GET key → miss
|
||||
C3->>R: GET key → miss
|
||||
|
||||
C1->>Lock: 尝试获锁 ✅
|
||||
C2->>Lock: 尝试获锁 ❌ 等待
|
||||
C3->>Lock: 尝试获锁 ❌ 等待
|
||||
|
||||
C1->>DB: SELECT ...
|
||||
DB-->>C1: 返回数据
|
||||
C1->>R: SET key + TTL
|
||||
C1->>Lock: 释放锁
|
||||
|
||||
C2->>R: GET key → hit ✅
|
||||
C3->>R: GET key → hit ✅
|
||||
```
|
||||
|
||||
```go
|
||||
var mu sync.Mutex
|
||||
|
||||
func (s *Service) GetWithLock(ctx context.Context, key string) (any, error) {
|
||||
// 先查缓存
|
||||
val, err := s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 缓存 miss,加锁
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// double-check:可能其他协程已加载
|
||||
val, err = s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 查 DB 并写缓存
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 10*time.Minute)
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "分布式环境用分布式锁"
|
||||
单机 `sync.Mutex` 只在单进程内有效,多实例部署时需要用 Redis 分布式锁(如 `redsync`)或 `singleflight`。
|
||||
|
||||
**更优雅的方式——`singleflight`**:
|
||||
|
||||
```go
|
||||
var g singleflight.Group
|
||||
|
||||
func (s *Service) GetWithSingleFlight(ctx context.Context, key string) (any, error) {
|
||||
v, err, _ := g.Do(key, func() (any, error) {
|
||||
// 只有一个协程执行查询
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 10*time.Minute)
|
||||
return data, nil
|
||||
})
|
||||
return v, err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 方案对比
|
||||
|
||||
| 方案 | 一致性 | 可用性 | 复杂度 | 适用场景 |
|
||||
|------|:---:|:---:|:---:|---------|
|
||||
| 逻辑过期 | 弱 | 高 | ★★ | 可接受短暂不一致的热点数据 |
|
||||
| 加锁排队 | 强 | 中 | ★★★ | 数据一致性要求高 |
|
||||
| singleflight | 强 | 中 | ★★ | Go 生态最佳实践 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见陷阱
|
||||
|
||||
!!! warning "加锁 ≠ 互斥锁就够了"
|
||||
单机 `sync.Mutex` 在多实例部署下无效,要么用分布式锁,要么用 `singleflight`。
|
||||
|
||||
!!! warning "逻辑过期的"脏窗口""
|
||||
异步刷新完成前,所有请求读到的都是旧数据。如果业务对一致性敏感,需要设置最大容忍时间,超时后降级。
|
||||
|
||||
---
|
||||
|
||||
## 🏋️ 练习题
|
||||
|
||||
??? question "练习 1:为什么 singleflight 能缓解击穿但不能解决雪崩?"
|
||||
singleflight 合并的是**同一个 key** 的并发请求。雪崩是**多个不同 key**同时失效,每个 key 形成独立的回源请求,singleflight 无法跨 key 合并。
|
||||
|
||||
??? success "答案"
|
||||
singleflight 按 key 做请求合并,只能保证同一个 key 只有一个回源请求。雪崩时大量不同 key 同时失效,各自独立回源,singleflight 无法跨 key 去重。解决雪崩需从 TTL 分散和高可用入手。
|
||||
|
||||
??? question "练习 2:逻辑过期方案中,如果后台异步刷新 DB 查询失败怎么办?"
|
||||
此时缓存返回的是旧数据,一致性要求不高的场景可以接受;如果一致性要求高,应在刷新失败时记录日志/告警,并设置一个最大容忍时间。
|
||||
|
||||
??? success "答案"
|
||||
1. 返回旧数据,保证可用性(AP 优先)
|
||||
2. 刷新失败时记录日志并告警
|
||||
3. 设置 `MaxStale` 上限,超过后返回错误或降级
|
||||
4. 关键业务不适合单独使用逻辑过期,需配合其他手段
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [缓存雪崩](cache-avalanche.md) — 大面积 key 同时失效
|
||||
- [缓存穿透](cache-penetration.md) — 查不存在的数据绕过缓存
|
||||
- [Go singleflight 文档](https://pkg.go.dev/golang.org/x/sync/singleflight) — 请求合并
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
# 缓存穿透
|
||||
|
||||
!!! note "💡 一句话概述"
|
||||
缓存穿透是请求查询根本不存在的数据,缓存永远无法命中,每次请求都直达 DB——防护核心是在缓存层之前拦截无效请求。
|
||||
|
||||
---
|
||||
|
||||
## 🔑 核心概念
|
||||
|
||||
1. **数据不存在**:请求的 key 在 DB 中根本不存在,缓存层也无法存储有效结果。
|
||||
2. **每次穿透**:每次请求都 miss → 查 DB → 空 → 回来,缓存形同虚设。
|
||||
3. **恶意攻击**:攻击者用大量不存在的 key 发起请求,每条都穿透到 DB。
|
||||
|
||||
---
|
||||
|
||||
## 📝 详细说明
|
||||
|
||||
### 发生机制
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 穿透["🕳️ 缓存穿透 — 查不存在的数据"]
|
||||
C1["请求查询不存在的 key"] --> C2["缓存永远 miss"]
|
||||
C2 --> C3["每次直达 DB"]
|
||||
C3 --> C4["DB 持续高压 💥"]
|
||||
end
|
||||
```
|
||||
|
||||
**典型场景**:
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 恶意攻击 | 用随机 ID 请求 `/user/{id}`,大量 ID 根本不存在 |
|
||||
| 业务异常 | 前端传了无效参数(负数 ID、非法格式),后端未校验 |
|
||||
| 数据删除后 | DB 中数据已删除,但请求仍用旧 key 访问 |
|
||||
|
||||
### 与击穿、雪崩的区别
|
||||
|
||||
| | 缓存击穿 | 缓存雪崩 | 缓存穿透 |
|
||||
|---|---------|---------|---------|
|
||||
| **根因** | 热 key 过期 | 大面积 key 同时过期 / 缓存宕机 | 查询不存在的数据 |
|
||||
| **特征** | 单 key、高并发 | 多 key、集中失效 | 缓存永远 miss |
|
||||
| **触发方式** | 自然过期 | 自然过期 / 节点故障 | 恶意攻击 / 业务异常 |
|
||||
| **危害** | DB 瞬时尖峰 | DB 持续高压 | DB 持续高压 |
|
||||
|
||||
**穿透的独特性**:击穿和雪崩是"有数据但缓存失效",穿透是"根本没数据"——这意味着常规的"缓存 miss → 回源"流程完全失效。
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 解决方案
|
||||
|
||||
### 方案 1:参数校验(第一道防线)
|
||||
|
||||
在入口层拦截非法请求,根本不让它进入缓存/DB 查询流程。
|
||||
|
||||
```go
|
||||
func (s *Service) GetUser(ctx context.Context, id int64) (any, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("invalid user id: %d", id)
|
||||
}
|
||||
// 继续正常查询流程...
|
||||
}
|
||||
```
|
||||
|
||||
**校验规则示例**:
|
||||
|
||||
| 参数类型 | 校验规则 |
|
||||
|---------|---------|
|
||||
| 用户 ID | 正整数,范围在合法区间 |
|
||||
| 订单号 | 符合格式(如日期+序号) |
|
||||
| 枚举值 | 必须在预定义集合内 |
|
||||
| 分页参数 | page ≤ maxPage、size ≤ maxSize |
|
||||
|
||||
### 方案 2:缓存空对象
|
||||
|
||||
当 DB 查不到时,缓存一个空值(带短 TTL),避免同一 key 反复穿透。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as 客户端
|
||||
participant R as Redis
|
||||
participant DB as DB
|
||||
|
||||
C->>R: GET user:9999 → miss
|
||||
R-->>C: nil
|
||||
C->>DB: SELECT * FROM users WHERE id=9999
|
||||
DB-->>C: 空(不存在)
|
||||
C->>R: SET user:9999 "NULL" EX 300
|
||||
Note over R: 缓存空值,5 分钟 TTL
|
||||
|
||||
C->>R: GET user:9999 → hit "NULL"
|
||||
C-->>C: 直接返回空,不再查 DB ✅
|
||||
```
|
||||
|
||||
```go
|
||||
const emptyTTL = 5 * time.Minute
|
||||
|
||||
func (s *Service) GetWithNullCache(ctx context.Context, key string) (any, error) {
|
||||
val, err := s.rdb.Get(ctx, key).Result()
|
||||
if err == nil {
|
||||
if val == "NULL" {
|
||||
return nil, nil // 命中空值缓存
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
data, err := s.db.Query(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
// DB 也没有,缓存空值
|
||||
s.rdb.Set(ctx, key, "NULL", emptyTTL)
|
||||
return nil, nil
|
||||
}
|
||||
s.rdb.Set(ctx, key, data, 30*time.Minute)
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
!!! warning "空值缓存的风险"
|
||||
- **短 TTL 是必须的**,否则真正写入数据后缓存中的空值会遮挡新数据
|
||||
- **大量不同 key 穿透时**,会缓存大量空值,占用内存
|
||||
- 适合穿透 key 种类有限的场景
|
||||
|
||||
### 方案 3:布隆过滤器
|
||||
|
||||
在缓存之前加一层布隆过滤器,不存在的 key 直接拦截。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Req["请求"] --> BF{"布隆过滤器"}
|
||||
BF -->|"一定不存在 ✗"| Reject["直接拒绝<br/>不查缓存/DB"]
|
||||
BF -->|"可能存在 ✓"| Cache{"Redis 缓存"}
|
||||
Cache -->|hit| Return["返回数据"]
|
||||
Cache -->|miss| DB["查询 DB"]
|
||||
DB -->|有数据| Cache
|
||||
DB -->|无数据| Null["缓存空对象<br/>短 TTL"]
|
||||
```
|
||||
|
||||
```go
|
||||
import "github.com/bits-and-blooms/bloom/v3"
|
||||
|
||||
var userFilter = bloom.NewWithEstimates(1_000_000, 0.01) // 100万条,1%误判率
|
||||
|
||||
// 服务启动时加载数据
|
||||
func InitFilter(ctx context.Context) {
|
||||
ids, _ := db.GetAllUserIDs(ctx)
|
||||
for _, id := range ids {
|
||||
userFilter.AddString(strconv.FormatInt(id, 10))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetWithBloom(ctx context.Context, id int64) (any, error) {
|
||||
key := strconv.FormatInt(id, 10)
|
||||
|
||||
// 布隆过滤器判断:如果一定不存在,直接返回
|
||||
if !userFilter.TestString(key) {
|
||||
return nil, fmt.Errorf("user %d does not exist", id)
|
||||
}
|
||||
|
||||
// 可能存在,走正常缓存 → DB 流程
|
||||
return s.GetWithNullCache(ctx, fmt.Sprintf("user:%d", id))
|
||||
}
|
||||
```
|
||||
|
||||
!!! info "布隆过滤器的特点"
|
||||
- **不存在 → 一定不存在**(无假阴性)
|
||||
- **存在 → 可能存在**(有假阳性,约 1% 误判率可接受)
|
||||
- 不支持删除,数据变更时需重建
|
||||
- 适合数据集相对稳定、查询频繁的场景
|
||||
|
||||
**布隆过滤器 + 缓存空对象的组合**是防御穿透最完整的方案:
|
||||
|
||||
| 情况 | 布隆过滤器 | 缓存空对象 |
|
||||
|------|:---:|:---:|
|
||||
| key 确实不存在 | 直接拦截 ✅ | 不需要 |
|
||||
| key 存在、缓存命中 | 放行 → 缓存返回 ✅ | 不需要 |
|
||||
| key 存在、缓存 miss | 放行 → 回源 ✅ | 不需要 |
|
||||
| 布隆误判(假阳性) | 放行 → 缓存 miss | 缓存空值兜底 ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 📊 方案对比
|
||||
|
||||
| 方案 | 防护强度 | 内存开销 | 复杂度 | 适用场景 |
|
||||
|------|:---:|:---:|:---:|---------|
|
||||
| 参数校验 | 弱(仅防非法参数) | 无 | ★ | 入口层拦截,第一道防线 |
|
||||
| 缓存空对象 | 中 | 较大(大量空值) | ★★ | 穿透 key 种类不多时 |
|
||||
| 布隆过滤器 | 强 | 小(~1.14MB/百万) | ★★★ | 数据量大、穿透 key 随机时 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见陷阱
|
||||
|
||||
!!! warning "空值缓存 TTL 太长"
|
||||
TTL 太长会遮挡后续写入的真实数据;太短则穿透频繁。一般 1~5 分钟,视业务容忍度调整。
|
||||
|
||||
!!! warning "布隆过滤器不支持删除"
|
||||
数据被删后布隆过滤器不会更新,可能误判"存在"。详见 [布隆过滤器删除问题](../../algorithm/bloom-filter-deletion.md)。
|
||||
|
||||
!!! warning "布隆过滤器需要预加载"
|
||||
服务启动时必须从 DB 加载全量 ID 到过滤器,启动时间与数据量成正比。数据量极大时需考虑异步加载。
|
||||
|
||||
!!! warning "攻击者用全随机 key 时,空值缓存会爆内存"
|
||||
每个随机 key 都会缓存一个空值,内存线性增长。此时应依赖布隆过滤器前置拦截,而非空值缓存。
|
||||
|
||||
---
|
||||
|
||||
## 🏋️ 练习题
|
||||
|
||||
??? question "练习 1:布隆过滤器说「存在 → 可能存在」,那假阳性时会发生什么?"
|
||||
误判时请求会正常走缓存 → DB 流程,缓存 miss 后查 DB 也没数据,此时可降级到缓存空对象方案兜底。
|
||||
|
||||
??? success "答案"
|
||||
假阳性不会导致错误结果——只是让本可拦截的请求穿透到了缓存/DB 层。配合缓存空对象,在 DB 查空后缓存一个短 TTL 的空值,后续同样的 key 就不会重复穿透了。实际中 1% 的误判率完全可控。
|
||||
|
||||
??? question "练习 2:攻击者用完全随机的 key 攻击,纯靠缓存空对象能防住吗?"
|
||||
不能。随机 key 意味着每个 key 都是全新的,每次都会穿透到 DB 并缓存一个空值。攻击量大时,Redis 内存会被空值占满。
|
||||
|
||||
??? success "答案"
|
||||
纯缓存空对象在随机 key 攻击下会**内存溢出**。正确做法是布隆过滤器前置拦截——随机 key 大概率不在过滤器中,直接被拒绝。布隆过滤器 + 缓存空对象的组合才能同时覆盖常规穿透和恶意攻击。
|
||||
|
||||
??? question "练习 3:业务数据频繁增删(如用户注册/注销),布隆过滤器还适用吗?"
|
||||
标准布隆过滤器不支持删除,频繁删除后误判率会上升。此时应考虑 Counting Bloom Filter 或 Cuckoo Filter。
|
||||
|
||||
??? success "答案"
|
||||
频繁增删场景下标准布隆过滤器不适用——删除的用户 ID 仍会在过滤器中标记为"存在",假阳性持续累积。解决方案:
|
||||
1. 使用 Counting Bloom Filter(支持删除,但 4x 空间开销)
|
||||
2. 使用 Cuckoo Filter(支持删除,空间更优)
|
||||
3. 定期全量重建(适合删除频率低的场景)
|
||||
详见 [布隆过滤器删除问题](../../algorithm/bloom-filter-deletion.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [缓存击穿](cache-breakdown.md) — 单热点 key 过期的并发涌入
|
||||
- [缓存雪崩](cache-avalanche.md) — 大面积 key 同时失效
|
||||
- [布隆过滤器](../../algorithm/bloom-filter.md) — 基础原理与实现
|
||||
- [布隆过滤器删除问题](../../algorithm/bloom-filter-deletion.md) — 删除问题与替代方案
|
||||
- [bloom 库](https://github.com/bits-and-blooms/bloom) — Go 布隆过滤器实现
|
||||
Vendored
+6
-1
@@ -1,4 +1,9 @@
|
||||
# 缓存
|
||||
|
||||
!!! note "💡 一句话概述"
|
||||
缓存是提升系统性能的核心手段,通过在高速存储中保留热点数据来减少访问延迟。
|
||||
|
||||
本分类聚焦缓存架构中的典型问题与解决方案:
|
||||
|
||||
- [缓存击穿](cache-breakdown.md) — 热 key 过期瞬间的并发涌入
|
||||
- [缓存雪崩](cache-avalanche.md) — 大面积 key 同时失效
|
||||
- [缓存穿透](cache-penetration.md) — 查不存在的数据绕过缓存
|
||||
|
||||
+3
-1
@@ -90,7 +90,9 @@ nav:
|
||||
- architecture/index.md
|
||||
- 缓存:
|
||||
- architecture/cache/index.md
|
||||
- 缓存击穿雪崩穿透: architecture/cache/cache-breakdown-avalanche-penetration.md
|
||||
- 缓存击穿: architecture/cache/cache-breakdown.md
|
||||
- 缓存雪崩: architecture/cache/cache-avalanche.md
|
||||
- 缓存穿透: architecture/cache/cache-penetration.md
|
||||
- 算法:
|
||||
- algorithm/index.md
|
||||
- 布隆过滤器:
|
||||
|
||||
Reference in New Issue
Block a user