保证缓存最终一致性的方案有哪些?

随着业务数据的增多和业务流量的增长,如果只依赖数据库来承接所有的流量,服务的性能和稳定性都会面临风险。由于互联网业务往往是读多写少的场景,所以非常适合增加缓存来提升系统的响应能力,同时降低数据库的访问压力。

使用缓存在带来好处的同时,也带来了数据一致性的问题。针对这个问题,业界已经总结了几种常用的更新缓存的设计模式,来保证缓存和数据库的最终一致性。注意这里是最终一致性,因为使用的缓存的大部分场景,最终一致性足以满足业务需求,很少有需要强一致性的场景。

接下来我们会分别介绍这几种更新缓存的设计模式。

Cache-Aside

Cache-Aside 叫做旁路缓存模式。下图描述了这种模式的处理流程。处理读请求时,首先查询缓存,如果缓存数据有效,则直接返回,否则查询数据库,然后将数据更新到缓存中,再返回数据;处理写请求时,首先更新数据库,然后删除缓存。

Cahce-Aside 模式读写请求处理流程


读请求的过程很容易理解,关于写请求,可能会有这么几个疑问。

问题1:为什么是先操作数据库,再操作缓?

先后顺序一定是先数据源,再缓存,这是非常重要的一点。因为如果先删除缓存,再更新数据源,则会存在一段时间内缓存已经删除完毕,数据源还未更新。此时如果有读请求进来,无法命中缓存,就会到达数据源中。

此时读到的还是旧数据,随后又会写到缓存中。等数据更新完成后,就出现了缓存中是旧数据,数据源是新数据,两者数据不一致的情况。下图描述了这种场景。

关于延时双删

为了解决先删除缓存,再更新数据库这种方案导致的问题,网上的资料经常看到一种叫做“延时双删”的方案。即在更新数据库后,延迟一段时间再次删除缓存。显然,再次删除缓存的操作应该在读请求更新缓存后,所以延迟的时间一般要比读请求的耗时稍大一些,通常可以采用 sleep 或者延迟队列实现。

我个人认为这种方案不具有应用价值。一方面是延时的时间很难界定;另一个更重要的原因是再更新数据库之前删除缓存并没有带来任何好处,反而要为此采用再次删除的操作作为补偿机制,那我们直接不要在更新数据前删除缓存就好了。

延时双删

问题2:为什么是删除缓存,是否能直接更新缓存呢?

1)因为如果是更新缓存,更新过程中数据源又被其它请求修改,缓存要面临多次赋值的复杂时序问题,可能会导致数据不一致。下图描述了这种场景。而删除操作是幂等的,不会出现这种情况。

2)如果缓存是经过大量的计算得到的,在写数据时去更新缓存可能是一笔不小的开销,如果更新缓存后没有来的及使用,缓存就再次被更新(这被称为缓存扰动),则会造成资源的浪费;读数据时更新缓存则符合懒加载的思想。

问题3:Cache-Aside 模式一定能保证数据一致性吗?

不一定,有可能出现数据不一致的情况。

1)当读请求查询缓存时,如果数据从未被缓存过或者缓存正好因超期而失效,当从数据库查询数据后,更新到缓存前,如果有写请求在此期间执行完成,则会导致缓存中的数据被旧数据覆盖。执行过程如下图所示:

这种情况发生要满足下面两个条件:

  • 数据从未被缓存过或者刚好失效
  • 读写并发执行,读请求查询数据库的执行要早于写请求更新数据库开始,但读请求执行完成要晚于写请求

所以这种不一致的场景产生的条件还是比较严格的,在实际生产环境中出现的可能很小。

2)另外,如果写请求删除缓存失败,也会导致缓存中数据落后于数据库中的数据。针对这种情况,通常可以采取以下几种机制。

  • 缓存设置过期时间

通常缓存都会设置一个过期时间,如果出现不一致,最多持续时长为缓存的过期时间。如果业务上可以接受,这是代价最小的解决方案;

  • 删除重试机制

正如前面提到的,删除操作是幂等的,所以很方便可以进行重试,但如何重试是值得考虑的一个问题;

首先同步重试会影响请求的性能,异步重试可以通过消息队列来实现,比如删除失败后写一条消息到消息队列中,或者更新数据库后直接写一条消息到队列中,通过消息队列执行删除操作,并自动进行重试。

这种方案的问题是为原本很简单的操作引入了太大的复杂性,非必要不建议采取,而且对缓存系统进行重试也要慎重对待,当系统出现问题时,重试导致的流量激增往往会导致问题进一步恶化。

  • 利用事务

还有一种方案是将更新数据库的操作和删除缓存的操作放到同一个事务中,删除失败时回滚事务。

