Files
autumn-recruitment/03.Redis/strategies/多级缓存架构设计.md
T

6.4 KiB
Raw Blame History

tags, create time, update time
tags create time update time
redis
multi-level-cache
caffeine
cache-consistency
avalanche
2026-08-08 18:43 2026-08-08 18:43

多级缓存架构设计

概述

在大型分布式系统中,仅靠 Redis 单级缓存已不足以支撑超高并发场景。多级缓存通过在客户端本地(L1)和远程服务(L2)之间分层存储热点数据,大幅降低远程调用延迟和网络带宽消耗。本文介绍 Caffeine 到 Redis 到 MySQL 的三级架构设计及一致性、雪崩等核心挑战的解决方案。

核心原理

三级缓存层次

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

缓存一致性挑战

多级缓存最大的痛点是数据更新时如何让所有层的缓存保持一致。

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 基础上加上随机偏移量:

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 防止缓存击穿:

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。这是评估集群规模的核心指标。

关联笔记