缓存和数据库打架的时候,到底谁先动手?
先说结论:先写数据库,再删缓存。这是绝大多数业务场景的正确答案,简单、够用、绝大多数情况下不会出事。
但你要是只记住这一句,早晚会在某个深夜被叫起来。因为「先删缓存再写库」和「先写缓存再写库」这两种写法,坑深得能养鱼;而即便是「先写库再删缓存」,也会有一小撮情况让你怀疑人生。咱们把这三种姿势挨个拆开,看看它们分别是怎么死法的。
一、先写缓存,再写数据库:缓存里住着"未来"
这个写法的迷惑性极强,因为它在「读多写少」的场景里跑得飞快。你更新用户资料,先改 Redis,再更新 MySQL,缓存命中率高得吓人,接口 P99 漂亮得能写进简历。
问题在于,它是把数据库当成了「备份」,而不是「权威」。想象一下这个流程:
- 请求 A 进来,把
user:1001从 MySQL 读出来,写进 Redis,TTL 300 秒。 - 请求 B 进来,把用户昵称改成了「隔壁老王」,先写 Redis。
- 然后 B 卡在了 MySQL 那一步——网络抖动、锁等待、或者干脆进程被 kill 了。
- MySQL 里还是「隔壁小李」,Redis 里是「隔壁老王」。
接下来 5 分钟,所有读请求都会拿到那个从来没在数据库里存在过的「隔壁老王」。而当 TTL 过期、缓存被淘汰、或者有人执行了一次 cache flush 的时候,这份错误数据才会灰飞烟灭,一切恢复「正常」。
缓存里住着一个从未在数据库存在过的用户,这事儿听起来像玄学,其实在多副本部署下是必然会发生的——只要有一个节点写缓存成功、数据库失败。
更糟的是,如果你没有给缓存设置 TTL,这个「隔壁老王」可以活到天荒地老。
二、先删缓存,再写数据库:那个短暂的「数据真空期」
这个写法比前一个「进步」了一点:至少缓存里的数据始终是「曾经正确过的」,不会凭空造出一个人来。
但它有一个致命窗口。假设缓存里 user:1001 原本是「小李」,删除后,数据库写入「老王」还没完成:
- 请求 B:删除缓存(此刻缓存里已经没人了)。
- 请求 B:正在 UPDATE 数据库,还没提交,或者提交了但还没改到从库。
- 请求 C 到达:缓存 miss,从数据库读到旧值「小李」,写回缓存。
- 请求 B 的 UPDATE 终于提交了,但缓存里已经是「小李」了。
结果:数据库是「老王」,缓存是「小李」。这个错误状态的持续时间,取决于你多久之后再次删除缓存——可能永远,因为请求 B 已经跑完,不会再来一次删除。
这就是经典的缓存与数据库双写不一致问题里最著名的「读请求把旧值写回缓存」的场景。很多人在网上看到「延迟双删」这个方案,其实就是给这个窗口打补丁:
// 延迟双删:把「删缓存」这个动作做两次
tx := db.Begin()
tx.Exec("UPDATE users SET nickname = ? WHERE id = ?", "老王", 1001)
tx.Commit()
redis.Del("user:1001") // 第一次删:挡住并发读
time.Sleep(500 * time.Millisecond) // 等读写链路追上
redis.Del("user:1001") // 第二次删:清掉窗口期内被写回的老值
说实话,这个方案能用,但它很脆。睡眠时长是拍的,网络抖动一次就失效;本地休眠在高并发下会浪费线程;而且它完全没解决「缓存删除失败」的情况。
三、正确姿势:Cache-Aside + 可重试的删除 + TTL 兜底
回到开头那句结论,我们把它拆成一个可落地的模板。
读路径(Cache-Aside):
func GetUser(id int64) (*User, error) {
if u, err := redis.Get(key(id)).Result(); err == nil {
return parse(u), nil
}
u, err := loadFromDB(id) // 主库或强一致从库
if err != nil {
return nil, err
}
// 回填失败绝不能影响主流程
redis.Set(key(id), marshal(u), 30*time.Minute)
return u, nil
}
写路径:先写库,再删缓存,而且删除失败不能吞掉,要能重试。
func UpdateUser(id int64, u *User) error {
tx := db.Begin()
if err := tx.Exec("UPDATE ...").Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Commit().Error; err != nil {
return err
}
// 关键:删除失败要留痕,要重试,而不是 log.Error 然后祈祷
if err := redis.Del(key(id)).Err(); err != nil {
retryQueue.Push(key(id)) // 进重试队列 / 发 MQ / 落库补偿表
}
return nil
}
为什么这个顺序更安全?因为「先写库再删缓存」的不一致窗口是一个极短的读请求周期,而且它天然可自愈:缓存是「脏」的,最坏情况是读请求在删除之前刚好 miss,然后从数据库读到了新值写回缓存——这个「写回」写的是新值,因为数据库已经更新了。
换句话说,窗口期内最多浪费一次缓存命中,正确性不受影响。而先删缓存的那个方案,窗口期写回的是旧值,会主动把脏数据固化下来。
四、再进一步:用 binlog 订阅替代手工删缓存
上面那套「写库 + 删缓存」还有两个麻烦:
- 业务代码里到处散落着「写完记得删缓存」,漏一个就出脏数据;
- 删除失败的重试机制要自己造轮子。
更工程化的做法是:业务代码只管写数据库,缓存失效交给 binlog 订阅者去做。
// Canal / Debezium / Maxwell / 自研 binlog 解析器
for {
ev := consumer.Next()
if ev.Type != "UPDATE" && ev.Type != "DELETE" {
continue
}
for _, key := range parseCacheKeys(ev) { // 把主键映射成缓存 key
redis.Del(key)
}
}
好处非常明显:
- 零侵入:业务代码不需要知道缓存的存在,删缓存这个动作从代码里消失了;
- 不丢:binlog 是持久化的,订阅者宕机重启后可以从位点继续追,天然重试;
- 顺序有保证:同一分区的变更按顺序消费,不会出现「老值覆盖新值」的乱序回填。
代价是引入了一个异步链路,缓存的失效会有毫秒到秒级的延迟。对绝大多数业务,这个延迟完全可以接受;对强一致要求的场景(比如余额、库存扣减),那就别用缓存,或者在业务层加锁/版本号兜底。
五、必须承认的现实:这不是「最终一致」问题,是「概率问题」
很多方案文档喜欢吹「最终一致」。说实话,这个词掩盖了太多细节。
真正该问的是:不一致窗口有多长、发生概率是多少、窗口内的错误数据会造成多大业务损失?
- 用户昵称写错 → 用户刷新一下就好,影响趋近于零。
- 商品价格写错 → 可能引发下单纠纷,需要对账补偿。
- 库存写错 → 超卖,可能真金白银亏钱。
- 余额写错 → 直接资损。
所以缓存一致性从来不是一个技术问题,是一个业务分级问题。给它定级,然后为每一级配不同强度的方案:
| 数据等级 | 示例 | 方案 |
|---|---|---|
| 弱一致 | 昵称、文章浏览量 | 先写库再删缓存,TTL 兜底,不一致窗口可接受 |
| 准一致 | 商品详情、列表页 | binlog 订阅失效 + 兜底 TTL + 定期校验 |
| 强一致 | 余额、库存、优惠券 | 不缓存,或缓存只做「读缓存 + 写时主动失效 + 分布式锁 + 对账」 |
六、几个容易被忽略的细节
1. 缓存 key 要能反查。binlog 订阅方案里,你必须能根据数据变更反推出受影响的缓存 key。如果你的缓存 key 是拼出来的(比如 user_profile:{id}:v2),那更新 profile 时 key 变了,老 key 就是永久垃圾。所以要么把版本号从 key 里拿掉,要么在变更时同时删旧 key。
2. 一定要设 TTL。TTL 是你的安全网,不是 inefficiency。就算你用 binlog 订阅完美工作,某天 binlog 消费组 lag 了五小时、或者你手滑改了配置导致订阅断了,TTL 保证系统能在有限时间内自愈。没有 TTL 的缓存系统,本质上是一个需要全量数据迁移才能重启的怪物。
3. 大 key 和热 key 会让删除失效。删一个 10MB 的缓存 key 可能阻塞 Redis 几秒。这时候「删除」实际是「延迟删除」,而且可能超时失败。热 key 更麻烦:删掉瞬间会有大量请求同时 miss 同时回源,把数据库打成雪崩。这时候你需要的不是一致性方案,是「逻辑过期 + 异步刷新」。
4. 预热与回源击穿。缓存 miss 后回源数据库,如果同一个 key 有几千个并发请求同时在等,你会看到数据库连接池瞬间打满。一个简单的解法是回源时加 singleflight:
var group singleflight.Group
func loadFromDB(id int64) (*User, error) {
v, err, _ := group.Do(fmt.Sprintf("user:%d", id), func() (interface{}, error) {
return queryDB(id) // 同一时刻只有一个请求真正打 DB
})
if err != nil {
return nil, err
}
return v.(*User), nil
}
Go 里用 golang.org/x/sync/singleflight,Java 里用 Guava 的 LoadingCache,思路都一样:把「并发回源」压缩成「一次回源 + 一次回填」。
七、写在最后
缓存一致性的本质,其实是一个老问题:两个没有事务绑定在一起的存储,如何在部分失败时保持协同。数据库有事务,缓存没有;你没法给它们签一份两阶段提交合同。
所以工程上的答案永远是「排序 + 兜底 + 降级」:
- 排序:先写权威源(数据库),再删派生源(缓存)。
- 兜底:删失败要重试,TTL 兜底,定期对账。
- 降级:缓存挂了就直接读数据库,宁可慢一点也别给用户看脏数据。
至于那些声称「一行代码解决缓存一致性」的方案,多半是把问题藏进了别的地方。你要是真想省心,binlog 订阅 + TTL 是当前投入产出比最高的组合,把复杂度集中在基础设施层,业务层保持干净。
最后送给还在用「先删缓存再更新数据库」的同学一句话:你不是在做优化,你是在给未来的自己埋雷。种雷的时候记得埋深一点,因为炸的是你。