4. 缓存读写模式:Cache-Aside、Read-Through、Write-Through、Write-Behind 模式详解与 Go 代码实现

在分布式系统中,缓存与数据库之间的数据一致性是核心挑战。不同的缓存读写模式决定了系统在性能、一致性和可靠性之间的取舍。本章将深入剖析四种主流缓存模式:Cache-Aside、Read-Through、Write-Through 和 Write-Behind,并结合 Go 代码展示其实现细节与适用场景。

4.1 四种模式核心概念对比

模式 读操作策略 写操作策略 一致性保证 性能特征 实现复杂度
Cache-Aside 应用先读缓存,未命中则读 DB 并回填缓存 应用直接写 DB,然后删除或更新缓存 最终一致性(需处理并发写) 高读性能,写延迟受 DB 影响 低
Read-Through 缓存层自动回填(应用只与缓存交互) 同 Cache-Aside 或 Write-Through 最终一致性 读路径简化,缓存层负责回填 中
Write-Through 同 Read-Through 缓存先写,同步写 DB,成功后返回 强一致性(写路径) 写延迟增加(双写同步) 中
Write-Behind 同 Read-Through 缓存先写,异步批量写 DB 弱一致性(可能丢失数据) 极高写吞吐,延迟低 高
选型建议: 对于大多数业务系统,Cache-Aside 是最稳妥的起点。当读路径需要统一治理时,升级为 Read-Through;当写一致性要求高时,考虑 Write-Through;当写吞吐是瓶颈且能容忍短暂不一致时,才选择 Write-Behind。

4.2 Cache-Aside 模式详解与 Go 实现

Cache-Aside(旁路缓存)是应用最广泛的模式。应用代码显式管理缓存与数据库的交互。

4.2.1 读流程

  1. 应用查询缓存,若命中则直接返回。
  2. 若未命中,查询数据库。
  3. 将查询结果写入缓存,设置过期时间。
  4. 返回数据。

4.2.2 写流程

  1. 应用更新数据库。
  2. 删除缓存(或更新缓存,推荐删除以避免并发写导致的数据不一致)。
// CacheAside 示例:用户信息缓存
type UserService struct {
    db    *sql.DB
    cache *redis.Client
}

func (s *UserService) GetUser(ctx context.Context, userID string) (*User, error) {
    // 1. 读缓存
    key := "user:" + userID
    data, err := s.cache.Get(ctx, key).Bytes()
    if err == nil {
        user := &User{}
        if json.Unmarshal(data, user) == nil {
            return user, nil
        }
    }

    // 2. 缓存未命中,读数据库
    user, err := s.queryUserFromDB(ctx, userID)
    if err != nil {
        return nil, err
    }

    // 3. 回填缓存(设置过期时间 5 分钟)
    if bytes, err := json.Marshal(user); err == nil {
        s.cache.Set(ctx, key, bytes, 5*time.Minute)
    }

    return user, nil
}

func (s *UserService) UpdateUser(ctx context.Context, user *User) error {
    // 1. 更新数据库
    if err := s.updateUserInDB(ctx, user); err != nil {
        return err
    }

    // 2. 删除缓存(而非更新)
    key := "user:" + user.ID
    s.cache.Del(ctx, key)
    return nil
}
常见陷阱: 写操作时如果选择“更新缓存”而非“删除缓存”,在并发场景下容易产生“脏数据”。例如:线程 A 写 DB 为 100,线程 B 写 DB 为 200,但 B 先更新缓存为 200,A 后更新缓存为 100,导致缓存中永久保留旧值 100。因此,删除缓存是更安全的选择。

4.3 Read-Through 模式详解与 Go 实现

Read-Through 将“缓存未命中时回填数据库”的逻辑下沉到缓存层。应用只与缓存交互,缓存内部实现数据加载器(Loader)。

// Read-Through 模拟:自定义缓存封装
type ReadThroughCache struct {
    client *redis.Client
    loader func(ctx context.Context, key string) ([]byte, error) // 数据加载函数
}

func (c *ReadThroughCache) Get(ctx context.Context, key string) ([]byte, error) {
    data, err := c.client.Get(ctx, key).Bytes()
    if err == nil {
        return data, nil
    }

    // 缓存未命中,调用加载器
    data, err = c.loader(ctx, key)
    if err != nil {
        return nil, err
    }

    // 自动回填
    c.client.Set(ctx, key, data, 5*time.Minute)
    return data, nil
}

// 使用示例
cache := &ReadThroughCache{
    client: redisClient,
    loader: func(ctx context.Context, key string) ([]byte, error) {
        userID := strings.TrimPrefix(key, "user:")
        user, err := queryUserFromDB(ctx, userID)
        if err != nil {
            return nil, err
        }
        return json.Marshal(user)
    },
}
优势: 应用代码不再关心缓存回填逻辑,所有读路径统一由缓存层治理。适合微服务架构中多个消费者共享同一缓存策略的场景。

4.4 Write-Through 模式详解与 Go 实现

Write-Through 要求写操作必须同步写入缓存和数据库,两者都成功后才算完成。这保证了写路径的强一致性。

// Write-Through 实现:同步双写
type WriteThroughCache struct {
    client *redis.Client
    db     *sql.DB
}

