浅谈分布式系统中的缓存一致性问题

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

在现代分布式系统和高性能计算环境中,缓存技术无疑是提高系统响应速度和吞吐量的关键。然而,缓存技术带来的性能提升也伴随着一个棘手的问题------缓存一致性问题。缓存一致性问题指的是在多层级、多副本的缓存系统中,如何确保所有缓存中的数据在任何时候都保持一致。这不仅涉及到数据的一致性和完整性,还关系到系统的可靠性和性能优化。在分布式数据库、内容分发网络(CDN)、多核处理器等领域,缓存一致性问题的解决方案直接影响到系统的稳定性和用户体验。因此,深入理解和有效应对缓存一致性问题,是每一位架构师和开发者必须掌握的核心技能。在本文中,笔者以 Redis 和 Mysql 数据一致性问题作为切入点,来探讨缓存一致性问题的原因、常见挑战以及解决方案。

问题域

相信绝大多数工程师在实际的工程中都会用 redis 来做缓存,用以提高系统的并发和性能。在不涉及到缓存更新的情况下,其实也就不会存在缓存一致性问题。但是一旦涉及到数据更新(数据库和缓存更新),就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。在 CAP 理论的前期下,这个个问题理论上没有完美的解。不管是先写 MySQL 数据库,再删除 Redis 缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况

目前关于更新缓存常见的 Design Pattern 有四种:

  • Cache aside(旁路缓存)
  • Read through(读穿透)
  • Write through(写穿透)
  • Write behind caching(异步缓存写入)

这四种 Design Pattern 很多细节描述笔者这里就不再展开,网上有很多相关的文章,这里笔者推荐去阅读左耳朵耗子的:缓存更新的套路 这篇文章,个人认为非常浅显易懂。

PS: 在思考这些问题的时候,要对分布式系统中的并发、延迟、网络抖动等有基本认识,因为它们是导致缓存一致性问题的基本原因。

下面针对主流使用的是 Cache Aside Pattern 进行介绍,并且分析其在某些特殊场景下可能存在的问题。

Cache Aside Pattern

什么是 Cache Aside Pattern

目前主流使用的是 Cache Aside Pattern ,即当数据发生变更时,先更新数据库,再删除缓存;其主要逻辑如下:

  • 失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

  • 命中:应用程序从 cache 中取数据,取到后返回。

  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

那么 Cache Aside Pattern 就能解决缓存一致性问题吗?很显然是不可能的,但是在绝大多数场景下,使用这种方式已经能够满足实际的业务情况了。这里笔者只针对 Cache Aside Pattern 情况下的问题进行分析。

Cache Aside Pattern 下的问题

案例: 某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。时序图如下:

前面提到, Cache Aside Pattern 在绝大多数场景下是可以满足业务需求的,结合上面的时序图,一般情况下缓存的写入肯定是要远远快于数据库的写入 ,但是可能就会因为某些特别的情况导致 A 在读取 DB 数据后到回写到缓存的这个间隙期出现了延迟,B 并发修改了 DB 数据并先于 A 回写之前提前删除了缓存,导致 A 将旧数据又写到了缓存中,那么在此后的这个缓存过期时间内,业务层读取到的就都是旧值了。(关于这个特别的情况,对于 JAVA 应用服务来说可能是触发了一次 GC)。

工程落地中的解和 Trade-off

不一致的产生原因

通过前面的一些基本了解,我们可以得出一个结论:在没有额外操作限定的情况下,Cache Aside Pattern 也不能解决或者说保证数据一致性问题 ,但实际上 Cache Aside Pattern 在并发场景下,已经能够很大程度的解决一致性问题了;如果想要彻底解决并发读写带来的一致性问题,从实际工程落地上就是通过资源加锁的方式。这里的 trade-off 在于,我们在项目中引入缓存的目的在于提供系统的性能和吞吐,而加锁则必然会导致资源的竞争和阻塞,这两者的取舍则在于你系统对于缓存一致性要求的强烈程度。

再回到最开始的问题域,缓存数据一致性问题会涉及到两个操作,不管是先操作数据库还是先操作缓存,这本身是两个独立的操作, 并不具备操作的原子性,那么就会必然出现第二个操作的成功或者失败带来的两种不同的结果。那对于这种场景呢?你可能说是 事务 ;但是数据库更新以及缓存操作并不适合放到一个事务中,一般来说,使用分布式缓存有网络传输的耗时,如果这个耗时较长,那么将更新数据库以及失效缓存放到一个事务中的话,会造成事务耗时过长,继而引发数据库连接池连接的快速耗尽,这会严重的降低系统性能,导致系统崩溃。在实际的工程中,对于这种情况的处理往往是通过 重试 来解决。

从系统对缓存一致性的要求

