关于数据库和缓存不一致问题
为什么会出现缓存一致性问题
在非并发的情况
首先,我们在非并发的场景中,出现不一致的问题大家都能比较容易的理解,因为缓存的操作和数据库的操作是存在一定的时间差的。
而且这两个操作是没办法保证原子性的,也就是说,是有可能一个操作成功,一个操作失败的。
所以,这就必然会存在不一致的情况。
但同时,因为我们的业务系统是开放给用户使用的,所以经常会出现各种各样的并发的场景,因为并发的存在,会使得数据一致性的问题更加的多。
并发情况
对于数据的操作,会存在“读读并发”,“读写并发”和“写写并发”。对于两个读线程,即使发生并发,因为只是读的动作,所以不会有数据的变更,发生了并发的话也不会有数据不一致的情况。
接下来我们先来分析比较容易理解的”写写并发”的情况。
写写并发
因为在数据库和缓存的操作过程中,可能存在”先写数据库,后删缓存”、”先写数据库,后更新缓存”、”先删缓存库,后写数据库”以及”先更新缓存库,后写数据库”这四种。
其中”删缓存”的这两种是把缓存清空,所以”写写并发”不会存在缓存和数据库不一致的情况。我们就看”更新缓存”的这两种:
先写数据库,后更新缓存:
W | W |
---|---|
写数据库,更新成20 | |
写数据库,更新成10 | |
写缓存,更新成10 | |
写缓存,更新成20(数据不一致) |
先更新缓存,后写数据库:
W | W |
---|---|
写缓存,更新成20 | |
写缓存,更新成10 | |
写数据库,更新成10 | |
写数据库,更新成20(数据不一致) |
以上两种情况,都是两个写线程并发之后,因为乱序的问题,导致最终缓存中的值是20,而数据库中的值是10,最终导致缓存和数据库中的值不一致的情况。
除了写写并发之外,还有一种比较容易被忽视的情况,那就是读写之间的并发也会导致数据库和缓存的不一致。
读写并发
在使用缓存之后,一个读线程在查询数据时要经过如下过程:
1、查询缓存,如果缓存中有值,则直接返回 2、查询数据库 3、把数据库的查询结果更新到缓存中
所以,对于一个读线程来说,虽然不会写数据库,但是是会更新缓存的,所以,在一些特殊的并发场景中,就会导致数据不一致的情况。
读写并发的时序如下:
W | R |
---|---|
读缓存,缓存没有值 | |
读数据库,数据库中得到结果为10 | |
写数据库和缓存,更新成20 | |
写缓存,更新成10(数据不一致) |
这也就导致了缓存和数据库不一致的现象
但是这种现象其实发生的概率比较低,因为一般一个读操作是很快的,数据库+缓存的读操作基本在十几毫秒左右就可以完成了。
我们提到过,在数据库和缓存的操作过程中,可能存在”先写数据库,后删缓存”、”先写数据库,后更新缓存”、”先删缓存库,后写数据库”以及”先更新缓存库,后写数据库”这四种。
那么,到底是应该删除缓存好呢,还是更新缓存好呢?到底应该先操作数据库呢还是先操作缓存呢?哪种方案更好呢?又该如何选择呢?
删除和更新
为了保证数据库和缓存里面的数据是一致的,很多人会很多人在做数据更新的时候,会同时更新缓存里面的内容。但是我其实告诉大家,应该优先选择删除缓存而不是更新缓存。
首先,我们暂时抛开数据一致性的问题,单独来看看更新缓存和删除缓存的复杂的的问题。
我们放到缓存中的数据,很多时候可能不只是简单的一个字符串类型的值,他还可能是一个大的JSON串,一个map类型等等。
举个栗子,我们需要通过缓存进行扣减库存的时候,你可能需要从缓存中查出整个订单模型数据,把他进行反序列化之后,再解析出其中的库存字段,把他修改掉,然后再序列化,最后再更新到缓存中。
可以看到,更新缓存的动作,相比于直接删除缓存,操作过程比较的复杂,而且也容易出错。
还有就是,在数据库和缓存的一致性保证方面,删除缓存相比更新缓存要更简单一点。
但是,如果是做缓存的删除的话,在写写并发的情况下,缓存中的数据都是要被清除的,所以就不会出现数据不一致的问题。
但是,更新缓存相比删除缓存还是有一个小的缺点,那就是带来的一次额外的cache miss,也就是说在删除缓存后的下一次查询会无法命中缓存,要查询一下数据库。
这种cache miss在某种程度上可能会导致缓存击穿,也就是刚好缓存被删除之后,同一个Key有大量的请求过来,导致缓存被击穿,大量请求访问到数据库。
但是,通过加锁的方式是可以比较方便的解决缓存击穿的问题的。
总之,删除缓存相比较更新缓存,方案更加简单,而且带来的一致性问题也更少。所以,在删除和更新缓存之间,我还是偏向于建议大家优先选择删除缓存。
先写数据库还是先删缓存
先写数据库
因为数据库和缓存的操作是两步的,没办法做到保证原子性,所以就有可能第一步成功而第二步失败。
而一般情况下,如果把缓存的删除动作放到第二步,有一个好处,那就是缓存删除失败的概率还是比较低的,除非是网络问题或者缓存服务器宕机的问题,否则大部分情况都是可以成功的。
还有就是,先写数据库后删除缓存虽然不存在”写写并发”导致的数据一致性问题,但是会存在”读写并发”情况下的数据一致性问题。
先删缓存
那么,如果是先删除缓存后操作数据库的话,会不会方案更完美一点呢?
首先,如果是选择先删除缓存后写数据库的这种方案,那么第二步的失败是可以接受的,因为这样不会有脏数据,也没什么影响,只需要重试就好了。
但是,先删除缓存后写数据库的这种方式,会无形中放大前面我们提到的”读写并发”导致的数据不一致的问题。
因为这种”读写并发”问题发生的前提是读线程读缓存没读到值,而先删缓存的动作一旦发生,刚好可以让读线程就从缓存中读不到值。
所以,本来一个小概率会发生的”读写并发”问题,在先删缓存的过程中,问题发生的概率会被放大。
而且这种问题的后果也比较严重,那就是缓存中的值一直是错的,就会导致后续的所以命中缓存的查询结果都是错的!
延迟双删
那么,虽然先写数据后删除缓存的这种情况,可以大大的降低并发问题的概率,但是,根据墨菲定律,只要有可能发生的坏事,那就基本上会发生。越是庞大的系统发生的概率越高。
那么,有没有什么办法可以来解决一下这种情况带来的不一致的问题呢?
其实是有一个比较常见的方案的,在很多公司内用的也比较多,那就是延迟双删。
因为”读写并发”的问题会导致并发发生后,缓存中的数被读线程写进去脏数据,那么就只需要在写线程在写数据库、删缓存之后,延迟一段时间,在执行一把删除动作就行了。
这样就能保证缓存中的脏数据被清理掉,避免后续的读操作都读到脏数据。当然,这个延迟的时长也很久讲究,到底多久来删除呢?一般建议设置1-2s就可以了。
当然,这种方案也是有一个弊端的,那就是可能会导致缓存中准确的数据被删除掉。当然这也问题不大,就像我们前面说过的,只是增加一次cache miss罢了。
缓存更新设计模式
Cache Aside Pattern。
这种模式的主要方案就是先写数据库,后删缓存,而且缓存的删除是可以在旁路异步执行的。
这种模式的优点就是我们说的,他可以解决”写写并发”导致的数据不一致问题,并且可以大大降低”读写并发”的问题,所以这也是Facebook比较推崇的一种模式。
Read/Write Through Pattern
在这两种模式中,应用程序将缓存作为主要的数据源,不需要感知数据库,更新数据库和从数据库的读取的任务都交给缓存来代理。
Read Through模式下,是由缓存配置一个读模块,它知道如何将数据库中的数据写入缓存。在数据被请求的时候,如果未命中,则将数据从数据库载入缓存。
Write Through模式下,缓存配置一个写模块,它知道如何将数据写入数据库。当应用要写入数据时,缓存会先存储数据,并调用写模块将数据写入数据库。
也就是说,这两种模式下,不需要应用自己去操作数据库,缓存自己就把活干完了。
Write Behind Caching Pattern
这种模式就是在更新数据的时候,只更新缓存,而不更新数据库,然后再异步的定时把缓存中的数据持久化到数据库中。
这种模式的优缺点比较明显,那就是读写速度都很快,但是会造成一定的数据丢失。
这种比较适合用在比如统计文章的访问量、点赞等场景中,允许数据少量丢失,但是速度要快。