func (c *WriteThroughCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
    // 1. 先写数据库(事务内)
    tx, err := c.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // 假设存储的是序列化后的 JSON
    if _, err := tx.ExecContext(ctx, "INSERT INTO cache_store (cache_key, data) VALUES ($1, $2) ON CONFLICT (cache_key) DO UPDATE SET data = $2", key, value); err != nil {
        return err
    }

    // 2. 写缓存
    if err := c.client.Set(ctx, key, value, ttl).Err(); err != nil {
        return err // 缓存失败,回滚数据库
    }

    // 3. 提交数据库事务
    return tx.Commit()
}
性能代价: Write-Through 的写延迟是“缓存延迟 + 数据库延迟”,通常比 Cache-Aside 的写延迟更高。适用于对数据一致性要求极高的场景,如金融交易、订单状态变更。

4.5 Write-Behind 模式详解与 Go 实现

Write-Behind(也叫 Write-Back)将写操作先写入缓存,然后异步批量写入数据库。这极大提升了写吞吐,但引入了数据丢失风险。

// Write-Behind 实现:异步批量刷盘
type WriteBehindCache struct {
    client    *redis.Client
    db        *sql.DB
    buffer    chan WriteOp       // 写操作缓冲通道
    batchSize int
    flushInterval time.Duration
}

type WriteOp struct {
    Key   string
    Value []byte
}

func (c *WriteBehindCache) Start(ctx context.Context) {
    ticker := time.NewTicker(c.flushInterval)
    defer ticker.Stop()

    batch := make([]WriteOp, 0, c.batchSize)
    for {
        select {
        case op := <-c.buffer:
            batch = append(batch, op)
            if len(batch) >= c.batchSize {
                c.flushToDB(ctx, batch)
                batch = batch[:0]
            }
        case <-ticker.C:
            if len(batch) > 0 {
                c.flushToDB(ctx, batch)
                batch = batch[:0]
            }
        case <-ctx.Done():
            // 退出前强制刷盘
            if len(batch) > 0 {
                c.flushToDB(ctx, batch)
            }
            return
        }
    }
}

func (c *WriteBehindCache) flushToDB(ctx context.Context, batch []WriteOp) {
    // 批量写入数据库(使用事务或批量 INSERT)
    tx, _ := c.db.BeginTx(ctx, nil)
    for _, op := range batch {
        tx.ExecContext(ctx, "INSERT INTO cache_store (cache_key, data) VALUES ($1, $2) ON CONFLICT (cache_key) DO UPDATE SET data = $2", op.Key, op.Value)
    }
    tx.Commit()
}

func (c *WriteBehindCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
    // 先写缓存
    if err := c.client.Set(ctx, key, value, ttl).Err(); err != nil {
        return err
    }
    // 异步入队
    c.buffer <- WriteOp{Key: key, Value: value}
    return nil
}
关键设计点: Write-Behind 必须考虑宕机时的数据丢失。生产环境中常结合 WAL(Write-Ahead Log) 或 消息队列 来保证数据不丢。例如:先写 Redis 并记录一条 WAL 日志,后台进程消费 WAL 写入 DB。

4.6 四种模式架构对比图

下图展示了四种模式在读写路径上的核心差异,帮助理解数据流向与一致性边界。

四种缓存读写模式架构对比 Cache-Aside 应用管理缓存 读: 缓存→DB→回填 写: DB→删缓存 一致性: 最终 复杂度: 低 Read-Through 缓存层自动回填 读: 缓存→Loader→回填 写: 同 Cache-Aside 一致性: 最终 复杂度: 中 Write-Through 同步双写 写: 缓存→DB→返回 读: 同 Read-Through 一致性: 强 复杂度: 中 Write-Behind 异步批量写 DB 写: 缓存→队列→DB 读: 同 Read-Through 一致性: 弱 复杂度: 高 关键决策维度 • 一致性要求:强一致性选 Write-Through;最终一致性选 Cache-Aside/Read-Through;弱一致性可接受选 Write-Behind • 写吞吐要求:高吞吐选 Write-Behind;中等吞吐选 Cache-Aside;低吞吐但高一致选 Write-Through • 实现成本:Cache-Aside 最低,Write-Behind 最高(需处理异步刷盘、宕机恢复、重复消费) • 运维复杂度:Read-Through/Write-Through 需要缓存层支持 Loader 函数,Write-Behind 需要额外队列和 WAL 组件 • 推荐路径:从 Cache-Aside 起步,按需演进;避免一开始就引入 Write-Behind 的复杂度

4.7 模式选型决策树

在实际项目中,可以根据以下决策树快速选择适合的缓存模式:

缓存模式选型决策树 开始选型 写一致性要求高? 是 否 Write-Through 写吞吐要求高? 是 否 Write-Behind 读路径需统一治理? 是 否 Read-Through Cache-Aside

4.8 生产环境注意事项

  • 缓存穿透: 查询一个不存在的数据,导致每次请求都穿透到 DB。解决方案:缓存空值(设置短 TTL)或使用布隆过滤器。
  • 缓存击穿: 热点 key 在失效瞬间被大量并发请求打到 DB。解决方案:互斥锁(Mutex)或后台异步刷新。
  • 缓存雪崩: 大量 key 在同一时间过期,导致 DB 压力激增。解决方案:设置随机 TTL,或使用多级缓存。
  • 删除缓存失败: 在 Cache-Aside 写流程中,如果删除缓存失败,会导致脏数据。解决方案:使用重试机制或监听 binlog 异步删除。
  • Write-Behind 数据丢失: 缓存宕机导致未刷盘的数据丢失。解决方案:结合 WAL 日志或消息队列的持久化机制。
最佳实践: 在 Go 项目中,推荐使用 singleflight 包(golang.org/x/sync/singleflight)来防止缓存击穿。对于热点 key,可以结合 sync.Map 做本地缓存,形成多级缓存架构,进一步降低 Redis 压力。