赞
踩
本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试。
记录日期:2022.1.25
大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习。
参考文章:深入理解缓存之缓存和数据库的一致性
主要有两种会导致缓存和 DB 的出现不一致的情况:
当然,有一点我们要注意,缓存和数据库的一致性,我们更多指的的是最终一致性。我们使用缓存只是为了提高读操作的性能,真正写操作的业务逻辑,还是以数据库为准。
例如,我们可能缓存用户钱包的余额在缓存中,在前端查询钱包余额时,读取缓存,在扣除钱包余额时,读取数据库。
注意一个大前提,假设更新数据库和更新缓存都可以成功时,我们更新缓存有四种设计模式:
Cache Aside Pattern
(旁路缓存)Read/Write Through Pattern
(直读/写缓存)Write Behind Caching Pattern
(回写缓存)这些缓存策略都是计算机体系结构里的设计,比如CPU的缓存,硬盘文件系统中的缓存,硬盘上的缓存,数据库中的缓存。并不是指软件架构里的mysql数据库和memcache/redis的更新策略。
Cache Aside Pattern
(旁路缓存)在我们平时的开发比较常见。分为读操作
和写操作
两种:
读操作流程步骤:
写操作流程步骤:
我们可以看到,在上面的 Cache Aside Pattern
(旁路缓存)套路中,我们的应用代码需要维护两个数据存储,一个是缓存,另一个是数据库。
而 Read/Write Through
套路是把更新数据库的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护的缓存。
Read Through
套路就是在查询操作中更新缓存。
当缓存失效
(缓存过期
或内存淘汰策略
)的时候:
Cache Aside
是由调用方负责把数据加载入缓存。Read Through
是用缓存服务自己来加载,从而对应用方是透明的。Write Through
和 Read Through
差不多,只不过是在更新数据时发生。
当有数据更新的时候:
如果没有命中缓存,直接更新数据库,然后返回。
如果命中缓存,则更新缓存,然后再用缓存服务自己更新数据库(二者是同步操作)。
参考
Linux文件系统的Page Cache算法
,可以寻找博客阅读。
Write Behind
又叫 Write Back
。在更新数据的时候,只更新缓存,不更新数据库,而缓存会异步地
、批量地
更新数据库。
这个设计的好处就是可以直接操作内存,让数据的I/O操作飞快无比。因为异步,Write Back
还可以合并对同一个数据的多次操作,所以性能的提高相当可观。
但是它会带来数据不是强一致性的问题,而且可能会导致数据丢失(Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是有取舍的。
另外,Write Back
实现逻辑比较复杂,因为他需要检查有哪数据是被更新了的,需要刷到持久层上。操作系统的 Write Back
会在仅当这个缓存需要失效的时候,才会被真正持久起来。比如,内存不够了,或是进程退出了等情况,这又叫 lazy write
。
参考文章:缓存架构设计细节二三事_【58沈剑】
我们拿 Cache Aside Pattern
来说,为什么在该策略中选择了淘汰缓存,而不是更新缓存?
更新缓存
:数据不但写入数据库,还会写入缓存。优点是缓存不会增加一次miss,命中率高。
淘汰缓存
:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉。优点是简单高效。
所以选择选择了淘汰缓存,而不是更新缓存,主要是基于两点来考量:
当然如果你的系统追求高一致性,同时降低了更新缓存的复杂度,在某些情况下可以选择
更新缓存
:场景一:更新用户余额,我们给用户余额添加money时,我们就需要直接更新用户余额的内存数据。
结论:对于用户余额需要保证高一致性,减少缓存未命中的次数,并且这种即时数据更新的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率。
场景二:用户购买商品,商品价格为price,同时商城在为该商品对应的商品类型type进行促销打折活动,要打n折,添加到购物车后,该商品实际付款价格的计算就比较复杂:1.先拿到商品价格;2.查询商品类别折扣;3.计算折扣后价格
结论:此时更新缓存的代价很大,我们应该更倾向于淘汰缓存。
综上所述,淘汰缓存操作简单,并且带来的副作用只是增加了少数 cache miss,建议作为通用的处理方式。
我们还是拿 Cache Aside Pattern
来说,对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。
有线程 A 和线程 B 两个线程,需要更新相同数据,会发生这样的场景:
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
也就是说,A 虽然先于 B 发生,但 B 操作数据库和缓存的时间,却要比 A 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。
有线程 A 和线程 B 两个线程,需要更新相同数据,会发生这样的场景:
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致,同上。
先更新数据,再淘汰缓存
,第一步更新数据操作成功,第二步淘汰缓存失败,则会出现DB中是新数据
,Cache中是旧数据
,数据不一致,之后的读请求读到的都是旧数据,只有当缓存失效后,才能从数据库中得到正确的值。
有线程 A 和线程 B 两个线程,线程 A 更新数据,线程 B 读取数据,会发生这样的场景:
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
但是这种情况实际上发生的概率很低,它需要满足3个条件才会存在这种情况:
- 缓存刚好已失效
- 读请求和写请求并发
- (更新数据库 + 删除缓存的时间)要比(读数据库 + 写缓存时间)短
其中,第三个条件的概率非常低,原因是,写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的。
先淘汰缓存,再更新数据
,数据的最终一致性是可以得到有效的保证的。第一步淘汰缓存成功,第二步写数据库失败,也就是引发一次Cache miss。
有线程 A 和线程 B 两个线程,线程 A 更新数据,线程 B 读取数据,会发生这样的场景:
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。
也就是说,先淘汰缓存,后更新数据库,当发生读写并发时,还是存在数据不一致的情况。
参考文章:缓存和数据库一致性问题,看这篇就够了 (qq.com)
从原子性上看就是要保证操作数据库和操作缓存的操作都成功。
在先操作数据库的情况下,如果后者执行失败了,最好的方式就是 重试。
但是我们要知道失败重试的几个问题:
所以我们应该选择使用 异步重试。
我们可以把重试请求写到 消息队列
中,然后由专门的消费者来重试,直到成功,或者为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。
这样我们有三个好处:
至于其他可能存在的问题:
我们操作数据库后,通过缓存服务订阅数据库日志变更,再来操作缓存。
简单说明就是,我们的业务应用在修改数据时,只需要修改数据库,而无需操作缓存。
那什么时候操作缓存就和数据库的日志变更有关了。
拿 MySQL
举例,当一条数据发生修改时,MySQL
就会产生一条变更日志(binlog
),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除或更新对应的缓存。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal
,使用这种方案的优点在于:
MySQL
成功,binlog` 肯定会有一条记录。canal
自动把数据库变更日志投递给下游的消息队列,由专门的消费者来操作缓存。与此同时,我们还需要投入精力去维护 canal
的高可用和稳定性。
至此,我们可以得出结论,想要保证数据库和缓存一致性,推荐采用「先更新数据,再淘汰缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做。
从并发上看就是要保证先发出的请求一定先执行完成。
参考文章:缓存与数据库一致性优化_【58沈剑】
前面在 “失败重试” 中提到过,将一个请求中的操作缓存和操作数据库串行化。
但是在极端情况下,如果并发写执行时,先更新成功 DB 的,结果后更新缓存:
理论来说,希望的更新缓存顺序是,线程 1 快于线程 2 ,但是实际线程1 晚于线程 2 ,导致数据不一致。
图中一直是基于消息队列来实现异步更新缓存,如果网络抖动,导致插入更新缓存消息的顺序不一致。
那么我们如何来解决这个问题呢?
在缓存值中,拼接上数据版本号或者时间戳。例如说:value = {value: 原值, version: xxx}
。然后在消息队列执行更新缓存时,先读取缓存,对比版本号或时间戳,大于才进行更新。 当然,此处也会有并发问题,所以还是得引入分布式锁
或 CAS 操作
。
因为先淘汰缓存,所以数据的最终一致性是可以得到有效的保证的。因为先淘汰缓存,即使写数据库发生异常,也就是下次缓存读取时,多读取一次数据库。
所以我们这里主要应用于 "先淘汰缓存,再更新数据"
。
所以我们我们需要解决缓存并行写
,实现串行写
。比较简单的方式,引入 分布式锁。
这样,缓存的并行写就成功的变成串行写落。写请求时,是否主动更新缓存,根据自己业务的需要,是否有,都没问题。
参考文章:技术专题讨论第五期:论系统架构设计中缓存的重要性 | Spring For All (spring4all.com)
具体步骤为:
如果更新Redis失败,同时在将数据发到MQ之前的时间,应用重启了,这时候MQ就没有需要更新的数据,如果Redis对所有数据没有设置过期时间,同时在读多写少的场景下,只能通过人工介入来更新缓存。
如何来解决这个问题?那么在写入Redis数据的时候,在数据中增加一个时间戳插入到Redis中。在从Redis中读取数据的时候,首先要判断一下当前时间有没有过期,如果没有则从缓存中读取,如果过期了则从数据库中读取最新数据覆盖当前Redis数据并更新时间戳。具体过程如下图所示:
上述方案对于应用的研发人员来讲比较重,需要研发人员同时考虑数据库和Redis是否成功来做不同方案,如何让研发人员只关注数据库层面,而不用关心缓存层呢?请看下图:
具体步骤:
可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。
使用缓存过程中,经常会遇到缓存数据的不一致性和脏读现象。一般情况下,采取缓存双淘汰机制,在更新数据库的前淘汰缓存。此外,设定超时时间,例如三十分钟。
极端场景下,即使有脏数据进入缓存,这个脏数据也最存在一段时间后自动销毁。
当存在主从数据库复制的情况下,参考文章:主从DB与cache一致性优化_w3cschool
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。