缓存数据库一致性

961 阅读8分钟

只要我们使用缓存,就必然会面对缓存和数据库间的一致性问题。如果缓存中的数据和数据库的数据不一致,那么业务应用从缓存中读取的数据就不是最新的数据,对业务的影响可想而知。比如我们把商品的库存数据存在缓存中,如果缓存中库存数据不对,那么可能就会影响下单操作,这是业务上很难接受的。本篇文章我们来一起聊一聊缓存的一致性问题。

先更新数据库,后更新缓存

这样会造成数据不一致。

A 先把数据库更新为 123,由于网络问题,更新缓存的动作慢了。

这时,B 去更新数据库了,改为了 456,紧接着把缓存也更新为 456。

现在 A 更新缓存的请求到了,把缓存更新为了 123。

那么这时数据就不一致了,数据库里是最新的 456,而缓存是 123,是旧数据。

因为数据库更新、缓存更新这2个动作不是原子的,在高并发操作时,这2个动作直接会插入其他动作。

先更新缓存,再更新数据库

同样可能数据不一致。

缓存更新成功,数据为最新的,但数据库更新失败,回滚了,还是旧数据。

还是非原子操作的原因。

先更新数据库,后删除缓存

其实,在更新数据时,我们可以不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没有,再从数据库中读取数据,更新到缓存中。

这就是 Cache Aside 策略(旁路缓存策略)

  • 读策略步骤

  • 写策略步骤

写时可以不可以先删除缓存?不行!

例如这个场景:

A 先删了缓存,还没等数据库更新完成呢,就被 B 把缓存更新为了旧值。

但是Cache Aside 策略也是不保证数据一致性的,它的作用是大大减少不一致性。在下面的读写并发场景下,首先来自线程1的读请求在未命中缓存的情况下查询数据库(step1),接着来自线程2的写请求更新数据库(step2),但由于一些极端原因,线程1中读请求的更新缓存操作晚于线程2中写请求的删除缓存的操作(step4晚于step3),那么这样便会导致最终写入缓存中的是来自线程1的旧值,而写入数据库中的是来自线程2的新值,即缓存落后于数据库,此时再有读请求命中缓存(step5),读取到的便是旧值。

这种场景的出现,不仅需要缓存失效且读写并发执行,而且还需要读请求查询数据库的执行早于写请求更新数据库,同时读请求的执行完成晚于写请求。足以见得,这种不一致场景产生的条件非常严格,在实际的生产中出现的可能性较小。

image.png 除此之外,在并发环境下,Cache-Aside中也存在读请求命中缓存的时间点在写请求更新数据库之后,删除缓存之前,这样也会导致读请求查询到的缓存落后于数据库的情况。

虽然在下一次读请求中,缓存会被更新,但如果业务层面对这种情况的容忍度较低,那么可以采用加锁在写请求中保证“更新数据库&删除缓存”的串行执行为原子性操作(同理也可对读请求中缓存的更新加锁)。加锁势必会导致吞吐量的下降,故采取加锁的方案应该对性能的损耗有所预期。

Cache Aside 补偿机制

我们在上面提到了,在Cache-Aside中可能存在更新数据库成功,但删除缓存失败的场景,如果发生这种情况,那么便会导致缓存中的数据落后于数据库,产生数据的不一致的问题。其实,不仅Cache-Aside存在这样的问题,在延时双删等策略中也存在这样的问题。针对可能出现的删除失败问题,目前业界主要有以下几种补偿机制。

  • 删除重试机制

由于同步重试删除在性能上会影响吞吐量,所以常通过引入消息队列,将删除失败的缓存对应的key放入消息队列中,在对应的消费者中获取删除失败的key,异步重试删除。这种方法在实现上相对简单,但由于删除失败后的逻辑需要基于业务代码的trigger来触发,对业务代码具有一定入侵性。

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

鉴于上述方案对业务代码具有一定入侵性,所以需要一种更加优雅的解决方案,让缓存删除失败的补偿机制运行在背后,尽量少的耦合于业务代码。一个简单的思路是通过后台任务使用更新时间戳或者版本作为对比获取数据库的增量数据更新至缓存中,这种方式在小规模数据的场景可以起到一定作用,但其扩展性、稳定性都有所欠缺。

一个相对成熟的方案是基于MySQL数据库增量日志进行解析和消费,这里较为流行的是阿里巴巴开源的作为MySQL binlog增量获取和解析的组件canal(类似的开源组件还有Maxwell、Databus等)。canal sever模拟MySQL slave的交互协议,伪装为MySQL slave,向MySQL master发dump协议,MySQL master收到dump请求,开始推送binary log给slave(即canal sever),canal sever解析binary log对象(原始为byte流),可由canal client拉取进行消费,同时canal server也默认支持将变更记录投递到MQ系统中,主动推送给其他系统进行消费。在ack机制的加持下,不管是推送还是拉取,都可以有效的保证数据按照预期被消费。当前版本的canal支持的MQ有kafka或者RocketMQ。另外,canal依赖zookeeper作为分布式协调组件来实现HA,canal的HA分为两个部分:

  • 为了减少对MySQL dump的请求压力,不同canal server上的instance要求同一时间只能有一个处于运行状态,其他的instance处于standby状态;
  • 为了保证有序性,对于一个instance在同一时间只能由一个canal client进行get/ack等动作。

那么,针对缓存的删除操作便可以在canal client或consumer中编写相关业务代码来完成。这样,结合数据库日志增量解析消费的方案以及Cache-Aside模型,在读请求中未命中缓存时更新缓存(通常这里会涉及到复杂的业务逻辑),在写请求更新数据库后删除缓存,并基于日志增量解析来补偿数据库更新时可能的缓存删除失败问题,在绝大多数场景下,可以有效的保证缓存的最终一致性。

另外需要注意的是,还应该隔离事务与缓存,确保数据库入库后再进行缓存的删除操作。比如考虑到数据库的主从架构,主从同步及读从写主的场景下,可能会造成读取到从库的旧数据后便更新了缓存,导致缓存落后于数据库的问题,这就要求对缓存的删除应该确保在数据库操作完成之后。所以,基于binlog增量日志进行数据同步的方案,可以通过选择解析从节点的binlog,来避免主从同步下删除缓存过早的问题。

数据传输服务(Data Transmission Service,简称DTS)是云服务商提供的一种支持RDBMS(关系型数据库)、NoSQL、OLAP等多种数据源之间进行数据交互的数据流服务。DTS提供了包括数据迁移、数据订阅、数据同步等在内的多种数据传输能力,常用于不停服数据迁移、数据异地灾备、异地多活(单元化)、跨境数据同步、实时数据仓库、查询报表分流、缓存更新、异步消息通知等多种业务应用场景。

相对于上述基于canal等开源组件自建系统,DTS的优势体现在对多种数据源的支持、对多种数据传输方式的支持,避免了部署维护的人力成本。目前,各家云服务商的DTS服务已 针对云数据库,云缓存等产品进行了适配,解决了Binlog日志回收,主备切换等场景下的订阅高可用问题。在大规模的缓存数据一致性场景下,优先推荐使用DTS服务。