缓存一致性问题
Table of Contents generated with DocToc (opens new window)
# 缓存和数据库双写不一致的原因
我们先来看看缓存和数据库一致性定义:
- 缓存中有数据,且和数据库数据一致;
- 缓存中无数据,数据库数据是最新的。
不符合这两种情况就属于缓存和数据库不一致的问题
当客户端发送一个数据修改的请求,我们不仅要修改数据库,还要一并操作(修改/删除)缓存。对数据库和缓存的操作又存在一个顺序的问题:到底是先操作数据库还是先操作缓存。
下面我们以客户端向 MySQL 中删改数据为例来分析数据不一致的情况。
先不考虑并发问题,正常情况下,无论谁先谁后,都可以让两者保持一致,但现在我们需要重点考虑异常情况。 此时应用既要修改数据库也要修改缓存(删除和修改的影响是类似的,方便起见,下面只描述修改操作)的数据。
这里有两种场景我们分别来看下:
- 先修改缓存,再修改数据库
- 先修改数据库,再修改缓存
我们假设应用先修改缓存,再修改数据库。如果缓存修改成功,但是数据库操作失败,那么,应用再访问数据时,缓存中的值是正确的,但是一旦缓存中「数据失效」或者「缓存宕机」,然后,应用再访问数据库,此时数据库中的值为旧值,应用就访问到旧值了。
如果我们先更新数据库,再更新缓存中的值,是不是就可以解决这个问题呢?我们继续分析。
如果应用先完成了数据库的更新,但是,在更新缓存时失败了。那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。
这个时候,如果有其他的并发 (opens new window)请求来访问数据,按照正常的访问流程,就会先在缓存中查询,此时,就会读到旧值了。
好了,到这里,我们可以看到,在操作数据库和更新缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要「第二步的操作」失败了,就会导致客户端读取到旧值。
我们继续分析,除了「第二步操作失败」的问题,还有什么场景会影响数据一致性:并发问题。
# 并发引起的一致性问题
这里列出来所有策略,并且对删除和修改操作分开讨论:
- 先更新数据库,后更新缓存
- 先更新缓存,后更新数据库
- 先更新数据库,后删除缓存
- 先删除缓存,后更新数据库
# 先更新数据库,后更新缓存
假设我们采用「先更新数据库,后更新缓存」的方案,并且在两步都可以成功执行的前提下,如果存在并发,情况会是怎样的呢? 有线程 A 和线程 B 两个线程,需要更新「同一条」数据 x,可能会发生这样的场景:
- 线程 A 更新数据库(x = 1)
- 线程 B 更新数据库(x = 2)
- 线程 B 更新缓存(x = 2)
- 线程 A 更新缓存(x = 1)
最后我们发现,数据库中的 x 是2,而缓存中是1。显然是不一致的。
另外这种场景一般是不推荐使用的。因为某些业务因素,最后写到缓存中的值并不是和数据库是一致的,可能需要一系列计算得出的,最后才把这个值写到缓存中;如果此时有大量的对数据库进行写数据的请求,但是读请求并不多,那么此时如果每次写请求都更新一下缓存,那么性能损耗是非常大的。
比如现在数据库中 x = 1,此时我们有 10 个请求对其每次加一的操作。但是这期间并没有读操作进来,如果用了先更新数据库的办法,那么此时就会有10个请求对缓存进行更新,会有大量的冷数据产生。 至于「先更新缓存,后更新数据库」这种情况和上述问题的是一致的,就不再继续讨论。 不管是先修改缓存还是后修改缓存,这样不仅对缓存的利用率不高,还浪费了机器性能。所以此时我们需要考虑另外一种方案:删除缓存。
# 先删除缓存,后更新数据库
假设有两个线程:线程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 进行删除操作。
消息队列的特性可以满足我们的需求:
- 保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心);
- 保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)。
引入队列带来的问题:
- 业务代码造成大量的侵入,同时增加了维护成本;
- 写队列时也会存在失败的问题。
对于这两个问题,第一个,我们在项目中一般都会用到消息队列,维护成本并没有新增很多。而且对于同时写队列和缓存都失败的概率还是很小的。
# 订阅binlog
如果是实在不想在应用中使用队列重试的,目前也有比较流行的解决方案:订阅数据库变更日志,再操作缓存。我们对 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 的这种查询操作,可以强制将其指向主库进行查询,也可以使用上述「延迟删除」
策略解决。
采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。
所以实际使用中,还是建议采用「先更新数据库,再删除缓存」
的方案,同时,要尽可能地保证「主从复制」
不要有太大延迟,降低出问题的概率。
# 总结删除缓存失败的解决办法
其实先写数据库,再删缓存的方案,跟缓存双删的方案一样,有一个共同的风险点,即:如果缓存删除失败了怎么办?
方案一:设置过期时间
缓存设置一个过期时间,比如5分钟。当然这种方案只适合数据更新不是太频繁的业务。
方案二:同步重试
在接口中判断是否删除成功,如果失败就重试,直到成功或超过最大重试次数为止,返回数据。当然,这种方案的缺点就是可能影响接口性能。
方案三:消息队列
将删除缓存任务写入MQ
等消息中间件中,在MQ的consumer中处理。但问题也很多:
- 引入消息中间件之后,问题更复杂了,对业务代码有一定侵入性、消息丢失怎么办
- 消息本身的延迟也会带来短暂的不一致性,不过这个延迟相对来说还是可以接受的
方案四:订阅MySQL的binlog
我们可以借助监听binlog
的消息队列来做删除缓存的操作。
这样做的好处是,删除动作无需侵入到业务代码,消息中间件帮你做了解耦,同时,中间件的这个东西本身就保证了高可用。
# 总结
- 系统引入缓存提高应用性能问题
- 引入缓存后,需要考虑缓存和数据库双写一致性问题,可选的方案有:
「更新数据库 + 更新缓存」
、「更新数据库 + 删除缓存」
- 不管哪种方案,只要第二步操作失败,都无法保证数据的一致性,针对这类问题,可以通过消息队列重试解决
「更新数据库 + 更新缓存」
方案,在「并发」
场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」
和「机器性能浪费」
的情况发生,一般不建议使用- 在
「更新数据库 + 删除缓存」
的方案中,「先删除缓存,再更新数据库」
在「并发」
场景下依旧有数据不一致问题,解决方案是「延迟双删」
,但这个延迟时间很难评估,所以**推荐用「先更新数据库,再删除缓存」**的方案 - 在
「先更新数据库,再删除缓存」
方案下,为了保证两步都成功执行,需配合「消息队列」或**「订阅变更日志」**的方案来做,本质是通过「重试」的方式保证数据一致性 - 在
「先更新数据库,再删除缓存」
方案下,「读写分离 + 主从库延迟」
也会导致缓存和数据库不一致,缓解此问题的方案是「强制读主库」
或者「延迟双删」
,凭借经验发送「延迟消息」
到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。