文章目录
旁路缓存模式(Cache-Aside Pattern)如何保证一致性的问题
1. 概念简介
相关概念:
- 经典缓存模式:
- 旁路缓存模式 (Cache-Aside Pattern)
- 读缓存:接收用户读请求 --> 从缓存中查询数据 --> 命中缓存数据直接返回 --> 未命中则从数据库中查询数据。
- 写缓存:接收用户写请求 --> 先写入数据库 --> 再写入缓存。
- 读/写穿透模式 (Read/Write Through)
- 将缓存操作(具体来说是数据读写操作) 封装成一个缓存服务(Cache Provider),所有数据的读写操作都调用这个缓存服务。而这个缓存服务采取旁路缓存模式缓存数据,所以读/写穿透模式是以 旁路缓存模式 为基础的封装结构
- 读缓存:原服务接收用户读请求 --> 原服务请求Cache Provider --> Cache Provider先查缓存,命中则retrun --> 如果缓存未命中,Cache Provider再查DB
- 写缓存:原服务接收用户写请求 --> 原服务请求Cache Provider --> Cache Provider先写入数据库 --> Cache Provider再写入缓存。
- 异步回写模式 (Write Behind)
- 与 读/写穿透模式 类似,只是在写缓存时,只同步写缓存,不同步写数据库,而是异步批量写数据库。
旁路缓存模式作为日常开发中使用最频繁的缓存模式,那么如何在缓存与数据库双写时保持一致性呢?
首先,既然要解决这个问题,那么就需要厘清在旁路缓存模式下有哪些双写策略及其优缺点。可能对此了解较少的开发者可能会认为写入策略就两种:要么先写入数据库再写入缓存;要么先写入缓存再写入数据库。其实不然,大致可以分为5种:
1.先更新数据库,再更新缓存;
2.先删除缓存,再更新数据库;
3.先更新数据库,再删除缓存;
4.延迟双删;
5.先更新数据库,再基于队列删除缓存。
相信看到这里,部分读者会感到疑惑,为什么还有删除缓存?为什么没有先更新缓存再更新数据库?what’s 双删?带着你们的疑惑继续往下就明白了。
2. 五种缓存双写策略
注:以下 redis代缓存,mysql 代数据库
2.0 为什么不先更新缓存、再更新数据库
作为程序员,首先要区分数据库和缓存两种的重要性。对于程序而言 “缓存可以没有、数据库必须有” ,没有缓存最多也就是性能上的差别,而没有数据库,程序就没有持久化的能力。再者,作为开发人员,都听过这句话 ”缓存数据来源于数据库“,虽然不够严谨,但也反映了数据库的重要性。
既然数据库更重要,那与先后更新次序有何关联呢?如果先更新缓存,数据库更新失败了,而在缓存失效之前的那段时间内,用户所请求的数据都是“虚假”的。但是理论上,缓存数据是最新数据(正确),数据库是过期数据(失效)。一旦缓存清空或者服务宕机,对应业务就”回溯“了。这必然是不理想的,必需保证数据库的正确性高于缓存,在不考虑特殊业务情况下,数据库数据的时效性 >= 缓存数据的时效性。
简单了解了数据库的重要性,接下来就可以思考上述五种缓存双写策略。
2.1 先更新数据库,再更新缓存
微服务架构中,有一种典型的高并发场景:两个微服务实例A和B,同时对同一条数据进行写操作,在考虑所有操作都成功的情况下,按照先更新数据库、再更新缓存的策略,可能会出现以下执行次序:
step1:实例A 执行更新数据库操作;
step2:实例B 执行更新数据库操作;
step3:实例B 执行更新缓存操作;
step4:实例A 执行更新缓存操作;
具体执行流程如图所示:
图1:先更新数据库、再更新缓存并发场景
由图1不难看出,最终执行结果是缓存中存储的是微服务实例B 的写入数据,数据库中存储的是微服务实例A 的写入数据,出现了缓存与数据库不一致的情况。而导致这种情况的原因是:微服务实例B 写入缓存的数据被微服务实例A 写入缓存的(脏数据)数据覆盖了(如果是先写缓存,此时数据库的数据为“脏数据”)。
之所以出现以上问题就是在分布式或者无锁多线程的并发写请求的情况下,很难保证操作的执行次序,所以缓存中存在脏数据的概率是很大的。
2.2 为什么不更新缓存、而是删除缓存
在讨论下一小节之前,先分析一下这个问题:为什么不更新缓存、而是删除缓存?
首先从性能上来说,删除操作与更新操作相比,删除操作执行效率更快,能支持更高并发量;从方法实现上看,更新缓存的值需要进行较复杂的计算,而删除缓存只有一个方法调用甚至是执行命令而已;当数据读少写多时,很多时候并没有来得及去读取刚写入的数据就又更新缓存了,相较于删除操作,不仅浪费缓存空间也浪费了 CPU资源。
再回到上一小节所讲的,如果更新缓存替换为删除缓存,那么下一次执行的查询操作都会从数据库中加载数据,此时数据一致性就有了保证。
2.3 先删除缓存、再更新数据库
考虑以下读写高并发场景:微服务实例A 进行更新数据操作,在同一时间,微服务实例B 对该数据进行查询操作。根据先删除缓存、再更新数据库的策略,可能会出现以下执行次序:
step1:实例A 执行删除缓存操作;
step2:实例B 执行该数据的查询操作,缓存没命中,则从数据库中加载数据;
step3:实例B 执行更新缓存操作;
step4:实例A 执行更新数据库操作;
具体执行流程如下图2 所示:
图2:先删除缓存、再更新数据库并发场景
从上述执行次序可知,最终数据库中存储的是微服务实例A 写入的数据,缓存中存储的是微服务实例A 写入之前的失效(过期)数据,此时仍然出现了数据库与缓存不一致的情况。
分析具体原因:在微服务实例A 删除缓存时,可能因为某种原因发生了阻塞或者延迟(CPU时间片用完、网络延迟等)。此时微服务实例B 在执行查询操作时,从数据库中加载了失效数据,并更新缓存。接下来微服务实例A 继续执行更新数据库操作,最终数据库写入了最新的数据,然而缓存还是失效数据。
究其根本原因,就是在执行写操作时,出现了并发读操作,并且读操作发生在step1和step4之间,很显然这种现象在读多写少的情况下是很容易出现。这将会导致在缓存中的失效数据在设置的过期时间之前,所有的读操作都是获取的过期数据,而一般这个过期时间都是2小时(具体配置以项目中的配置为准)。
2.4 先更新数据库、再删除缓存
先更新数据库、再删除缓存基本上可以解决日常并发问题。对于某些特殊场景还是会出现数据不一致的情况,仍然以上一小节的场景为例,但是执行次序如下:
step1:实例A 执行更新数据库操作;
step2:实例B 执行该数据的查询操作,从缓存中命中该数据;
step3:实例A 执行删除缓存操作;
具体执行流程如下图3 所示:
图3:先更新数据库、再删除缓存并发场景
从上图3 可以看出,在step1和step3 这段时间之间,仍然会出现数据库与缓存不一致的情况,如果此时有其他服务执行查询操作,所获取的数据仍然是失效数据。但是,等到微服务实例A 删除缓存之后,数据就又恢复一致性了。
所以,策略3 存在什么问题呢?
(1)在更新数据库和删除缓存这段时间,数据仍然不一致,相较于策略2 ,其出现不一致性的时间短上很多(以ms为单位)。
(2)再考虑一个问题,如果在step3也就是删除缓存,执行失败了,那么缓存仍然会出现长时间的不一致。
2.5 延迟双删
延时双删策略是基于策略2 的一种改进,也就是在更新数据库之后再进行延时删除缓存。仍然以2.3小节的场景为例,出现的执行次序如下:
step1:实例A 执行删除缓存操作;
step2:实例B 执行该数据的查询操作,缓存没命中,则从数据库中加载数据;
step3:实例B 执行更新缓存操作;
step4:实例A 执行更新数据库操作;
step5:实例A 执行延时删除缓存操作;
具体流程如下图所示:
图4:延迟双删并发场景
虽然这种方式可以解决策略2 出现长时间数据不一致的问题,但是仔细分析会发现。如果step5 执行失败了,该策略不就退化成了策略2 了吗。再者,与策略3 相比额外多了一次删除缓存的负担(虽然并没有太大的执行开销),但是性能上肯定是不如策略3的。所以删除缓存失败的问题仍然没有被解决,如何解决呢?可以看策略5:先更新数据库,再基于队列删除缓存。
2.6 先更新数据库,再基于队列删除缓存
先更新数据库,再基于队列删除缓存策略则是在策略3的基础之上做的改进。将删除缓存操作提交给一个任务队列中,然后再由专门的消费线程去从该任务队列中获取任务,执行删除缓存操作,由此可见,缓存的删除操作变成了异步的。以2.4小节中的场景为例,可能出现的执行次序如下:
step1:实例A 执行更新数据库操作;
step2:实例B 执行该数据的查询操作,从缓存中命中该数据;
step3:实例A 执行提交删除缓存任务到任务队列;
step4:异步消费线程从任务队列中获取任务,执行删除缓存操作,直到删除成功;
具体流程如下图所示:
图5:先更新数据库,再基于队列删除缓存并发场景
在真正执行删除缓存操作之前,仍会出现数据库和缓存中数据不一致问题,但是这个不一致性是短暂的,避免了策略3 中的长时间不一致问题。既然理论上解决了这个问题,那如何实现这个策略就是关键,重点就在于如何实现任务消费队列。
基于队列删除缓存,可细分为:
1. 基于内存队列删除缓存;
2. 基于消息队列删除缓存;
3. 基于binlog+消息队列删除缓存。
2.7. 基于队列删除缓存
1. 基于内存队列删除缓存
内存队列经常应用于单机架构,在应用内部自定义线程池或者线程工厂构造,甚至不需要线程池,符合生产者-消费者模式也可(优先推荐线程池)。这种模式下又会出现什么问题呢?
(1)程序复杂度提高,这点是毋庸置疑的。因为至少需要引入任务队列、消费线程、删除失败重试等功能实现,并且还需要根据业务发展不断优化。
(2)可拓展性低。起始,业务写入量不大时,会选择适当数量的消费线程(过多避免资源浪费),后续增多需要不断增加消费线程以确保删除缓存任务及时消费。
(3)可靠性低。内存队列是依赖于JVM进程的,一旦JVM崩溃或者某些问题,内存队列就会将还未处理完的任务都丢弃了,此时带来的负面影响甚至更糟糕。
2. 基于消息队列删除缓存
提到任务队列,就该联想到消息队列。市面上成熟的消息中间件有许多,如RocketMQ、RabbitMQ、Pulsar等,其使用效果都显著高于内存队列,并且还满足高可用性和高可靠性。这里我们以较高吞吐量、应用广泛的RocketMQ为例。
引入RocketMQ消费中间件后,此时的微服务实例A 在删除缓存操作时,只要发送一个删除缓存操作的消息并序列化传递给RocketMQ集群,注意同步发送消息确保先更新完数据库和消息发送成功,切勿异步发送。具体执行流程如下图:
图5:先更新数据库,再基于消息队列删除缓存并发场景
在接受并消费删除缓存消息时,RocketMQ的ACK机制保证了消息的可靠传递。消费者从消息队列中消费消息后,可以手动发送ACK确认消息的处理状态。只有在收到ACK后,RocketMQ才会将消息标记为已成功消费,否则会将消息重新投递给其他消费者。如果在重新投敌一定次数(默认16)之后,消息仍然消费失败,则会将消息加入死信队列。RocketMQ的监控程序会对死信队列进行监控,一旦发现死信消息,监控程序会进行运维警告,由运维人员解决最终的缓存删除问题。但除非Redis集群崩溃,一般都不会出现这种极端情况。
3. 基于binlog+消息队列删除缓存
明明上一小节基于消息队列删除缓存方案已经解决了删除缓存操作的可靠性。那么这个binlog又有何作用呢?
参考图5:先更新数据库,再基于消息队列删除缓存并发场景 可发现微服务实例A需要同步执行更新数据库、发送删除缓存消息操作。因此更新数据请求需要额外执行一次发送消息的负担,有没有一种方式将让微服务实例A 只执行更新操作(相当于没加缓存服务),也就是专注于更新,这样不仅解耦还提高吞吐量。所以我们需要一个可以监听数据库更新操作的监听功能,熟悉Mysql的开发者应该了解过binlog。
binlog是MySQL数据库中的二进制日志,用于记录MySQL数据库中所有修改操作,包括增删改等操作。binlog以二进制格式保存,可通过解析binlog文件来查看数据库的操作历史记录。binlog日志可以用于数据恢复、数据备份、数据同步等场景。在MySQL数据库中,binlog有两种模式:statement模式和row模式。statement模式记录的是SQL语句,row模式记录的是每一行数据的变化。binlog日志的开启和关闭可以通过设置MySQL的配置文件实现。
如何获取并发送binlog给RocketMQ消费端呢,此处推荐阿里巴巴开源中间件 Canal。可通过采集binlog,并将其发送给消息消费端,在消费端编写一个专门的消费删除缓存线程,通过binlog解析出其中有关数据库中数据更新类型的日志,解析日志并执行对应数据的删除缓存操作,并且RocketMQ也可以通过ACK机制,确保这条日志的执行成功。具体执行流程如下所示:
3. 总结
3.1 解惑
读到这里,很多人可能发现,最终我们还有一个问题没有解决:不论是以上介绍的那种方案,都会出现数据不一致性,只是出现这个问题的时间长短不同。
仔细想想,我们加入缓存的初衷是什么,不就是提高吞吐量,获得更高的性能吗。作为开发者,应该都知道一个非常著名的三角悖论(CAP定理),即对于一个分布式计算系统来说,不可能同时满足以下三点:
(1)一致性(Consistency) 所有节点在同一时间具有相同的数据
(2)可用性(Availability)保证每个请求不管成功或者失败都有响应
(3)分区容错性(Partition tolerance)系统中任意信息的丢失或失败不会影响系统的继续运作
所以我们只能从CA、CP、AP中选择一种,而不管如何作为一个系统,正常运行是其最最最基本的条件。所以我们只能从CP、AP中选择,既然选择了高性能和高吞吐量,所以我们只能满足AP。由此也可明白,以上介绍的所有方案都是为了保证将不一致性尽可能的降低(如果非要强一致性,就是不加入缓存)。
3.2 结语
介绍了这么多的基于旁路缓存模式的数据库与缓存双写的一致性方案,在实际应用中如何选择呢?其实没有答案,没有最好的方案,只有最合适的方案。我们都知道每个解决方案都是基于实际应用场景得出的,不然市面上的各种技术框架也不会层出不穷。