本文主要讨论这么几个问题:
(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中是旧数据,数据不一致。
假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次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