Files
cs-note/hhs/Redis/10-缓存架构模式.md
T
2026-05-25 23:50:33 +08:00

18 KiB
Raw Blame History

tags, create time
tags create time
Redis
缓存
架构
2026-05-15 18:15

Redis 缓存架构模式

概述

在实际生产系统中,Redis 很少直接裸用——它需要与业务数据库、应用层紧密配合。本节介绍最常见的缓存设计模式以及经典的三大问题(穿透/击穿/雪崩)的解决方案。

[!QUESTION] 为什么有了 DB 还需要缓存?

  • 性能:内存读写 ≈ 1μs,磁盘 IO ≈ 1ms,差距三个数量级
  • 容量:DB 扛不住瞬时峰值,缓存可以做"削峰填谷"
  • 成本:一台 Redis 集群的成本远低于扩容 MySQL

核心矛盾:缓存带来了性能提升,也引入了一致性和可用性的新风险。下文所有模式都是围绕这个矛盾在做取舍。

一、基础缓存策略

Cache-Aside Pattern(旁路缓存)—— 最常用

flowchart TD
    Req["请求到达"] --> Hit{"读缓存命中?"}
    Hit -->|是| Data["返回缓存数据"]
    Hit -->|否| DB["查询数据库"]
    DB --> WriteCache["写入缓存<br/>set key val EX 3600"]
    WriteCache --> Data
    
    UpReq["写请求到达"] --> DelCache["先更新 DB"]
    DelCache --> DelKV["再删除缓存 Key"]
    DelKV --> Done["结束"]
    
    style WriteCache fill:#dfd,stroke:#090
    style DelKV fill:#fdd,stroke:#900

[!QUESTION] 写操作为什么"删缓存"而不是"更新缓存"?

  • 删缓存:简单可靠,下次读时自然回源 DB 拿到最新数据
  • 更新缓存:需要构造完整值(可能跨表 JOIN),复杂且容易不一致
  • 关键原则:读时保证一致性,写后不保证实时性(延迟满足)

Go 示例

// 读操作
func GetUser(ctx context.Context, id int64) (*User, error) {
    data, err := rdb.Get(ctx, "user:"+strconv.FormatInt(id, 10)).Bytes()
    if err == redis.Nil {
        // 缓存未命中 → 查 DB
        u, err := db.GetUser(id)
        if err != nil {
            return nil, err
        }
        // 回填缓存
        ttl := 30*time.Minute + time.Duration(rand.Intn(300))*time.Second
        rdb.Set(ctx, "user:"+strconv.FormatInt(id, 10), toJSON(u), ttl)
        return u, nil
    }
    
    var u User
    json.Unmarshal(data, &u)
    return &u, nil
}

// 写操作 —— 先更 DB,再删缓存
func UpdateUser(ctx context.Context, u *User) error {
    if err := db.UpdateUser(u); err != nil {
        return err
    }
    rdb.Del(ctx, "user:"+strconv.FormatInt(u.ID, 10))
    return nil
}

[!WARNING] Cache-Aside 的竞态窗口 典型竞态:读请求 A 先 miss 缓存并开始查 DB,此时写请求 B 更新 DB 并删缓存;A 从 DB 取到旧值后回填缓存,覆盖了 B 的删除操作。后续读请求将持续拿到旧数据。 解决方案:

  • 延迟双删:写后删一次缓存,延迟几百毫秒再删一次(覆盖并发读的回填)
  • 读操作加短锁:回填缓存前加锁,避免并发读覆盖新值
  • 版本号 / 时间戳:回填时比对版本,旧版本不写入缓存

Read/Write Through(读写穿透)

通过中间件层自动处理缓存读写,业务代码只跟中间件交互:

flowchart LR
    App["App Code<br/>业务层"] --> Cache["Cache Layer<br/>中间件透明封装"]
    Cache --> DB["Database<br/>持久层"]
    
    style App fill:#efe,stroke:#090
    style Cache fill:#fda,stroke:#da0
    style DB fill:#ddf,stroke:#66c

