缓存和数据库一致性探讨

2022-01-01  本文已影响0人  PioneerYi

引言

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

持久化层和缓存层的一致性问题也通常被称为双写一致性问题,“双写”意为数据既在数据库中保存一份,也在缓存中保存一份。对于应用缓存的大部分场景来说,追求的则是最终一致性,少部分对数据一致性要求极高的场景则会追求强一致性。

读写缓存策略

缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般我们是如何使用缓存呢?有三种经典的读写缓存策略:
● 旁路缓存策略(Cache-Aside Strategy):只有当有应用来请求时,才将对应的对象进行缓存;
● 读写穿透缓存策略(Read/Write-Through Caching Strategy):读写请求由缓存层统一封装处理,业务服务仅操作缓存;
● 异步写入缓存策略(Write-Behind Caching Strategy):数据读取与Read-Through类似,但是数据写入由独立线程异步批量处理更新数据库。

旁路缓存策略(Cache-Aside Strategy)

这种策略的核心思想是:只有当有应用来请求时,才将对应的对象进行缓存。适用于读取频繁写入及更新不频繁的场景。

缓存-数据库读写流程

缓存-数据库读流程

image
主要流程为:
  1. 用户发起查询请求
  2. 业务服务首先根据关键参数作为key查询缓存
  3. 如果数据在缓存中存在cache hit,则直接返回缓存中查询结果。
  4. 如果数据不在缓存中cache miss,则进行数据库查询操作,将结果缓存并返回查询结果。

缓存-数据库写流程

image
主要流程为:
  1. 用户发起请求,需要写数据。
  2. 业务服务在完成逻辑处理后,开始更新数据库。
  3. 数据库更新完成后根据key删除缓存数据

为什么建议删除,而不是更新?

在 Cache-Aside 中,对于读请求的处理比较容易理解,但在写请求中,可能会有读者提出疑问,为什么要删除缓存,而不是更新缓存?站在符合直觉的角度来看,更新缓存是一个容易被理解的方案,但站在性能和安全的角度,更新缓存则可能会导致一些不好的后果。

首先是安全,在并发场景下,在写请求中更新缓存可能会引发数据的不一致问题,比如如下实际多请求并发的情况下:
● step1: 时刻1,线程1更新数据库的值为value1;
● step2: 时刻2,线程2更新数据库的值为value2;
● step3: 时刻3,线程2更新缓存的值为value2(因为一些网络原因,或其他因素,线程2快于线程1,这种情况是存在的);
● step4: 时刻4,线程1更新数据库的值为value1;


image

可以看到,最终的结果数据库中的值为value2,缓存中的数据为value1,数据不一致情况出现。选择删除缓存可以避免出现类似问题,最多会出现cache miss,触发从数据库查询加载。

其次是性能,当该缓存对应的结果需要消耗大量的计算过程才能得到时,比如需要访问多张数据库表并联合计算,那么在写操作中更新缓存的动作将会是一笔不小的开销。同时,当写操作较多时,可能也会存在刚更新的缓存还没有被读取到,又再次被更新的情况(这常被称为缓存扰动),显然,这样的更新是白白消耗机器性能的,会导致缓存利用率不高。而等到读请求未命中缓存时再去更新,也符合懒加载的思路,需要时再进行计算。删除缓存的操作不仅是幂等的,可以在发生异常时重试,而且写-删除和读-更新在语义上更加对称。

因此,建议是淘汰,而不是更新。

为什么先更新数据库,而不是先删除缓存?

缓存操作上确定了,应该删除而不是更新,那么时序上了,为什么不先删除缓存,再更新数据库呢?

在单线程下,这种方案看似具有一定合理性,这种合理性体现在删除缓存成功,但更新数据库失败的场景下,尽管缓存被删除了,下次读操作时,仍能将正确的数据写回缓存,相对于 Cache-Aside 中更新数据库成功,删除缓存失败的场景来说,先删除缓存的方案似乎更合理一些。那么,先删除缓存有什么问题呢?

首先是安全,在并发场景下,先删除缓存可能会引发数据的不一致问题,比如如下实际多请求并发的情况下:


image

比如初始缓存和DB中值都为value1,存在这样的场景:
● step1: 线程1写请求删除缓存;
● step2: 线程2读请求先读缓存,未命中;
● step3: 线程2读请求再读DB拿到值为value1;
● step4: 线程2读请求将读到的值刷回缓存,缓存中值为value1;
● step5: 线程1写请求更新DB的值为value2;
结论:可以看到,最终的结果数据库中的值为value2,缓存中的数据为value1,数据不一致情况出现。

其次是性能,先删除缓存,由于缓存中数据缺失,加剧数据库的请求压力,可能会增大缓存穿透出现的概率。

先更新数据库再删除缓存一定没问题吗?

其实即便是选择删除缓存,也存在数据不一致的可能性。


image

在上面的读写并发场景下,首先来自线程 1 的读请求在未命中缓存的情况下查询数据库( step 1 ),接着来自线程 2 的写请求更新数据库( step 2 ),但由于一些极端原因,线程 1 中读请求的更新缓存操作晚于线程 2 中写请求的删除缓存的操作( step 4 晚于 step 3 ),那么这样便会导致最终写入缓存中的是来自线程 1 的旧值,而写入数据库中的是来自线程 2 的新值,即缓存落后于数据库,此时再有读请求命中缓存( step 5 ),读取到的便是旧值。

这种场景的出现,不仅需要缓存失效且读写并发执行,而且还需要读请求查询数据库的执行早于写请求更新数据库,同时读请求的执行完成晚于写请求。足以见得,这种不一致场景产生的条件非常严格,在实际的生产中出现的可能性较小。

