看完这篇缓存双写分析,你面试不再有问题!!!!

Posted 皮卡皮卡程序员

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了看完这篇缓存双写分析,你面试不再有问题!!!!相关的知识,希望对你有一定的参考价值。

今天天朗气清,吃饱撑之余,回想了想之前面试阿里巴巴的时候,面试官问的缓存和数据库,两者在生产环境中,容易出现数据不一致的情况,这对业务是十分影响的,我当时回答得很不错,面试官还是排板叫你滚的那种,哈哈开玩笑,我在网上看了很多博客

写关于这块的内容,很多直接拷贝过来的不说,写的也是我站在一个小白的立场上,基本看的云里雾里的,那我就今天讲一讲我熟悉的业务场景,以及面对这些业务场景,我们一般会用什么解决方案来处理,前提要记住一点,面试官问你一些比较刁钻的问题的

时候,一定是针对业务逻辑去做的,在实际开发中,我们更偏向于舍我求全的方案,没有一个解决方案是完美的,一定是舍弃某些,牺牲某些,而采用较优的方案!!!废话不多说了,开始正文

作为开发,如果不了解缓存,那么你一定知道数据库,为什么会有缓存的出现?其实很简单,数据库的读写是基于磁盘IO的,数据库的性能,极大程度由你服务器的磁盘性能,硬件性能决定,目前数据库在软件层面的优化,已经做到了很不错了,但是你硬件

跟不上,最终的下场就是GG,而面对目前互联网的流量越来越大,对数据库的要求,还是只能满足软件层面,硬件的成本太高,所以大部分的企业是无法承担这样的费用,业务越来越复杂,对数据库的读写压力会越来越大,撑不住会经常打挂几台,集群有时

也扛不住,怎么办呢?

那么缓存就出来了,挡在数据库的前面,洪峰流量先走缓存,抗住一波后,再根据业务走数据库;市面上有很多缓存,比如Redis,Memcache等等,这些都是基于内存模型的,学过计算机的都知道,内存的读写速度要大于磁盘的读写速度。

所以内存的读写一定要快的多,这样也就降低了数据库压力,降低系统的瓶颈。

虽然缓存出现了,带来极大的好处,同时给业务上带来了额外的问题。

比如有

  • 数据库和缓存数据不一致,业界称为双写不一致
  • 缓存数据一大批集体失效,导致流量一瞬间直接打到数据库上,给数据库服务打挂了,这个现象业界称为缓存雪崩
  • 缓存和数据库中,都不存在某个数据,但是用户恶意攻击频繁请求一些大字段,大返回结果,以及不存在的数据,导致数据库压力过大而挂掉,这个业界称为缓存穿透
  • 缓存中某条数据不存在,但是数据库中存在,这样的场合一般是某个数据缓存到期了,这个时候读缓存没读到,并发请求上来都去读数据库的这条请求,导致数据库压力很大,这个称为缓存击穿

其实雪崩和击穿很类似,但是不一样的地方在于:

缓存击穿指并发查同一条数据,而缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

进入章节:

1、缓存双写不一致的解决方案

缓存由于其高并发和高性能的特性,已经在项目中被广泛使用。在读取缓存方面,大家没啥疑问,都是按照下图的流程来进行业务操作。

但是在更新缓存方面,对于更新完数据库,是更新缓存呢,还是删除缓存。又或者是先删除缓存,再更新数据库,其实大家存在很大的争议。目前没有一篇全面的博客,对这几种方案进行解析。于是博主战战兢兢,顶着被大家喷的风险,写了这篇文章。

发生数据库不一致的情况,一定是基于写的请求产生的。不管是写数据库还是写缓存。

我们先来看个图,根据图来描述下业务场景的需求,我再给指定的解决方案。

假设2个线程,都去做更新库存的业务,一开始,数据库的stock=9,缓存的stock=9,对于你的更新操作,我想让数据库和缓存的数据保持一致,常见的第一个方法如下

第一种最简单的方案:

1.1、先更新数据库,再更新缓存

  • 往数据库里写stock=10
  • 更新缓存stock=10

第二个线程2来了,也要做两个操作

  • 往数据库里写stock=6
  • 更新缓存stock=6

