3. 缓存读写模式:Cache-Aside 模式详解

Cache-Aside(旁路缓存)是生产环境中最常用的缓存读写模式,也是 Go 语言操作 Redis 缓存的标准实践。该模式的核心思想是:应用程序直接管理缓存的读写,缓存与数据库之间不直接通信。本章将深入剖析 Cache-Aside 模式的读流程、写流程及其 Go 实现。

3.1 读流程:先查缓存,后查 DB

读流程遵循“缓存优先”原则,具体步骤如下:

  1. 查询缓存:应用程序首先尝试从 Redis 中获取数据。
  2. 缓存命中:若缓存中存在数据,直接返回给调用方,流程结束。
  3. 缓存未命中:若缓存中不存在数据,则查询数据库。
  4. 回写缓存:将从数据库查询到的数据写入 Redis,并设置过期时间。
  5. 返回数据:将数据返回给调用方。
关键指标:读流程的目标是最大化缓存命中率(Cache Hit Ratio)。每提升 1% 的命中率,数据库查询压力可降低 10%~30%。建议将命中率监控作为核心指标。
Cache-Aside 读流程 客户端 Redis 缓存 数据库 ① 查询缓存 ② 缓存命中 ③ 未命中,查 DB ④ 回写缓存 ⑤ 返回数据 核心原则 • 缓存未命中才查 DB • 回写必须设置 TTL • 避免缓存穿透

3.2 写流程:先更新 DB,后删除缓存

写流程采用“删除缓存”策略,而非更新缓存。这是为了避免并发写操作导致的缓存与数据库数据不一致问题。具体步骤如下:

  1. 更新数据库:应用程序先执行数据库写操作(INSERT / UPDATE / DELETE)。
  2. 删除缓存:数据库操作成功后,立即删除 Redis 中对应的缓存键。
  3. 后续读取:下次读取时,因缓存缺失,会重新从数据库加载并回写缓存。
为什么是删除而不是更新? 如果先更新缓存,在高并发场景下,多个写请求可能导致缓存中的值被旧数据覆盖。删除缓存则让下一次读请求自动拉取最新数据,天然避免了“写写冲突”。
Cache-Aside 写流程 客户端 数据库 Redis 缓存 ① 更新 DB ② 返回成功 ③ 删除缓存 ④ 删除确认 写流程要点 • 先更新 DB,后删缓存 • 删除失败需重试 • 最终一致性保证 • 避免缓存与 DB 双写

3.3 读/写流程对比

维度 读流程 写流程
操作顺序 先查缓存 → 未命中再查 DB 先更新 DB → 后删除缓存
缓存操作 回写(SET) 删除(DEL)
一致性模型 最终一致(依赖 TTL) 最终一致(删除后下次读拉新)
并发风险 缓存穿透、缓存击穿 删除失败导致脏数据
性能影响 命中时极快,未命中时增加一次 DB 查询 增加一次缓存删除操作

3.4 Go 代码实现

以下代码展示了 Cache-Aside 模式在 Go 中的完整实现,包含读流程和写流程的核心逻辑。

3.4.1 读流程实现

// GetUser 读取用户信息,实现 Cache-Aside 读流程
func GetUser(ctx context.Context, userID string) (*User, error) {
    cacheKey := "user:" + userID

    // 1. 先查缓存
    val, err := redisClient.Get(ctx, cacheKey).Result()
    if err == nil {
        // 缓存命中,直接反序列化返回
        user := &User{}
        if err := json.Unmarshal([]byte(val), user); err != nil {
            return nil, fmt.Errorf("缓存数据反序列化失败: %w", err)
        }
        return user, nil
    }

    // 2. 缓存未命中(包括 key 不存在或连接错误)
    if err != redis.Nil {
        // Redis 连接异常,记录告警,降级查 DB
        log.Warnf("Redis 读取异常: %v,降级查 DB", err)
    }

    // 3. 查询数据库
    user, err := db.GetUserByID(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("数据库查询失败: %w", err)
    }

    // 4. 回写缓存,设置过期时间 1 小时
    data, _ := json.Marshal(user)
    err = redisClient.Set(ctx, cacheKey, data, 1*time.Hour).Err()
    if err != nil {
        log.Warnf("缓存回写失败: %v", err)
        // 回写失败不影响主流程,仅记录日志
    }

    return user, nil
}

3.4.2 写流程实现

// UpdateUser 更新用户信息,实现 Cache-Aside 写流程
func UpdateUser(ctx context.Context, user *User) error {
    cacheKey := "user:" + user.ID

    // 1. 先更新数据库(使用事务保证原子性)
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("开启事务失败: %w", err)
    }
    defer tx.Rollback() // 确保异常时回滚

    if err := tx.UpdateUser(ctx, user); err != nil {
        return fmt.Errorf("数据库更新失败: %w", err)
    }

    // 2. 提交事务
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("事务提交失败: %w", err)
    }

    // 3. 数据库更新成功后,删除缓存
    //    使用重试机制保证最终一致性
    retryCount := 3
    for i := 0; i < retryCount; i++ {
        err = redisClient.Del(ctx, cacheKey).Err()
        if err == nil {
            return nil // 删除成功
        }
        log.Warnf("缓存删除失败(第%d次): %v", i+1, err)
        time.Sleep(100 * time.Millisecond) // 短暂等待后重试
    }

    // 4. 重试仍失败,记录告警并投递到延迟队列
    log.Errorf("缓存删除最终失败,key=%s,需人工介入或异步补偿", cacheKey)
    // 投递到延迟队列,由后台任务异步删除
    _ = delayQueue.Publish(ctx, &CacheDeleteTask{
        Key:       cacheKey,
        RetryTime: time.Now().Add(5 * time.Minute),
    })

    return nil // 返回 nil 表示业务成功,缓存最终一致
}

3.5 常见陷阱与最佳实践

陷阱 1:先删缓存,后更新 DB
并发场景下,线程 A 删除缓存后,线程 B 读取到旧数据并回写缓存,导致缓存中永久为旧数据。必须坚持“先更新 DB,后删缓存”。
陷阱 2:缓存删除失败不处理
删除缓存可能因网络抖动失败。必须实现重试机制,并在最终失败时投递到延迟队列进行异步补偿。
陷阱 3:缓存未设置过期时间
即使写流程删除了缓存,仍应设置 TTL 作为兜底。防止因删除失败导致缓存永不过期,造成长期数据不一致。

3.6 一致性保障策略对比

策略 一致性强度 实现复杂度 适用场景
Cache-Aside(删除缓存) 最终一致 低 大多数业务场景
延迟双删 最终一致(更强) 中 对一致性要求较高的场景
消息队列异步同步 最终一致 高 跨服务、跨数据源场景
分布式锁 + 缓存更新 强一致 高 金融、库存等强一致场景
推荐实践:对于 90% 的业务场景,Cache-Aside 模式配合 TTL 兜底 + 删除重试机制,即可在性能和一致性之间取得良好平衡。无需过度设计引入分布式锁。