这种场景是不是没有解法了,也不是,可以延时双删,该方案的本质在于在延迟一定时间后,再进行一次缓存的删除,来解决并发情况下缓存到老数据的问题,即使先操作缓存后操作数据库也可以保证最终数据的一致。

该方案的核心点在于延迟时间T,通常我们把T设置为相同业务中一次查询操作耗时+几百毫秒,这样保证了第二次的删除可以清除掉因并发导致的缓存脏数据。该方案的劣势在于:

  1. 需要针对也许评估延迟时间,并增加二次删除逻辑,代码强耦合,增加了复杂度。
  2. 二次删除也可能出现缓存失败。

除此之外,在并发环境下,Cache-Aside 中也存在读请求命中缓存的时间点在写请求更新数据库之后,删除缓存之前,这样也会导致读请求查询到的缓存落后于数据库的情况。


image

虽然在下一次读请求中,缓存会被更新,但如果业务层面对这种情况的容忍度较低,那么可以采用加锁在写请求中保证“更新数据库&删除缓存”的串行执行为原子性操作(同理也可对读请求中缓存的更新加锁)。加锁势必会导致吞吐量的下降,故采取加锁的方案应该对性能的损耗有所预期。

如果删除失败怎么办?

除上面考虑的问题外,我们没有考虑操作数据库或者操作缓存可能失败的情况,而这种情况也是客观存在的。那么在这里我们简单讨论下,首先是如果更新数据库失败了,其实没有太大关系,因为此时数据库和缓存中都还是老数据,不存在不一致的问题。假设删除缓存失败了呢?此时确实会存在数据不一致的情况。

最简单的,就是设置一个缓存过期时间,那么当缓存过期后,用户再请求,就是新的数据了。除此之外有没有其他好的办法了?

缓存失败时可以增加重试机制。可以直接在代码中增加重试,这样简单但有一些不足,在于如果是网络原因导致的失败,立刻进行重试操作很可能也是失败的,因此在每次重试之间可能需要等待一段时间,比如几百毫秒甚至是秒级等待。为了不影响主流程的正常运行,你可能会将这个事情交给一个异步线程或者线程池来执行,但是如果机器此时也宕机了,这个删除操作也就丢失了。那要怎么解决这个问题呢?首先可以考虑引入消息队列,写入消息队列一样可能会失败,但是这是建立在缓存跟消息队列都不可用的情况下,应该说这样的概率是不高的。引入消息队列之后,就由消费端负责删除缓存以及重试,可能会慢一些但是可以保证操作不会丢失。


image

上述方案需要在正常业务逻辑中加入删除失败处理代码,侵入性很强,是否还有其他方案了?可以考虑订阅数据库的BinLog,数据库的BinLog存储了对数据库的更改操作日志记录,可以通过订阅该日志,来进行缓存的更新,业务代码不再关心缓存更新操作。


image.png

读写穿透缓存策略(Read/Write-Through Caching Strategy)

Read Through

和旁路缓存模式类似,先查询缓存:1. 缓存中存在,直接返回;2. 缓存中不存在,缓存服务自动从数据库中读取数据写入缓存,然后返回。


image

和旁路缓存模式的区别就是,旁路缓存模式是我们手动写入缓存,而读写穿透模式是自动从数据库中读取数据并写入缓存。

这种模式的缺陷是很多缓存层都不支持,例如Redis无法直接从MySQL中获取数据保存到自身中(除非使用Redis插件)。

Write-Through

在写请求时,先查询缓存中存不存在: 不存在,直接写入数据库; 存在,先更新缓存,然后同步更新数据库。两个操作都在一个事务中完成,只有两次都写成功了才是最终写成功了。


image

程序只和缓存交互,编码会变得更加简单和整洁,且保证了数据的一致性,但是写入延迟较大。适合写操作较多,并且对一致性要求较高的场景。

异步写入缓存策略(Write-Behind Caching Strategy)

Write behind意为异步回写模式,Write behind 在处理写请求时,只更新缓存而不更新数据库,对于数据库的更新,则是通过批量异步更新的方式进行的。


image

这种模式写性能非常好,因为都是直接写缓存,减轻了数据库的压力,具有较好的吞吐性。但数据库和缓存的一致性较弱,比如当更新的数据还未被写入数据库时,直接从数据库中查询数据是落后于缓存的。同时,缓存的负载较大,如果缓存宕机会导致数据丢失,所以需要做好缓存的高可用。

显然,Write behind 模式下适合大量写操作的场景,常用于电商秒杀场景中库存的扣减。

策略总结

image

从下到上,一致性保障逐渐增强。

怎么做到强一致性?

绝大多数业务场景,保证缓存和数据库的最终一致性就可以了,但是一些场景真的就要保证强一致性,是否有方案了?

如果要实现这两者的强一致性,只能是在更新完数据库之前,所有的读请求都必须要被阻塞直到缓存最终被删除为止,这里是不是让你想起了什么?比如volatile,ReadWriteLock等等,是的原理类似,只是当前场景不是单机而是一个分布式场景,那么可以考虑引入分布式锁。

初次之外,如果更有想法,可以引入一致性协议,比如Paxos和Raft等等,当然引入一个缓存,搞得这么复杂,可能不太合适。

总结

如果遇到一致性要求非常高的场景,应该考虑是否有必要引入缓存;
设置缓存的过期时间、每隔一段时间自动刷新,能解决大部分问题;
加读写锁可能会导致系统变得沉重,系统变慢;

参考文献

聊聊数据库与缓存数据一致性问题
一文搞定缓存和数据库一致性

上一篇下一篇

猜你喜欢

热点阅读