高并发系统设计 -- 缓存与数据库一致性问题详细讲解

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

CAP理论

CAP

首先来谈一下数据的一致性这个话题所谓的一致性就是数据保持一致在分布式系统中可以理解为多个节点中的数据的值是一致的。

  • 强一致性这种一致性级别是最符合用户直觉的它要求系统写入什么读出来的也会是什么用户体验好但实现起来往往对系统的性能影响大
  • 弱一致性这种一致性级别约束了系统在写入成功后不承诺立即可以读到写入的值也不承诺多久之后数据能够达到一致但会尽可能地保证到某个时间级别比如秒级别后数据能够达到一致状态
  • 最终一致性最终一致性是弱一致性的一个特例系统会保证在一定时间内能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来是因为它是弱一致性中非常推崇的一种一致性模型也是业界在大型分布式系统的数据一致性上比较推崇的模型。

​ CAP理论指的是在一个分布式系统中 Consistency一致性、 Availability可用性、Partition tolerance分区容错性三者不可得兼。分布式系统要么满足CA要么CP要么AP无法同时满足CAP。

– 《维基百科 – CAP定理》

因此使用缓存提高性能也就是可用性那么就必定会有数据更新的延迟也就是一致性不满足了使用缓存满足的是可用性和分区容错型抛弃的是一致性。是AP。

但是我们可以通过一些方案保证服务的最终一致性也就是弱一致性。

常见的方案有

  • 延迟双删
  • 重试机制删除缓存
  • 读取binlog异步删除缓存

延迟双删

  • 先删除缓存
  • 再更新数据库
  • 休眠一会儿读业务逻辑数据的耗时+几百毫秒
  • 再次删除缓存
  • 待后续请求落到DB后将查询数据更新到缓存确保后续请求一直落在缓存上

第二次删除的目的是确保读请求结束写请求可以删除读请求可能带来的缓存脏数据因为删除缓存和更新DB之间会有时间差。

我个人觉得这个解决方法是有点儿问题的首先等待多少时间是一个玄学这个其实都是小问题最关键的问题是如果我再次删除的这一步出错了怎么办这个时候再用消息队列就鸡肋了这样一整套操作下来可能要花费0.5秒这0.5秒可能会有100个用户请求MySQL直接被打宕机了。

写请求串行化

  1. 写请求更新之前先获取分布式锁获得之后才能去数据库更新这个数据获取不到就进行等待超时后就返回更新失败。
  2. 更新完之后去刷新缓存如果刷新失败就放到消息队列中进行重试重试时取数据库最新数据更新缓存。

读请求发现缓存中没有数据时直接去数据库读读完更新缓存。

这个方案其实还可以但是这是对写业务进行了串行化处理导致吞吐量变低了。银行这样业务是完全可行的。

重试机制删除缓存

image.png

1.先更新数据库

2.异步删除缓存如果数据库是读写分离的那么删除缓存时需要延迟删除否则可能会在删除缓存时从库还没有收到更新后的数据其他读请求就去从库读到旧数据然后设置到缓存中。

3.删除缓存失败时将删除的key放到内存队列或者是消息队列中进行异步重试

在更新完数据库后我们为什么不直接更新而是采用删除缓存呢

这是因为直接更新缓存的话在高并发场景下有多个更新请求时难以保证后更新数据库的请求会后更新缓存也就是上面的高并发写问题。如果采用删除缓存可以让下次读时读取数据库更新缓存保证一致性。

业务项目更新数据库其他项目订阅binlog更新

image.png

1.业务项目直接更新数据库。

2.cannal项目会读取数据库的binlog然后解析后发消息到kafka。

3.然后缓存更新项目订阅topic从kafka接收到更新数据库操作的消息后更新缓存更新缓存失败时新建异步线程去重试或者将操作发到消息队列后续再进行处理。

你可能还是不懂

上面说的其实很概括很抽象。

但很多人对这个问题依旧有很多疑惑

  • 到底是更新缓存还是删缓存
  • 到底选择先更新数据库再删除缓存还是先删除缓存再更新数据库
  • 为什么要引入消息队列保证一致性
  • 延迟双删会有什么问题到底要不要用

这篇文章我们就来把这些问题讲清楚。

这篇文章干货很多希望你可以耐心读完。

img

引入缓存提高性能

我们从最简单的场景开始讲起。

