第 30 章

缓存架构设计

MySQL + Redis 缓存架构

缓存是解决 MySQL 读压力的最有效手段之一。正确的缓存架构可以将 MySQL 的 QPS 压力降低 90% 以上,而错误的缓存设计会带来数据不一致、缓存击穿等灾难性问题。本章从工程实战角度,系统讲解缓存模式选型、三大经典问题及解法、数据一致性保障。

1. 为什么需要缓存层?

内存访问约 100 ns,本地 SSD 约 100 μs,网络 MySQL 查询约 1-10 ms。缓存的本质是以内存的速度提供数据服务,避免每次都走 DB。

层级 典型延迟 适合数据特征
进程内 Guava Cache / Caffeine <1μs 极高频、数据量小、可接受短暂不一致
Redis 分布式缓存 0.5-2ms 高频、跨服务共享、需要原子操作
MySQL 从库(读写分离) 5-20ms 次高频、复杂查询、强一致要求
MySQL 主库 5-50ms 写操作、强一致读

2. 缓存读写模式

2.1 Cache-Aside(旁路缓存,最常用)

读: Application ──→ 查 Redis ──→ 命中 ──→ 返回数据 └── 未命中 ──→ 查 MySQL ──→ 写入 Redis ──→ 返回

写: Application ──→ 更新 MySQL ──→ 删除 Redis key(而非更新)

// Go 示例:Cache-Aside 读取
func GetUser(ctx context.Context, userID int64) (*User, error) {
    key := fmt.Sprintf("user:%d", userID)

    // 1. 查 Redis
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        var user User
        json.Unmarshal([]byte(val), &user)
        return &user, nil
    }

    // 2. 查 MySQL
    user, err := db.QueryUser(ctx, userID)
    if err != nil {
        return nil, err
    }

    // 3. 写回 Redis(设置 TTL 防止永不过期)
    data, _ := json.Marshal(user)
    rdb.Set(ctx, key, data, 30*time.Minute)
    return user, nil
}

// 写操作:先写 DB,再删缓存
func UpdateUser(ctx context.Context, user *User) error {
    if err := db.UpdateUser(ctx, user); err != nil {
        return err
    }
    key := fmt.Sprintf("user:%d", user.ID)
    rdb.Del(ctx, key) // 删除而非更新,简单且安全
    return nil
}

**为什么是"删除"而不是"更新"缓存?**更新缓存在并发写时可能出现:线程A写DB完→线程B写DB完→线程B更新缓存→线程A更新缓存(覆盖了B,导致缓存是旧值)。删除则只会导致下一次读回源,不会有这种竞争。

2.2 Read-Through

缓存层代理 DB 查询,应用只和缓存交互。缓存未命中时,缓存层自动查 DB 并填充。实现较复杂,需要缓存层支持(如 Redis + 自定义 Cache Provider)。

2.3 Write-Through

每次写操作同步写入缓存和 DB,由缓存层保证两者一致。写延迟较高,适合读多写少、一致性要求高的场景。

2.4 Write-Behind(Write-Back)

写操作只写缓存,异步批量刷新到 DB。写性能最高(内存级),但有丢数据风险(缓存故障)。适合日志统计、计数器类数据。

模式 读性能 写性能 一致性 适用场景
Cache-Aside 最终一致 通用首选
Read-Through 最终一致 统一缓存代理
Write-Through 强一致 金融类强一致
Write-Behind 最高 计数器/日志

3. 三大经典问题

🔴 缓存击穿(Cache Breakdown)

定义:某个热点 key 过期的瞬间,大量并发请求同时涌入 DB,造成 DB 瞬间压力激增。

**场景:**明星直播间、秒杀商品页在缓存刚过期时被几万个请求同时打穿。

解法:

// 互斥锁方案(Lua 脚本保证原子性)
func GetWithMutex(ctx context.Context, key string) (string, error) {
    val, err := rdb.Get(ctx, key).Result()
    if err == nil { return val, nil } // 命中直接返回

    lockKey := "lock:" + key
    // 尝试获取锁(SET NX EX)
    ok, _ := rdb.SetNX(ctx, lockKey, "1", 5*time.Second).Result()
    if !ok {
        // 未拿到锁,等待后重试(或返回 loading 状态)
        time.Sleep(50 * time.Millisecond)
        return GetWithMutex(ctx, key)
    }
    defer rdb.Del(ctx, lockKey)

    // 双重检查(拿到锁后再查一次缓存)
    val, err = rdb.Get(ctx, key).Result()
    if err == nil { return val, nil }

    // 查 DB 并回填
    dbVal := queryDB(key)
    rdb.Set(ctx, key, dbVal, 30*time.Minute)
    return dbVal, nil
}

🟠 缓存雪崩(Cache Avalanche)

