分布式缓存,你真的会用吗?

科技   2024-11-15 15:43   安徽  

缓存能加快数据的访问速度,几乎每个软件都会使用这一技术。

自1968 年 在 360/85 系统上引入高速缓存(cache)一词以来,缓存技术经历了多次迭代更新, 还出现了许多种缓存框架和工具,以降低其使用门槛和风险。

在分布式技术中, 缓存尤为重要,相关使用方法和介绍文档也相当丰富。

然而,互联网技术历史中不乏因缓存异常导致的重大故障:

  • 2012 年,Facebook 的 Memcached 缓存更新异常, 导致用户看到了错误的信息;

  • 2013 年,Google 的 Spanner 数据库缓存更新异常, 使得数百万用户无法使用 Google 服务;

  • 2016 年,亚马逊 AWS 云服务由于 Elastic Load Balancer 缓存未能正确更新,造成大量网站和应用程序停机。 

这些案例引发我们深思:我们是否真正会用缓存?是否所有应用场景都适合引入缓存?在哪些情况下,缓存可能会造成严重损害?

接下来,我们将基于大厂 的实践经验,通过具体案例分析缓存使用中可能遇到的可用性和一致性问题,并 探索解决这些问题的方法,以深化对缓存技术的理解,并确保其更好地服务于我 们的应用。

01
只要使用缓存,就会存在可用性风险

在系统链路上增加一个环节就会增加可用性风险。

尽管缓存的引入提升了数据访问速度,但缓存架构的复杂性也给系统引入了更多的可用性风险,令系统更加脆弱。

因此,在设计缓存系统时,必须充分考虑这些风险,并采取相应的措施来确保系统的稳定性和可靠性。

1. 缓存加载不当导致服务器宕机

缓存通常架设在数据库之前,用于缓存常用数据,以加快访问速度并减轻数据库的负担。为了保持缓存数据与数据库中的数据尽可能一致,需要对缓存数据进行刷新。然而,一旦缓存刷新策略不当,就可能会对数据库造成严重影响。

下面以会员系统缓存刷新为例进行分析。

会员系统存储着用户的基础信息,这类数据的写入和更新频率不高,但读取量大,非常适合放入缓存中。

图1展示了一种利用缓存 JAR 包的方法。服务 提供方将缓存功能封装在一个 JAR 包中,供服务调用方系统集成。这样,服务调 用方可以像访问本地数据一样轻松、迅速地获取远程数据。

图1 缓存 JAR 包的利用过程 

在现实中,由于项目时间紧迫或开发者经验不足,缓存刷新方法可能非常简单,例如设定固定的过期时间,一旦缓存数据失效,就立即刷新缓存数据。

这种方法在缓存数据量较小的情况下通常不会出现问题。然而,它存在一个致命的缺点:可能导致大部分数据在同一时刻失效,进而导致所有缓存 JAR 包在同一时刻发起查询请求,将数据更新到缓存中。

一旦大量查询请求集中在同一时间点到达会员系统,就可能使会员系统的数据库过载,导致宕机,从而使整个会员服务不可用。 

为了解决这个问题,可以通过调整缓存刷新的频率来减轻数据库的压力。例如,在缓存失效时间上增加随机数,以错开缓存刷新的高峰期,避免集中刷新对服务器造成过大的压力。这种方法可以有效地规避因集中刷新而导致的系统崩溃。

2. 缓存刷新不当导致服务宕机 

除了注意缓存刷新的时机,缓存刷新的小细节也同样重要。

如图2所示,这种做法在大多数情况下可能没有问题,但如果远程调用服务 userService.queryAllUsers 时出现网络抖动,缓存就可能会变成空值。

在这种情况下,由于无法从缓存中找到数据,所以系统可能再次触发缓存刷新逻辑,导致远程调用,而远程调用由于网络抖动无法快速返回结果,从而引发服务雪崩, 导致服务调用方和服务提供方全部宕机。

图2 错误的缓存刷新的代码

一个相对更严谨的做法是在远程调用获取数据结果后,再将新的数据结果赋给原缓存变量。这样即使远程调用出现异常,缓存内容也不会为空。

然而,这种全量刷新缓存数据的方法可能会对系统资源造成较大压力。

一个更好的做法是,当服务端数据变化时,通过推送的方式对缓存进行增量刷新。

