解决缓存和数据库双写数据一致性问题原创
缓存的作用
在公司刚起步,业务量比较小的时候。对于系统的读写请求,我们一般的做法都是直接操作数据库。但是随着业务体量的不断增长,用户请求增多,这时候只用数据库处理业务是不够的,存在着「性能问题」。业内通用做法就是引入「缓存」。
缓存可以提升性能,缓解数据库压力,但是使用缓存也会出现「缓存和数据库数据不一致」的问题。
如果数据不一致,就会导致应用在缓存中读取的不是最新的数据,这显然是不能接受的。在解决这个问题之前,我们首先要了解:「导致缓存和数据库双写不一致」的原因。
缓存和数据库双写不一致的原因
我们先来看看缓存和数据库一致性定义:
-
缓存中有数据,且和数据库数据一致;
-
缓存中无数据,数据库数据是最新的。
那么不符合这两种情况就属于缓存和数据库不一致的问题了。
当客户端发送一个数据修改的请求,我们不仅要修改数据库,还要一并操作(修改/删除)缓存。对数据库和缓存的操作又存在一个顺序的问题:到底是先操作数据库还是先操作缓存。
下面我们以客户端向 MySQL 中删改数据为例来分析数据不一致的情况。
先不考虑并发问题,正常情况下,无论谁先谁后,都可以让两者保持一致,但现在我们需要重点考虑异常情况。
此时应用既要修改数据库也要修改缓存(删除和修改的影响是类似的,方便起见,下面只描述修改操作)的数据。
这里有两种场景我们分别来看下:
-
先修改缓存,再修改数据库
-
先修改数据库,再修改缓存
我们假设应用先修改缓存,再修改数据库。如果缓存修改成功,但是数据库操作失败,那么,应用再访问数据时,缓存中的值是正确的,但是一旦缓存中「数据失效」或者「缓存宕机」,然后,应用再访问数据库,此时数据库中的值为旧值,应用就访问到旧值了。
如果我们先更新数据库,再更新缓存中的值,是不是就可以解决这个问题呢?我们继续分析。
如果应用先完成了数据库的更新,但是,在更新缓存时失败了。那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。
这个时候,如果有其他的并发请求来访问数据,按照正常的访问流程,就会先在缓存中查询,此时,就会读到旧值了。
好了,到这里,我们可以看到,在操作数据库和更新缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要「第二步的操作」失败了,就会导致客户端读取到旧值。
我们继续分析,除了「第二步操作失败」的问题,还有什么场景会影响数据一致性:并发问题。
并发引发的一致性问题
这里列出来所有策略,并且对删除和修改操作分开讨论:
-
先更新数据库,后更新缓存
-
先更新缓存,后更新数据库
-
先更新数据库,后删除缓存
-
先删除缓存,后更新数据库
先更新数据库,后更新缓存
假设我们采用「先更新数据库,后更新缓存」的方案,并且在两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢?
有线程 A 和线程 B 两个线程,需要更新「同一条」数据 x,可能会发生这样的场景:
-
线程 A 更新数据库(x = 1)
-
线程 B 更新数据库(x = 2)
-
线程 B 更新缓存(x = 2)
-
线程 A 更新缓存(x = 1)
最后我们发现,数据库中的 x 是 2,而缓存中是 1 。显然是不一致的。
另外这种场景一般是不推荐使用的。因为某些业务因素,最后写到缓存中的值并不是和数据库是一致的,可能需要一系列计算得出的,最后才把这个值写到缓存中;如果此时有大量的对数据库进行写数据的请求,但是读请求并不多,那么此时如果每次写请求都更新一下缓存,那么性能损耗是非常大的。
比如现在数据库中
,此时我们有 10 个请求对其每次加 1 的操作。但是这期间并没有读操作进来,如果用了先更新数据库的办法,那么此时就会有10个请求对缓存进行更新,会有大量的冷数据产生。x=1
至于「先更新缓存,后更新数据库」这种情况和上述问题的是一致的,我们就不在继续讨论。
不管是先修改缓存还是后修改缓存,这样不仅对缓存的利用率不高,还浪费了机器性能。所以此时我们需要考虑另外一种方案:删除缓存。
先删除缓存,后更新数据库
假设有两个线程:线程A(更新 x ),线程B(读取 x )。可能会发生如下场景:
-
线程 A 先删除缓存中的 x ,然后去数据库进行更新操作;
-
线程B 此时来读取 x,发现数据不在缓存,查询数据库并补录到缓存中;
-
而此时线程 A 的事务还未提交。
这个时候「先删除缓存,后更新数据库」仍会产生数据库与缓存的不一致问题。
先更新数据库,后删除缓存
我们还用两个线程:线程 A(更新 x ),线程B(读取 x )举例。
-
线程 A 要把数据 x 的值从 1更新为 2,首先先成功更新了数据库;
-
线程 B 需要读取 x 的值,但线程 A 还没有把新的值更新到缓存中;
-
这个时候线程 B 读到的还是旧数据 1;
不过,这种情况发生的概率很小,线程 A 会很快删除缓存中值。这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。
由此,我们可以采用这种方案,来尽量避免数据库和缓存在并发情况下的一致性问题。
下面,我们继续分析「第二步操作失败」,我们该如何处理?
如何保证「第二步操作失败」的双写一致?
前面我们分析到,无论是「更新缓存」还是「删除缓存」,只要第二步发生失败,那么就会导致数据库和缓存不一致。
这里的关键在于如何保证第二步执行成功。
首先,介绍一种方法:「基于消息队列的重试机制」。
具体来说,就是把「第二步」(操作缓存,或者操作数据库)的请求暂存到队列中。通过消费队列来重新处理这些请求。
流程如下:
-
请求 A 先对数据库进行更新操作;
-
在对 Redis 进行删除操作的时候发现删除失败;
-
此时将 对 Redis 的删除操作 作为消息体发送到消息队列中;
-
系统接收到消息队列发送的消息,再次对 Redis 进行删除操作。
消息队列的两个特性满足了我们重试的需求:
-
保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心);
-
保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)。
引入队列带来的问题:
-
业务代码造成大量的侵入,同时增加了维护成本;
-
写队列时也会存在失败的问题。
对于这两个问题,第一个,我们在项目中一般都会用到消息队列,维护成本并没有新增很多。而且对于同时写队列和缓存都失败的概率还是很小的。
如果是实在不想在应用中使用队列重试的,目前也有比较流行的解决方案:订阅数据库变更日志,再操作缓存。我们对MySQL 数据库进行更新操作后,在binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 MySQL 数据库的 binlog 日志对缓存进行操作。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的
。canal
大概流程如下:
-
系统修改数据库,生成 binlog 日志;
-
canal 订阅这个日志,获取具体的操作数据,投递给消息队列;
-
通过消息队列,删除缓存中的数据。
总结:推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来保证数据库和缓存一致性。
如何保证并发场景下的数据一致性
我们前面分析,在并发场景下,「先删除缓存,再更新数据库」,由于存在网络延迟等,可能会存在数据不一致问题。我再把上面的图贴过来。
这个问题的核心在于:缓存都被种了「旧值」。
解决这种问题,最有效的办法就是,把缓存删掉。但是,不能立即删,而是需要「延迟删」,这就是业界给出的方案:缓存延迟双删策略。
在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作。
之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除。
但问题来了,这个「延迟删除」缓存,延迟时间到底设置要多久呢?
线程 A sleep 的时间,需要大于线程 B 读取数据再写入缓存的时间。这需要在实际业务运行时估算。
除此之外,其实还有一种场景也会出现不一致问题:如果数据库采用读写分离架构,主从同步之间也会有时间差,也可能会导致不一致:
-
线程 A 更新主库 x = 2(原值 x = 1);
-
线程 A 删除缓存;
-
线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 x = 1);
-
从库「同步」完成(主从库 x = 2);
-
线程 B 将「旧值」写入缓存(x = 1)。
最终缓存中的 x 是旧值 1,而主从库最终值是新值 2。发生了数据不一致问题。
针对该问题的解决办法是,对于线程 B 的这种查询操作,可以强制将其指向主库进行查询,也可以使用上述「延迟删除」策略解决。
采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。
所以实际使用中,还是建议采用「先更新数据库,再删除缓存」的方案,同时,要尽可能地保证「主从复制」不要有太大延迟,降低出问题的概率。
总结
-
系统引入缓存提高应用性能问题
-
引入缓存后,需要考虑缓存和数据库双写一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」
-
不管哪种方案,只要第二步操作失败,都无法保证数据的一致性,针对这类问题,可以通过消息队列重试解决
-
「更新数据库 + 更新缓存」方案,在「并发」场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」和「机器性能浪费」的情况发生,一般不建议使用
-
在「更新数据库+删除缓存」的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案
-
在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据一致性
-
在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「强制读主库」或者「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。