找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2800

积分

0

好友

398

主题
发表于 前天 01:00 | 查看: 8| 回复: 0

数据库和缓存(比如:Redis)双写数据一致性问题,是一个与开发语言无关的公共问题。尤其在高并发的场景下,这个问题会变得更加严重。

无论面试还是实际工作,遇到这个问题的概率都非常大。今天,我们就来深入探讨一下,在高并发下,先写数据库还是先写缓存?各种方案可能存在的坑,以及最优解究竟是什么。

常见方案

通常,我们使用缓存的主要目的是为了提升查询性能。大多数情况下的使用流程如下:

缓存查询通用流程图

  1. 用户请求过来之后,先查缓存有没有数据,如果有则直接返回。
  2. 如果缓存没数据,再继续查数据库。
  3. 如果数据库有数据,则将查询出来的数据,放入缓存中,然后返回该数据。
  4. 如果数据库也没数据,则直接返回空。

这是缓存非常常见的用法。但这里忽略了一个关键细节:如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么该如何更新缓存呢?

不更新缓存行不行?当然不行。如果不更新,在缓存过期前,用户从缓存中获取到的都可能是旧值,导致数据不一致。

那么,我们该如何更新缓存呢?目前主要有以下4种思路:

  1. 先写缓存,再写数据库
  2. 先写数据库,再写缓存
  3. 先删缓存,再写数据库
  4. 先写数据库,再删缓存

接下来,我们详细分析这4种方案。

先写缓存,再写数据库

很多人首先想到的更新方式可能是在写操作中直接更新缓存。那么,是先写缓存,还是先写数据库呢?

我们先分析“先写缓存,再写数据库”的情况,因为它的问题最严重。

先写缓存再写数据库流程图

假设用户的写操作,刚写完缓存,网络就出现异常,导致写数据库失败了。

写缓存成功但写数据库失败流程图

结果是缓存更新成了新数据,但数据库没有。此时缓存中的数据变成了“脏数据”。如果紧接着有查询请求读取到该数据,就会出现问题,因为该数据在数据库中根本不存在。

缓存的主要目的是把数据库的数据临时保存在内存,便于后续查询。但如果某条数据在数据库中都不存在,缓存这种“假数据”毫无意义。

因此,先写缓存,再写数据库的方案在实际工作中不可取

先写数据库,再写缓存

既然上面的方案不行,我们来看看“先写数据库,再写缓存”。该方案在低并发场景中或许有人使用。

先写数据库再写缓存流程图

用户的写操作,先写数据库,再写缓存,可以避免产生“假数据”的问题。但它引入了新的挑战。

写缓存失败了怎么办?

如果把写数据库和写缓存放在同一个事务中,当写缓存失败时,可以回滚数据库的数据。

写缓存失败事务回滚示意图

对于并发量小、对接口性能要求不高的系统,可以这样操作。但在高并发业务场景中,写数据库和写缓存都属于远程操作。为了防止大事务导致的死锁问题,通常不建议将两者放在同一个事务中。

也就是说,如果写数据库成功但写缓存失败,已写入数据库的数据不会回滚。这会导致数据库是新数据,而缓存是旧数据,出现数据不一致

高并发下的竞态条件

假设在高并发场景中,针对同一数据的两个写请求a和b同时到达。

其中请求a操作的是旧数据,请求b操作的是新数据,流程如下图所示:

高并发下写库写缓存竞态条件流程图

  1. 请求a先到,写完了数据库。但由于网络卡顿,还没来得及写缓存。
  2. 请求b到达,写入数据库(新值)。
  3. 请求b顺利写入缓存(新值)。
  4. 请求a卡顿结束,写入缓存(旧值)。

显然,请求b在缓存中的新数据,被请求a的旧数据覆盖了。在高并发场景下,多线程同时执行“先写数据库,再写缓存”,可能导致数据库是新值而缓存是旧值的数据不一致问题。

浪费系统资源

该方案还有一个明显问题:每个写操作在写完数据库后,都会立即写缓存,这可能比较浪费系统资源

试想,如果写入缓存的数据需要经过非常复杂的计算才能得出,那么每次写操作都伴随一次复杂计算,无疑会浪费大量CPU和内存资源。

对于写多读少的业务场景,每个写操作都需要写一次缓存,更是得不偿失。

由此可见,在高并发场景中,“先写数据库,再写缓存”这套方案问题颇多,同样不建议使用

先删缓存,再写数据库

既然直接更新缓存问题多,我们不妨换个思路:不直接更新缓存,而是改为删除缓存