注意看时间轴,如果按照正常的并发顺序来执行,没问题,业务一切都正常:最后缓存和数据库的结果都是stock=6,数据是一致的;但是有没有问题??

如果是在很高很高的并发下,极有可能出现下面图的情况,线程2的写数据+更新缓存的操作,在线程1卡顿的时候,先执行完了,然后线程1再执行了更新缓存stock=10,结果是什么?

数据库stock=6,缓存stock=10,出现了数据库和缓存双写不一致的情况,对于线程1来说,数据库应该是10的,但是却是6,对于线程2来说,缓存应该是6的,但是却是10。针对每个线程,都做了个双写操作,结果却导致了写的不对,数据库和缓存不一致!

那么我们考虑下怎么处理这个问题?业界有这几个所谓的解决方案,我描述下:

我引出第二种解决方案:

1.2、先更新数据库,再删除缓存

还是先贴出来出现上述问题的操作,对于写操作的线程

  • 先往数据库里更新
  • 再去更新缓存的数据

很多公司会有这种处理方案,对于写操作的线程:

  • 先往数据库里更新
  • 再删除缓存的数据

下面的图,还是以时间轴为标准,一开始的状态,数据库数据stock=9,缓存stock=9,此时,线程1要往数据库里写stock=10,进而删除缓存,所以缓存数据为空了。

假设这个时候另一个线程2进来,先进行查询操作,发现缓存没有,读数据库,然后去写缓存,此时数据库和缓存都是10,没问题,保持了缓存和数据库数据一致了;其他线程如果再来,再进行更新的数据库,删除缓存;

这个方案相比之前的写数据库,更新缓存的方案来说,看起来是可以避免了缓存和数据库数据不一致的问题,不管你怎么看时间轴,结果是你更新数据库,并要保持数据库数据和缓存数据同步的操作

那看似没问题,如果此时我在线程1和线程2之间,插入了一个线程3,线程3是写数据库,删除缓存的 ,如果是线程2先执行完后插入的,那么数据也是正常的。最终落地数据库stock=6,缓存为空,如果缓存的数据有,只需要查询下就可以保证一致就行

但是如果出现这样的情况,线程2在写缓存的时候卡住了,卡成这个鬼样~此时线程3结束了,把数据库更新为6,线程2写缓存在3结束后,才结束,导致缓存现在是10,数据库和缓存数据不一致了。

所以这对于这个方案来说,也会出现缓存和数据库数据不一致的情况,于是市面上出现了个所谓的延时双删的操作

1.3、延时双删

我解释下延时双删的意思,就拿上述这个图来说,线程3在更新数据库,删除缓存后,线程休眠一段时间,再去删除一边缓存,原因很简单:

就是上面这个情况,对于线程2卡顿后,写缓存的操作在线程3执行完毕,所以导致了数据库数据和缓存不一致的情况。

延时双删的方案:

  • 先往数据库里更新
  • 再删除缓存的数据
  • 休眠一段时间后,再删一次缓存的数据

如图,现在的方案改成了延迟双删,就是在上面的基础上多删一次,叫做双删。为什么要加这个,这个就是要避免其他线程在查询数据库后,发现不存在,于是写一把缓存,类似线程2的操作,这个时候的缓存很有可能是脏数据,并不是数据库最近的数据;

于是要再删除一次缓存,有人会问,这个线程休眠,你多少s合适呢?一般正常来说,看你的写缓存这个操作大概要多久,在此基础上加个几百毫秒就差不多了。比如写缓存要1s,夸张点哈,那么你休眠个1.5s就差不多;

那这个所谓的延时双删,真能解决数据库数据和缓存数据不一致的问题吗?

假设这个线程2卡的时间,并不是你正常预料的那个写缓存的时间,他是真的卡了,服务器卡顿了,这个操作卡了很久,而你休眠的时间,是无法跟上的,就会出现下面的图:

延时双删可以解决一部分的场景,但是如果是这样,还是解决不了数据不一致的问题。在真实的生产环境中,很少用延时双删的,因为你无法预估写缓存的时间。不确定的因素太多了。既然不能百分之百解决这个问题,而且还会带来弊端,你看是不是所有的