如果你的业务处于起步阶段流量非常小那无论是读请求还是写请求直接操作数据库即可这时你的架构模型是这样的

img

但随着业务量的增长你的项目请求量越来越大这时如果每次都从数据库中读数据那肯定会有性能问题。

这个阶段通常的做法是引入「缓存」来提高读性能架构模型就变成了这样

img

当下优秀的缓存中间件当属 Redis 莫属它不仅性能非常高还提供了很多友好的数据类型可以很好地满足我们的业务需求。

但引入缓存之后你就会面临一个问题之前数据只存在数据库中现在要放到缓存中读取具体要怎么存呢

最简单直接的方案是「全量数据刷到缓存中」

  • 数据库的数据全量刷入缓存不设置失效时间
  • 写请求只更新数据库不更新缓存
  • 启动一个定时任务定时把数据库的数据更新到缓存中

img

这个方案的优点是所有读请求都可以直接「命中」缓存不需要再查数据库性能非常高。

但缺点也很明显有 2 个问题

  1. 缓存利用率低不经常访问的数据还一直留在缓存中
  2. 数据不一致因为是「定时」刷新缓存缓存和数据库存在不一致取决于定时任务的执行频率

所以这种方案一般更适合业务「体量小」且对数据一致性要求不高的业务场景。

那如果我们的业务体量很大怎么解决这 2 个问题呢

缓存利用率和一致性问题

先来看第一个问题如何提高缓存利用率

想要缓存利用率「最大化」我们很容易想到的方案是缓存中只保留最近访问的「热数据」。但具体要怎么做呢

我们可以这样优化

  • 写请求依旧只写数据库
  • 读请求先读缓存如果缓存不存在则从数据库读取并重建缓存
  • 同时写入缓存中的数据都设置失效时间

img

这样一来缓存中不经常访问的数据随着时间的推移都会逐渐「过期」淘汰掉最终缓存中保留的都是经常被访问的「热数据」缓存利用率得以最大化。

再来看数据一致性问题。

要想保证缓存和数据库「实时」一致那就不能再用定时任务刷新缓存了。

所以当数据发生更新时我们不仅要操作数据库还要一并操作缓存。具体操作就是修改一条数据时不仅要更新数据库也要连带缓存一起更新。

但数据库和缓存都更新又存在先后问题那对应的方案就有 2 个

  1. 先更新缓存后更新数据库
  2. 先更新数据库后更新缓存

哪个方案更好呢

先不考虑并发问题正常情况下无论谁先谁后都可以让两者保持一致但现在我们需要重点考虑「异常」情况。

因为操作分为两步那么就很有可能存在「第一步成功、第二步失败」的情况发生。

这 2 种方案我们一个个来分析。

1) 先更新缓存后更新数据库

如果缓存更新成功了但数据库更新失败那么此时缓存中是最新值但数据库中是「旧值」。

虽然此时读请求可以命中缓存拿到正确的值但是一旦缓存「失效」就会从数据库中读取到「旧值」重建缓存也是这个旧值。

这时用户会发现自己之前修改的数据又「变回去」了对业务造成影响。

2) 先更新数据库后更新缓存

如果数据库更新成功了但缓存更新失败那么此时数据库中是最新值缓存中是「旧值」。

之后的读请求读到的都是旧数据只有当缓存「失效」后才能从数据库中得到正确的值。

这时用户会发现自己刚刚修改了数据但却看不到变更一段时间过后数据才变更过来对业务也会有影响。

可见无论谁先谁后但凡后者发生异常就会对业务造成影响。那怎么解决这个问题呢

别急后面我会详细给出对应的解决方案。

我们继续分析除了操作失败问题还有什么场景会影响数据一致性

这里我们还需要重点关注并发问题

并发引发的一致性问题

假设我们采用「先更新数据库再更新缓存」的方案并且两步都可以「成功执行」的前提下如果存在并发情况会是怎样的呢

有线程 A 和线程 B 两个线程需要更新「同一条」数据会发生这样的场景

  1. 线程 A 更新数据库X = 1
  2. 线程 B 更新数据库X = 2
  3. 线程 B 更新缓存X = 2
  4. 线程 A 更新缓存X = 1

最终 X 的值在缓存中是 1在数据库中是 2发生不一致。

也就是说A 虽然先于 B 发生但 B 操作数据库和缓存的时间却要比 A 的时间短执行时序发生「错乱」最终这条数据结果是不符合预期的。