删除缓存方案同样有两种:

  1. 先删缓存,再写数据库
  2. 先写数据库,再删缓存

我们先分析“先删缓存,再写数据库”。

先删缓存再写数据库流程图

在写操作中,先执行删除缓存操作,再去写数据库。这套方案可行,但同样存在并发问题。

高并发下的不一致

假设高并发场景中,一个读请求c和一个写请求d(更新操作)同时到达。如下图所示:

先删缓存再写库的并发问题流程图

  1. 请求d(写)先到,删除了缓存。但由于网络卡顿,未来得及写数据库。
  2. 请求c(读)到达,查缓存无数据,继而查询数据库得到旧值。
  3. 请求c将数据库中的旧值,更新到缓存中。
  4. 请求d卡顿结束,将新值写入数据库。

在这个过程中,请求d的新值并没有被请求c写入缓存,同样会导致缓存和数据库的数据不一致。

缓存双删

在上述场景中,写请求删除缓存后,读请求可能把从数据库查出的旧值再次写入缓存。一个解决方案是:请求d在写完数据库后,把缓存再删一次。

缓存双删流程图

这就是缓存双删,即在写数据库之前删除一次,写完数据库后,间隔一段时间再删除一次。

该方案的关键在于:第二次删除缓存,并非立即执行,而是要延迟一定的时间间隔

我们回顾一下数据不一致的产生过程:

  1. 请求d删除缓存,网络卡顿。
  2. 请求c查询并加载旧值到缓存。
  3. 请求d写入数据库新值。
  4. 一段时间后(如500ms),请求d第二次删除缓存。

这样确实可以解决缓存不一致问题。那么,为什么第二次删除必须延迟呢?

因为必须确保请求c(或类似请求)已经将旧值更新到缓存之后,第二次删除操作才能生效。如果请求d在请求c更新缓存之前就执行了第二次删除,那么这次删除就失去了意义。

延迟时间需要根据业务读写操作的耗时来评估设定。还有一个遗留问题:如果第二次删除缓存失败了呢?我们稍后讨论。

先写数据库,再删缓存

最后,我们重点分析“先写数据库,再删缓存”的方案。

先写数据库再删缓存流程图

在高并发场景下,一个读请求f和一个写请求e的更新过程如下:

  1. 请求e(写)先写数据库,由于网络卡顿,没有及时删除缓存。
  2. 请求f(读)查询缓存,发现有数据(旧值),直接返回。
  3. 请求e删除缓存。

在这个过程中,只有请求f读到了一次旧数据,但随后旧数据被请求e删除,问题看起来不大。

如果是读请求先过来呢?

  1. 请求f查询缓存(旧值)并返回。
  2. 请求e写数据库(新值)。
  3. 请求e删除缓存。

这种情况看起来也没有问题。

但有一种极端情况:缓存自己失效了。如下图所示:

缓存失效导致的不一致流程图

  1. 缓存过期,自动失效。
  2. 请求f查询缓存无数据,查数据库得到旧值,但网络卡顿,未来得及更新缓存。
  3. 请求e写数据库(新值),并删除缓存(此时缓存为空,删除无影响)。
  4. 请求f将查询到的旧值更新到缓存中。

这时,缓存(旧值)和数据库(新值)再次出现不一致。但这种情况的发生需要同时满足两个条件:

  1. 缓存刚好自动失效。
  2. 请求f从数据库查出旧值并更新缓存的耗时,比请求e写数据库+删除缓存的耗时更长。

我们知道,查询操作通常比写操作更快,更何况写操作还包括了删除缓存的步骤。因此,同时满足这两个条件的概率非常小

推荐使用“先写数据库,再删缓存”的方案。它虽不能100%避免数据不一致,但出现问题的概率相对于其他方案是最小的。

不过,该方案同样面临一个问题:如果删除缓存失败怎么办?

删除缓存失败怎么办?

无论是“先写库再删缓存”,还是“缓存双删”方案,都有一个共同风险:如果缓存删除失败,都会导致数据不一致。

那么,删除缓存失败该怎么办?答案是引入重试机制

在业务接口中,如果更新数据库成功但删除缓存失败,可以立刻同步重试3次。如果其中一次成功,则返回成功。如果3次都失败,则将失败记录写入数据库或消息队列,等待后续异步处理。

但在高并发接口中,同步重试可能会影响性能。因此,我们通常需要改为异步重试

