--- 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
内存缓存 (微秒)"| B["应用服务 App Service"] B -->|"L2: Redis
远程缓存 (毫秒)"| C["MySQL
持久化存储"] 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
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 热点探测算法]]