vault backup: 2026-08-08 19:01:04
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
---
|
||||
tags: [redis, multi-level-cache, caffeine, cache-consistency, avalanche]
|
||||
create time: 2026-08-08 18:43
|
||||
update time: 2026-08-08 18:43
|
||||
---
|
||||
|
||||
# 多级缓存架构设计
|
||||
|
||||
## 概述
|
||||
|
||||
在大型分布式系统中,仅靠 Redis 单级缓存已不足以支撑超高并发场景。多级缓存通过在客户端本地(L1)和远程服务(L2)之间分层存储热点数据,大幅降低远程调用延迟和网络带宽消耗。本文介绍 Caffeine 到 Redis 到 MySQL 的三级架构设计及一致性、雪崩等核心挑战的解决方案。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### 三级缓存层次
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["应用服务 App Service"] -->|"L1: Caffeine<br/>内存缓存 (微秒)"| B["应用服务 App Service"]
|
||||
B -->|"L2: Redis<br/>远程缓存 (毫秒)"| C["MySQL<br/>持久化存储"]
|
||||
A -->|"miss"| B
|
||||
B -->|"miss"| C
|
||||
```
|
||||
|
||||
**L1 — Caffeine(本地缓存)**:
|
||||
- 基于 JVM 堆内内存,读写延迟小于 1 微秒
|
||||
- 支持 LRU、LFU、Window LFU 多种淘汰算法
|
||||
- 通过 maximumSize 限制内存占用,自动驱逐过期条目
|
||||
- 局限:多实例间数据无法同步,每个实例有自己的缓存视图
|
||||
|
||||
**L2 — Redis(远程缓存)**:
|
||||
- 集中式共享缓存,所有实例共享同一份数据
|
||||
- 网络 RTT 通常在 1~5ms(同城)到 50ms+(跨城)
|
||||
- 支持 TTL、持久化、复杂数据结构
|
||||
|
||||
**L3 — MySQL(持久层)**:
|
||||
- 最终数据来源,保证数据的持久性和强一致性
|
||||
- 查询延迟通常 10ms~数百 ms
|
||||
|
||||
### 缓存一致性挑战
|
||||
|
||||
多级缓存最大的痛点是数据更新时如何让所有层的缓存保持一致。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Writer as 写请求
|
||||
participant DB as MySQL
|
||||
participant Redis as L2: Redis
|
||||
participant App1 as 实例A (L1)
|
||||
participant App2 as 实例B (L1)
|
||||
|
||||
Writer->>DB: UPDATE key = val
|
||||
DB-->>Writer: OK
|
||||
Writer->>Redis: DEL key
|
||||
Redis-->>Writer: OK
|
||||
|
||||
Note over App1,App2: 注意:此处只删除了 Redis<br/>L1 缓存需要下次访问时刷新
|
||||
|
||||
App1->>Redis: GET key (miss)
|
||||
Redis->>DB: SELECT * FROM ...
|
||||
DB-->>Redis: result
|
||||
Redis->>App1: result
|
||||
App1->>App1: 写入 L1 Caffeine
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 最实用的策略是先删缓存再更新 DB(或先更新 DB 再删缓存)。推荐先更新 DB 再删缓存因为后一种情况下极端竞态(读请求在新旧值切换期间读到旧值并回写到缓存)的概率更低。绝不使用先删缓存再写 DB——那会导致写操作完成后、DB 写入前的窗口期内读请求拿到陈旧缓存。
|
||||
|
||||
### 二级缓存失效传播方案
|
||||
|
||||
当多个应用实例同时修改同一个 key 时,可能出现两个问题:
|
||||
1. **重复重建**:多个实例同时发现缓存缺失,各自去查 DB 并重写缓存
|
||||
2. **脏数据残留**:L1 缓存不知道 L2 已经被删除
|
||||
|
||||
解决策略:
|
||||
|
||||
| 方案 | 做法 | 优缺点 |
|
||||
|------|-----|--------|
|
||||
| **短 TTL + 异步刷新** | 给缓存设置随机短 TTL(如 5~15min),后台线程提前 2min 刷新 | 简单有效,容忍短暂不一致 |
|
||||
| **消息队列广播** | DB 更新后发 MQ,各实例监听后清除自己的 L1 | 实时性好,但增加系统复杂度 |
|
||||
| **Canal + binlog 监听** | 通过 Canal 解析 MySQL binlog,自动推送 invalidate 事件 | 解耦彻底,适合大规模部署 |
|
||||
|
||||
> [!WARNING]
|
||||
> 不要依赖定时扫描比对来做一致性校验——延迟太高且成本高。生产环境首选方案:MQ 或 binlog 监听加短 TTL 兜底。
|
||||
|
||||
### 过期时间随机化防雪崩
|
||||
|
||||
大量缓存同时过期会导致请求瞬间穿透到数据库,引发雪崩。解决方法是在 TTL 基础上加上随机偏移量:
|
||||
|
||||
```go
|
||||
import "math/rand"
|
||||
|
||||
func generateTTL(baseMinutes int, jitterPercent int) time.Duration {
|
||||
jitter := baseMinutes * jitterPercent / 100
|
||||
actualMinutes := baseMinutes - jitter + rand.Intn(2*jitter)
|
||||
return time.Duration(actualMinutes) * time.Minute
|
||||
}
|
||||
|
||||
// 例如 baseMinutes=10, jitterPercent=30
|
||||
// 实际 TTL 落在 [7, 13] 分钟之间均匀分布
|
||||
```
|
||||
|
||||
### 缓存穿透防护
|
||||
|
||||
场景:恶意用户或异常流量反复查询不存在的 key,绕过缓存直接打到 DB。
|
||||
|
||||
防护策略:
|
||||
|
||||
| 策略 | 做法 | 适用场景 |
|
||||
|------|-----|---------|
|
||||
| **空值缓存** | 查询结果为空时也缓存一个特殊标记(如 nil),设极短 TTL(30s~2min) | 适用于不存在的数据比例较低的场景 |
|
||||
| **布隆过滤器** | 在缓存前先用 Bloom Filter 判断 key 是否存在 | 适用于 key 集合相对稳定、允许误判的场景 |
|
||||
| **接口层鉴权限流** | 对高频无效查询做 IP 或 token 级别的限流 | 作为辅助防线 |
|
||||
|
||||
## 代码示例
|
||||
|
||||
Go 中用 singleflight 防止缓存击穿:
|
||||
|
||||
```go
|
||||
import "golang.org/x/sync/singleflight"
|
||||
|
||||
type Cache struct {
|
||||
group singleflight.Group
|
||||
mu sync.RWMutex
|
||||
local map[string]cache.Entry
|
||||
}
|
||||
|
||||
func (c *Cache) Get(ctx context.Context, key string,
|
||||
fn func() (interface{}, error)) (interface{}, error) {
|
||||
|
||||
// 1. L1 命中直接返回
|
||||
c.mu.RLock()
|
||||
if entry, ok := c.local[key]; ok && !entry.IsExpired() {
|
||||
c.mu.RUnlock()
|
||||
return entry.Value, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// 2. L1 miss + L2 miss → singleflight 阻止并发重复查 DB
|
||||
val, err, _ := c.group.Do(key, func() (interface{}, error) {
|
||||
val, err := redisGet(ctx, key)
|
||||
if err != nil {
|
||||
val, err = fn() // 查 DB
|
||||
if err == nil {
|
||||
redisSetWithTTL(ctx, key, val, generateTTL(10, 30))
|
||||
c.setLocal(key, val, 15*time.Minute)
|
||||
}
|
||||
}
|
||||
return val, err
|
||||
})
|
||||
return val, err
|
||||
}
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
1. **商品详情页缓存**:电商商品 SKU 信息变更频率低(小时级)、读请求极高(万 QPS),非常适合多级缓存。L1 存热点 SKU,L2 存全量活跃 SKU,DB 做兜底。
|
||||
|
||||
2. **配置中心类数据**:开关配置、规则引擎参数等全局配置,更新时通过 MQ 广播失效,L1 TTL 设 1 小时加后台预刷。
|
||||
|
||||
3. **限流计数**:高频调用的限流 counter 不适合放多级缓存(每次都涉及网络开销),直接用 Redis INCR 或本地原子计数器即可。
|
||||
|
||||
4. **容量规划参考**:假设单机 QPS 10000,L1 hit rate 90%,L2 hit rate 80%(相对 L1 miss),则对外部 Redis 的请求约 1000 × (1 - 0.8) = 200 QPS。这是评估集群规模的核心指标。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[03.Redis/core/Redis 五大核心数据结构]]
|
||||
- [[03.Redis/strategies/缓存穿透击穿雪崩解决方案]]
|
||||
- [[03.Redis/strategies/HeavyKeeper 热点探测算法]]
|
||||
Reference in New Issue
Block a user