[!NOTE] 实际中较少纯原生实现 Redisson(Java)、Spring Cache 提供此抽象;Go 生态没有标准库,通常自行封装。核心思路是 用 AOP / Middleware 把缓存逻辑从业务代码中剥离。

Write-Behind(异步写回)—— 高写入吞吐场景

与 Read/Write Through 不同,Write-Behind 将写操作先落到缓存,再异步批量刷回 DB,适合写密集型场景(如计数器、日志采集)。

flowchart LR
    App["写请求"] --> Cache["Redis<br/>立即返回 ✅"]
    Cache -->|"异步队列<br/>批量合并"| Writer["写回 Worker"]
    Writer --> DB["MySQL"]
    
    style App fill:#efe,stroke:#090
    style Cache fill:#fda,stroke:#da0
    style Writer fill:#ddf,stroke:#66c

[!WARNING] Write-Behind 的风险

  • 数据丢失风险:缓存宕机时,尚未刷回 DB 的数据会丢失
  • 实现复杂度高:需要可靠的异步队列 + 重试机制 + 幂等保证
  • 适用场景有限:适合"可容忍少量丢失"的计数、埋点类业务,不适合核心交易数据

二、三大经典问题

缓存穿透(Penetration)—— 不存在的数据一直打到 DB

[!QUESTION] 为什么是"不存在的数据"而不是"过期数据"?

  • 过期数据:过段时间就自然恢复了,是 TTL 管理的范畴
  • 不存在的数据:DB 和缓存都没有,请求每次都打到 DB,属于恶意攻击或 Bug
sequenceDiagram
    participant C as 客户端
    participant R as Redis
    participant D as MySQL

    C->>R: GET user:999999999
    R-->>C: nil (未命中)
    C->>D: SELECT * FROM users WHERE id=999999999
    D-->>C: empty result
    Note over C,D: 每次非法请求都走完整链路<br/>QPS = N × 非法流量

方案 1:布隆过滤器(Bloom Filter)

flowchart LR
    Req["请求"] --> BF{"布隆过滤器"}
    BF -->|不存在<br/>直接拒绝| Reject["返回空 / 错误"]
    BF -->|可能存在| Cache["查 Redis"]
    Cache -->|"nil"| DB["查 DB"]
    Cache -->|"命中"| Return["返回缓存值"]
    DB -->|"有数据"| StoreCache["写入缓存 + 回刷 BF"]
    DB -->|"无数据"| EmptyCache["空值缓存"]
    
    style Reject fill:#fcc,stroke:#c00
    style Return fill:#cfc,stroke:#090
    style StoreCache fill:#dfd,stroke:#090
// Package main — 布隆过滤器预加载示例
import "github.com/bits-and-blooms/bloom/v3"

bf := bloom.NewWithEstimates(1_000_000, 0.01) // 百万级元素,错误率 1%
bf.Add([]byte("user:1"))
bf.Add([]byte("user:2"))

if !bf.Test([]byte("user:999999")) {
    return nil, fmt.Errorf("key does not exist") // 直接拒绝,不查 DB
}

[!TIP] 布隆过滤器的误判 false positive 会发生(判断存在但实际不存在),但不会 false negative。所以把拦截器放在 DB 前面是安全的。

方案 2:空值缓存

// 缓存一个短过期时间的空值(如 30s ~ 5min)
rdb.Set(ctx, "user:999999", "", 5*time.Minute)
// TTL 不宜过长,否则业务数据新增后仍然拦截

[!QUESTION] 两种方案怎么选?

  • 全量已知数据集(如商品 SKU ID 列表)→ 布隆过滤器,精确高效
  • 动态未知数据集(如任意用户 ID)→ 空值缓存,实现简单

缓存击穿(Breakdown)—— 热点 key 过期瞬间 QPS 洪峰

