背景
在高并发的业务场景下,系统的性能瓶颈往往是出现在数据库上,用户并发访问过大,压力都打到数据库上。所以一般都会用redis做缓存层,起到一个缓冲作用,让请求先访问到缓存层,而不是直接去访问数据库,减轻数据库压力,从而减少网络请求的延迟响应,提高系统性能。一般在使用缓存和数据库结合的时候就会面临数据一致性问题,可能会经常遇到“明明数据已经更新了,怎么还是显示旧的”,下面就来分析下产生的原因及其对应处理方案。
数据更新的常见操作
注意:我们讲的数据一致性的前提是数据库更新和缓存删除不把它当成一个原子性操作。因为高并发场景下,我们不可能引入分布式锁将这两者操作绑定为一个原子性操作,如果绑定的话就会很大程度上影响系统并发性能,所以一般只追求最终一致性,本文也是针对非追求强一致性要求的场景,金融或银行业务的小伙伴请自行判断。
本文涉及到一种常用的缓存模式:Cache-Aside Pattern,即旁路缓存模式,这种模式就是为了尽可能地解决缓存和数据库的数据一致性问题。这种模式分为读请求和写请求两种。
读请求流程:
读的时候,先读取缓存,若缓存命中的话,直接返回数据。
若缓存没有命中,就去读数据库,从数据库取出数据,放入缓存后,返回响应。
写请求流程:
更新数据的时候,先更新数据库,然后再删除缓存。
下面罗列出常见的几种数据更新的方式及其对应的问题:
1、先更新数据库,后更新缓存
流程如下图:
1.请求A先发起一个写操作,先更新数据库
2.请求B再发起一个写操作,更新了数据库
3.由于网络等原因,请求B先更新了缓存
4.请求A更新缓存
问题如上所示,缓存保存的是A的数据(旧数据),数据库保存的是B的数据(新数据),数据不一致,脏数据出现。
这种场景一般不推荐使用。因为有的业务需求中缓存里的值并不是直接从数据库中查出的,有的是需要经过一系列操作计算出缓存的值,那么这时候你要更新缓存的代价是很高的。如果这时有大量请求需要对数据库进行写操作,但是读的请求并不多,那么每次写操作都更新一次缓存,性能损耗是非常大的。
举个最简单的例子:数据库中有一个num字段的值为1,这时有10个请求对其进行递增加一的操作,但是这期间读请求很少,如果是先更新数据库,后更新缓存的话,那么就会有十个请求对缓存进行更新,这样会有大量的冷数据产生。如果选择删除缓存而不是更新缓存,那么在读请求进来的时候就只会更新一次缓存。这样的话哪种操作消耗的资源更多是不是就很明显了。
2、先更新缓存,后更新数据库
这一种情况和上一种是类似的,这里就不再赘述。
3、先删除缓存,后更新数据库
流程如下图:
1.请求A先发起一个写操作,先删除缓存,此时会还没更新数据库完成(可能在还没更新,或者正在更新,但事务还未提交)
2.此时请求B发起一个读操作,读取到缓存数据为空
3.请求B在缓存中读不到数据,就去读取数据库并将旧数据写入缓存(脏数据)
4.请求A更新DB完成
问题如上所示,缓存保存的是旧数据(请求B将脏数据写入了缓存),如果是一个读多写少的数据,可能脏数据会存在比较长的时间(要么后续有更新,要么等待缓存过期),这在业务上是不能接受的。
4、先更新数据库,后删除缓存
流程如下图:
1.请求A先发起一个写操作,先更新数据库,此时还没删除缓存完成
2.此时请求B发起一个读操作,读取到的缓存数据为旧数据
3.请求A删除缓存
问题如上所示,在请求A更新数据库和删除缓存之间请求B会读取到旧数据,因为此时整个请求A的操作还没有完成,并且读到旧数据的时间是非常短的,而后请求后会删除缓存,所以可以满足数据最终一致性要求。但是不排除请求A删除缓存失败的可能。
分析
先来对以上几种方式进行概括分析,可以分为这两种选择,1、是更新缓存还是删除缓存,2、是先更新数据库还是先操作缓存
是更新缓存还是删除缓存,结合上面的第1、2点的例子可以得出是选择删除缓存
更新缓存相对于删除缓存两点劣势:
- 如果你写入的缓存,是经过复杂计算才得到的话。更新缓存的频率高,性能损耗大。
- 在写操作多,读数据少的场景下,缓存数据很多时候还没被读取到,又被更新了,浪费了资源(写多读少的场景用缓存不是很划算)
根据上面分析的结论是删除缓存,那么是先更新数据库还是先删除缓存?
由上面第3点的例子,可以看出如果是先删除缓存再更新DB,会有较大可能导致缓存保存的是旧数据,数据库保存的是新数据。
有的人会说,那第4点的先更新数据库再删除缓存,不也可能导致缓存中是旧数据?
其实只要是非原子性操作就都可能出现数据不一致的情况,但是第四点这种方式,一般是因为删除缓存失败等原因,才会导致缓存了脏数据,这个概率会低很多。
解决方案
其实上面第3点和第4点的问题,删除缓存失败的情况,我们只要保证他删除成功就可以。
一、延时双删缓存
以上面第3点例,先删除缓存再更新数据库,最直观能想到的最简单的办法就是延时双删。什么是延时双删,看完如下图流程就明白了。
在原来第3点,先删除缓存再更新数据库的基础上,在请求A更新完数据库后,休眠一下(比如1秒),然后再次删除缓存。
这种方案只有休眠那一下,可能有脏数据被读取,一般业务也可以接受的。这个休眠延迟时间一般要根据读业务逻辑的耗时去估算(比较难)然后增加相应几百毫秒的延迟。
但是如果第二次删除缓存又失败了?给key设置一个过期时间?业务上能否接受在key过期之前的这段时间内的数据不一致?
二、重试删除缓存
根据业务预估缓存过期时间很麻烦,而且你预估的也不一定准,可能还有其他什么原因造成过期时间过短或过长而影响了正常业务。
既然如此,前两次都删除失败了,那我多删他几次保证他删除成功就可以了。
利用消息队列进行重试删除缓存的补偿机制,流程如下图:
1.写请求进来,先更新数据库
2.由于某些原因,删除缓存失败
3.把删除失败的key推送到消息队列
4.消费队列消息,获取要删除的key
5.重试删除缓存
上面第3点和第四点的例子,均可以使用此方案。但这个方案有一个缺点:会对业务代码造成大量侵入,耦合在一起。
三、异步淘汰缓存
重试删除缓存机制已经满足保持数据一致性的要求,但是会造成好多业务代码入侵。所以可以优化下:开一个订阅服务(独立的中间系统),负责通过订阅数据库(如mysql)的binlog来异步淘汰缓存。
mysql更新数据后在binlog日志中都有相应的记录,我们可以订阅mysql的binlog对缓存进行操作。流程如下图:
异步淘汰机制可以达到想要的双写一致性效果,但是对应的也有他的缺点:增加了整个系统的复杂度。
踩坑
注意⚠️:上面讲的数据库和缓存都是普通的单机情况下的。
这里讲下我以前踩过的坑,上面第3点的例子,先删除缓存,后更新数据库,然后异步淘汰。
这里还隐藏着一个问题:如果你使用的是mysql读写分离架构的话,主从同步之间会有时延问题,这就有可能产生脏数据。
看如下图流程你就明白了,先不复杂化,搞个大家都能看懂的。我假设缓存删除成功,更新数据库也成功,这两者之间没有其他读请求插入:
如上所示,请求A和请求B操作时序没问题,是主从同步的时延问题(假设1s),导致读请求读取到从库中的脏数据
1.请求A先发起一个写请求,先删除了缓存
2.请求A请求主库进行更新数据
3.主库与从库进行数据同步
4.请求B发起一个读请求,读取缓存中的数据为空
5.请求B去DB从库中取数据,由于主库压力大/处理数据量多/网络原因等,此时主从同步还没完成。请求B读取到DB从库中的旧数据并写入缓存中
6.最后主从同步完成
我之前的解决方案:如果缓存数据为空,需要查询DB再设置到缓存的操作,就强制将查询的DB指向master进行查询。
总结
每种方式和方案都各有利弊。
比如先删除缓存,后更新数据库这个方式,我们最终选择了重试删除缓存+更新Redis的时候强制走主库查询就能解决问题,但是这操作会对业务代码进行大量的侵入,但是不需要增加新的中间系统去处理,不需要增加整体的服务的复杂度。
如果我们选择异步淘汰缓存的方案,利用订阅binlog日志进行搭建独立的中间系统来操作缓存,但就样就增加了系统复杂度,复杂度增加带来的风险往往是后知后觉的。
其实每种方案的选择都需要我们对本身的业务进行评估,没有一种技术是对所有业务都通用的。我觉得最难的是寻找最佳效益的平衡点的取舍问题,就像常说的:没有最好的,只有最适合你的。
我是六涛sheliutao,文章编写总结不易,转载注明出处,喜欢本篇文章的小伙伴欢迎点赞、关注,有问题可以评论区留言或者私信我,相互交流!!!