数据库和缓存一致性,多文总结,欢迎纠错

本文主要讨论这么几个问题:

(1)“缓存与数据库”需求缘起

(2)“淘汰缓存”还是“更新缓存”

(3)缓存和数据库的操作时序

(4)缓存和数据库架构简析

(5)方案选择,最终一致性和强一致性

一、需求缘起


场景介绍

缓存是一种提高系统读性能的常见技术,对于读多写少的应用场景,我们经常使用缓存来进行优化。

例如对于用户的余额信息表account(uid, money),业务上的需求是:

(1)查询用户的余额,SELECT money FROM account WHERE uid=XXX,占99%的请求

(2)更改用户余额,UPDATE account SET money=XXX WHERE uid=XXX,占1%的请求

缓存场景

由于大部分的请求是查询,我们在缓存中建立uid到money的键值对,能够极大降低数据库的压力。

读操作流程

有了数据库和缓存两个地方存放数据之后(uid->money),每当需要读取相关数据时(money),操作流程一般是这样的:

(1)读取缓存中是否有相关数据,uid->money

(2)如果缓存中有相关数据money,则返回【这就是所谓的数据命中“hit”

(3)如果缓存中没有相关数据money,则从数据库读取相关数据money【这就是所谓的数据未命中“miss”】,放入缓存中uid->money,再返回

缓存的命中率 = 命中缓存请求个数/总缓存访问请求个数 = hit/(hit+miss)

上面举例的余额场景,99%的读,1%的写,这个缓存的命中率是非常高的,会在95%以上。

网上找了一个流程图,能够简单说明这个流程:

但是在更新缓存方面,对于更新完数据库,是更新缓存呢,还是删除缓存。又或者是先删除缓存,再更新数据库,其实大家存在很大的争议。所以参考了网上一些资料对这块进行讲解。

先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。因此,接下来讨论的思路不依赖于给缓存设置过期时间这个方案。

二、更新缓存 VS 淘汰缓存

什么是更新缓存:数据不但写入数据库,还会写入缓存

什么是淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉,也就是删除掉

2.1、先更新数据库,再更新缓存

首先先讲下为什么没有先更新缓存,再更新数据库的做法,因为缓存的值的状态有可能需要数据库的结果,例如主键返回,或者一些聚合操作。所以不存在这种操作。

然后是先更新数据库,再更新缓存,这个方案有一个特别致命的漏洞,就是下面的原因1,容易造成脏数据。

原因一(线程安全角度)

同时有请求A和请求B进行更新操作,那么会出现

