一、缓存一致性问题
我们现在读所有的数据,都是先来看缓存,缓存中没有我们就来读数据库。假如我们第一次读取,缓存中没有,然后,来查数据库,是一条新值。
现在来考虑一个问题,如果数据库修改了一个数据,而这个数据缓存中也有,我们再来从缓存中拿数据,就是一条旧数据。
所以,就牵扯到另外一个问题,缓存数据一致性问题,缓存里面的数据如何和数据库保持一致。
解决缓存一致性问题,比较常用的两种模式有
1.双写模式
2.失效模式
这两种方式再大并发下,都会产生一些漏洞
二、双写模式
2.1 双写模式思想
首先,我们使用双写模式,我们对数据库做了修改,比如我们修改了一个菜单,然后redis里面的数据想要变,我们改完数据库里面的数据以后,把缓存里面的数据也改一下
2.2 双写模式存在的问题
双写模式,我们的想法是写完以后,把缓存里的数据跟着改一下,假设现在我们模拟两个并发,全部进来
比如1号线程先来执行,它先去改数据库,把数据改成了1,它把数据改完了以后,
此时第二个修改请求,还是改这条数据,它想把这个数据改为2,相当于我们最后一次对数据库的修改想改为2。
但是,如果我们采用双写模式,我们第一个线程改完数据库,要改缓存,但是可能由于各种原因,它改完数据库以后 ,CPU没转到它这,或者它卡顿了一下,它想要写缓存,但是,没有2号线程跑的快。
2号线程,改完数据库以后,嗖一下,把缓存改为了2
但此时呢,1号线程,才慢慢吞吞的将数据写到缓存,改为了1。
此时,我们缓存中就将数据保存成了1,但实际上我们数据库的最新数据是2,而不是1,这相当于我们缓存中就出现了一条脏数据。
所以,我们说,双写模式,会有产生脏数据的风险。
怎么解决这个问题呢?
2.2.1 解决方案一
我们可以考虑一个设计方案,就是加锁,比如我们产生并发写的时候,因为你要改数据库,同时要改缓存,那我们就对整个操作来加一个锁,如果1号先进来了,那么1号得到一个锁,只有1号将它的整个流程全走完了,2号才能得到锁,去来写它的整个流程。
这就不会产生我们说的数据不一致性问题了。
2.2.2 解决方案二
看我们业务允不允许我们数据有暂时性的数据不一致性问题,举一个例子,我们商品数据的菜单数据改了,可能我们5分钟20分钟,我们首页里的网站展示才是我们改了的数据,我们允不允许这种操作呢?如果允许,我们就可以不管这个事情。
不管怎么办?就是我们以前将数据放入缓存的时候,给每一个数据都给了一个过期时间,比如是一天,假如这个数据过期了,我们从redis里面就会自动删除,删了以后下次再来查这个数据,就会查一份新的放到redis里面,所以,我们也可以称为这是暂时性的脏数据问题,前提是,我们在设计的时候,为缓存加上了过期时间。
而且,我们最终也发现,无论我们怎么操作,我们写数据库,完了以后写缓存,缓存里面更新,再来读数据,读到的最新数据,肯定跟数据库那一刻刚存的最新数据,有一段延迟时间,也就是说这一段延迟时间,大家容忍有多大,我们容忍1ms,1s还是1天,无论我们怎么容忍,我们都称这种为最终一致性。
就是我们数据库改后的值,到我们最后看到的值,他们之间有一个比较大的延迟时间,但无论怎么延迟,我们最终都会看到数据库最新修改的值。
所以,我们缓存数据的一致性,就是完全满足我们最终一致性的,我们无论怎么设计,我们都保证最终缓存里面读到的数据,就是数据库里面最新的数据。
这是我们说的双写模式容易出现的问题。
三、失效模式
3.1 失效模式思想
失效模式是,我们把数据库改完,我们直接将缓存删掉,这样做的好处是什么 ?
比如我们将数据库改完了,但是我们缓存中还缓存了我们三级分类的所有数据,比如redis.del(key);
删除以后呢,缓存中就相当于没有数据了,下一次再来查这一个菜单,我们查询方法就会判断,缓存中没有数据,主动查一次数据库。
3.2 失效模式存在问题
失效模式同样存在相同的问题,比如我们举一个例子,比如我们更新数据,数据库更新成功以后,我们再来删除缓存,删除缓存以后,缓存中就没有数据了,下次再来查这个数据,缓存中没有,会主动查数据库,主动更新缓存,自动触发了主动更新功能,最终得到最新的数据。
下面有这样一个场景,现在是三个请求,这三个请求顺序是这样子的,首先第一个请求,还是来改1号的菜单数据,将这个数据改为1,改完以后,删缓存,然后执行完了。
接下来,第二个请求,将我们这个数据改为2,但是,它的机器可能比较慢,花的时间比较长
这时来了第三个请求
现在出现这么一个问题,当第一个请求缓存删完了以后,第二个请求进来读,缓存里面确实没数据,然后它就去读数据库,但是数据库,此时2号数据还没改完,还没提交最新的修改
那就相当于3号读到了老的数据1,然后3号读完以后,它要更新缓存
这又是一个问题,如果它的更新缓存比较快还好,它更了一个错误的缓存,然后我们2号线程写操作,又把缓存删了,相当于它没更新。
但如果它更新的比较慢,2号机器更新完以后,立马把缓存删了,那三号机器之前读到的1,就直接放到缓存里面了
所以,最后我们最新的数据2还是没放到缓存,放到的是旧数据1,还是我们说的脏数据问题。
3.2.1 解决方案
这个问题,是我们写和读的并发问题。这个问题我们还是可以通过加锁来解决,但是加了锁以后,系统比较笨重一点。
所以,我们现在考虑另外一个问题,如果我们这个数据经常修改的话,我们还要不要放缓存?
如果数据经常修改,为了保证我们缓存里面的数据一致性,为写数据,和读数据都加了一个锁,就会导致我们整个系统会慢,还不如不加锁,直接查数据库。
所以,对于经常修改的数据,我们还是直接读数据库。
四、总结
所以,这两种模式无论用哪个都行,但是,如果真的要精细的解决缓存一致性问题,可以有以下解决方案
无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
1、如果是用户纬度数据(订单数据、用户数据) ,这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可
.2、如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
.3、缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
.4、通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略) ;
总结:
我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
我们不应该过度设计,增加系统的复杂性
遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。
我们系统的一致性解决方案:
1.缓存的所有数据都有过期时间,数据过期下一次查询触发主动更新
2.读写数据的时候,加上分布式的读写锁
经常写,经常读的数据,对性能肯定会有极大的影响,但是,读多写少的情况下,影响极小,因为对于读写锁,读读情况,相当于无锁。而且需要分布式下的读写锁,因为有多个实例,保证锁住每一台服务器。