在缓存和数据库双写场景下,一致性是如何保证的


redis 参考目录:

生产级Redis 高并发分布式锁实战1:高并发分布式锁如何实现 https://www.cnblogs.com/yizhiamumu/p/16556153.html

生产级Redis 高并发分布式锁实战2:缓存架构设计问题优化 https://www.cnblogs.com/yizhiamumu/p/16556667.html

总结篇3:redis 典型缓存架构设计问题及性能优化 https://www.cnblogs.com/yizhiamumu/p/16557996.html

总结篇4:redis 核心数据存储结构及核心业务模型实现应用场景 https://www.cnblogs.com/yizhiamumu/p/16566540.html

 

DB\redis\zookeeper分布式锁设计 https://www.cnblogs.com/yizhiamumu/p/16663243.html

在缓存和数据库双写场景下,一致性是如何保证的 https://www.cnblogs.com/yizhiamumu/p/16686751.html

如何保证 Redis 的高并发和高可用?讨论redis的单点,高可用,集群 https://www.cnblogs.com/yizhiamumu/p/16586968.html

分布式缓存应用场景与redis持久化机制 https://www.cnblogs.com/yizhiamumu/p/16702154.html

 

Redisson 源码分析及实际应用场景介绍 https://www.cnblogs.com/yizhiamumu/p/16706048.html

Redis 高可用方案原理初探 https://www.cnblogs.com/yizhiamumu/p/16709290.html

RedisCluster集群架构原理与通信原理 https://www.cnblogs.com/yizhiamumu/p/16704556.html

 

 

在缓存和数据库双写场景下,一致性是如何保证的

缓存一般是直接将数据放到离计算最近的地方(目前大部分放在内存中),解决 CPU 和 I/O 的速度不匹配的问题,用来加快计算处理速度,通常会对热点数据进行缓存,保证较高的命中率。在互联网的架构设计中,数据库及缓存一般相互配合使用来满足不同的场景需求,比如在大流量的请求中会使用缓存来加速。

Redis 在互联网行业中使用最为广泛。Redis 在很多时候也被称为“内存数据库”,它集合了缓存和数据库的优势,但并非开启持久化和主备同步机制就可以高枕无忧。从架构设计的角度思考:缓存就是缓存,缓存数据会随时丢失,缓存存在的目的是拦截到数据库的请求,相比数据的可靠性、一致性,还是吞吐量、稳定性优先。

缓存有三大矛盾:

  1. 缓存实时性和一致性问题:当有了写入后咋办?
  2. 缓存的穿透问题:当没有读到咋办?
  3. 缓存对数据库高并发访问:都来访问数据库咋办?

第一个也就是本问题。而解决这三大矛盾的刷新策略包括:

  1. 实时策略——用户体验好,是默认应该使用的策略;
  2. 异步策略——适用于并发量大,但是数据没有那么关键的情况,好处是实时性好;
  3. 定时策略——并发量实在太大,数据量也大的情况,异步都难以满足的场景;

 

写入数据库成功,即让缓存失效,下一次读取时再缓存。这是缓存的实时策略。当然,并不适用于所有的场景。

实时策略是最常用的策略,也是保持实时性最好的策略:

  • 读取的过程,应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。如果命中,应用程序从 cache 中取数据,取到后返回。
  • 写入的过程,把数据存到数据库中,成功后,再让缓存失效,失效后下次读取的时候,会被写入缓存。

从用户体验的角度,应该数据库有了写入,就马上废弃缓存,触发一次数据库的读取,从而更新缓存。

然而,这和高并发就矛盾了——如果所有的都实时从数据库里面读取,高并发场景下,数据库往往受不了。

 

一台MySQL,一台Redis,两台应用服务器,用户的数据存储持久化在MySQL中,缓存在Redis,有请求的时候从Redis中获取缓存的用户数据,有修改则同时修改MySQL和Redis中的数据。现在问题是:
1. 先保存到MySQL和先保存到Redis都面临着一个保存成功而另外一个保存失败的情况,这样,如何保证MySQL与Redis中的数据同步?
2. 两台应用服务器的并发访问,如何保证数据的安全性?

 

一些用户请求在某些情况下是可能重复发送的,如果是查询类操作并无大碍,但其中有些涉及写入操作,一旦重复了,可能会导致很严重的后果。例如交易接口如果重复请求,可能会重复下单。

 

要想达到数据一致性,需要保证两点:

    • 无并发请求下,保证A和B步骤都能成功执行。
    • 并发请求下,在A和B步骤的间隔中,避免或消除其他线程的影响。

在缓存和数据库在双写场景下,一致性是如何保证的?

 
 

谈谈一致性

 
 

一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。

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

三个经典的缓存模式

缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般我们是如何使用缓存呢?有三种经典的缓存模式:

  • 旁路缓存模式 Cache-Aside Pattern
  • 读写穿透 Read-Through/Write through
  • 异步缓存写入 Write behind

