3. 缓存读写模式:Cache-Aside 模式详解
Cache-Aside(旁路缓存)是生产环境中最常用的缓存读写模式,也是 Go 语言操作 Redis 缓存的标准实践。该模式的核心思想是:应用程序直接管理缓存的读写,缓存与数据库之间不直接通信。本章将深入剖析 Cache-Aside 模式的读流程、写流程及其 Go 实现。
3.1 读流程:先查缓存,后查 DB
读流程遵循“缓存优先”原则,具体步骤如下:
- 查询缓存:应用程序首先尝试从 Redis 中获取数据。
- 缓存命中:若缓存中存在数据,直接返回给调用方,流程结束。
- 缓存未命中:若缓存中不存在数据,则查询数据库。
- 回写缓存:将从数据库查询到的数据写入 Redis,并设置过期时间。
- 返回数据:将数据返回给调用方。
关键指标:读流程的目标是最大化缓存命中率(Cache Hit Ratio)。每提升 1% 的命中率,数据库查询压力可降低 10%~30%。建议将命中率监控作为核心指标。
3.2 写流程:先更新 DB,后删除缓存
写流程采用“删除缓存”策略,而非更新缓存。这是为了避免并发写操作导致的缓存与数据库数据不一致问题。具体步骤如下:
- 更新数据库:应用程序先执行数据库写操作(INSERT / UPDATE / DELETE)。
- 删除缓存:数据库操作成功后,立即删除 Redis 中对应的缓存键。
- 后续读取:下次读取时,因缓存缺失,会重新从数据库加载并回写缓存。
为什么是删除而不是更新? 如果先更新缓存,在高并发场景下,多个写请求可能导致缓存中的值被旧数据覆盖。删除缓存则让下一次读请求自动拉取最新数据,天然避免了“写写冲突”。
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,后删缓存”。
并发场景下,线程 A 删除缓存后,线程 B 读取到旧数据并回写缓存,导致缓存中永久为旧数据。必须坚持“先更新 DB,后删缓存”。
陷阱 2:缓存删除失败不处理
删除缓存可能因网络抖动失败。必须实现重试机制,并在最终失败时投递到延迟队列进行异步补偿。
删除缓存可能因网络抖动失败。必须实现重试机制,并在最终失败时投递到延迟队列进行异步补偿。
陷阱 3:缓存未设置过期时间
即使写流程删除了缓存,仍应设置 TTL 作为兜底。防止因删除失败导致缓存永不过期,造成长期数据不一致。
即使写流程删除了缓存,仍应设置 TTL 作为兜底。防止因删除失败导致缓存永不过期,造成长期数据不一致。
3.6 一致性保障策略对比
| 策略 | 一致性强度 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| Cache-Aside(删除缓存) | 最终一致 | 低 | 大多数业务场景 |
| 延迟双删 | 最终一致(更强) | 中 | 对一致性要求较高的场景 |
| 消息队列异步同步 | 最终一致 | 高 | 跨服务、跨数据源场景 |
| 分布式锁 + 缓存更新 | 强一致 | 高 | 金融、库存等强一致场景 |
推荐实践:对于 90% 的业务场景,Cache-Aside 模式配合 TTL 兜底 + 删除重试机制,即可在性能和一致性之间取得良好平衡。无需过度设计引入分布式锁。