当前位置:   article > 正文

备战面试日记(6.3) - (缓存相关.理解缓存数据一致性)_a cache acts as a filter. for example, for

a cache acts as a filter. for example, for

本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试。

记录日期:2022.1.25

大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习。

缓存 - 理解缓存数据一致性

参考文章:深入理解缓存之缓存和数据库的一致性

产生原因

主要有两种会导致缓存和 DB 的出现不一致的情况:

  1. 并发的场景下,导致读取老的数据库数据,更新到缓存中。
    1. 缓存和数据库的操作不在一个事务中,可能只有一个操作成功,而另一个操作失败,导致不一致。

当然,有一点我们要注意,缓存和数据库的一致性,我们更多指的的是最终一致性我们使用缓存只是为了提高读操作的性能,真正写操作的业务逻辑,还是以数据库为准。

例如,我们可能缓存用户钱包的余额在缓存中,在前端查询钱包余额时,读取缓存,在扣除钱包余额时,读取数据库。

更新缓存策略

注意一个大前提,假设更新数据库和更新缓存都可以成功时,我们更新缓存有四种设计模式:

  1. Cache Aside Pattern(旁路缓存)
  2. Read/Write Through Pattern(直读/写缓存)
  3. Write Behind Caching Pattern(回写缓存)

这些缓存策略都是计算机体系结构里的设计,比如CPU的缓存,硬盘文件系统中的缓存,硬盘上的缓存,数据库中的缓存。并不是指软件架构里的mysql数据库和memcache/redis的更新策略。

Cache Aside Pattern

Cache Aside Pattern(旁路缓存)在我们平时的开发比较常见。分为读操作写操作两种:

读操作

读操作流程步骤:

  1. 从缓存中查询数据。
    1. 如果缓存命中则直接返回。
    2. 缓存未命中,则去数据库中读取,将从数据库中读取的结果的副本放入到缓存中,并返回。

在这里插入图片描述

写操作

写操作流程步骤:

  1. 首先更新数据库。
  2. 然后删除缓存中的数据

在这里插入图片描述

Read/Write Through Pattern

我们可以看到,在上面的 Cache Aside Pattern(旁路缓存)套路中,我们的应用代码需要维护两个数据存储,一个是缓存,另一个是数据库。

Read/Write Through 套路是把更新数据库的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护的缓存。

Read Through

Read Through 套路就是在查询操作中更新缓存。

缓存失效缓存过期内存淘汰策略)的时候:

  • Cache Aside 是由调用方负责把数据加载入缓存。
  • Read Through 是用缓存服务自己来加载,从而对应用方是透明的。

在这里插入图片描述

Write Through

Write ThroughRead Through 差不多,只不过是在更新数据时发生。

当有数据更新的时候:

  • 如果没有命中缓存,直接更新数据库,然后返回。

  • 如果命中缓存,则更新缓存,然后再用缓存服务自己更新数据库(二者是同步操作)。

在这里插入图片描述

Write Behind Caching Pattern

参考Linux文件系统的Page Cache算法,可以寻找博客阅读。

Write Behind 又叫 Write Back。在更新数据的时候,只更新缓存,不更新数据库,而缓存会异步地批量地更新数据库。

这个设计的好处就是可以直接操作内存,让数据的I/O操作飞快无比。因为异步,Write Back 还可以合并对同一个数据的多次操作,所以性能的提高相当可观。