这种方案实现起来很简单,但不建议使用,原因如下:
1)在事务操作中增加一次网络调用,会延长事务的持续时间,相当于降低了数据库的并发性能
2)缓存系统的问题会引起连锁反应,导致数据库大量操作回滚,本来只是数据一致性的问题,现在则会影响到整个系统的可用性
3)由于网络原因,出现删除缓存成功,但返回客户端网络异常的情况,导致数据库操作会滚,但缓存实际已经失效了,可能导致更多的请求因缓存缺失而访问数据库,给数据库带来压力

Read-Through

Read-Through 叫做读穿透模式,和 Cache-Aside 的读请求流程基本一样,不同的是多了一个访问控制层。问控制层封装了缓存和数据库交互的逻辑,业务层只和访问控制层进行交互,实现更加简洁,良好的封装性也让程序更容易维护和移植。

在我看来,这种方案相比 Cache-Aside 只是更加强调了代码实现上的封装和解耦,核心思路是完全一样的。

Write-Through

Write-Through 叫做直写模式,它也提供了访问控制层,和 Cache-Aside 的区别是,Write-Through 更新数据库后会直接更新缓存,而不是删除缓存。

当然这里如果是先更新缓存,再更新数据库也是可以的。但无论如何,更新缓存而不是删除缓存,就会面临前面提到的两个问题:

  • 并发更新时缓存要面临多次赋值的复杂时序问题
  • 对写请求的性能影响及缓存扰动问题

第一个问题是必须要解决的,典型的方案是通过分布式锁来保证两个操作的原子性,当然不可避免地会影响系统的并发处理能力。

一个简化的方式是将两个操作都放到数据库的事务中执行,这种方案的问题前面也分析过两点,这里还要再提一点,因为 Redis 并不支持事务,所以极端情况下可能出现 Redis 更新成功,但 MySQL 事务回滚的情况(执行 commit 命令时发生异常)。

关于第二个问题,虽然对写请求的性能有影响,但因为直接更新缓存,读取时就可以快速地从缓存中获取数据。所以 Write-Through 更加适合对读取操作要求较高性能要求的场景。

另外,在 Write-Through 模式下,不管是先更新缓存还是先更新数据库,都存在更新缓存或者更新数据库失败的情况,前面提到的重试机制这里也是奏效的。

Write-Behind

Write-Behind 叫做异步回写模式。和 Read-Through/Write-Through 具有类似的访问控制层,不同的是 Write-Behind 处理写请求时只更新缓存而不更新数据库。对数据库的更新,通过异步批量更新的方式进行,批量写入的时间点可以在数据库负载较低的时间进行。

Write-Behind 模式减轻了数据库压力,写请求延迟低,可以支持更高的吞吐量。但是数据一致性较弱,缓存数据未写入数据库时,直接从数据库中查到的是旧数据。同时对缓存系统的压力比较大,缓存宕机回导致数据丢失,所以要做好缓存系统的高可用。所以,Write-Behind 更加适合大量写操作的场景,比如电商秒杀场景中的减库存。

Write-Around

对于非核心业务场景,可以选择在 Cache-Aside 模式下增加缓存过期时间,在写请求中仅仅更新数据库,不做任何删除或者更新缓存的操作,缓存仅能通过过期时间失效。

这种方案实现简单,但数据一致性较差,可能导致用户体验较差,要慎重选择。

基于数据库日志( MySQL binlog )增量订阅和消费

除了上面介绍的几种缓存更新模式,还有一种方案,就是订阅 MySQL binlog,将数据库更新事件写入 MQ 中,然后在消费逻辑中删除相应的的缓存。

这种方案的优点是删除缓存的逻辑和业务进行了解耦,并且消息队列天然具备重试能力。不过要注意如果 binlog 消费存在较大延迟,在此期间从缓存读取到是旧数据。

因为消费 binlog 不存在并发更新的复杂时序问题,所以这里直接更新缓存也是可以的。但是当读请求正好触发更新缓存时,和 binlog 触发的更新缓存之间也会出现并发更新的时序问题,虽然这种场景出现的概率很小,但还是更加推荐删除缓存而不是更新。

总结

以上是业界常用的几种解决缓存一致性的方案,其中,Cache-Aside 模式是以低成本更新缓存,并且获得相对可靠结果的解决方案,适用于大部分场景;另外对于大量写操作的场景,考虑使用 Write-Behind 模式;如果业务中有成熟的 binlog 增量订阅和消费机制,则可以考虑基于 binlog 来维护缓存的一致性。

参考

  1. 浅谈缓存最终一致性的解决方案
  2. 分布式缓存如何与本地缓存配合,提高系统性能?