缓存一致性问题解决

通常情况下,我们使用缓存的主要目的是为了提升查询的性能。大多数情况下,是这样使用缓存的:
缓存一致性问题解决_第1张图片
当数据库有数据更新时,在很长的一段时间内(决定于缓存的过期时间),用户请求从缓存中获取到的都可能是旧值,而非数据库的最新值。那么,该如何更新缓存呢?目前有以下四种解决方案:

  • 1.先写缓存,再写数据库(差)
  • 2.先写数据库,再写缓存(一般)
  • 3.先删缓存,再写数据库(能接受)
  • 4.先写数据库,再删缓存(比较优秀)

讨论四种方案前先统一两个认知,以便更好理解四种方案:

  • 1.缓存一致性问题没有绝对可靠的方案,我们只能让两者尽量接近,但无论如何也不能百分百达到一致性效果。
  • 2.缓存和数据库,无论先处理谁,只要后者有延迟/失败,都会导致不一致的情况,这也正是缓存不一致的根本原因所在。所有解决方案和讨论都是围绕这一点来进行的。

方案一:先写缓存,再写数据库

缓存一致性问题解决_第2张图片

缺点:如果刚写完缓存,突然网络出现了异常,导致写数据库失败了。这样缓存中的数据就变成脏数据,这个问题非常严重,也是最差的一种解决方案。

方案二:先写数据库,再写缓存

缓存一致性问题解决_第3张图片

缺点一: 问题又来了,写数据库成功,但写缓存失败了,依然会造成缓存脏数据的问题。但写缓存失败比写数据库失败的概率要小很多了(因为数据库可能有加锁、外键约束、超时等机制限制),所以此方案要比第一种方案好一点。

如果对接口性能要求不高,还可以把写数据库和写缓存放到一个事务中,写缓存失败就回滚数据库。

缺点二: 然而高并发场景下,还会有个棘手问题:

缓存一致性问题解决_第4张图片

  • 请求a先过来,刚写完了数据库。但由于网络原因,卡顿了一下,还没来得及写缓存。
  • 这时候请求b过来了,先写了数据库。
  • 接下来,请求b顺利写了缓存。
  • 此时,请求a卡顿结束,也写了缓存。

很显然,在这个过程当中,请求b在缓存中的新数据,被请求a的旧数据覆盖了。

也就是说:在高并发场景中,如果多个线程同时执行先写数据库,再写缓存的操作,可能会出现数据库是新值,而缓存中是旧值,两边数据不一致的情况。

缺点三: 浪费系统资源

写的缓存的内容,并不是简单的数据,而是要经过非常复杂的计算或者查询筛选得出的结果,这样每写一次缓存都要计算一次,这是非常浪费系统资源的,尤其对那些写多读少的业务场景,更是雪上加霜。

方案三:先删缓存,再写数据库

既然更新缓存会有浪费系统资源等问题,那就直接删除缓存来代替更新缓存呢?

方案一:

缓存一致性问题解决_第5张图片
嗯,看起来还不错。即使写数据库失败了,下个请求也会重新触发写缓存操作,基本上避免更新缓存的所有弊端,然而也不是十全十美。

缺点:
缓存一致性问题解决_第6张图片

  • 1.请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
  • 2.这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
  • 3.请求c将数据库中的旧值,更新到缓存中。
  • 4.此时,请求d卡顿结束,把新值写入数据库。

这种极端情况下依然会导致写入的缓存为旧值。

方案二:延迟双删

缓存一致性问题解决_第7张图片
为了避免方案一的错误,写完数据库后,再删除一次。

该方案有个非常关键的地方是:第二次删除缓存,并非立马就删,而是要在一定的时间间隔之后。

sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于读写缓存的时间即可。

那么,为什么一定要间隔一段时间之后,才能删除缓存呢?

请求d卡顿结束,把新值写入数据库后,请求c将数据库中的旧值,更新到缓存中。此时,如果请求d删除太快,在请求c将数据库中的旧值更新到缓存之前,就已经把缓存删除了,这次删除就没任何意义。必须要在请求c更新缓存之后,再删除缓存,才能把旧值及时删除了。

方案四:先写数据库,再删缓存

缓存一致性问题解决_第8张图片

  • 1.请求e先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存。
  • 2.请求f查询缓存,发现缓存中有数据,直接返回该数据。
  • 3.请求e删除缓存。

这种情况下,只会影响f或类似f的少数请求读了一次脏数据,看起来好多了。

但如果是读数据请求先过来呢?

  • 1.请求f查询缓存,发现缓存中有数据,直接返回该数据。
  • 2.请求e先写数据库。
  • 3.请求e删除缓存。

这种情况看起来也没问题。

但就怕一种情况:缓存失效。

  • 1.缓存自动失效。
  • 2.请求f查询缓存,发缓存中没有数据,查询数据库的旧值,但由于网络原因卡顿了,没有来得及更新缓存。
  • 3.请求e先写数据库,接着删除了缓存。
  • 4.请求f更新旧值到缓存中。

这时,缓存和数据库的数据同样出现不一致的情况了。但这种情况还是比较少的,需要同时满足以下条件:

  • 1.缓存刚好自动失效。
  • 2.请求f从数据库查出旧值,更新缓存的耗时,比请求e写数据库,并且删除缓存的耗时还长。

出现这种情况的概率已经极低了,除非是查询比写入还慢。说实话如果对于这种极低概率的脏数据都不能容忍,建议不需要使用缓存了。毕竟现在大部分都是读写分离,主从还存在延时呢。这种要强一致性的建议走mysql。对msql进行扩容比如分库分表,读写分离等等。

删除缓存失败怎么办?

其实先写数据库,再删缓存的方案,跟缓存双删的方案一样,有一个共同的风险点,即:如果缓存删除失败了怎么办?

方案一:设置过期时间

缓存设置一个过期时间,比如5分钟。当然这种方案只适合数据更新不是太频繁的业务。

方案二:同步重试

在接口中判断是否删除成功,如果失败就重试,直到成功或超过最大重试次数为止,返回数据。当然,这种方案的缺点就是可能影响接口性能。

方案三:消息队列

将删除缓存任务写入mq等消息中间件中,在mq的consumer中处理。但问题也很多:

  • 1.引入消息中间件之后,问题更复杂了,对业务代码有一定侵入性、消息丢失怎么办
  • 2.消息本身的延迟也会带来短暂的不一致性,不过这个延迟相对来说还是可以接受的

方案四:订阅mysql的binlog

我们可以借助监听binlog的消息队列来做删除缓存的操作。这样做的好处是,删除动作无需侵入到业务代码,消息中间件帮你做了解耦,同时,中间件的这个东西本身就保证了高可用。

总结

首先,要明确一点,缓存删除比更新效果更好。为什么呢?

举个例子:如果数据库1小时内更新了1000次,那么缓存也要更新1000次,但是这个缓存可能只在最后一次更新后被读取了1次,那么前999次的更新有必要吗?

反过来,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除(删除前判断key是否存在),只有当缓存真正被读取的时候才去数据库加载

删除缓存有两种方式:

  • 1.先删除缓存,再更新数据库。解决方案是使用延迟双删。
  • 2.先更新数据库,再删除缓存。解决方案是消息队列或者监听binlog同步,引入消息队列会带来更多的问题,对业务代码有一定侵入性,并不推荐直接使用。

针对缓存一致性要求不是很高的场景,那么只通过设置超时时间就可以了。

你可能感兴趣的:(Redis,缓存,数据库,java)