**定义:**大量缓存 key 同时失效(如同一时间批量设置相同 TTL),或 Redis 集群整体宕机,导致所有请求涌入 DB。

解法:

// TTL 随机抖动
baseTTL := 30 * time.Minute
jitter := time.Duration(rand.Intn(300)) * time.Second
rdb.Set(ctx, key, value, baseTTL + jitter)

🔵 缓存穿透(Cache Penetration)

定义:查询不存在的数据,缓存永远未命中(因为 DB 也没有),每次都打到 DB。恶意爬虫/攻击可利用此造成 DB 压力。

解法:

-- Redis 布隆过滤器(需要 RedisBloom 模块)
BF.RESERVE user_bloom 0.001 1000000  -- 错误率 0.1%,预期 100 万元素
BF.ADD user_bloom 1001   -- 插入已存在的用户 ID

-- 查询前先检查
BF.EXISTS user_bloom 9999  -- 返回 0:确定不存在,直接返回空
BF.EXISTS user_bloom 1001  -- 返回 1:可能存在,继续查缓存/DB

4. 缓存与数据库的数据一致性

4.1 一致性问题的根源

分布式系统中,缓存和 DB 是两个独立存储,无法原子更新两者。任何 "先写A后写B" 的方案都存在窗口期不一致。

4.2 先删缓存还是先写 DB?

方案 问题 推荐程度
先删缓存,后写 DB 写 DB 失败:缓存已删,下次读到旧 DB 值并回填缓存(不一致时间短)。更大问题:并发情况下,先删缓存后,另一个读请求读到旧 DB 值回填缓存,然后写完 DB,导致缓存是旧值 ❌ 不推荐
先写 DB,后删缓存 大多数情况正确。极端并发场景(缓存刚好过期 + 旧值读回填 + DB 写完删缓存)仍有短暂不一致窗口 ✅ 推荐
先写 DB,延迟双删 写 DB → 删缓存 → 延迟 500ms → 再次删缓存。覆盖并发回填的旧值 ✅ 推荐(高要求场景)

4.3 基于 Canal 的异步缓存失效(最终一致)

应用写 MySQL ↓ MySQL Binlog ↓ Canal 监听 Binlog 变更 ↓ 消息队列(Kafka/RocketMQ) ↓ 缓存失效服务 → DELETE Redis keys

这种方案将缓存失效从业务代码中解耦,缓存失效由 DB 变更事件驱动,是大型系统的标准做法。

5. 多级缓存架构

请求 ↓ L1: 进程内缓存(Caffeine/Guava,<1μs,容量小) ↓ 未命中 L2: Redis 分布式缓存(~1ms,容量大) ↓ 未命中 L3: MySQL 数据库(~10ms,持久化) ↓ 回填 L2 → 回填 L1

// 多级缓存 Go 实现框架
type MultiLevelCache struct {
    local  *ristretto.Cache  // 进程内缓存(ristretto 高性能)
    redis  *redis.Client
    db     *sql.DB
}

func (c *MultiLevelCache) Get(ctx context.Context, key string) (interface{}, error) {
    // L1: 本地缓存
    if val, ok := c.local.Get(key); ok {
        return val, nil
    }
    // L2: Redis
    val, err := c.redis.Get(ctx, key).Result()
    if err == nil {
        c.local.SetWithTTL(key, val, 1, 1*time.Minute) // 回填 L1
        return val, nil
    }
    // L3: DB
    dbVal, err := queryFromDB(c.db, key)
    if err != nil { return nil, err }

    c.redis.Set(ctx, key, dbVal, 30*time.Minute)        // 回填 L2
    c.local.SetWithTTL(key, dbVal, 1, 1*time.Minute)    // 回填 L1
    return dbVal, nil
}

6. Redis Key 设计规范

-- 命名规范:业务:对象:ID[:字段]
user:profile:1001           -- 用户基本信息
user:orders:1001            -- 用户订单列表(Set/ZSet)
product:detail:SKU2024001   -- 商品详情
order:status:ORD20240101    -- 订单状态
session:token:abc123        -- 会话

-- 避免:
userId1001                  -- 无业务前缀,难以管理
user_profile_1001           -- 下划线(不统一,用冒号)

-- 批量操作建议:Hash 结构聚合小对象
HSET user:1001 name "Alice" age 25 email "[email protected]"
-- 一次网络往返获取多个字段,比多个 GET 效率高

**Big Key 问题:**单个 Redis Key 存储超过 10KB(String)或元素超过 5000(Hash/List/Set)算大 key。大 key 的查询和删除会阻塞 Redis 单线程。拆分策略:Hash 结构按字段 hash 分桶,List 按 page 分段,大 String 考虑压缩或拆分。

本章评分
4.7  / 5  (3 评分)

💬 留言讨论