但是它会带来数据不是强一致性的问题,而且可能会导致数据丢失(Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是有取舍的。

另外,Write Back 实现逻辑比较复杂,因为他需要检查有哪数据是被更新了的,需要刷到持久层上。操作系统的 Write Back 会在仅当这个缓存需要失效的时候,才会被真正持久起来。比如,内存不够了,或是进程退出了等情况,这又叫 lazy write

读操作

在这里插入图片描述

写操作

在这里插入图片描述

缓存策略设计细节

参考文章:缓存架构设计细节二三事_【58沈剑】

淘汰缓存和更新缓存的选择?

我们拿 Cache Aside Pattern 来说,为什么在该策略中选择了淘汰缓存,而不是更新缓存?

更新缓存:数据不但写入数据库,还会写入缓存。优点是缓存不会增加一次miss,命中率高

淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉。优点是简单高效

所以选择选择了淘汰缓存,而不是更新缓存,主要是基于两点来考量:

  1. 数据更新后,可能不会有大量的访问。如果每次更新数据后都更新缓存,可能会造成大量不必要的计算开销。因此,这里采用一种Lazy思想,每次更新数据时仅仅是删除缓存,只有在真正读请求到来时才进行缓存的更新。
  2. 在高并发场景下,并发地更新缓存可能会造成缓存可数据库中数据不一致的问题。

当然如果你的系统追求高一致性,同时降低了更新缓存的复杂度,在某些情况下可以选择更新缓存

场景一:更新用户余额,我们给用户余额添加money时,我们就需要直接更新用户余额的内存数据。

结论:对于用户余额需要保证高一致性,减少缓存未命中的次数,并且这种即时数据更新的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率。

场景二:用户购买商品,商品价格为price,同时商城在为该商品对应的商品类型type进行促销打折活动,要打n折,添加到购物车后,该商品实际付款价格的计算就比较复杂:1.先拿到商品价格;2.查询商品类别折扣;3.计算折扣后价格

结论:此时更新缓存的代价很大,我们应该更倾向于淘汰缓存。

综上所述,淘汰缓存操作简单,并且带来的副作用只是增加了少数 cache miss,建议作为通用的处理方式。

先操作数据和先操作缓存的问题?

我们还是拿 Cache Aside Pattern 来说,对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:如果出现不一致,谁先做对业务的影响较小,就谁先执行。

更新缓存【不采用】
先更新数据,再更新缓存

有线程 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 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。

先更新缓存,再更新数据

有线程 A 和线程 B 两个线程,需要更新相同数据,会发生这样的场景:

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

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

淘汰缓存
先更新数据,再淘汰缓存
原子性考虑

先更新数据,再淘汰缓存,第一步更新数据操作成功,第二步淘汰缓存失败,则会出现DB中是新数据Cache中是旧数据,数据不一致,之后的读请求读到的都是旧数据,只有当缓存失效后,才能从数据库中得到正确的值。

从并发考虑

有线程 A 和线程 B 两个线程,线程 A 更新数据,线程 B 读取数据,会发生这样的场景:

  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. (更新数据库 + 删除缓存的时间)要比(读数据库 + 写缓存时间)短

其中,第三个条件的概率非常低,原因是,写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的。

先淘汰缓存,再更新数据
从原子性考虑

先淘汰缓存,再更新数据,数据的最终一致性是可以得到有效的保证的。第一步淘汰缓存成功,第二步写数据库失败,也就是引发一次Cache miss。

从并发考虑

有线程 A 和线程 B 两个线程,线程 A 更新数据,线程 B 读取数据,会发生这样的场景:

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

最终 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)

重客户端

写入缓存

在这里插入图片描述

具体步骤为:

  • 应用同时更新数据库和缓存
  • 如果数据库更新成功,则开始更新缓存,否则如果数据库更新失败,则整个更新过程失败。
  • 判断更新缓存是否成功,如果成功则返回
  • 如果缓存没有更新成功,则将数据发到MQ中
  • 应用监控MQ通道,收到消息后继续更新Redis。
问题

如果更新Redis失败,同时在将数据发到MQ之前的时间,应用重启了,这时候MQ就没有需要更新的数据,如果Redis对所有数据没有设置过期时间,同时在读多写少的场景下,只能通过人工介入来更新缓存。

读缓存

如何来解决这个问题?那么在写入Redis数据的时候,在数据中增加一个时间戳插入到Redis中。在从Redis中读取数据的时候,首先要判断一下当前时间有没有过期,如果没有则从缓存中读取,如果过期了则从数据库中读取最新数据覆盖当前Redis数据并更新时间戳。具体过程如下图所示:

在这里插入图片描述

客户端数据库与缓存解耦

上述方案对于应用的研发人员来讲比较重,需要研发人员同时考虑数据库和Redis是否成功来做不同方案,如何让研发人员只关注数据库层面,而不用关心缓存层呢?请看下图:

在这里插入图片描述

具体步骤:

  • 应用直接写数据到数据库中。
  • 数据库更新binlog日志。
  • 利用Canal中间件读取binlog日志。
  • Canal借助于限流组件按频率将数据发到MQ中。
  • 应用监控MQ通道,将MQ的数据更新到Redis缓存中。

可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。

使用缓存过程中,经常会遇到缓存数据的不一致性和脏读现象。一般情况下,采取缓存双淘汰机制,在更新数据库的淘汰缓存。此外,设定超时时间,例如三十分钟。

极端场景下,即使有脏数据进入缓存,这个脏数据也最存在一段时间后自动销毁。

当存在主从数据库复制的情况下,参考文章:主从DB与cache一致性优化_w3cschool

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/weixin_40725706/article/detail/146834
推荐阅读
相关标签
  

闽ICP备14008679号