在讨论具体的实践方案之前,这里还是需要从对缓存一致性的要求进行说明,这部分权衡系统架构策略的关键因素。关于,主要涉及以下几个问题:

  • 是否一定要做到缓存+数据库完全一致性?

    • 在某些应用场景中,数据的高度一致性是至关重要的,例如金融交易系统、库存管理系统等。这些系统对数据的准确性要求极高,任何微小的差异都可能导致严重的后果。因此,对于这些系统而言,缓存和数据库的一致性必须得到严格保证。然而,这种严格一致性通常会带来较高的性能开销和复杂的实现成本,需要在设计之初就做好充分的准备和评估。
  • 是否能够接受偶尔的数据不一致性问题?

    • 在某些应用中,偶尔的数据不一致是可以接受的,例如社交媒体平台的点赞数、浏览量等。这类数据在短时间内的小幅度不一致并不会对整体用户体验造成显著影响。对于这些系统,可以采用较为宽松的一致性策略,以换取更高的性能和响应速度。这种策略常见于最终一致性模型(Eventual Consistency),通过在后台异步同步数据,最终达到一致。
  • 能够接受最长时间的数据不一致性是多久?

    • 对于能够接受一定程度不一致性的系统,明确可接受的不一致时间窗口是关键。例如,在电商网站中,购物车数据的短暂不一致可能是可接受的,但支付信息必须在短时间内达到一致。这就要求系统设计时需要设定一个合理的时间窗口,在此窗口内数据不一致是可以接受的,超过此窗口则必须采取措施强制同步数据。这种策略有助于在性能和一致性之间找到平衡点,确保系统在高负载下仍能保持稳定运行,但往往需要依赖经验来决策。

在明确上述问题的要求后,我们可以更好地制定适合具体业务场景的缓存一致性策略,既保证系统的性能,又满足业务对数据一致性的要求,这就是设计上的 trade-off 所在。

下面是结合笔者工程实践和网上关于该问题讨论整理出的一些常规方案,希望可以给各位工程师以参考。

延迟双删

在前面【Cache Aside Pattern 下的问题】小节中提到的问题是:在并发场景下,由于 A 请求在更新数据库和回写缓存的短暂间隙中可能出现 **缓存「旧值」**的情况。那针对这种场景,业界目前主流的做法是在一段时间之后再出发一次缓存删除的动作,从而能够有效的保证两者的数据一致性问题。

但问题来了,这个「延迟双删」缓存,延迟时间到底设置要多久又是一个很难评估的点,从某种程度上来说,完全取决于系统 owner 对于业务影响的认知和经验,不同系统之间的差异很大,没有标准答案。

消息队列+删除重试

这里主要针对前面提到的操作原子性问题,在不引入事务的情况下,将第二步删除缓存的动作进行异步化,通过引入消息队列来实现消费失败场景下的重试操作,从而达到最终一致性的效果。引入消息队列当然也会带来一定的维护成本,好处在于消息队列本身具备持久化能力,可以保障消息的可靠投递;消费端做好消费失败重试,就可以很大程度解决掉一些因网络抖动、机器宕机来带的一些额外的边际影响。

基于 binlog 订阅的异步更新

消息队列虽然已经比较简单,但是存在一定的业务侵入性;比如消息投递和消息消费逻辑需要耦合到我们的业务代码中去。那如果你不想侵入代码,那目前主流的方案则是通过基于 mysql 的 binlog + canal 订阅来实现数据的异步更新。binlog 本身就是 mysql 在事务执行完成之后写入的一条归档日志,相当于这个一阶段是被确认正确的;另外这种方式也不会给业务层面带来侵入,全由 canal 来解决。当然,他也会带来一些问题,如canal 的维护、canal 高可用问题等等。

总结

本文主要针对数据库和缓存一致性的问题进行了分析,并探讨了主流的一些模式和方案。首先 Cache Aside Pattern 在绝大多数场景下是可以满足业务需求的,但在并发情况下会存在 旧值 问题。为了应对这种情况,可以通过延迟双删的机制来解决,即在更新数据库后,延迟一段时间再次删除缓存。然而,延迟双删的延迟时间完全取决于系统owner的业务理解和经验,没有通用的标准答案,这需要根据具体业务场景进行调整和优化。

在权衡操作原子性和系统吞吐性能方面,通常通过 消息队列+重试删除 或者 基于 binlog 订阅的异步更新 的方式来实现步骤二中的失败补偿兜底。这些方案可以确保在更新数据库的同时,及时处理缓存中的数据,减少数据不一致的窗口期。消息队列的方式通过在数据库更新操作后,发送消息到队列中,由消费端进行重试删除或更新缓存,从而保证缓存与数据库的一致性。基于binlog的订阅异步更新则通过订阅数据库的binlog日志,实时捕获数据变化并同步到缓存中,确保缓存的数据始终与数据库保持同步。

总的来说,缓存一致性问题本质上是一个在性能和一致性之间寻找平衡点的过程。引入缓存的初衷是为了提高系统性能,因此,缓存机制天然地倾向于性能优化,这使得「最终一致性」方案在许多场景中成为首选。最终一致性允许系统在短时间内存在数据不一致的情况,从而显著提升系统的吞吐量和响应速度。但是实际工程中,也还存在其他的一些因素,使得业务侧更倾向最终一致性,比如缓存更新之后并不意味着立马会被使用等等。

参考

相关推荐
monkey_meng32 分钟前
【Rust中的迭代器】
开发语言·后端·rust
余衫马35 分钟前
Rust-Trait 特征编程
开发语言·后端·rust
monkey_meng38 分钟前
【Rust中多线程同步机制】
开发语言·redis·后端·rust
hlsd#1 小时前
go 集成go-redis 缓存操作
redis·缓存·golang
瓜牛_gn2 小时前
mysql特性
数据库·mysql
奶糖趣多多3 小时前
Redis知识点
数据库·redis·缓存
CoderIsArt4 小时前
Redis的三种模式:主从模式,哨兵与集群模式
数据库·redis·缓存
paopaokaka_luck5 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
码农小旋风7 小时前
详解K8S--声明式API
后端
Peter_chq7 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端