GORM 钩子与事务:缓存一致性的终极方案
缓存一致性是分布式系统最经典的坑。缓存和数据库的双写,无论"先删缓存再写库"还是"先写库再删缓存",都有脏读窗口。这篇文章分享我在 Go 微服务里的方案:用 GORM 的 AfterSave 钩子 + 事务联动,把"删缓存失败就回滚数据库",从机制上消灭脏读。
一、经典方案为什么都有洞
方案 A:先删缓存,再写库
洞:第 1 步删了缓存,第 2 步写库完成前,有请求读到旧库数据并写回缓存(缓存回填),然后库里已经是新数据——缓存和库不一致了。并发越高窗口越大。
方案 B:先写库,再删缓存
洞:第 1 步写库后、第 2 步删缓存前,有请求读到旧缓存——不一致。而且第 2 步如果失败,缓存就永远旧了。
“延迟双删"能缓解,但本质还是概率性方案:总有窗口,总有意外。
二、机制级方案:写库成功 = 缓存必删
核心思路:把"删缓存"变成事务的一部分——删不掉,库就回滚。
GORM 的钩子体系给了我们抓手:AfterSave 钩子在 INSERT/UPDATE 成功之后、事务提交之前触发。在这个钩子里:
|
|
关键点:钩子返回 error,GORM 会自动回滚整个事务。所以**“缓存删除失败"在业务上等价于"写库失败”**——要么缓存和库都是新的,要么都是旧的,不存在中间状态。
三、为什么这样是"机制级"而不是"概率级”
| 方案 | 脏读概率 | 失败处理 |
|---|---|---|
| 先删缓存再写库 | 有窗口 | 靠重试/延迟双删兜底 |
| 先写库再删缓存 | 有窗口 | 删失败静默,缓存永久旧 |
| AfterSave + 事务回滚 | 0 | 删失败 → 写库也失败,天然一致 |
代价是:缓存删除失败时,写库也会失败——用可用性换一致性。我们的业务(简历更新)对一致性敏感度远高于可用性(更新失败客户端重试一次即可),所以这笔交易很值。
四、实战细节
1. Redis 操作必须能感知失败
很多人 Redis 挂了不报错(客户端吞异常),钩子里必须显式检查:
|
|
2. 只对热点模型挂钩子
不是所有表都值得挂——只挂高频读 + 有缓存的核心模型(简历、职位)。小表直接读库,别让钩子增加复杂度。
3. 钩子内别再走 GORM 写操作
AfterSave 里再写库会递归触发钩子。钩子里只做副作用操作(删缓存、发事件),不写库。要发事件用 Outbox 模式,别在钩子里直接发 MQ(失败没法回滚)。
4. 批量操作的钩子要小心
GORM 的批量更新(Updates 带 Where 条件)不触发单个 AfterSave,只触发 AfterSave 的批量版本(AfterSave(tx) 对批量不生效)。批量更新场景单独处理:事务结束后统一删缓存,或禁用缓存。
5. 分布式锁不需要了
以前防止"并发更新 + 缓存回填"打架要加分布式锁,现在缓存删除失败直接回滚,回填的是新库数据——锁可以省了。少一个组件,少一种故障。
五、效果
- 简历服务上线后0 脏读故障(3 个月线上观察)
- 缓存命中率 95%+,一致性靠机制保证,不再靠人肉 review 调用顺序
- 删掉了原来的一堆"延迟双删"重试代码,逻辑更简单
总结
缓存一致性不是"选哪种顺序"的问题,是失败语义的问题:
- 让"删缓存"成为事务的原子部分,失败即回滚
- 一致性敏感 > 可用性敏感的业务,用可用性换一致性
- 钩子只做副作用,别在钩子里再写库
从概率上避免脏读,还是从机制上杜绝脏读?我选后者。