sequenceDiagram
    participant K as Key: hot_product:42
    participant R as Redis
    participant D as DB

    time t1
    K->>R: GET key
    R-->>K: ✅ 命中,正常返回

    time t2 (TTL 到期)
    K->>R: GET key → nil!

    time t3 — "击穿瞬间"
    rect rgba(255, 0, 0, 0.1)
        loop 1000 个并发请求
            R-->>Req: nil (未命中)
            Req->>D: SELECT ... (全部打到 DB!)
        end
    end

    Note over D: ⚠️ DB QPS 瞬时暴增 → 可能崩溃

方案 1:互斥锁(Mutex Lock)

func GetProduct(ctx context.Context, id int64) (*Product, error) {
    cacheKey := "product:" + strconv.FormatInt(id, 10)

    // 先查缓存
    data, _ := rdb.Get(ctx, cacheKey).Bytes()
    if data != nil {
        var p Product
        json.Unmarshal(data, &p)
        return &p, nil
    }

    // 拿分布式锁,只有一个 goroutine 去查 DB + 重建缓存
    lockKey := "lock:" + cacheKey
    const maxRetries = 3
    for i := 0; i < maxRetries; i++ {
        locked, _ := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
        if locked {
            defer rdb.Del(ctx, lockKey)
            break
        }
        // 未拿到锁 → 等别人重建完后再查缓存
        time.Sleep(50 * time.Millisecond)
        if v, _ := rdb.Get(ctx, cacheKey).Bytes(); v != nil {
            var p Product
            json.Unmarshal(v, &p)
            return &p, nil
        }
        if i == maxRetries-1 {
            return nil, errors.New("获取锁超时,降级处理")
        }
    }

    // 双重检查——防止自己等太久,另一个已经重建了
    data, _ = rdb.Get(ctx, cacheKey).Bytes()
    if data != nil {
        var p Product
        json.Unmarshal(data, &p)
        return &p, nil
    }

    p, _ := db.GetProduct(id)
    rdb.Set(ctx, cacheKey, toJSON(p), 30*time.Minute)
    return p, nil
}

方案 2:逻辑过期(Logical Expiration)

flowchart LR
    Read["读请求"] --> Check{"检查 expire_at"}
    Check -->|"未过期"| Return["直接返回旧值 ✅"]
    Check -->|"已过期"| Async["启动 goroutine 重建缓存"]
    Async --> ReturnOld["返回旧值(不阻塞)"]
    
    subgraph "后台重建"
        Rebuild["查 DB 最新数据"] --> SetNew["覆盖 Redis value"]
    end
    
    style Return fill:#cfc,stroke:#090
    style ReturnOld fill:#ffd,stroke:#da0

[!TIP] 两种方案的本质区别

  • Mutex Lock:保证返回的一定是最新数据,但会短暂阻塞
  • Logical Expiration:保证永远不阻塞用户,但可能返回略旧的数据
// 读取时不检查物理 TTL,只检查逻辑过期时间
v := rdb.Get(ctx, key).Val()
var item struct {
    Content   string
    ExpireAt  int64
}
json.Unmarshal([]byte(v), &item)

if time.Now().Unix() < item.ExpireAt {
    return item.Content // 未过期,直接返回
}

// 异步重建缓存(不阻塞当前请求)
// ⚠️ 需配合 singleflight 或分布式锁,防止并发触发大量重建
go rebuildCacheAsync(key, item.ExpireAt)
return item.Content // 返回旧值,后台静默刷新

如何发现热点 Key?

击穿的前提是"热点",但如果不知道哪些 Key 是热点,防护就无从谈起:

方案 原理 适用场景
Redis MONITOR / redis-cli --hotkeys Redis 4.0+ 的 LFU 采样,直接输出高频 Key 开发调试、小规模集群
客户端代理统计 在 SDK 层埋点,按 Key 维度统计 QPS 生产环境精确统计
Redis Cluster CLUSTER COUNTKEYSINSLOT 统计 slot 级别的 Key 分布 定位数据倾斜
离线分析慢查询日志 SLOWLOG GET + 日志聚合 事后分析

缓存雪崩(Avalanche)—— 大量 key 同时过期