这样可以更有效地更新缓存,减少对系统资源的消耗。如图 3所示, 代码稍作调整,采用推送方式进行缓存增量刷新。

图3 调整后的缓存刷新的代码 

3. 本地缓存不当导致服务宕机

缓存 JAR 包对服务调用方友好,因为它提供了一种便捷的方法来获取缓存数 据。然而,由于缓存 JAR 包寄宿在服务调用方系统中,需要注意以下一些潜在的风险。 

缓存刷新的任务量过大:当缓存刷新的任务量过大时,可能会导致服务调用方的负载急剧增加,甚至引发宿主系统崩溃。这是因为缓存刷新通常涉及大量数 据的读取和写入操作,如果这些操作过于频繁或数据量过大,可能就会超出服务 器的处理能力。

缓存 JAR 包中缓存的数据量过大:如果缓存 JAR 包中缓存的数据量过大, 就可能会直接影响宿主系统的稳定性。例如,过大的缓存数据量可能会导致频繁 的垃圾回收(Full GC),这会严重影响系统的响应时间和吞吐量。

4. 分布式缓存穿透击垮数据库

若换成分布式缓存,是不是能够一劳永逸地解决问题呢?会员系统将数据都存储在分布式缓存中的具体情况如图4所示。

图4 分布式缓存示例

当查询的数据已存在于分布式缓存中时,直接返回结果可以提高查询效率。

然而,如果部分数据本来就不存在,直接查询数据库并在返回数据库结果的同时将结果写入缓存中,就可能导致问题。

如果服务调用方在缓存中找不到数据,它就会继续查询数据库;如果数据库也找不到,就可能导致服务调用方不断重试查 询,最终可能引起雪崩效应,击垮数据库。

对于分布式缓存中的数据也需要提前预热,对于不存在的数据需要在缓存中构建特殊空对象以防止缓存被穿透。 


02
只要使用缓存,就会存在数据不一致问题

从原理上来说,同一份数据既放到缓存中又存储在数据库中,就一定会带来数据一致性的挑战。

尽管可以通过各种策略和技术手段来减少数据不一致的时间窗口, 例如设置合理的缓存过期时间、使用缓存预读取和后写入机制、实施分布式锁等, 但这些措施并不能从根本上杜绝数据不一致的问题。接下来将分场景论述数据不一致的根源。

1. 数据不一致的本质分析 

(1)纯写场景。在正常的业务处理逻辑完成后,可以在本地事务结束之后, 通过回调方法 afterCompletion 将模型写入缓存,如图5所示。

图5 纯写场景

写入缓存的请求可能会失败,导致数据库中有数据而缓存中却没有相应的数据。为了处理这种情况,需要实施一个补偿方案。

具体来说,当缓存中缺少数据时, 系统应该查询数据库,并将查询结果重新写入缓存。在纯写场景中,由于数据库已经包含了最新的数据,因此不会出现数据一致性问题。 

在这种情况下,主要关注的是分布式缓存的命中率,即缓存中的数据与数据库中的数据保持一致的频率。如果缓存命中率较低,则意味着系统需要频繁查询数据库来获取缺失的数据,这会增加数据库的负载,从而降低系统的整体性能。

(2)纯删场景。这个场景也是比较简单的,先将缓存中的数据删除,再删 除数据库中的数据,如图6所示。

图6 纯删场景

先删除缓存中的数据再删除数据库中数据的风险在于,最终数据库事务提交可能会失败,这可能导致数据不一致。

为了降低数据不一致的概率,可将删除缓存数据的操作放在最后一步,即在所有业务逻辑处理完毕后再调用删除缓存数据 的方法。

即使缓存数据被删除,但数据库中的数据依然存在,最终读取到的数据库数据不会是脏读。

因此,在纯删场景下,实际上并不存在数据不一致的问题。

(3)纯读并写场景。为了提高缓存命中率并确保数据的最终一致性,常见的做法是首先尝试从缓存中读数据。如果缓存中没有数据(即缓存未命中),则回退到数据库中读数据。一旦从数据库中获取数据,不论是空数据还是有实际内 容的数据,都应该将其更新回缓存中,以便后续的请求能够直接从缓存中获取数 据,减少数据库的访问压力。

这种策略如图7所示。

图7 纯读并写场景 

在系统中仅涉及数据读取操作,而不包含数据更新、删除或写入的场景下, 不存在数据不一致的问题。