同样地采用「先更新缓存再更新数据库」的方案也会有类似问题这里不再详述。

那怎么解决这个问题呢这里通常的解决方案是加「分布式锁」。

两个线程要修改「同一条」数据每个线程在改之前先去申请分布式锁拿到锁的线程才允许更新数据库和缓存拿不到锁的线程返回失败等待下次重试。

这么做的目的就是为了只允许一个线程去操作数据和缓存避免并发问题。

除此之外我们从「缓存利用率」的角度来评估这个方案也是不太推荐的。

这是因为每次数据发生变更都「无脑」更新缓存但是缓存中的数据不一定会被「马上读取」这就会导致缓存中可能存放了很多不常访问的数据浪费缓存资源。

而且很多情况下写到缓存中的值并不是与数据库中的值一一对应的很有可能是先查询数据库再经过一系列「计算」得出一个值才把这个值才写到缓存中。

由此可见这种「更新数据库 + 更新缓存」的方案不仅缓存利用率不高还会造成机器性能的浪费。

所以此时我们需要考虑另外一种方案删除缓存

删除缓存可以保证一致性吗

删除缓存对应的方案也有 2 种

  1. 先删除缓存后更新数据库
  2. 先更新数据库后删除缓存

同样地先来看「第二步」操作失败的情况。

先删除缓存后更新数据库第二步操作失败数据库没有更新成功那下次读缓存发现不存在则从数据库中读取并重建缓存此时数据库和缓存依旧保持一致。

但如果是先更新数据库后删除缓存第二步操作失败数据库是最新值缓存中是旧值发生不一致。所以这个方案依旧存在问题。

总之和前面提到的问题类似第二步失败依旧有不一致的风险。

好我们再来看「并发」问题这个问题是我们需要关注的「重点」。

1) 先删除缓存后更新数据库

如果有 2 个线程要并发「读写」数据可能会发生以下场景

  1. 线程 A 要更新 X = 2原值 X = 1
  2. 线程 A 先删除缓存
  3. 线程 B 读缓存发现不存在从数据库中读取到旧值X = 1
  4. 线程 A 将新值写入数据库X = 2
  5. 线程 B 将旧值写入缓存X = 1

最终 X 的值在缓存中是 1旧值在数据库中是 2新值发生不一致。

可见先删除缓存后更新数据库当发生「读+写」并发时还是存在数据不一致的情况。

2) 先更新数据库后删除缓存

依旧是 2 个线程并发「读写」数据

  1. 缓存中 X 不存在数据库 X = 1
  2. 线程 A 读取数据库得到旧值X = 1
  3. 线程 B 更新数据库X = 2)
  4. 线程 B 删除缓存
  5. 线程 A 将旧值写入缓存X = 1

最终 X 的值在缓存中是 1旧值在数据库中是 2新值也发生不一致。

这种情况「理论」来说是可能发生的但实际真的有可能发生吗

其实概率「很低」这是因为它必须满足 3 个条件

  1. 缓存刚好已失效
  2. 读请求 + 写请求并发
  3. 更新数据库 + 删除缓存的时间步骤 3-4要比读数据库 + 写缓存时间短步骤 2 和 5

仔细想一下条件 3 发生的概率其实是非常低的。

因为写数据库一般会先「加锁」所以写数据库通常是要比读数据库的时间更长的。

这么来看「先更新数据库 + 再删除缓存」的方案是可以保证数据一致性的。

所以我们应该采用这种方案来操作数据库和缓存。

好解决了并发问题我们继续来看前面遗留的第二步执行「失败」导致数据不一致的问题

如何保证两步都执行成功

前面我们分析到无论是更新缓存还是删除缓存只要第二步发生失败那么就会导致数据库和缓存不一致。

保证第二步成功执行就是解决问题的关键

想一下程序在执行过程中发生异常最简单的解决办法是什么

答案是重试

是的其实这里我们也可以这样做。

无论是先操作缓存还是先操作数据库但凡后者执行失败了我们就可以发起重试尽可能地去做「补偿」。

那这是不是意味着只要执行失败我们「无脑重试」就可以了呢

答案是否定的。现实情况往往没有想的这么简单失败后立即重试的问题在于

  • 立即重试很大概率「还会失败」
  • 「重试次数」设置多少才合理
  • 重试会一直「占用」这个线程资源无法服务其它客户端请求