(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。


原因二(业务场景角度)

有如下两点:

(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

这个是取决于业务场景的,当更新缓存的代价很大的时候,此时我们应该更倾向于淘汰缓存。

接下来讨论的就是争议最大的,先删缓存,再更新数据库。还是先更新数据库,再删缓存的问题。

三、先操作数据库 vs 先操作缓存

如果出现不一致,谁先做对业务的影响较小,就谁先执行。

3.1从原子性和容错性角度考虑

由于写数据库与淘汰缓存不能保证原子性,谁先谁后同样要遵循上述原则。

db与cache不一致

假设先写数据库,再淘汰缓存

:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。

db中修改失败

假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。

结论:假设在数据库和淘汰缓存的操作下都可能失败的情况,先更新数据库,后淘汰缓存是最好的选择。

事实证明,先更新数据库,后删除缓存是业界普遍的做法,知名社交网站facebook也在论文《Scaling Memcache at Facebook》中提出,他们用的也是先更新数据库,再删缓存的策略。

链接:       https://www.oschina.net/translate/scaling-memcache-facebook?cmp&p=1

那么接下来我就不考虑先删除缓存再更新数据库的做法了。

3.2 先更新数据库,再删缓存

该方案同样会有并发问题,导致不一致的原因是。同时有一个请求A进行查询操作,另一个请求B进行更新操作。那么会出现如下情形:

(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存

上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

那么,如何解决呢?采用延时双删策略

伪代码如下

public void write(String key,Object data){

db.updateData(data);

redis.delKey(key);

Thread.sleep(1000);

redis.delKey(key);

}

 

转化为中文描述就是

(1)先更新数据库
(2)淘汰缓存
(3)休眠1秒,再次淘汰缓存

这么做,可以将1秒内所造成的缓存脏数据,再次删除。

那么,这个1秒怎么确定的,具体该休眠多久呢?

针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
如果你用了mysql的读写分离架构怎么办?

上述情形,就是数据不一致的原因。还是使用双删延时策略,只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。
采用这种同步淘汰策略,吞吐量降低怎么办?

ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。

然而,发生这种情况的概率又有多少呢?

发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。

假设,有人非要抬杠,有强迫症,一定要解决怎么办?
如何解决上述并发问题?

首先,给缓存设有效时间是一种方案。其次,采用策略(2)里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。


还有其他造成不一致的原因么?

有的,如果删缓存失败了怎么办,那不是会有不一致的情况出现么。比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况了。这也是缓存更新策略里留下的一个疑问,也就是高可用性,也就是分布式架构里面一个很重要的问题。

如何解决?

提供一个保障的重试机制即可,这里给出两套方案。

方案一:

流程如下所示

(1)更新数据库数据;
(2)缓存因为种种问题删除失败
(3)将需要删除的key发送至消息队列
(4)自己消费消息,获得需要删除的key
(5)继续重试删除操作,直到成功

然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。


方案二:

流程如下图所示:

(1)更新数据库数据
(2)数据库会将操作信息写入binlog日志当中
(3)订阅程序提取出所需要的数据以及key
(4)另起一段非业务代码,获得该信息
(5)尝试删除缓存操作,发现删除失败
(6)将这些信息发送至消息队列
(7)重新从消息队列中获得该数据,重试操作。

读取binlog的开源框架有 Canal 和Databus ,具体使用请自行百度学习。

上面第二个方案算是一个比较好的方案了,但是也相应的增加了系统的复杂度,如果对缓存的实时性要求不高的情况下,不建议,简单的使用缓存过期就可以解决大多数场景,这个方案毕竟会增加系统的复杂度和不必要的运维成本。

然而,说了半天,有些人会吐糟,这系统在某些情况下还是会出现缓存和数据库不一致的情况。是的,因为上面所有的总结全是基于最终一致性来讨论的,其实还有个词语,叫做强一致性

最终一致性:数据库和缓存到一定时间后一定可以一致,中间时间是否完全一致无法保证。

强一致性

  • 缓存和DB数据一致
  • 缓存中没有数据(或者说:不会去读缓存中的老版本数据)

四:强一致性方案

我们来分析一下,既然已经实现了“最终一致性”,那它和“强一致性”的区别是什么呢?没错,就是“时间差”,所以:

最终一致性方案” + “时间差” = “强一致性方案

那我们的工作呢,就是加上时间差,实现方式:我们加一个缓存,将近期被修改的数据进行标记锁定。读的时候,标记锁定的数据强行走DB,没锁定的数据,先走缓存

写流程:

我们把修改的数据通过Cache_0标记“正在被修改”,如果标记成功,则继续往下走;那如果标记失败,则要放弃这次修改。

何为标记锁定呢?比如你可以设定一个有效期为10S的key,Key存在即为锁定。一般来说10S对于后面的同步操作来说基本是够了~

假如10s之后,我们发现缓存还存在,那么我们回退更新数据库的操作,放弃此次修改

读流程:

先读Cache_0,看看要读的数据是否被标记,如果被标记,则直接读主库;如果没有被标记,后面的步骤就是正常的流程了。

 

参考:

https://www.w3cschool.cn/architectroad/architectroad-cache-architecture-design.html

https://blog.csdn.net/diweikang/article/details/94406186

https://blog.kido.site/2018/12/09/db-and-cache-04/

https://www.oschina.net/translate/scaling-memcache-facebook?cmp&p=3

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值