3. 缓存读写模式:Cache-Aside(旁路缓存)模式原理与Go实现

Cache-Aside(旁路缓存)是生产环境中最常用的缓存读写模式,也是理解缓存与数据库一致性的基础。该模式的核心思想是:应用程序直接同时操作缓存和数据库,缓存层不感知数据库的存在。读写流程由应用层代码显式编排,因此被称为“旁路”。

3.1 核心流程与原理

Cache-Aside 将每一次数据访问拆分为四个原子步骤,分别对应读请求和写请求的不同路径。下表总结了两种操作的标准流程:

操作类型 步骤序列 关键原则
读操作 ① 查询缓存 → ② 缓存命中则直接返回 → ③ 缓存未命中则查询数据库 → ④ 将数据库结果写入缓存并返回 先查缓存,后查库;回填缓存时设置过期时间
写操作 ① 更新数据库 → ② 删除缓存(或更新缓存) 先更新库,后淘汰缓存;避免并发脏写
关键认知:写操作中“先更新数据库,再删除缓存”是经过业界验证的推荐顺序。如果先删除缓存再更新数据库,在并发场景下会导致缓存与数据库长时间不一致(即“缓存击穿 + 脏读”组合问题)。

3.2 Cache-Aside 读写流程图

下图展示了 Cache-Aside 模式在读写请求下的完整数据流向。左侧为读路径,右侧为写路径,中间为共享的 Redis 缓存层与 MySQL 数据库层。

Cache-Aside 模式读写流程图 Go 应用层 读请求路径 写请求路径 ① 查询 Redis 缓存 ② 缓存命中 → 直接返回 ③ 缓存未命中 → 查询 MySQL ④ 回填缓存 + 设置 TTL ① 更新 MySQL 数据库 ② 删除 Redis 缓存 写操作:先更新数据库,再删除缓存 读操作:缓存未命中时回填并设置过期时间

3.3 为什么写操作要“先更新库,后删缓存”?

这是 Cache-Aside 模式中最容易引发争议的设计点。下面通过一个并发场景对比两种顺序的差异:

执行顺序 并发问题 一致性结果
先删缓存 → 后更新库 线程A删除缓存后,线程B读请求发现缓存为空,查询旧数据并回填缓存;随后线程A才更新数据库 缓存中为旧数据,数据库为新数据,不一致窗口期较长
先更新库 → 后删缓存 线程A更新数据库后,线程B读请求可能命中旧缓存(删除前瞬间),但缓存很快被删除,后续读请求会拉取新数据 不一致窗口极短(毫秒级),且最终一致
实践结论:“先更新库,后删缓存”是 Cache-Aside 的标准做法。如果删除缓存失败,应通过重试机制或异步补偿(如消息队列)保证最终删除成功,而不是改变顺序。

3.4 Go 语言实现:通用 Cache-Aside 封装

以下代码实现了一个泛型化的 Cache-Aside 读写组件,支持任意数据类型的缓存与回填。核心逻辑集中在 Get 和 Set 两个方法中。

package cacheaside

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
    "gorm.io/gorm"
)

// CacheAside 旁路缓存核心结构
type CacheAside[T any] struct {
    rdb  *redis.Client
    db   *gorm.DB
    ttl  time.Duration // 缓存过期时间
}

// NewCacheAside 创建泛型 CacheAside 实例
func NewCacheAside[T any](rdb *redis.Client, db *gorm.DB, ttl time.Duration) *CacheAside[T] {
    return &CacheAside[T]{
        rdb: rdb,
        db:  db,
        ttl: ttl,
    }
}

// Get 读取数据:先查缓存,未命中则查库并回填
func (c *CacheAside[T]) Get(ctx context.Context, key string, queryFunc func(db *gorm.DB) (*T, error)) (*T, error) {
    // 1. 查询缓存
    val, err := c.rdb.Get(ctx, key).Result()
    if err == nil {
        // 缓存命中,反序列化返回
        var data T
        if jsonErr := json.Unmarshal([]byte(val), &data); jsonErr == nil {
            return &data, nil
        }
    }

    // 2. 缓存未命中或数据损坏,查询数据库
    data, err := queryFunc(c.db)
    if err != nil {
        return nil, fmt.Errorf("db query failed: %w", err)
    }

    // 3. 回填缓存(异步或同步均可,此处同步回填)
    if bytes, jsonErr := json.Marshal(data); jsonErr == nil {
        c.rdb.Set(ctx, key, string(bytes), c.ttl)
    }

    return data, nil
}