看到了么虽然我们想通过重试的方式解决问题但这种「同步」重试的方案依旧不严谨。

那更好的方案应该怎么做

答案是异步重试。什么是异步重试

其实就是把重试请求写到「消息队列」中然后由专门的消费者来重试直到成功。

或者更直接的做法为了避免第二步执行失败我们可以把操作缓存这一步直接放到消息队列中由消费者来操作缓存。

到这里你可能会问写消息队列也有可能会失败啊而且引入消息队列这又增加了更多的维护成本这样做值得吗

这个问题很好但我们思考这样一个问题如果在执行失败的线程中一直重试还没等执行成功此时如果项目「重启」了那这次重试请求也就「丢失」了那这条数据就一直不一致了。

所以这里我们必须把重试消息或第二步操作放到另一个「服务」中这个服务用「消息队列」最为合适。这是因为消息队列的特性正好符合我们的需求

  • 消息队列保证可靠性写到队列中的消息成功消费之前不会丢失重启项目也不担心
  • 消息队列保证消息成功投递下游从队列拉取消息成功消费后才会删除消息否则还会继续投递消息给消费者符合我们重试的需求

至于写队列失败和消息队列的维护成本问题

  • 写队列失败操作缓存和写消息队列「同时失败」的概率其实是很小的
  • 维护成本我们项目中一般都会用到消息队列维护成本并没有新增很多

所以引入消息队列来解决这个问题是比较合适的。这时架构模型就变成了这样

img

那如果你确实不想在应用中去写消息队列是否有更简单的方案同时又可以保证一致性呢

方案还是有的这就是近几年比较流行的解决方案订阅数据库变更日志再操作缓存

具体来讲就是我们的业务应用在修改数据时「只需」修改数据库无需操作缓存。

那什么时候操作缓存呢这就和数据库的「变更日志」有关了。

拿 MySQL 举例当一条数据发生修改时MySQL 就会产生一条变更日志Binlog我们可以订阅这个日志拿到具体操作的数据然后再根据这条数据去删除对应的缓存。

img

订阅变更日志目前也有了比较成熟的开源中间件例如阿里的 canal使用这种方案的优点在于

  • 无需考虑写消息队列失败情况只要写 MySQL 成功Binlog 肯定会有
  • 自动投递到下游队列canal 自动把数据库变更日志「投递」给下游的消息队列

当然与此同时我们需要投入精力去维护 canal 的高可用和稳定性。

如果你有留意观察很多数据库的特性就会发现其实很多数据库都逐渐开始提供「订阅变更日志」的功能了相信不远的将来我们就不用通过中间件来拉取日志自己写程序就可以订阅变更日志了这样可以进一步简化流程。

至此我们可以得出结论想要保证数据库和缓存一致性推荐采用「先更新数据库再删除缓存」方案并配合「消息队列」或「订阅变更日志」的方式来做

主从库延迟和延迟双删问题

到这里还有 2 个问题是我们没有重点分析过的。

第一个问题还记得前面讲到的「先删除缓存再更新数据库」导致不一致的场景么

这里我再把例子拿过来让你复习一下

2 个线程要并发「读写」数据可能会发生以下场景

  1. 线程 A 要更新 X = 2原值 X = 1
  2. 线程 A 先删除缓存
  3. 线程 B 读缓存发现不存在从数据库中读取到旧值X = 1
  4. 线程 A 将新值写入数据库X = 2
  5. 线程 B 将旧值写入缓存X = 1

最终 X 的值在缓存中是 1旧值在数据库中是 2新值发生不一致。

第二个问题是关于「读写分离 + 主从复制延迟」情况下缓存和数据库一致性的问题。

如果使用「先更新数据库再删除缓存」方案其实也发生不一致

  1. 线程 A 更新主库 X = 2原值 X = 1
  2. 线程 A 删除缓存
  3. 线程 B 查询缓存没有命中查询「从库」得到旧值从库 X = 1
  4. 从库「同步」完成主从库 X = 2
  5. 线程 B 将「旧值」写入缓存X = 1

最终 X 的值在缓存中是 1旧值在主从库中是 2新值也发生不一致。

看到了么这 2 个问题的核心在于缓存都被回种了「旧值」

那怎么解决这类问题呢

最有效的办法就是把缓存删掉

