Java面试我爱编程程序员

谈谈缓存跟数据库的数据一致性问题

2018-04-15  本文已影响231人  1d96ba4c1912

场景说明

通常来说,在我们的系统中会把数据永久保存在DB中,并且冗余一份数据在缓存中。读请求优先从缓存读取数据,没有再从DB读取,如下图:


这样做的好处是可以减小DB的压力,提高请求的响应速度。

但这种架构在提升系统读请求处理能力的同时,给系统写请求的处理带来了不少的麻烦。因为数据在DB跟缓存中各自保存了一份,如何保证它们之间的数据一致就是本文要讨论的问题。

提出问题

当处理写请求时有两种方式:

一、先写缓存再写DB

  1. 如果第一步写缓存失败,直接返回,无影响。
  2. 如果缓存写成功,DB写失败,此时如果不清除缓存中已写入的数据,则会造成数据不一致(缓存中是新值,DB中是旧值)。
    如果增加清除缓存的逻辑,那么清除操作又失败了该如何处理?

二、先写DB再写缓存

  1. 如果DB写入失败,直接返回,无影响。
  2. 如果DB写入成功,缓存写入失败则会造成数据不一致(即DB中是新值,缓存中是旧值)。
    如果重试写入缓存,那重试也失败该如何处理?

三、问题分析

上面所说的问题本质上就是一个分布式数据一致性问题,在不要求强一致性的场景下,我们只要开辟一个异步任务去保证最终一致性即可。

就上面所说的场景来说,发生失败时,我们可以开启一个异步线程去做数据回填操作,反复重试直到成功。如果采用异步线程回填数据的方式做最终一致性,那么这个容错性是内存级别的,也就是说如果此时重启服务(线程消失),那么这个重试任务就丢失了,导致数据不一致。

其实宏观上来说,缓存数据设置过期时间就是一种数据最终一致性的方案。这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以DB为准,对缓存操作只是尽最大努力即可。也就是说如果DB写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从DB中读取新值然后回填缓存,完成数据的最终一致性。

本文所讨论的方案是不依赖过期时间的解决思路。关于上面说的回填任务重启导致丢失的请求,其实还可以换其他方式实现,比如引入任务机制去不断重试(任务可以做成持久化),我们这里就不展开说了,下面我要介绍另外一种比较巧妙的方式。

解决思路

一、写请求流程:

写请求流程

如上图,每次处理写请求时,将会经过如下几个步骤:

  1. 首先针对要写入的数据设置一个状态,失败则结束,成功则转2。
  2. 如果设置状态成功,则直接清除缓存,失败则解除状态并结束,成功则转3。
  3. 清除缓存后,再写入DB,失败则解除状态并结束,成功则转4。
  4. DB写入成功以后,把新值回填缓存,失败则解除状态并结束,成功则转5。
  5. 回填成功,解除状态并结束。

二、读请求流程:

读请求流程

如上图,每次处理读请求时,将会经过如下几个步骤:

  1. 直接从缓存读取数据,成功则结束,失败则转2。
  2. 从DB读取数据,失败则返回,成功则转3。
  3. 根据从DB读取到的数据判断该数据对应的状态,如果没有状态,则回填缓存并结束,如果有状态,则直接结束。

总结来说就是我们通过一个状态把读写请求关联起来,这里先不讨论这个状态的实现细节以及各种容错,比如说解除失败以后怎么处理。

三、失败情况下数据一致性分析

接下来分析一下各个过程失败以后,读写请求是如何通过状态位解决数据不一致的问题的,如下:

  1. 获取状态失败,直接返回失败信息。
    此时写请求知道自己写失败了并且缓存跟DB中也都是旧值,没有造成数据不一致问题。如果获取状态成功,只是网络问题造成失败,这种场景下写请求并没有对数据进行任何修改,因此不会导致数据不一致,只要在状态实现的方案中考虑到这个容错即可,细节后面再讨论。

  2. 清缓存失败,解除状态并返回失败信息。
    如果清缓存失败,则缓存中还有旧值,没影响。如果清缓存成功,失败是因为网络造成的,即缓存中已经没有数据,此时缓存跟DB虽然数据不一致,但后面读请求会直接从DB读取数据,然后查询数据对应的状态,如果状态已经解除则回填缓存,如果状态还存在,也就是说在写请求清缓存成功(数据清理成功)到解除状态之间的读请求会直接返回DB中的值,这样所有的读请求读取到的值都是一样的,没有数据不一致问题。

  3. 写DB失败,解除状态并返回失败信息。
    此时缓存已经没有值,如果读请求发生在清除缓存之后,写入DB之前,那么会从DB读到旧值,由于这次写请求还没完成,所以读到旧值是合理的,并且由于此时数据的状态还在,读请求并不会把该旧值回填缓存。如果DB写入失败,则DB中是旧值,后面的读请求会把该数据回填缓存。如果写入成功,但由于网络问题导致失败,此时DB中是新值,缓存中没有值,那么后续的读请求会读取到新值并回填缓存。但写数据的请求收到的却是写失败,这就造成了写请求失败,但读请求已经读取到了新值。也就是说写请求是成功了,但写请求返回的处理结果是失败,此时如果写请求忽略了这个错误什么也不做,那么可以认为写成功了,因为读取到了新值并且没有数据不一致的问题。如果写请求收到错误以后没有忽略而是进行了重试,那就要求写操作满足幂等性,在分布式系统中解决写操作时由于网络原因导致的失败问题必须通过幂等解决,这没有问题。

  4. 回填缓存失败,解除状态并返回。
    此时如果回填成功,失败是由网络造成的,那么读请求直接走缓存读取,没有问题。如果回填失败,后面的读请求从DB读取到数据以后会根据状态进行回填缓存的操作,最终数据保持一致。这里其实回填操作不是强制要求的,也就是说如果DB写入成功,回填缓存失败,完全可以返回写入成功,写请求不需要重试,回填交给后面的读请求。

综上所述,这种读写方案在各种失败的场景中均满足了数据最终一致性。虽然过程中缓存跟DB会有数据不一致(缓存中没有值,DB中有值)但所有的读请求读取到的数据都是相同的。

状态的实现方式

最后我们来讨论一下这个方案中最关键的一个技术点,如何实现这个状态标识。

需求是这个状态需要与数据一一对应,并且更新前进行获取, 更新结束以后(不论成败)进行解除,还需要兼容实际成功但由于网络问题导致失败的场景。我想你已经猜到了,它刚好与分布式锁契合,获取状态等同于获取分布式锁,接触状态等同于释放分布式锁。由于网络问题导致锁一直没有释放的各种场景分布式锁的实现方案已经给出了解决办法。

注:必须要说明一点,分布式锁只是状态的一种实现方式,我们完全可以通过其他方式实现。

关于分布式锁的实现原理网上有很多文章,本文就不再细说了,这里给大家推荐两篇我认为写的非常棒的文章:
Redis 分布式锁的正确实现方式(Java版)
Distributed locks with Redis

上一篇下一篇

猜你喜欢

热点阅读