写操作都要带来一些延迟啊?因为你每次都要休眠。无疑是造成了不必要的吞吐量的降低。

这样既然解决不了,那有人又提出了一种方案:

1.4、基于内存模型的序列化队列

把每个操作都放在一个内存队列里面,按照顺序执行,比如多个线程的请求进来,严格按照线程的进来的顺序,串行化依次执行,没错这个是可以彻底解决问题。你并发的请求我都给你做成串行化了,怎么会有问题呢?

但是带来了性能上的大大降低,吞吐量大大降低,此外我还需要搞个内存结构,再搞个监听去一个一个取,再顺序执行,或者本身一个查询很快的操作,被排列在这个内存队列里,无疑是不太可取的方案。业内也极少用这样的情况,除非要保持数据的强

一致性,但是有其他的方案,也不会采用这个的。所以也不推荐这个方案。

那就没有什么好的解决方案,能解决数据库的数据和缓存的数据不一致的问题吗?

我们要解决问题,就要找到问题的根本原因,出现数据库和缓存数据不一致的原因,就是因为这个数据在被某个线程做操作的时候,他不是加了锁的,即这个资源,是不被他所独占的!!!!

会有其他的线程在中间插一脚,去访问这个共享资源,从而修改了数据,引起了这个数据不一致的情况,所以讲到这里,是不是有点头绪了?

对,我们可以在你对数据库和缓存双写的操作上,加一把锁!你同一个线程来对这个共享资源访问的时候,就要等待这个锁的释放,不然你无法做操作。

那这样是不是就百分之百不会出现,数据库和缓存不一致的问题了?

1.5、加分布式锁

假设线程1来了,不管怎么操作,一定是有对数据库的修改,然后再对缓存的修改(应该没人会问为什么不先更新缓存,再更新数据库吧???),访问的时候看这个资源有没有被上锁,如果没有,就加一把锁,线程2进来发现被上锁了

于是在外面等着,其实进入一个阻塞对列,等待锁释放后,喊出来,该你了,下一位准备......

但是你发现会有性能问题啊,放在阻塞队列里,也就成了串行化执行,和内存队列来说,好像没啥区别对吧?对,看样子是没啥区别,实际上比内存模型的效率要高一点,那有多高呢?三四层那么高吗?哈哈 显然不是,所以我们用分布式锁会带来性能问题

那我们可以不可以优化下呢?

1.5、加分布式锁之读写锁

在互联网一线大厂中,业务中可以这么说:大部分的业务,80%的场景是读操作,20%的场景是写操作。读的需求远远大于写的需求,所以我们加锁是不是要先让这80%的读,做到较优化,剩下的20%,我们再去分析?

不知道大伙有没有听过一个叫做读写锁??

对于redisson来说,存在一个api,叫做读写锁api——RReadWriteLock,里面你可以获取读锁,也可以获取写锁

读锁和读锁之间是不会互斥的,就是不冲突的。你虽然加了读锁了,但是你不会影响程序并发执行的效果,也就意味着没有加锁。这个读写锁,是同一个,什么意思呢,对于redis的同一个key,进行加的读锁,或者写锁。

实际上在分布式锁Redisson中,把锁细分化下,一个是读锁,一个是写锁。

对于Redisson来说,底层lua脚本对读锁是这么实现的,如果多个线程来进行读操作,没关系,底层会用lua脚本操作redis的同时,绑定了mode模式,读锁,即绑定的是read模式,写锁绑定的是write模式;

如果多个请求同时尝试去加读锁,每个线程进来的时候会先判断下当前key加锁的模式,如果是读锁read,可以继续加锁,不会互斥,不会影响继续执行自己的读逻辑;

假设之前这把锁已经是写锁了,这个时候读请求进来,写请求进来,不好意思,全部给我在外面等着,直到这个锁被释放后,下一个请求再进来;再重新加锁;

讲白了,读请求进来,判断mode=read还是write,是read,执行逻辑;判断mode=write,就表明当前有请求在写,则在外面等待,直到这个变成了mode=read;