[!QUESTION] 穿透/击穿/雪崩 的区别是什么?

  • 穿透:数据永远不存在,请求直达 DB → 关注"过滤非法请求"
  • 击穿:单个热点 key 过期,瞬时洪峰 → 关注"防并发重建"
  • 雪崩:大批量 / 全部 key 同时过期 → 关注"错峰 + 兜底"
flowchart LR
    subgraph "❌ 无抖动"
        T["TTL=30min"] -->|"t=30:00"| AllExpire["100% keys expire simultaneously"]
        AllExpire --> Crash["DB overload → crash"]
        style Crash fill:#fcc,stroke:#c00
    end

    subgraph "✅ 加随机抖动"
        T2["TTL=30min ±5min"] -->|"t=30:00"| P1["20% expire"]
        T2 -->|"t=31:00"| P2["20% expire"]
        T2 -->|"t=32:00"| P3["20% expire"]
        T2 -->|"t=33:00"| P4["20% expire"]
        T2 -->|"t=34:00"| P5["20% expire"]
        P1 -->|"DB 平稳承受"| Safe
        P2 --> Safe
        P3 --> Safe
        P4 --> Safe
        P5 --> Safe
        style Safe fill:#cfc,stroke:#090
    end

解决方案

① TTL 加随机偏移(最常用)

// ✅ 给 TTL 加随机偏移(抖动)
baseTTL := 30 * time.Minute
jitter := time.Duration(rand.Intn(300)) * time.Second // 0~5min 随机
rdb.Set(ctx, key, value, baseTTL+jitter)

[!NOTE] 抖动范围怎么定? 建议设为基础 TTL 的 10% ~ 20%。太小效果不明显,太大则可能导致冷数据过早失效。

② 多级缓存

// L1 本地缓存(进程内)→ L2 Redis(分布式)→ DB
func GetValue(key string) ([]byte, error) {
    if v := localCache.Get(key); v != nil { // ~1μs,L1 hit
        return v, nil
    }
    
    rdbKey := "cache:" + key
    if v, err := rdb.Get(ctx, rdbKey).Bytes(); err == nil { // ~1ms,L2 hit
        localCache.Set(key, v) // L1 回填
        return v, nil
    }
    
    // L2 miss → 查 DB → 回填两级缓存
    data := queryDB(key)
    l1TTL, l2TTL := 5*time.Minute, 30*time.Minute
    rdb.Set(ctx, rdbKey, data, l2TTL)
    localCache.Set(key, data)
    return data, nil
}

③ 限流降级(兜底策略)

// 当缓存命中率骤降时,触发熔断
if cacheMissRate > 0.5 {
    // 返回默认响应 / 排队等待 / 直接限流
    return fallbackResponse()
}

[!WARNING] 多级缓存的一致性挑战 本地缓存和 Redis 之间天然存在延迟不一致。多节点部署下,A 节点的本地缓存和 B 节点的本地缓存可能看到不同值。适合读多写少、允许短暂不一致的场景。

flowchart LR
    subgraph "应用进程内"
        L1["L1 本地缓存<br/>sync.Map / bigcache<br/>⚡ ~1μs"]
    end
    
    subgraph "分布式层"
        L2["L2 Redis Cluster<br/>⚡ ~1ms"]
    end
    
    L1 -->|"miss"| L2
    L2 -->|"miss"| DB["MySQL / 外部 API<br/>⚡ ~5~50ms"]
    DB -->|"回填"| L2
    L2 -->|"广播失效"| L1

三、缓存设计原则总结

# 原则 说明
1 永远不信任单一数据源 缓存和 DB 保持最终一致
2 设 TTL 是必须的 即使内存够也要设,防止永久占用
3 TTL 加随机抖动 避免大规模同时过期
4 大 Key 拆小 单个 Key ≤ 5KB,防止网络阻塞
5 Pipeline 批量操作 减少 RTT,提升吞吐
6 监控命中率 低于 80% 考虑调整策略
7 静态数据不必放 Redis 配置、字典等低频变化数据用本地缓存即可,节省网络开销
8 敏感数据不缓存明文 密码、手机号等脱敏或加密后再写入缓存

