缓存和数据库打架的时候,到底谁先动手?

2026-11-14 12 0

缓存和数据库打架的时候,到底谁先动手?

先说结论:先写数据库,再删缓存。这是绝大多数业务场景的正确答案,简单、够用、绝大多数情况下不会出事。

但你要是只记住这一句,早晚会在某个深夜被叫起来。因为「先删缓存再写库」和「先写缓存再写库」这两种写法,坑深得能养鱼;而即便是「先写库再删缓存」,也会有一小撮情况让你怀疑人生。咱们把这三种姿势挨个拆开,看看它们分别是怎么死法的。


一、先写缓存,再写数据库:缓存里住着"未来"

这个写法的迷惑性极强,因为它在「读多写少」的场景里跑得飞快。你更新用户资料,先改 Redis,再更新 MySQL,缓存命中率高得吓人,接口 P99 漂亮得能写进简历。

问题在于,它是把数据库当成了「备份」,而不是「权威」。想象一下这个流程:

  1. 请求 A 进来,把 user:1001 从 MySQL 读出来,写进 Redis,TTL 300 秒。
  2. 请求 B 进来,把用户昵称改成了「隔壁老王」,先写 Redis。
  3. 然后 B 卡在了 MySQL 那一步——网络抖动、锁等待、或者干脆进程被 kill 了。
  4. MySQL 里还是「隔壁小李」,Redis 里是「隔壁老王」。

接下来 5 分钟,所有读请求都会拿到那个从来没在数据库里存在过的「隔壁老王」。而当 TTL 过期、缓存被淘汰、或者有人执行了一次 cache flush 的时候,这份错误数据才会灰飞烟灭,一切恢复「正常」。

缓存里住着一个从未在数据库存在过的用户,这事儿听起来像玄学,其实在多副本部署下是必然会发生的——只要有一个节点写缓存成功、数据库失败。

更糟的是,如果你没有给缓存设置 TTL,这个「隔壁老王」可以活到天荒地老。


二、先删缓存,再写数据库:那个短暂的「数据真空期」

这个写法比前一个「进步」了一点:至少缓存里的数据始终是「曾经正确过的」,不会凭空造出一个人来。

但它有一个致命窗口。假设缓存里 user:1001 原本是「小李」,删除后,数据库写入「老王」还没完成:

  1. 请求 B:删除缓存(此刻缓存里已经没人了)。
  2. 请求 B:正在 UPDATE 数据库,还没提交,或者提交了但还没改到从库。
  3. 请求 C 到达:缓存 miss,从数据库读到旧值「小李」,写回缓存。
  4. 请求 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)
    }
}

好处非常明显:

  1. 零侵入:业务代码不需要知道缓存的存在,删缓存这个动作从代码里消失了;
  2. 不丢:binlog 是持久化的,订阅者宕机重启后可以从位点继续追,天然重试;
  3. 顺序有保证:同一分区的变更按顺序消费,不会出现「老值覆盖新值」的乱序回填。

代价是引入了一个异步链路,缓存的失效会有毫秒到秒级的延迟。对绝大多数业务,这个延迟完全可以接受;对强一致要求的场景(比如余额、库存扣减),那就别用缓存,或者在业务层加锁/版本号兜底。


五、必须承认的现实:这不是「最终一致」问题,是「概率问题」

很多方案文档喜欢吹「最终一致」。说实话,这个词掩盖了太多细节。

真正该问的是:不一致窗口有多长、发生概率是多少、窗口内的错误数据会造成多大业务损失?

  • 用户昵称写错 → 用户刷新一下就好,影响趋近于零。
  • 商品价格写错 → 可能引发下单纠纷,需要对账补偿。
  • 库存写错 → 超卖,可能真金白银亏钱。
  • 余额写错 → 直接资损。

所以缓存一致性从来不是一个技术问题,是一个业务分级问题。给它定级,然后为每一级配不同强度的方案:

数据等级 示例 方案
弱一致 昵称、文章浏览量 先写库再删缓存,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,思路都一样:把「并发回源」压缩成「一次回源 + 一次回填」。


七、写在最后

缓存一致性的本质,其实是一个老问题:两个没有事务绑定在一起的存储,如何在部分失败时保持协同。数据库有事务,缓存没有;你没法给它们签一份两阶段提交合同。

所以工程上的答案永远是「排序 + 兜底 + 降级」:

  1. 排序:先写权威源(数据库),再删派生源(缓存)。
  2. 兜底:删失败要重试,TTL 兜底,定期对账。
  3. 降级:缓存挂了就直接读数据库,宁可慢一点也别给用户看脏数据。

至于那些声称「一行代码解决缓存一致性」的方案,多半是把问题藏进了别的地方。你要是真想省心,binlog 订阅 + TTL 是当前投入产出比最高的组合,把复杂度集中在基础设施层,业务层保持干净。

最后送给还在用「先删缓存再更新数据库」的同学一句话:你不是在做优化,你是在给未来的自己埋雷。种雷的时候记得埋深一点,因为炸的是你。

相关文章

你的数据库连接池正在悄悄排队:21 个线程抢 10 个连接,然后集体石化
你写的正则正在用 2^n 的时间等你:回溯引擎的灾难性回溯,一次线上事故复盘
半夜三点,你的 Kafka 消费者在互相踢来踢去:一次 rebalance 风暴的现场重建
分布式系统里没有「现在」:时钟、超时与租约,一次讲透
我们给支付接口加了幂等键,结果它照样扣了三次钱
Prometheus 悄悄吃掉了你 40G 内存:一次指标基数爆炸的现场拆解

发布评论