美文网首页
数据库缓存双写一致性

数据库缓存双写一致性

作者: 耶律枣 | 来源:发表于2021-03-07 10:46 被阅读0次

    前言

    在日常的开发当中,我们经常使用缓存来替代一部分数据库的读写操作,以此提升系统性能和减轻DB负载。因为数据存储在了两个位置,所以需要保证这两份数据是一致的,或者在某段可以接受的时间范围内存在短暂的不一致(即需要保证最终一致性)。

    常见方案

    不同的业务对于一致性有自己的处理方法,目前我了解到的保证一致性大致有五种方案:(以下所有方案只讨论实时的操作步骤,暂不引入后台程序定时监控和补偿的机制)

    方案1

    读Cache,miss则从DB中load数据进Cache;写时先写DB,后写Cache。

    流程图如下:


    这个方案应该很容易想到,是比较小白的做法。但是这个方案在请求并发时会导致数据不一致:



    如上图所示,当两个进程/线程同时进行写操作,线程A先把 DB 中的 x 更新为1,然后线程B把 DB 和 Cache 中的 x 更新为2,最后线程A把 Cache 中的 x 更新为1。此时 DB 中 x 的值为2,Cache 中 x 的值为1,所以 DB 和 Cache 的数据就不一致了,Cache 中存在脏数据/旧数据,当进行读操作时就会从 Cache 中读取到旧数据。

    方案2

    读Cache,miss则从DB中load数据进Cache;写时先写DB,然后删Cache。

    这个是经典的 Cache Aside Pattern,facebook也在论文《Scaling Memcache at Facebook》中提到,他们用的也是先更新数据库,再删缓存的策略。



    读操作和方案1一样,二次读,先读 Cache,miss 则从 DB 中读数据再加载进 Cache 中。和方案1不同的是写操作的第二步,由原来的更新 Cache 改为删除 Cache,这个改造解决了方案1并发写时造成数据不一致的现象。为什么呢?通过模拟并发写的流程可以发现,因为第二步是删除,两个线程无论谁先删谁后删结果都是一样的,Cache 中 x 都为空(不存在),自然就不会出现和 DB 中 x 的值不一样的情况;Cache 里 x 的值待触发读操作时再从 DB 中去加载数据。这里解决问题的本质是因为 delete 操作是个幂等性操作,和 add 一样,无论执行多少次,结果都是一样的,而 update 就不是幂等性操作,结果和执行的先后顺序有关。

    这个方案并不是完全没问题,并发写时是保证了数据一致性,但如果读写同时执行是会有问题的:



    可以看到,当写线程的两步操作在读线程读 DB 和回填 Cache 之间时,就会出现不一致的问题。最终 DB 中 x 的值是 2,而 Cache 中 x 是旧值 1。不过实际上这种情况发生的概率并不高,因为它需要读线程在读 DB(T2时刻)和写 Cache(T5时刻)中停留较长一段时间,等待写线程的T3和T4时刻的操作完成才能发生,除非读线程在请求写 Cache 时发生网络拥堵或丢包,或者在请求 Cache 前因为GC或操作系统进程调度等出现停顿。

    方案3

    读操作和方案2相同;写时先删Cache,再写DB


    假设不考虑读操作的并发,方案2还存在一个问题,就是写时因为是双写,有可能 update DB 成功,但 delete Cache 失败,那么 Cache 中就会存在脏数据了,读的时候就会从 Cache 中拿到旧的数据。改进的方案也有,把双写的次序交换一下即可,先 delete Cache 再 update DB,如上图所示。即使第二步的 update DB 失败,但因为 Cache 中的数据已经被 delete 了,读时会从 DB 中去加载数据,所以不会出现不一致。

    不过读写并发时还是会出现和上图类似的不一致现象:

    除此以外,方案3有个不足的地方,一种场景,当并发请求,读写交替执行,读的最后一步操作在写的前面时,方案2具有容错性,而3没有,如下图:



    可以看到,先写 DB 再删 Cache 的方案2具有容错性,不会不一致(最终 DB 中 x=2,Cache 中 x 不存在,待触发读操作时重新加载);而先删 Cache 再写 DB 的方案3最终 DB 的 x=2,Cache 的 x=1,出现了不一致现象。

    方案4

    读Cache,miss读DB,不回填Cache;写时先删Cache,再更新DB,然后发送异步消息回填Cache。



    方案4和前三个方案很大一个不同是,缓存的构建不再由读触发,而是由写来负责,先删除旧数据再异步更新,通过一个消息组件来处理(处理的逻辑不是简单地执行application传过来的update语句,而是先从DB中去读取源数据,再更新进缓存,这样就不会出现同一个变量/数据更新次序错乱的的问题。该consumer本质上是一个队列,串行顺序消费,因此可以优化下,引入多个队列,把不同数据的更新消息分发到不同的队列中。不过同一个数据必须在同一个队列中,否则和方案1的本质就是一样的了,后更新者会把先前读到的脏数据写到缓存中,若限定在同一个队列时,最后的消息读到的一定是最新的数据,所以没问题)。
    假设不考虑各种操作的失败情况,也假设 consumer 消费得足够快,那么这个方案在读写并发时就不会出现数据不一致的情况。因为它把缓存的更新收敛到了一个地方,变成了串行更新,不会出现方案1中的交错更新现象,也不会像方案2和3那样回填了延迟到来的旧数据,所以比起上述三个方案是更优的。

    但是也有个缺点,因为引入了多一个组件,写的步骤变多了,整个流程失败的概率会变高,比如 Application 在发送 message 前就宕机了:


    或者 consumer 发送给 Cache 的消息丢了,或者 Cache 执行 rebuild 的消息时失败了:


    还有 consumer 本身挂了,无法接收/消费消息等等一系列情况,都会导致 Cache 和 DB 中数据的不一致。

    方案5

    读Cache,miss读DB,不回填Cache;写时先删Cache,再更新DB,然后通过消费binlog回填Cache。



    读时和方案4一样,不回填 Cache,写时前半部分逻辑也和方案4一样,不同的是重建缓存的策略。这里不再由 Application 来发送消息给 consumer,而是通过 DB 自带的 binlog 机制来通知 consumer。这个改造虽然没有解决方案4中所有可能存在的问题,但是解决了 Application 发送消息失败的问题,这个意义还是很大的,因为方案4其实已经不是双写了,而是三处写:(1) delete Cache; (2) update DB; (3) send message 。(1) 和 (2) 任意一个失败都不会导致数据不一致,但是如果 (1) 和 (2) 成功了,但 (3) 失败了,就会导致数据不一致。现在我们把步骤 (3) 挪到了 binlog 中,看起来还是三处写,不还是会出现这个问题吗?其实不会,由 DB 的事务机制可知,所有的写操作都会直接反映到 binlog 中,也就是说 binlog 和真实的数据是绑定在一起的,这两者不会存在不一致的情况。所以这里实际上是通过 DB 把三处写合并为了两处写:(1) delete Cache; (2) update DB && binlog send message。

       虽然 binlog 向 consumer send message 这一步可能也会失败,但是因为源数据没有丢失(存在 binlog 中),和方案4的 Application 发送消息的失败不一样,还有重试的机会。
    
       对于方案5中 consumer 的选取,业界有个很好用的工具,就是阿里开源的 [canal](https://github.com/alibaba/canal),它是一个 MySQL binlog 增量订阅&消费组件,主要用途是基于 MySQL 数据库日志解析,提供增量数据订阅和消费,Cache 数据的刷新重建是它的一个很广泛的用途。
    

    一点理解

    粗略分析五种方案,我认为,双写一致性难以保证的本质是因为存在\color{red}{两个生产者和两个消费者} </font>:


    两个消费者很好理解, DB 和 Cache。至于两个生产者,方案1,2,3中都是 Application;方案4对于 DB 的生产者是 Application,Cache 的生产者是 consumer;而方案5 Cache 的生产者是 binlog。方案4, 5优于方案1, 2, 3的原因是解决了并发时由于时序导致的数据不一致,方案5优于方案4是因为它把消费者和第二个生产者结合了起来,减少了操作步骤,步骤越少,最终失败的可能性就越低。

    补充

    即使是看起来最优的方案5,其实也无法保证最终一致性,所以无论哪种方案,都需要一个保障措施,保障 DB 和 Cache 数据的最终一致,可以使用一个后台程序进行定时监控,对比 DB 和 Cache 中的数据,若出现不一致则自动修复或通知告警,然后人工介入处理;或者对 Cache 中的数据设置过期时间,选取合理的时间范围,使当出现不一致时脏数据不至于停留太久,能够及时过期掉,也不会因太快过期而导致大量的读 miss 进而影响系统性能。
    最终一致性容易保证,但是强一致性却很难做到。因为 DB 和 Cache 是两个独立的系统/存储介质,要做到强一致性,就需要用到分布式事务,而分布式事务要求所有系统都有一个可靠的事务/回滚机制。我们常用的缓存组件如 redis 并没有实现真正的事务,当出错时不支持回滚。假设某个缓存组件像 DB 一样支持了事务,此时条件齐备了,能实现强一致性了,但是这时候的性能是很低下的。为什么呢?因为每个事务里肯定用到锁操作 -- 要保证并发时每个系统里执行的更新次序是一样的就必须用到锁,无论是乐观锁还是悲观锁。所以部分请求的失败是在所难免的,这时候系统吞吐量就下来了;如果不用锁,在分布式调度中心把所有请求串行发送至 DB 和 Cache 时,这时候虽然成功率提高了,但是性能已经不敢恭维了。综上,我认为,对于一致性要求很高的业务,就不要使用缓存,缓存只适合那些对实时性要求高、且能接受短时间数据不一致的业务。

    相关文章

      网友评论

          本文标题:数据库缓存双写一致性

          本文链接:https://www.haomeiwen.com/subject/pmhdqltx.html