但是不能立即删而是需要「延迟删」这就是业界给出的方案缓存延迟双删策略

按照延时双删策略这 2 个问题的解决方案是这样的

解决第一个问题在线程 A 删除缓存、更新完数据库之后先「休眠一会」再「删除」一次缓存。

解决第二个问题线程 A 可以生成一条「延时消息」写到消息队列中消费者延时「删除」缓存。

这两个方案的目的都是为了把缓存清掉这样一来下次就可以从数据库读取到最新值写入缓存。

但问题来了这个「延迟删除」缓存延迟时间到底设置要多久呢

  • 问题1延迟时间要大于「主从复制」的延迟时间
  • 问题2延迟时间要大于线程 B 读取数据库 + 写入缓存的时间

但是这个时间在分布式和高并发场景下其实是很难评估的。

很多时候我们都是凭借经验大致估算这个延迟时间例如延迟 1-5s只能尽可能地降低不一致的概率。

所以你看采用这种方案也只是尽可能保证一致性而已极端情况下还是有可能发生不一致。

所以实际使用中我还是建议你采用「先更新数据库再删除缓存」的方案同时要尽可能地保证「主从复制」不要有太大延迟降低出问题的概率。

可以做到强一致吗

看到这里你可能会想这些方案还是不够完美我就想让缓存和数据库「强一致」到底能不能做到呢

其实很难。

要想做到强一致最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议但它们的性能往往比较差而且这些方案也比较复杂还要考虑各种容错问题。

相反这时我们换个角度思考一下我们引入缓存的目的是什么

没错性能

一旦我们决定使用缓存那必然要面临一致性问题。性能和一致性就像天平的两端无法做到都满足要求。

而且就拿我们前面讲到的方案来说当操作数据库和缓存完成之前只要有其它请求可以进来都有可能查到「中间状态」的数据。

所以如果非要追求强一致那必须要求所有更新操作完成之前期间不能有「任何请求」进来。

虽然我们可以通过加「分布锁」的方式来实现但我们也要付出相应的代价甚至很可能会超过引入缓存带来的性能提升。

所以既然决定使用缓存就必须容忍「一致性」问题我们只能尽可能地去降低问题出现的概率。

同时我们也要知道缓存都是有「失效时间」的就算在这期间存在短期不一致我们依旧有失效时间来兜底这样也能达到最终一致。

总结

好了总结一下这篇文章的重点。

1、想要提高应用的性能可以引入「缓存」来解决

2、引入缓存后需要考虑缓存和数据库一致性问题可选的方案有「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」

3、更新数据库 + 更新缓存方案在「并发」场景下无法保证缓存和数据一致性解决方案是加「分布锁」但这种方案存在「缓存资源浪费」和「机器性能浪费」的情况

4、采用「先删除缓存再更新数据库」方案在「并发」场景下依旧有不一致问题解决方案是「延迟双删」但这个延迟时间很难评估

5、采用「先更新数据库再删除缓存」方案为了保证两步都成功执行需配合「消息队列」或「订阅变更日志」的方案来做本质是通过「重试」的方式保证数据最终一致

6、采用「先更新数据库再删除缓存」方案「读写分离 + 主从库延迟」也会导致缓存和数据库不一致缓解此问题的方案是「延迟双删」凭借经验发送「延迟消息」到队列中延迟删除缓存同时也要控制主从库延迟尽可能降低不一致发生的概率

后记

本以为这个老生常谈的话题写起来很好写没想到在写的过程中还是挖到了很多之前没有深度思考过的细节。

在这里我也分享 4 点心得给你

1、性能和一致性不能同时满足为了性能考虑通常会采用「最终一致性」的方案

2、掌握缓存和数据库一致性问题核心问题有 3 点缓存利用率、并发、缓存 + 数据库一起成功问题

3、失败场景下要保证一致性常见手段就是「重试」同步重试会影响吞吐量所以通常会采用异步重试的方案

4、订阅变更日志的思想本质是把权威数据源例如 MySQL当做 leader 副本让其它异质系统例如 Redis / Elasticsearch成为它的 follower 副本通过同步变更日志的方式保证 leader 和 follower 之间保持一致

很多一致性问题都会采用这些方案来解决希望我的这些心得对你有所启发。

部分搬运来自http://kaito-kidd.com/2021/09/08/how-to-keep-cache-and-consistency-of-db/

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: 数据库