四、进阶:基于 Binlog 的缓存一致性

Cache-Aside 的"先删缓存再更新 DB"存在竞态窗口。生产中更可靠的方案是监听 DB 变更日志(Binlog),异步驱动缓存失效。

flowchart LR
    App["业务写请求"] --> DB["MySQL"]
    DB -->|"Binlog"| Canal["Canal / Debezium"]
    Canal --> MQ["Kafka / RocketMQ"]
    MQ --> Consumer["缓存失效 Worker"]
    Consumer -->|"DEL key"| Redis["Redis"]
    
    style DB fill:#ddf,stroke:#66c
    style Canal fill:#fda,stroke:#da0
    style Redis fill:#fcc,stroke:#c00

[!QUESTION] 这种方案解决了什么问题?

  • 解除业务代码耦合:写操作不需要关心缓存失效,由 Binlog 订阅统一处理
  • 减少竞态窗口:Binlog 保证有序,可以按变更顺序精确删除缓存
  • 适合多数据源:一次 DB 变更可以同时驱动多个缓存实例失效

[!NOTE] 实际落地注意点

  • 延迟:Binlog → MQ → Consumer 链路通常有 100ms~1s 延迟,仍属于最终一致性
  • 幂等:Consumer 必须保证幂等(重复消费同一 Binlog 不产生副作用)
  • 顺序性:同一行的多次变更需要保证消费顺序,通常按 ROW_ID 分区

五、常见架构模式速览

flowchart TD
    Client["客户端请求"]
    
    Client --> LocalCache{"本地缓存?"}
    LocalCache -->|hit| Return["返回"]
    LocalCache -->|miss| Redis["Redis"]
    
    Redis -->|hit| Return
    Redis -->|miss| DB["数据库"]
    
    DB --> Redis
    Redis --> LocalCache
    
    Writer["写操作"] -->|"先更 DB"| DB
    DB -->|"异步/同步刷缓存"| Redis
    
    style Redis fill:#fda,stroke:#da0
    style LocalCache fill:#dfd,stroke:#090
    style DB fill:#ddf,stroke:#66c

六、缓存选型与演进路径

[!QUESTION] 项目初期应该从哪一层开始做缓存? 答案:先做 L2(Redis),稳定后再加 L1(本地缓存)。 原因如下:

决策树

flowchart TD
    Start["有新需求要加缓存"] --> Q1{"数据是否频繁变化?"}
    
    Q1 -->|"否 — 配置/字典"| Config["直接放应用内存<br/>不经过 Redis"]
    Q1 -->|"是"| Q2{"QPS > 5000?"}
    
    Q2 -->|"否 — 低频场景"| Simple["L2 only: Cache-Aside<br/>+ Redis"]
    Q2 -->|"是 — 中频场景"| Medium["L2 + ReadThrough<br/>引入布隆过滤器防穿透"]
    Q2 -->|"是 — 高频热点"| HotKey["L1 + L2 多级缓存<br/>+ 逻辑过期防击穿"]
    
    Config --> End["完成 ✅"]
    Simple --> End
    Medium --> End
    HotKey --> End
    
    style Config fill:#ffd,stroke:#da0
    style Simple fill:#dfd,stroke:#090
    style Medium fill:#fdd,stroke:#900
    style HotKey fill:#fcc,stroke:#c00

不同阶段的关键关注点

阶段 核心目标 推荐模式 避坑指南
V1 起步期 快速上线,能跑就行 Cache-Aside + Redis 不要过度设计,先解决有无问题
V2 增长期 扛住流量高峰 布隆过滤器 + 互斥锁 关注监控指标,建立告警机制
V3 大规模 极致性能和稳定性 多级缓存 + 逻辑过期 + 降级策略 一致性 vs 可用性的平衡取舍

[!TIP] 一句话总结 好的缓存架构不是设计出来的,是演进出来的。先保证正确性,再优化性能,最后打磨可用性。

关联笔记