1 旁路缓存模式 Cache-Aside Pattern

Cache-Aside Pattern,即旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。

1.1 Cache-Aside读流程

Cache-Aside Pattern的读请求流程如下:

 
 
  1. 读的时候,先读缓存,缓存命中的话,直接返回数据
  2. 缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。

1.2 Cache-Aside 写流程

Cache-Aside Pattern的写请求流程如下:

 
 

更新的时候,先更新数据库,然后再删除缓存

2 读写穿透 Read-Through/Write-Through

Read/Write Through模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。

2.1 Read-Through

Read-Through的简要流程如下

 
 
  1. 从缓存读取数据,读到直接返回
  2. 如果读取不到的话,从数据库加载,写入缓存后,再返回响应。

这个简要流程是不是跟Cache-Aside很像呢?其实Read-Through就是多了一层Cache-Provider,流程如下:

 
 

Read-Through实际只是在Cache-Aside之上进行了一层封装,它会让程序代码变得更简洁,同时也减少数据源上的负载。

2.2 Write-Through

Write-Through模式下,当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的更新,流程如下:

 
 

3 异步缓存写入 Write behind 

Write behindRead-Through/Write-Through有相似的地方,都是由Cache Provider来负责缓存和数据库的读写。它两又有个很大的不同:Read/Write Through是同步更新缓存和数据的,Write Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。

 
 

这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。

操作缓存的时候,删除缓存呢,还是更新缓存?

一般业务场景,我们使用的就是Cache-Aside模式。 有些小伙伴可能会问, Cache-Aside在写入请求的时候,为什么是删除缓存而不是更新缓存呢?

 
 

我们在操作缓存的时候,到底应该删除缓存还是更新缓存呢?我们先来看个例子:

  1. 线程A先发起一个写操作,第一步先更新数据库
  2. 线程B再发起一个写操作,第二步更新了数据库
  3. 由于网络等原因,线程B先更新了缓存
  4. 线程A更新缓存。

这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),数据不一致了,脏数据出现啦。如果是删除缓存取代更新缓存则不会出现这个脏数据问题。

更新缓存相对于删除缓存,还有两点劣势:

  • 如果你写入的缓存值,是经过复杂计算才得到的话。更新缓存频率高的话,就浪费性能啦。
  • 在写数据库场景多,读数据场景少的情况下,数据很多时候还没被读取到,又被更新了,这也浪费了性能呢(实际上,写多的场景,用缓存也不是很划算了)

双写的情况下,先操作数据库还是先操作缓存?

Cache-Aside缓存模式中,有些小伙伴还是有疑问,在写入请求的时候,为什么是先操作数据库呢?为什么不先操作缓存呢?

假设有A、B两个请求,请求A做更新操作,请求B做查询读取操作。


 
 
  1. 线程A发起一个写操作,第一步del cache
  2. 此时线程B发起一个读操作,cache miss
  3. 线程B继续读DB,读出来一个老数据
  4. 然后线程B把老数据设置入cache
  5. 线程A写入DB最新的数据

酱紫就有问题啦,缓存和数据库的数据不一致了。缓存保存的是老数据,数据库保存的是新数据。因此,Cache-Aside缓存模式,选择了先操作数据库而不是先操作缓存。

缓存延时双删

有些小伙伴可能会说,不一定要先操作数据库呀,采用缓存延时双删策略就好啦?什么是延时双删呢?

 
 
  1. 先删除缓存
  2. 再更新数据库
  3. 休眠一会(比如1秒),再次删除缓存。

这个休眠一会,一般多久呢?都是1秒?

这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。 为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。

删除缓存重试机制

不管是延时双删还是Cache-Aside的先操作数据库再删除缓存,如果第二步的删除缓存失败呢,删除失败会导致脏数据哦~

删除失败就多删除几次呀,保证删除缓存成功呀~ 所以可以引入删除缓存重试机制

 
 
  1. 写请求更新数据库
  2. 缓存因为某些原因,删除失败
  3. 把删除失败的key放到消息队列
  4. 消费消息队列的消息,获取要删除的key
  5. 重试删除缓存操作

读取biglog异步删除缓存

解析MySQL的binlog实现缓存同步,将数据库中的数据同步到Redis

  • MySQL复制的原理
    • 主服务器操作数据,并将数据写入Bin log
    • 从服务器调用I/O线程读取主服务器的Bin log,并且写入到自己的Relay log中,再调用SQL线程从Relay log中解析数据,从而同步到自己的数据库中

总结起来就是,从服务器读取主服务器Bin log中的数据,从而同步到自己的数据库中。

