Files

170 lines
6.4 KiB
Markdown
Raw Permalink Normal View History

2026-08-08 19:01:04 +08:00
---
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 热点探测算法]]