(4)纯更新场景。在这个场景中,由于数据库和缓存的操作不是原子性的, 无论是先更新数据库还是先更新缓存,都存在数据不一致的风险。

如图8所示, 无论先更新数据库,而缓存更新失败,还是先更新缓存,而数据库更新失败,都会导致数据不一致。这是因为这两个操作不能保证同时成功,所以无法实现强一致性,只能追求最终一致性。

图8 纯更新场景

清晰地认识问题的本质是我们选择解决方案的基础。为了减轻数据库的压力并确保其高可用性,缓存仅是一种手段。

为了维护数据的最终一致性,我们必须优先确保数据库数据的正确性,然后尽最大努力去修正缓存中的数据。

在图8所示的纯更新场景中,应该首先确保数据库更新成功。

然后,可以持久化一个缓存补偿任务,这个任务会在数据库事务提交后执行,用于更新缓存。

最后,通过这个缓存补充任务来检查数据库与缓存的数据一致性。如果发现不一致,应该以数据库的数据为准来修正缓存中的数据。 

因此,数据库与缓存之间的数据不一致窗口期取决于缓存写入的成功率,以及定时补偿任务的执行频率。

这种方式,可以最大限度地减少数据不一致的可能 性,并确保系统最终达到一致性状态。 

(5)综合场景。以上论述的场景是在仅考虑单一场景的理想情况下进行的 推演(实际上一个系统中不太可能只有数据写入而没有数据更新)。

然而,在现实中,系统通常涉及多种操作,包括数据的读取、写入、更新和删除。 

假设需要删除数据,即使缓存和数据库的删除操作都成功执行,仍然存在一种情况:在删除操作之后,并发的读请求可能会将旧数据重新写入缓存, 如图9所示。

这是因为,在多线程或分布式系统中,可能会有多个请求同时进行,其中一些请求可能在删除操作之后但缓存补偿任务执行之前到达。这种情况下,数据的一致性可能会受到影响,因为缓存中可能会短暂地存储过时的数据。

图9 综合场景

2. 减少不一致窗口的方案

虽然数据不一致性在某种程度上是不可避免的,但这并不意味着我们无法对其进行优化。

当优化的效果达到投入与产出比的最佳平衡时,实际上问题也就得到了有效解决。

整个优化思路如图10所示。

图10 减少不一致窗口的方案

具体步骤如下。

(1)本地事务中更新业务数据和持久化缓存补偿任务:在本地事务中,首 先更新数据库的业务数据。同时,在事务中持久化一个缓存补偿任务,这个任务 包含了更新缓存所需的信息。

(2)事务提交后更新分布式缓存:当数据库事务成功提交后,执行之前持 久化的缓存补偿任务。将最新的数据模型存放到分布式缓存中,确保缓存与数据 库的数据一致。

(3)数据版本控制:存入缓存的数据应该包含版本信息,以便检测数据的 新旧。可以选择数据的最新修改时间作为版本号,这样在读取数据时可以比较版 本号,确保使用的是最新数据。

(4)查询请求中的缓存补偿:当查询请求在缓存中找不到数据时,触发缓 存补偿机制。从数据库的主库中捞取最新的数据进行补偿,确保缓存中数据的准确性。

在处理修改和删除场景时,需要特别注意几个容易出错的地方,以确保数据 的一致性和准确性。 

(1)使用排他锁:在修改或删除数据时,应该对数据记录加上排他锁 (Exclusive Lock),以防止并发操作导致缓存中出现脏数据。

排他锁可以确保在锁释放之前,其他事务无法读取或修改相同的数据,从而避免了并发问题。 

(2)更新缓存前的再次读取:如果系统中没有排他锁的条件或者无法使用排 他锁,那么在更新缓存之前,应该从缓存中再次读取数据。

将这次读取到的数据与新更改的数据合并,然后再次放入缓存。这样做可以在一定程度上避免数据不一致,尽管可能会丢失本次修改的内容,但这是局部的 数据丢失,而不是数据错误。

(3)补偿任务使用主库:在执行缓存补偿任务时,一定要使用主数据库(主库)。 

如果系统设计中包含了主库和读库(从库),那么使用读库进行补偿可能会导致数据同步延迟,出现数据不一致的时间窗口。使用主库可以确保补偿任务获 取的是最新的、已经提交的数据,从而提高数据的一致性。


