Redis与MySQL双写一致性
Redis与MySQL双写一致性如何保证
引言
实际业务中,通常使用Redis作为数据库和业务系统之间的缓冲层,尽量减少数据库的压力,在这种架构中,一个不容忽视的问题就是如何确保Redis缓存与数据库之间的双写一致性。所谓双写一致性,是指当数据在数据库中发生变更时,能够及时且准确地反映在Redis缓存中,反之亦然,以避免出现因缓存与数据库数据不一致导致的业务逻辑错误或用户体验下降。尤其在高并发场景下,由于网络延迟、并发控制等因素,保证双写一致性变得更加复杂。
在实际业务开发中,若不能妥善处理好缓存与数据库的双写一致性问题,可能会带来诸如数据丢失、脏读、重复读等一系列严重影响系统稳定性和可靠性的后果。本文将尝试剖析这一问题,介绍日常开发中常用的一些策略和模式,并结合具体场景分析不同的解决方案,为大家提供一些有力的技术参考和支持。
什么是一致性
一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。
- 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
- 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
- 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型
什么是双写一致性
在分布式系统中,双写一致性主要指在一个数据同时存在于缓存(如Redis)和持久化存储(如数据库)的情况下,任何一方的数据更新都必须确保另一方数据的同步更新,以保持双方数据的一致状态。这一问题的核心在于如何在并发环境下正确处理缓存与数据库的读写交互,防止数据出现不一致的情况。
典型场景分析
写数据库后忘记更新缓存:
当直接对数据库进行更新操作而没有相应地更新缓存时,后续的读请求可能仍然从缓存中获取旧数据,导致数据的不一致。删除缓存后数据库更新失败:
在某些场景下,为了保证数据新鲜度,会在更新数据库前先删除缓存。但如果数据库更新过程中出现异常导致更新失败,那么缓存将长时间处于空缺状态,新的查询将会直接命中数据库,加重数据库压力,并可能导致数据版本混乱。并发环境下读写操作的交错执行:
在高并发场景下,可能存在多个读写请求同时操作同一份数据的情况。比如,在删除缓存、写入数据库的过程中,新的读请求获取到了旧的数据库数据并放入缓存,此时就出现了数据不一致的现象。主从复制延迟与缓存失效时间窗口冲突:
对于具备主从复制功能的数据库集群,主库更新数据后,存在一定的延迟才将数据同步到从库。如果在此期间缓存刚好过期并重新从数据库加载数据,可能会从尚未完成同步的从库读取到旧数据,进而导致缓存与主库数据的不一致。
数据不一致不仅会导致业务逻辑出错,还可能引发用户界面展示错误、交易状态不准确等问题,严重时甚至会影响系统的正常运行和用户体验。
三个经典的缓存模式
缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般我们是如何使用缓存呢?有三种经典的缓存使用模式:
- Cache-Aside Pattern:旁路缓存
- Read-Through/Write-through
- Write-behind
Cache-Aside Pattern
Cache-Aside Pattern,即旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。
Cache-Aside读流程
Cache-Aside Pattern的读请求流程如下:
通过上图我们可以看到旁路缓存的工作原理:
读数据
读的时候,先读缓存,缓存命中的话,直接返回数据,缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。
更新数据
当需要更新数据时,首先更新数据库,然后再清除或使缓存中的对应数据失效。这样一来,后续的读请求将无法从缓存获取数据,从而迫使系统从数据库加载最新的数据并重新填充缓存。
Cache-Aside 写流程
Cache-Aside Pattern的写请求流程如下:
更新的时候,先更新数据库,然后再删除缓存。
操作缓存的时候,到底是删除缓存呢,还是更新缓存?
日常开发中,我们一般使用的就是Cache-Aside模式。 Cache-Aside在写入请求的时候,为什么是删除缓存而不是更新缓存呢?
我们在操作缓存的时候,到底应该删除缓存还是更新缓存呢?我们先来看个例子:
- 程A先发起一个写操作,第一步先更新数据库,然后更新缓存;
- 线程B再发起一个写操作,第二步更新了数据库,然后更新缓存数据;
- 由于网络等原因,线程B先更新了缓存
- 紧接着线程A更新缓存。
这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),数据不一致了,脏数据出现啦。如果是删除缓存取代更新缓存则不会出现这个脏数据问题。
如果是删除缓存,就不会存在上述情况,因为线程去缓存中查数据会不命中,紧接着就会去数据库中查询数据然后更新缓存;
更新缓存相对于删除缓存,还有两点劣势:
- 如果你写入的缓存值,是经过复杂计算才得到的话。更新缓存频率高的话,就浪费性能。
- 在写数据库场景多,读数据场景少的情况下,数据很多时候还没被读取到,又被更新了,这也浪费了性能呢(实际上,写多的场景,用缓存也不是很划算的,哈哈)
双写的情况下,先操作数据库还是先操作缓存?
Cache-Aside
缓存模式中,有些小伙伴还是会有疑问,在写请求过来的时候,为什么是先操作数据库呢?为什么不先操作缓存呢?
假设有A、B两个请求,请求A做更新操作,请求B做查询读取操作。
- 线程A发起一个写操作,第一步del cache,删除缓存;
- 此时线程B发起一个读操作,cache miss,缓存不命中;
- 线程B继续读DB,读出来一个老数据
- 然后线程B把老数据设置入cache
- 线程A写入DB最新的数据
这就有问题啦,缓存和数据库的数据不一致了。缓存保存的是老数据(线程B读取到的数据库数据),数据库保存的是新数据(线程A更新的数据)。因此,Cache-Aside缓存模式,选择了先操作数据库而不是先操作缓存。
- 个别小伙伴可能会问,先操作数据库再操作缓存,不一样也会导致数据不一致嘛?它俩又不是原子性操作的。这个是会的,但是这种方式,一般因为删除缓存失败等原因,才会导致脏数据,这个概率就很低。小伙伴们可以画下操作流程图,自己先分析下哈。接下来我们再来分析这种删除缓存失败的情况,如何保证一致性。
数据库和缓存数据保持强一致,可以嘛?
实际上,没办法做到数据库与缓存绝对的一致性。
- 加锁可以嘛?并发写期间加锁,任何读操作不写入缓存?
- 缓存及数据库封装CAS乐观锁,更新缓存时通过lua脚本?
- 分布式事务,3PC?TCC?
其实,这是由CAP理论决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP(分区容错性+可用性,牺牲数据强一致性)。个人觉得,追求绝对一致性的业务场景,不适合引入缓存。
CAP理论,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
但是,通过一些方案优化处理,是可以保证弱一致性,最终一致性的。
Read-Through/Write-Through(读写穿透)
Read-Through 和 Write-Through 是两种与缓存相关的策略,它们主要用于缓存系统与持久化存储之间的数据交互,旨在确保缓存与底层数据存储的一致性。
Read-Through(读穿透)
Read-Through 是一种在缓存中找不到数据时,自动从持久化存储中加载数据并回填到缓存中的策略。具体执行流程如下:
- 客户端发起读请求到缓存系统。
- 缓存系统检查是否存在请求的数据。
- 如果数据不在缓存中,缓存系统会透明地向底层数据存储(如数据库)发起读请求。
- 数据库返回数据后,缓存系统将数据存储到缓存中,并将数据返回给客户端。
- 下次同样的读请求就可以直接从缓存中获取数据,提高了读取效率。
整体简要流程类似Cache Aside Pattern,但在缓存未命中的情况下,Read-Through 策略会自动隐式地从数据库加载数据并填充到缓存中,而无需应用程序显式地进行数据库查询。
Cache Aside Pattern 更多地依赖于应用程序自己来管理缓存与数据库之间的数据流动,包括缓存填充、失效和更新。而Read-Through Pattern 则是在缓存系统内部实现了一个更加自动化的过程,使得应用程序无需关心数据是从缓存还是数据库中获取,以及如何保持两者的一致性。在Read-Through 中,缓存系统承担了更多的职责,实现了更紧密的缓存与数据库集成,从而简化了应用程序的设计和实现。
Write-Through(写穿透)
Write-Through 是一种在缓存中更新数据时,同时将更新操作同步到持久化存储的策略。具体流程如下:
- 当客户端向缓存系统发出写请求时,缓存系统首先更新缓存中的数据。
- 同时,缓存系统还会把这次更新操作同步到底层数据存储(如数据库)。
- 当数据在数据库中成功更新后,整个写操作才算完成。
- 这样,无论是从缓存还是直接从数据库读取,都能得到最新一致的数据。
Read-Through 和 Write-Through 的共同目标是确保缓存与底层数据存储之间的一致性,并通过自动化的方式隐藏了缓存与持久化存储之间的交互细节,简化了客户端的处理逻辑。这两种策略经常一起使用,以提供无缝且一致的数据访问体验,特别适用于那些对数据一致性要求较高的应用场景。然而,需要注意的是,虽然它们有助于提高数据一致性,但在高并发或网络不稳定的情况下,仍然需要考虑并发控制和事务处理等问题,以防止数据不一致的情况发生。
Write behind (异步缓存写入)
Write Behind(异步缓存写入),也称为 Write Back(回写)或 异步更新策略,是一种在处理缓存与持久化存储(如数据库)之间数据同步时的策略。在这种模式下,当数据在缓存中被更新时,并非立即同步更新到数据库,而是将更新操作暂存起来,随后以异步的方式批量地将缓存中的更改写入持久化存储。其流程如下:
- 应用程序首先在缓存中执行数据更新操作,而不是直接更新数据库。
- 缓存系统会将此次更新操作记录下来,暂存于一个队列(如日志文件或内存队列)中,而不是立刻同步到数据库。
- 在后台有一个独立的进程或线程定期(或者当队列积累到一定大小时)从暂存队列中取出更新操作,然后批量地将这些更改写入数据库。
使用 Write Behind 策略时,由于更新并非即时同步到数据库,所以在异步处理完成之前,如果缓存或系统出现故障,可能会丢失部分更新操作。并且对于高度敏感且要求强一致性的数据,Write Behind 策略并不适用,因为它无法提供严格的事务性和实时一致性保证。Write Behind 适用于那些可以容忍一定延迟的数据一致性场景,通过牺牲一定程度的一致性换取更高的系统性能和扩展性。
3种方案保证数据库与缓存的一致性
缓存延时双删
有些小伙伴可能会说,并不一定要先操作数据库呀,采用缓存延时双删策略,就可以保证数据的一致性啦;什么是延时双删呢?
- 先删除缓存
- 再更新数据库
- 休眠一会(比如1秒),再次删除缓存。
当更新数据库时,首先删除缓存中的数据,然后在去更新数据库,以确保后续在缓存中请求的数据都是数据库更新后的数据;
但是实际场景中可能存在删除缓存后更新数据库存在时间窗口,实际从数据库中读取的还是老数据,因此在更新数据库后,过段时间后重新删除缓存,这样就可以保证从缓存中读取的数据是数据库中更新过的数据;
这个休眠一会,一般多久呢?都是1秒?
这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。
这种方案还算可以,只有休眠那一会(比如就那1秒),可能有脏数据,一般业务也会接受的。但是如果第二次删除缓存失败呢?缓存和数据库的数据还是可能不一致,对吧?给Key设置一个自然的expire过期时间,让它自动过期怎样?那业务要接受过期时间内,数据的不一致咯?还是有其他更佳方案呢?
删除缓存重试机制
不管是延时双删还是Cache-Aside的先操作数据库再删除缓存,都可能会存在第二步的删除缓存失败,导致的数据不一致问题。可以使用这个方案优化:删除失败就多删除几次呀,保证删除缓存成功就可以了呀~ 所以可以引入删除缓存重试机制
- 写请求更新数据库
- 缓存因为某些原因,删除失败
- 把删除失败的key放到消息队列
- 消费消息队列的消息,获取要删除的key
- 重试删除缓存操作
这种删除失败重试机制一定程度上可以保证缓存和数据库数据一致性,应对网络抖动带来的删除缓存失败影响,缺点是销号更多的资源;
读取biglog异步删除缓存
重试删除缓存机制还可以吧,就是会造成好多业务代码入侵。其实,还可以这样优化:通过数据库的binlog来异步淘汰key。
以mysql为例吧
在写数据库时,将对数据的更新日志都写入mysql的binlog日志中,然后使用工具去监听binlog日志,一旦监听到日志有变化,就将变化的数据发送到消息队列中,消费者处理消息队列中的数据,异步的更新缓存中对应的数据,确保缓存和数据库数据一致性;
可以使用阿里的canal将binlog日志采集发送到MQ队列里面
然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性;
这种方法的好处是将缓存的更新操作与主业务流程解耦,避免阻塞主线程,同时还能处理数据库更新后由于网络问题或并发问题导致的缓存更新滞后情况。当然,实现这一策略相对复杂,需要对数据库的binlog机制有深入理解和定制开发。
总结
在分布式系统中,为了保证缓存与数据库双写一致性,可以采用以下方案:
读取操作:
- 先尝试从缓存读取数据,若缓存命中,则直接返回缓存中的数据。
- 若缓存未命中,则从数据库读取数据,并将数据放入缓存。
更新操作:
- 在更新数据时,首先在数据库进行写入操作,确保主数据库数据的即时更新。
- 为了减少数据不一致窗口,采用异步方式处理缓存更新,具体做法是监听数据库的binlog事件,异步进行删除缓存。
- 在一主多从的场景下,为了确保数据一致性,需要等待所有从库的binlog事件都被处理后才删除缓存(确保全部从库均已更新)。
同时,还需注意以下要点:
- 对于高并发环境,可能需要结合分布式锁、消息队列或缓存失效延时等技术,进一步确保并发写操作下的数据一致性。
- 异步处理binlog时,务必考虑异常处理机制和重试策略,确保binlog事件能够正确处理并执行缓存更新操作。