// Set 写入数据:先更新数据库,再删除缓存
func (c *CacheAside[T]) Set(ctx context.Context, key string, data *T, saveFunc func(db *gorm.DB, data *T) error) error {
    // 1. 更新数据库
    if err := saveFunc(c.db, data); err != nil {
        return fmt.Errorf("db save failed: %w", err)
    }

    // 2. 删除缓存(即使删除失败也不影响数据库一致性)
    c.rdb.Del(ctx, key)

    return nil
}

3.5 使用示例:用户信息缓存

以下代码演示了如何使用上述泛型组件管理用户信息的缓存读写:

type User struct {
    ID   uint   `gorm:"primaryKey"`
    Name string `gorm:"size:100"`
    Age  int
}

func main() {
    // 初始化 Redis 和 GORM(省略连接配置)
    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    db, _ := gorm.Open(mysql.Open("dsn"), &gorm.Config{})

    // 创建用户缓存管理器,TTL 设置为 5 分钟
    userCache := cacheaside.NewCacheAside[User](rdb, db, 5*time.Minute)

    ctx := context.Background()
    userID := uint(42)
    cacheKey := fmt.Sprintf("user:%d", userID)

    // 读操作:自动走 Cache-Aside 流程
    user, err := userCache.Get(ctx, cacheKey, func(tx *gorm.DB) (*User, error) {
        var u User
        if err := tx.First(&u, userID).Error; err != nil {
            return nil, err
        }
        return &u, nil
    })
    if err != nil {
        panic(err)
    }
    fmt.Printf("User: %+v\n", user)

    // 写操作:更新数据库并删除缓存
    updatedUser := &User{ID: userID, Name: "Alice", Age: 30}
    err = userCache.Set(ctx, cacheKey, updatedUser, func(tx *gorm.DB, data *User) error {
        return tx.Model(&User{}).Where("id = ?", data.ID).Updates(data).Error
    })
    if err != nil {
        panic(err)
    }
}

3.6 避坑指南与最佳实践

  • 缓存穿透防护:当数据库查询结果为空时,仍然回填一个空值占位(如 null)并设置较短 TTL,防止恶意请求穿透缓存直接打库。
  • 缓存雪崩防护:不同 key 的 TTL 应增加随机偏移量(如 ±10%),避免大量缓存同时过期导致数据库压力骤增。
  • 删除缓存失败补偿:生产环境应引入重试队列(如基于 Redis Stream 或 Kafka),当删除缓存返回错误时异步重试,确保最终一致性。
  • 序列化选择:推荐使用 json.Marshal/json.Unmarshal 或 protobuf,避免使用 gob(跨语言兼容性差)。
  • 泛型版本要求:上述代码基于 Go 1.18+ 泛型,若使用旧版本可将 T 替换为 interface{} 并配合类型断言。
注意:Cache-Aside 模式不保证强一致性,只保证最终一致性。在要求严格强一致的金融、库存扣减等场景,应使用分布式锁或乐观锁配合缓存失效策略,不可单独依赖 Cache-Aside。

3.7 模式对比:Cache-Aside vs 其他模式

对比维度 Cache-Aside(旁路缓存) Read-Through(穿透读) Write-Through(穿透写)
控制权 应用层完全控制读写逻辑 缓存层自动加载数据库数据 缓存层自动同步写入数据库
实现复杂度 低,代码显式编排 中,需实现缓存加载器 高,需实现缓存写入器
一致性风险 低(先更新库后删缓存) 中(缓存层可能加载旧数据) 高(缓存与数据库写顺序耦合)
适用场景 大多数业务系统,灵活可控 读多写少、数据量稳定的场景 写操作需要严格顺序的场景