缓存与数据库一致性在 gf 项目中的落地
面试问法:GoFrame 项目里缓存和数据库怎么保证一致?先更新谁? 一句话结论:主流落地是 Cache Aside(旁路缓存):读走缓存、miss 回源 DB 并回填;写走“先更新 DB,成功后删缓存”,让下次读自然重建,配合过期时间兜底与删除失败重试,达成最终一致。框架无内置缓存注解,用 gcache(本地)或 contrib Redis 封装,自行封装 repository 读写即可。
读路径与写路径
// 读:先缓存,miss 回源并回填(key 建议带业务前缀与版本)
v, err := cache.Get(ctx, key)
if err == nil && !v.IsNil() { return v }
row, err := dao.User.Ctx(ctx).Where("id", id).One()
cache.Set(ctx, key, row, time.Hour) // TTL 是最终一致的最后兜底
return row
// 写:先 DB 后删缓存(而不是更新缓存)
if _, err = dao.User.Ctx(ctx).Where("id", id).Data(update).Update(); err != nil { return err }
_, _ = cache.Remove(ctx, key) // 删除失败要补偿:重试队列/延迟双删
为什么“删缓存”而不是“写缓存”
- 更新缓存要与事务成败对齐,事务回滚后缓存已是脏值;删除则让缓存“自然重建”,实现更简单。
- 并发写场景,“更新缓存”会产生时序覆盖;删除策略配合 TTL 更容易收敛。
- 删除也会失败:补偿手段有消息队列重试、延迟双删(先删→更新 DB→短暂延迟再删一次),并用 TTL 兜底。
工程要点
- 分层:本地
gcache适合单实例热点;多实例共享用 Redis;本地缓存 + Redis 两级要注意失效广播,复杂度高、默认不用。 - 击穿防护:热点 key 回源用锁或 singleflight 思想(gcache 提供加锁版 GetOrSetFunc 类方法,能力以官方文档为准),避免同时回源打爆 DB。
- 穿透防护:不存在的 key 也缓存空值并给短 TTL。
- 强一致需求:直接读 DB(同事务内读已提交数据),不要为了“一致”而缓存。
- 缓存 key 规范:业务前缀 + 参数摘要 + 版本号,发布时整体失效旧版本 key。
常见追问
- 追问:先删缓存再更新 DB 可以吗?——会放大“删完还没写、旧值被读回填”的窗口;常见是“先 DB 后删 + 延迟双删 + TTL 兜底”,取舍取决于业务容忍度。
- 追问:强一致要怎么做?——缓存无法保证强一致,回到“DB 直读 + 事务”,或用 binlog 订阅等方案做异步可靠失效(架构成本高,只在确需时引入)。
- 追问:GoFrame 有现成缓存注解吗?——没有“自动缓存方法结果”的注解机制;用 dao + gcache/Redis 自封装 repository 是官方示例与社区的主流做法。
记忆点
- Cache Aside + 删缓存 + TTL 兜底 + 失败重试 = 工程化的最终一致;别在缓存上追求强一致。