分布式系统中的缓存失效模式解析
目录
为什么分布式系统中的缓存失效变得困难 基于时间的过期策略 (TTL) 旁路缓存模式 (Cache-Aside) 基于事件的缓存失效 版本化缓存键 多级缓存 事件驱动的缓存重建 选择合适的策略 总结
缓存是开发人员优化应用程序性能最强大的工具之一。通过将频繁访问的数据存储在尽可能靠近应用层的位置,缓存系统可以显著降低延迟,并减轻数据库或外部系统的负载。其结果是提升了系统的响应速度和整体可用性。
在小型单体应用中,缓存管理通常非常简单。服务从数据库中获取数据,将其存储在内存中,并通过直接从缓存中检索数据来处理后续请求。当数据发生变化时,只需使缓存键失效或更新即可。
然而,当系统演进为分布式架构时,情况就会变得复杂——而且不仅仅是稍微复杂一点。
现代云原生应用在负载均衡器后运行多个服务实例。每个实例都可以维护自己的本地缓存,系统可能还包含 Redis 或 Memcached 等共享分布式缓存。在这种环境下,维护缓存的一致性和连贯性变得困难得多。
如果一个节点更新了记录,而其他节点继续从缓存中提供陈旧(stale)的记录,用户可能会注意到跨请求的不一致行为。系统可能依然运行快速,但不再保证正确性。
这就是为什么在分布式基础设施中,缓存失效通常被认为是管理上最复杂的问题之一。
在本文中,我们将探讨几种管理缓存失效的实用模型。我们将重点介绍开发人员如何使用 Spring Boot、Redis 和 Apache Kafka 等工具在实际系统中应用这些策略。
为什么分布式系统中的缓存失效变得困难
为了更好地理解为什么分布式系统中的缓存失效如此复杂,我们先来看看现代系统通常是如何实现的。
大多数按照 12-factor 原则构建的云应用,都会运行同一个服务的多个实例,以确保可扩展性和容错能力。每个实例独立处理请求,并且这些应用通常维护一个本地内存缓存,以避免重复调用数据库或外部服务。
设想一个简单的服务,其任务是从数据库获取产品信息:
- 请求到达实例 A
- 从数据库加载产品数据
- 结果存储在本地缓存中
- 未来的请求将使用缓存中的数据进行处理
现在,假设另一个请求更新了产品信息。如果更新发生在实例 B 上,则只有该实例知道更新,因此它只会使自己的缓存记录失效。实例 A 可能仍然在内存中保留旧值。
结果是什么?当负载均衡器在各个实例间路由请求时,用户可能会根据处理请求的节点不同而收到不同的响应。
下图展示并解释了当前的情况:
[LOADING...]
当架构包含多个缓存层时,这个问题会变得更加复杂,例如:
- 应用实例内的内存缓存
- 跨服务共享的分布式缓存
- CDN 或边缘缓存
确保所有这些层保持一致并非易事。随着系统规模的扩大和分布式程度的提高,我们必须平衡相互冲突的优先级:数据时效性、数据一致性、系统性能和运维复杂性。
解决方案是什么?一个好的缓存失效策略应该在保持系统可扩展和弹性的同时,最大限度地减少陈旧数据。让我们看看如何做到这一点。
基于时间的过期策略 (TTL)
缓存失效最简单的策略之一是应用基于时间的过期策略,通常通过 TTL(生存时间)实现。
采用该策略时,系统允许缓存值在预定义的 TTL 后过期,而不是在数据发生变化时主动使缓存记录失效。这是一种简化方法,避免了服务实例之间进行分布式协调的需要。
例如,Spring Boot 应用中的 Redis 缓存可以配置默认过期时间。
条目在逻辑上会在十分钟后过期。Redis 会在访问时惰性删除过期键,此外还有一个后台进程会定期清理它们。这意味着过期键在 TTL 到期后可能会短暂占用内存。
我们可以使用 Spring 的缓存抽象来指定方法的结果应被缓存:
基于 TTL 的缓存的主要优势无疑是其简单性:当应用能够容忍短时间的数据陈旧时,它运行良好。
然而,仅靠 TTL 很少能解决所有问题:如果一条记录在被缓存后立即发生更改,系统可能会在整个 TTL 期间提供陈旧信息。
在处理高度动态的数据时,需要更主动、更有效的失效策略。
旁路缓存模式 (Cache-Aside)
应用层缓存中广泛使用的一种方法是“旁路缓存”(cache-aside)模型,也称为“惰性加载”(lazy loading)机制。在这种模型中,应用本身负责处理与缓存和数据库(或任何其他需要缓存的系统)的交互。
读取数据时,服务首先检查缓存。如果缓存中没有值,应用会从数据库获取该值并将其存储在缓存中,以供后续请求使用。
这个模型正是 Spring 的 @Cacheable 注解所实现的功能:
当数据发生变化时,应用会显式删除相应的缓存条目。
下一个请求将再次触发上述过程:从数据库读取并重新填充缓存。
旁路缓存模式在单实例应用中工作得非常好。然而,在分布式系统中,它只会在执行更新的节点上使缓存失效。除非实现额外的协调机制,否则其他节点可能会继续提供陈旧值。
基于事件的缓存失效
使分布式缓存失效的一种常用方法是使用事件驱动通信。
与其依赖各个节点自行使缓存失效,不如在数据发生变化时由服务发布事件。其他节点监听这些事件,并相应地使自己的缓存条目失效。
典型的工作流程如下:
- 记录被更新
- 服务发布失效事件
- 所有应用实例接收该事件
- 每个实例删除相应的缓存条目
为此,通常使用 Apache Kafka 或 RabbitMQ 等消息传递平台。对于较简单的系统,Redis Pub/Sub 可能就足够了。
让我们看一个简单示例,它使用 Redis 在每次产品更新时发布失效消息:
每个服务实例订阅失效频道并清除本地的缓存条目。
这种方法确保所有节点都有机会响应同一事件流,从而在整个系统中保持缓存同步。
主要的挑战在于管理可靠性问题,例如消息传递保证和重复事件。因此,有严格要求的企业系统通常依赖持久化消息平台,而不是简单的 Pub/Sub 模型。
版本化缓存键
另一种有效的策略是使用版本化缓存键。系统在数据发生变化时不是删除缓存条目,而是创建一个带有递增版本号的新缓存键。
例如:
当产品发生变化时,应用会增加版本号并将更新后的值写入新键;此时,用户会自动获取最新版本。
我们可以创建一个辅助方法来管理版本化键:
该技术消除了竞争条件,即当一个节点使缓存条目失效时,另一个节点正在向该条目写入新值。
版本化键在高吞吐量系统中特别有用,因为失效事件可能会以乱序到达。缺点是什么?键会随时间累积,导致缓存过载。因此,必须实施定期清理过程来删除过时且不再有用的版本。
多级缓存
许多现代系统将本地内存缓存与分布式缓存结合起来。这种多级方法既降低了延迟,又保持了必要的可扩展性。
设想一个典型的架构:
- 一个或多个应用实例
- 本地内存缓存(例如 Caffeine)
- 分布式缓存(例如 Redis)
- 数据库
本地缓存确保了极快的读取速度,而分布式缓存确保了数据在节点间共享。例如,我们可以配置应用使用 Caffeine 进行本地缓存,使用 Redis 进行分布式内存存储。
在这种设置中,失效事件必须清除所有缓存层。虽然这增加了复杂性,但它允许我们在高负载下显著减少远程缓存调用的次数并改善响应时间。需要注意的是,本地缓存的大小应根据实例数量进行调整。如果有 10 个实例,每个实例缓存 10,000 个条目,则整个集群的内存消耗为 100,000 个条目。请务必谨慎设置!
事件驱动的缓存重建
在一些架构策略中(特别是受 CQRS 启发的策略),缓存不仅仅是失效,而是根据领域事件进行重建。
在这种情况下,系统维护从事件流派生出的读取模型,而不是存储任意的缓存条目。
每当实体发生变化时,系统都会发出以下类型的事件:
- ProductCreatedEvent(产品已创建)
- ProductUpdatedEvent(产品已更新)
- InventoryAdjustedEvent(库存已调整)
消费者订阅这些事件并更新针对读取优化的数据结构。
Spring Boot 应用中的 Kafka 监听器可能如下所示:
应用此模式将缓存转化为事件流的投影,而不是临时存储层。
这是一个强大的模式,但它需要成熟的事件基础设施和仔细的设计,以确保最终结果的一致性。
选择合适的策略
那么什么是最好的方法?答案是没有。分布式系统中的缓存失效没有单一的最优方案。
不同的应用对数据陈旧度、运维复杂性和基础设施弹性的容忍度不同。此外,最佳策略取决于具体的数据和业务流程。每个案例都是独特的,必须区别对待。
在许多实际系统中,混合策略无疑是最佳方案。
一个起始组合可以是:
- 以 TTL 过期作为安全网
- 以旁路缓存加载实现简单性
- 以事件驱动失效实现更快的同步
具有高吞吐量要求的系统可以采用版本化键或事件驱动的读取模式,以确保失效模型的整体有效性。
总结
缓存仍然是提高分布式系统性能最有效的方法之一。当实现得当且符合业务需求时,它可以显著减轻数据库或外部服务的负载,并极大地改善响应时间和整体系统延迟。
然而,它也有缺点。分布式缓存引入了关于一致性和协调性的新挑战。如果没有适当的失效策略,缓存可能会提供陈旧数据,并在无人察觉的情况下损害系统的正确性。
现代 Java 生态系统提供了出色的工具来实现稳健、可靠的缓存解决方案。Spring Boot 简化了应用内的缓存集成(无论是本地还是分布式)。Redis 和 Apache Kafka 等技术则实现了可扩展且具有弹性的分布式协调。
通过结合 TTL 过期、旁路缓存加载、事件驱动失效和多级缓存等模型,你可以构建出即使在扩展时也能保持快速和一致的系统。
总之,缓存不是一个简单的启用或禁用功能。它是一个需要集成并管理在生态系统中的架构组件,应与应用程序协同设计,以确保一致性和可靠性。
如果你想查看本文中的示例,欢迎访问 代码仓库。