03
缓存是把“双刃剑”

缓存无疑是一项伟大的发明,它极大地提高了数据访问的速度。

然而,正如 所有强大的工具一样,缓存也有其固有的弱点。

在使用缓存时,我们必须注意以下几点。

强时效性要求的场景不适合使用缓存。由于数据库和缓存之间必然存在时间不一致的窗口,对于对数据时效性要求极高的场景,使用缓存可能会引入不可接受的数据延迟。只有那些读多写少,且能够容忍一定程度数据不一致性的场景, 才适合使用缓存。

数据不可丢失的场景不应使用缓存。缓存之所以能够提供快速的数据访问, 是因为它将数据存储在内存中。然而,内存存储的一个固有风险是数据可能会丢失。尽管可以采取各种补救措施,但只要使用缓存,数据丢失的可能性就无法完全消除。因此,只有当我们接受这种潜在风险时,才能安全地使用缓存。像用户余额这样的关键数据不应该被存储在缓存中,以避免数据丢失的风险。 

缓存不能替代数据库。缓存和数据库之间存在显著差异。除了缓存数据可能丢失,数据库还提供了事务处理和 ACID 特性,这是缓存所不具备的。一个典型的例子是幂等性控制,数据库事务的原子性可以确保一组操作要么全部成功,要么全部失败。而缓存无法提供这种保证,尤其是在缓存与数据库结合使用时,更难以保证操作的原子性。因此,试图仅通过缓存来实现幂等性控制是错误的。 

总结来说,缓存是一个强大的工具,但我们必须谨慎使用,确保它适用于当前的场景,并且不会引入无法接受的风险。


蚂蚁集团国际事业群的技术专家们结合了超过 10 年的互联网大厂工作经验,总结了一套技术人的工作方法,旨在帮助大家深入理解技术、架构和团队领导力的本质,从而获得持续成长的方法。

《P9工作法:夯实技术硬实力、架构力和领导力》一书便应运而生!



本书内容


全书分为三篇。

  • 第一篇重点讨论技术硬实力。内容涵盖如何编写优质代码、撰写 系统分析文档、进行领域模型设计、执行代码自测,以及识别典型的分布式技术 盲区等五个方面,以提升技术编码的硬实力。

    要成为团队的资深研发力量,不仅 需要具备足够强的硬实力,软实力同样不容忽视,两者都需要坚实。

    为此,我们还总结了技术人在日常项目协作中所需的软技能,包括沟通、协作、会议等。 

  • 第二篇重点讨论技术架构力。从技术人的成长路径来看,成为技术架构师是必 经之路。

    虽然许多技术人都渴望成为架构师,但架构的复杂性往往导致他们仅学 会了方法论(套路),而未能掌握其精髓。

    为此,我们根据实际工作经验,沉淀 并总结了一些实战技巧。

    本篇将从客观认知技术架构的复杂性、理解技术架构是 做取舍的本质、清晰把握技术架构的演进过程,以及技术架构师的系统性思维这 四个方面,深入剖析如何提升技术架构力。

  • 第三篇重点讨论技术领导力。常言道,“不想当将军的士兵不是好士兵”,在 技术人的职业发展道路上,大多数人都期望能成为 CTO。

    CTO 这一角色的要求更 为综合和全面,不仅需要具备技术硬实力和技术架构力,更重要的是,还要拥有 技术领导力——即通过技术的掌握和运用,带领技术团队助力业务实现突破并取 得商业成功。

    我们根据领导上百人规模团队的实际工作经验,提炼并总结了如何从团队绩效管理、技术目标的设定、技术组织的成长与发展三个方面打造一个持续发展的技术团队。

    一般而言,团队的状态往往反映了主管的状态,团队的上限 往往由团队的主管决定。因此,我们也根据实践经验,总结了技术主管的自我提升方法。

技术硬实力是技术人的立身之本,技术架构力让技术人能够脱颖而出,而技术领导力则使技术人能够协同作战,取得更大的成功。

实践证明,这“三力”是 每个技术人精进成长的必备技能。


小哈学Java
码龄9年,前某厂中台研发。专注于Java领域干货分享,不限于BAT面试, 算法,数据库,Spring Boot, 微服务,高并发, JVM, Docker容器,ELK相关知识,期待与您一同进步。
 最新文章