异步重试主要有以下几种方式:

  1. 单开线程重试:每次失败单独起一个线程处理。高并发下可能创建过多线程导致OOM,不推荐。
  2. 线程池处理:将重试任务提交给线程池。但服务器重启可能导致数据丢失。
  3. 定时任务扫描:将重试数据写入数据库表,由定时任务(如elastic-job)进行重试。
  4. 消息队列:将重试请求写入MQ,由消费者异步处理。
  5. 订阅Binlog:订阅数据库的Binlog(如使用Canal),在订阅者中删除缓存。

方案一:定时任务重试

使用定时任务重试的具体方案如下:

  1. 写入重试表:当用户写操作成功,但删除缓存失败时,将这次操作的关键信息(如用户ID、数据KEY)写入一张“重试表”。
    写入重试表业务流程图

  2. 定时任务处理:由定时任务异步读取重试表中的记录。每条记录包含一个“重试次数”字段,初始为0。定时任务尝试删除缓存,每次重试该字段+1。如果5次重试内有任意一次成功,则标记该记录为成功。如果重试5次均失败,则标记为失败,等待人工介入。
    定时任务重试流程图

  3. 任务框架选择:高并发下,推荐使用支持分片的定时任务框架(如Elastic-Job)来提高处理速度,并可设置不同的重试间隔(如1s, 2s, 3s)。

该方案的缺点是实时性相对较低。但其优点是数据持久化,不会丢失,适用于对实时性要求不苛刻的一般场景。

方案二:消息队列(MQ)重试

在高并发业务中,消息队列是必不可少的技术,它能实现异步解耦和削峰填谷。

使用MQ重试的具体方案如下:
MQ重试机制流程图

  1. 发送MQ消息:当写数据库成功但删除缓存失败时,业务代码发送一条MQ消息到服务器。
  2. 消费者重试:MQ消费者读取消息,尝试删除缓存。可配置重试次数(如5次)。若成功,则消费完成;若失败,则消息进入死信队列
  3. MQ选型:推荐使用RocketMQ,它原生支持重试机制和死信队列,并且支持顺序消息、延迟消息等,非常适合此类场景。

甚至可以进一步简化业务逻辑:用户的写操作在写完数据库后,不立刻删除缓存,而是直接发送一条MQ消息。由MQ消费者全权负责删除缓存的任务。由于MQ实时性较高,这也是一种优秀的异步解决方案。

方案三:监听Binlog

无论是定时任务还是MQ方案,都对业务代码有一定的侵入性。有没有更优雅的方式呢?答案是监听Binlog,例如使用Canal这类中间件。

具体方案如下:
Binlog订阅删除缓存流程图

  1. 业务解耦:业务接口只负责写数据库,然后直接返回成功,无需关心缓存。
  2. Binlog捕获:MySQL服务器会自动将数据变更写入Binlog。
  3. 订阅者处理:Binlog订阅者(如Canal客户端)捕获到数据更新事件后,执行删除缓存操作。

这套方案极大简化了业务逻辑。但图中的流程只删除一次缓存,同样可能失败。如何解决?仍需结合重试机制

一个更健壮的组合方案是:Binlog订阅者捕获到更新事件后,不直接删除缓存,而是发送一条MQ消息。后续由MQ的自动重试机制来保证缓存删除的最终成功。

Binlog结合MQ重试流程图

总结

在高并发下处理数据库与缓存双写一致性问题,核心结论是:优先采用“先更新数据库,再删除缓存”的策略,并结合异步重试机制来保证缓存删除的最终执行。

对于重试机制,可以根据业务特点选择:

  • 对一致性要求高,实时性要求也高:采用 “更新数据库 + 发MQ消息” 的组合,利用MQ的可靠性和重试能力。
  • 希望业务代码最简洁,解耦彻底:采用 “订阅Binlog + MQ” 的组合,对业务代码无侵入。
  • 对实时性要求不高,允许短暂延迟:可采用 “定时任务” 扫描重试表的方式,实现简单且数据可靠。

在实际的系统设计中,没有银弹,需要根据业务容忍度和系统复杂度做出权衡。希望本文的探讨能帮助你更好地理解缓存双写的一致性问题。如果你有更多实战经验或不同见解,欢迎在云栈社区后端与架构板块参与讨论。




上一篇:Twitter 推荐算法开源:日活 2 亿的推荐系统怎么做的?
下一篇:PolarCTF 2025秋季个人赛PWN方向全题解:UAF、格式化字符串与堆漏洞利用剖析
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-24 01:41 , Processed in 0.320463 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表