在缓存和数据库双写场景下,一致性是如何保证的
缓存一般是直接将数据放到离计算最近的地方(目前大部分放在内存中),解决 CPU 和 I/O 的速度不匹配的问题,用来加快计算处理速度,通常会对热点数据进行缓存,保证较高的命中率。在互联网的架构设计中,数据库及缓存一般相互配合使用来满足不同的场景需求,比如在大流量的请求中会使用缓存来加速。
Redis 在互联网行业中使用最为广泛。Redis 在很多时候也被称为“内存数据库”,它集合了缓存和数据库的优势,但并非开启持久化和主备同步机制就可以高枕无忧。从架构设计的角度思考:缓存就是缓存,缓存数据会随时丢失,缓存存在的目的是拦截到数据库的请求,相比数据的可靠性、一致性,还是吞吐量、稳定性优先。
缓存有三大矛盾:
缓存实时性和一致性问题:当有了写入后咋办?
缓存的穿透问题:当没有读到咋办?
缓存对数据库高并发访问:都来访问数据库咋办?
第一个也就是本问题。而解决这三大矛盾的刷新策略包括:
实时策略——用户体验好,是默认应该使用的策略;
异步策略——适用于并发量大,但是数据没有那么关键的情况,好处是实时性好;
定时策略——并发量实在太大,数据量也大的情况,异步都难以满足的场景;
写入数据库成功,即让缓存失效,下一次读取时再缓存。这是缓存的实时策略。当然,并不适用于所有的场景。
实时策略是最常用的策略,也是保持实时性最好的策略:
读取的过程,应用程序先从 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 Cache-Aside 写流程
Cache-Aside Pattern的写请求流程如下:
更新的时候,先更新数据库,然后再删除缓存。
2 读写穿透 Read-Through/Write-Through
Read/Write Through模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。
2.1 Read-Through
Read-Through的简要流程如下
从缓存读取数据,读到直接返回
如果读取不到的话,从数据库加载,写入缓存后,再返回响应。
这个简要流程是不是跟Cache-Aside很像呢?其实Read-Through就是多了一层Cache-Provider,流程如下:
Read-Through实际只是在Cache-Aside之上进行了一层封装,它会让程序代码变得更简洁,同时也减少数据源上的负载。
2.2 Write-Through
Write-Through模式下,当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的更新,流程如下:
3 异步缓存写入 Write behind
Write behind跟Read-Through/Write-Through有相似的地方,都是由Cache Provider来负责缓存和数据库的读写。它两又有个很大的不同:Read/Write Through是同步更新缓存和数据的,Write Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。
这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。
操作缓存的时候,删除缓存呢,还是更新缓存?
一般业务场景,我们使用的就是Cache-Aside模式。 有些小伙伴可能会问, Cache-Aside在写入请求的时候,为什么是删除缓存而不是更新缓存呢?
我们在操作缓存的时候,到底应该删除缓存还是更新缓存呢?我们先来看个例子:
线程A先发起一个写操作,第一步先更新数据库
线程B再发起一个写操作,第二步更新了数据库
由于网络等原因,线程B先更新了缓存
线程A更新缓存。
这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),数据不一致了,脏数据出现啦。如果是删除缓存取代更新缓存则不会出现这个脏数据问题。
更新缓存相对于删除缓存,还有两点劣势:
如果你写入的缓存值,是经过复杂计算才得到的话。更新缓存频率高的话,就浪费性能啦。
在写数据库场景多,读数据场景少的情况下,数据很多时候还没被读取到,又被更新了,这也浪费了性能呢(实际上,写多的场景,用缓存也不是很划算了)
双写的情况下,先操作数据库还是先操作缓存?
Cache-Aside缓存模式中,有些小伙伴还是有疑问,在写入请求的时候,为什么是先操作数据库呢?为什么不先操作缓存呢?
假设有A、B两个请求,请求A做更新操作,请求B做查询读取操作。
线程A发起一个写操作,第一步del cache
此时线程B发起一个读操作,cache miss
线程B继续读DB,读出来一个老数据
然后线程B把老数据设置入cache
线程A写入DB最新的数据
酱紫就有问题啦,缓存和数据库的数据不一致了。缓存保存的是老数据,数据库保存的是新数据。因此,Cache-Aside缓存模式,选择了先操作数据库而不是先操作缓存。
缓存延时双删
有些小伙伴可能会说,不一定要先操作数据库呀,采用缓存延时双删策略就好啦?什么是延时双删呢?
先删除缓存
再更新数据库
休眠一会(比如1秒),再次删除缓存。
这个休眠一会,一般多久呢?都是1秒?
这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。 为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。
删除缓存重试机制
不管是延时双删还是Cache-Aside的先操作数据库再删除缓存,如果第二步的删除缓存失败呢,删除失败会导致脏数据哦~
删除失败就多删除几次呀,保证删除缓存成功呀~ 所以可以引入删除缓存重试机制
写请求更新数据库
缓存因为某些原因,删除失败
把删除失败的key放到消息队列
消费消息队列的消息,获取要删除的key
重试删除缓存操作
读取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 来储存。线程安全。