所以这样,其实把性能,就大大提升了在读这块的,不至于读写都放在阻塞队列里,影响性能。

那这个是完美的解决方案吗?这个当然不是,但是是一个比较不错的解决方案!!

如果想了解更多,关注我,我后面会以案例讲解读写锁的具体实现。

1.6、总结

实际上在互联网大厂中,对于数据库和缓存一致性的问题,一定是要结合业务去谈方案,如果面试官问你,你对于缓存和数据库数据不一致的问题,你有哪些方案可以解决,你一定要回答,有很多可取的方案,把我上面列的一些方案,尽量说明清楚

并且说明下每个方案可能会面临的一些问题,记住,所有的解决方案,一定是围绕着具体的业务逻辑去展开,而不是一种可以百用,这是不可能的,你回答了上述的一些方案,再结合你自己的理解,表示如果真的想保持数据的强一致性,可以采用加读写

锁的方案实现,这样一定会带来其他的额外开销,比如性能上的消耗,对于有些业务场景不一定非要要求缓存和数据库强一致性的,就没必要加额外的锁,增加额外的开销,降低系统的吞吐量:

如果对于很多电商平台,对于秒杀的业务场景,是同时具备读多写多的场景的,那么我们不可能总是去加锁,那一瞬间的并发是非常高,加锁就无疑意味着系统很容易卡慢,所以其实这个时候,更多的互联网公司会选择用缓存的超时时间,来解决这个频繁写

的问题,比如我库存一开始有8个,此时缓存有8个,那么经过一系列的秒杀流程,到下单,到付款,到出单,这个过程走完,其实在这期间,数据库的值已经被修改很多次,那不可能我每次修改一次数据库,就要更新下缓存,甚至加锁;

所以更多会选择,在页面上显示的数据,和真实的数据库的值,是存在一定的偏差,而这个偏差,用超时时间足够了,超时了,缓存空,再去数据库查,再去更新缓存。

真正下单扣除库存,是在校验数据库数据的时候,而不是对比缓存数据;所以架构师们出于很多角度考虑,一般会选择这个方式来减少一些不必要的开销。你看到库存剩下5个,实际上你去下单的时候,数据库其实是0了,被抢完了,其实也是很合理的。

 

2、缓存雪崩

缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据缓存不在,流量打到数据库上,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

 常见的解决方案有这么几个

  1. 缓存每个数据的过期时间设置随机值,防止同一时间大量数据过期现象发生。
  2. 如果缓存数据库是分布式来部署的,将热点数据均匀分布在不同得缓存数据库中。查询的时候,不会导致某一台压力过大,压力可以分担下去
  3. 设置热点数据永远不过期。——这个不推荐,因为永不过期,无疑数据热点会很多很大,有可能会让缓存也会内存溢出。

3、缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

常见的解决方案也有几个:

  1. 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截,不走缓存,代码上就直接return出去;
  2. 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value的键值对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。

4、缓存击穿

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,并发查同一条数据缓存不在,流量打到数据库上,引起数据库压力瞬间增大,造成过大压力。

常见的解决方案也有几个:

  1. 设置热点数据永远不过期——不是很推荐。
  2. 加互斥锁,表示并发要一个一个加锁执行,等于在阻塞队列等待,释放锁后,再去执行下一个请求,互斥锁参考代码如下:

好了,本章更新缓存相关的一些面试,以及业务场景,其实足够你喝一壶了,对于真正大厂怎么做的,其实无非就是在锁的基础上不断的优化,后

面我会整理下,给出真实案例,再进一步分析互联网中遇到的场景,原创不易,谢谢!!

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

以上是关于看完这篇缓存双写分析,你面试不再有问题!!!!的主要内容,如果未能解决你的问题,请参考以下文章

看完这篇Redis缓存三大问题,够你和面试官battle几回合了

面试还在被红黑树虐?看完这篇轻松搞定面试官

看完这篇 HTTPS,和面试官扯皮就没问题了

深度分析:面试90%被问到的 SessionCookieToken,看完这篇你就掌握了!

看完这篇总结,你会发现其实spring面试真的没那么难,一篇帮你彻底搞定spring。

看完这篇垃圾回收,和面试官扯皮没问题了