canal是阿里巴巴旗下的一款开源项目,纯Java开发。基于数据库增量日志解析,提供增量数据订阅&消费,目前主要支持了MySQL(也支持mariaDB)

  • 架构:
    • server代表一个canal运行实例,对应于一个jvm
    • instance对应于一个数据队列 (1个server对应1..n个instance)
    • instance模块:
      • eventParser (数据源接入,模拟slave协议和master进行交互,协议解析)
      • eventSink (Parser和Store链接器,进行数据过滤,加工,分发的工作)
      • eventStore (数据存储)
      • metaManager (增量订阅&消费信息管理器)
  • 工作原理(模仿MySQL复制):
    • canal模拟mysql slave的交互协议,伪装自己为mysql slave,向mysql master发送dump协议
    • mysql master收到dump请求,开始推送binary log给slave(也就是canal)
    • canal解析binary log对象(原始为byte流)
  • 大致的解析过程如下:
    • parse解析MySQL的Bin log,然后将数据放入到sink中
    • sink对数据进行过滤,加工,分发
    • store从sink中读取解析好的数据存储起来
    • 然后自己用设计代码将store中的数据同步写入Redis中就可以了
    • 其中parse/sink是框架封装好的,我们做的是store的数据读取那一步

 



重试删除缓存机制还可以,就是会造成好多业务代码入侵。其实,还可以通过数据库的binlog来异步淘汰key

 
 

以mysql为例 可以使用阿里的canal将binlog日志采集发送到MQ队列里面,然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性

但如果只是进行删除缓存,只删除了一次,也可能会失败。

就需要加上重试机制了。如果删除缓存失败,写入重试表,使用定时任务重试。或者写入mq,让mq自动重试。

推荐使用mq自动重试机制。

在binlog订阅者中如果删除缓存失败,则发送一条mq消息到mq服务器,在mq消费者中自动重试3次。如果有任意一次成功,则直接返回成功。如果重试3次后还是失败,则该消息自动被放入死信队列,后面可能需要人工介入。

 

binlog同步的方式相对比较优雅。

1、mysql发生变更产生一条binlog

2、binlog写进消息队列(MQ)

3、程序监听消息队列,得到binlog消息

4、解析binlog,得到变更的内容

5、将变更的内容更新至redis

由于借助了MQ消息队列,那无须担心有漏变更的情况(MQ一般都能确保至少一次性)。两个数据源的更新只能保证最终一致性,无法保证强一致性。


如果业务层要求必须读取数据的强一致性,可以采取以下策略:

  • 暂存并发 读请求

在更新数据库时,先在Redis缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。

读写请求入队列,工作线程从队列中取任务来依次执行

  • 修改服务Service连接池,id取模选取服务连接,能够保证同一个数据的读写都落在同一个后端服务上。
  • 修改数据库DB连接池,id取模选取DB连接,能够保证同一个数据的读写在数据库层面是串行的。
  • 使用Redis分布式读写锁

将淘汰缓存与更新库表放入同一把写锁中,与其它读请求互斥,防止其间产生旧数据。读写互斥、写写互斥、读读共享,可满足读多写少的场景数据一致,也保证了并发性。并根据逻辑平均运行时间、响应超时时间来确定过期时间。



小结:

 

读读并发解决方案:

a. 延迟消息凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。

b. 订阅binlog,异步删除。通过数据库的binlog来异步淘汰key,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机制确认处理删除缓存。

c. 删除消息写入数据库通过比对数据库中的数据,进行删除确认 先更新数据库再删除缓存,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力,也就是缓存穿透的问题。针对缓存穿透问题,可以用缓存空结果、布隆过滤器进行解决。

d. 加锁更新数据时,加写锁;查询数据时,加读锁。


读写并发解决方案:
保存请求对缓存的读取记录,延时消息比较,发现不一致后,做业务补偿
 
写写并发解决方案:
对于写请求,需要配合分布式锁使用。
写请求进来时,针对同一个资源的修改操作,先加分布式锁,保证同一时间只有一个线程去更新数据库和缓存;没有拿到锁的线程把操作放入到队列中,延时处理。用这种方式保证多个线程操作同一资源的顺序性,以此保证一致性。

其中,分布式锁的实现可以使用以下策略:

- 乐观锁:使用版本号, updatetime;缓存中,只允许高版本覆盖低版本

- watch 实现 redis 乐观锁:watch 监控redisKey 状态值,创建redis 事务,key+1, 执行事务,key 被修改过则回滚

- setnx : 获取锁,set/setnx;释放锁:del 命令/ lua 脚本 

- redisson 分布式锁:利用redis 的hash 结构作为储存单元,将业务指定的名称作为key, 将随机uuid 和 线程id 作为 field, 最后将加乐的次数作为 value 来储存。线程安全。

 

 讨论版:多级缓存设计的流程图

 

 系统设计对于强一致性,和系统的高性能是一个权衡的问题,由上图,你能得到什么结论呢?



posted on 2022-09-12 17:31  一只阿木木  阅读(770)  评论(0编辑  收藏  举报