3. 缓存读写模式:Cache-Aside(旁路缓存)模式原理与Go实现
Cache-Aside(旁路缓存)是生产环境中最常用的缓存读写模式,也是理解缓存与数据库一致性的基础。该模式的核心思想是:应用程序直接同时操作缓存和数据库,缓存层不感知数据库的存在。读写流程由应用层代码显式编排,因此被称为“旁路”。
3.1 核心流程与原理
Cache-Aside 将每一次数据访问拆分为四个原子步骤,分别对应读请求和写请求的不同路径。下表总结了两种操作的标准流程:
| 操作类型 | 步骤序列 | 关键原则 |
|---|---|---|
| 读操作 | ① 查询缓存 → ② 缓存命中则直接返回 → ③ 缓存未命中则查询数据库 → ④ 将数据库结果写入缓存并返回 | 先查缓存,后查库;回填缓存时设置过期时间 |
| 写操作 | ① 更新数据库 → ② 删除缓存(或更新缓存) | 先更新库,后淘汰缓存;避免并发脏写 |
关键认知:写操作中“先更新数据库,再删除缓存”是经过业界验证的推荐顺序。如果先删除缓存再更新数据库,在并发场景下会导致缓存与数据库长时间不一致(即“缓存击穿 + 脏读”组合问题)。
3.2 Cache-Aside 读写流程图
下图展示了 Cache-Aside 模式在读写请求下的完整数据流向。左侧为读路径,右侧为写路径,中间为共享的 Redis 缓存层与 MySQL 数据库层。
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(穿透写) |
|---|---|---|---|
| 控制权 | 应用层完全控制读写逻辑 | 缓存层自动加载数据库数据 | 缓存层自动同步写入数据库 |
| 实现复杂度 | 低,代码显式编排 | 中,需实现缓存加载器 | 高,需实现缓存写入器 |
| 一致性风险 | 低(先更新库后删缓存) | 中(缓存层可能加载旧数据) | 高(缓存与数据库写顺序耦合) |
| 适用场景 | 大多数业务系统,灵活可控 | 读多写少、数据量稳定的场景 | 写操作需要严格顺序的场景 |