分布式缓存基础教程
缓存是增强分布式应用程序性能和可扩展性的关键技术。这篇文章“掌握分布式应用程序中的缓存”全面概述了高级缓存技术和策略。
在大规模分布式应用程序中缓存很难,团队经常会经历一个迭代和实验的过程来调整他们的缓存策略和实现,直到希望在某个时候,他们能够将其调整到某种合理且半最佳的状态。
在本文中,我想揭开并澄清一些经常被忽视或误解的缓存方面的问题。
希望阅读本文后,您能够更清楚地了解什么是缓存、缓存的主要方法、需要注意的事项以及各种缓存技术在实际用例中的应用。
什么是缓存?
简而言之,缓存是将数据存储在临时介质中的行为,与从原始存储(记录系统)中检索数据相比,这种临时介质更便宜、更快或更易于检索。
订单管理系统需要从库存系统中检索产品信息。假设库存系统性能不佳。每次收到请求时,它都必须去中央数据库获取产品信息。该数据库速度很慢,无法支持过多的并行请求。
为了提高性能并减轻库存数据库的压力,我们引入了一个缓存层,现在我们将在其中存储相同的产品信息。只是现在,我们不再使用笨重的数据库来访问库存系统,而是先访问缓存,如果数据在缓存中,我们就从那里获取数据。
我们在这里所做的是引入一种临时存储介质(缓存),以提高性能并优化原始清单数据库的资源使用。
我们软件开发领域的大多数人在听到 "缓存 "一词时都会有非常具体的联想。我们通常会将其与分布式缓存产品联系起来,如 Redis、Memcached 或 EHCache。在其他时候,我们会想到浏览器缓存、数据库缓存、操作系统缓存,甚至硬件缓存。这正是问题的关键所在。
缓存的概念并不局限于计算机科学领域的某一特定产品或领域。从最广泛的意义上讲,"缓存 "实际上是我们从某个记录系统中复制数据的任何类型的临时介质。我们之所以这样做,是因为将数据存储在临时介质中在某种程度上是有利的。
如果我们看一下前面的订单管理和库存系统的例子,缓存层理论上可以是很多东西:
- 分布式缓存产品(例如 Redis)
- 另一个拥有自己数据库的微服务
- 实际库存管理系统中的内存存储
以上所有方式都符合缓存的标准,尽管每种方式的实现都不尽相同。
简单地说,以上所有这些都可以成为缓存。缓存,作为一个概念,可以而且事实上已经在计算机系统堆栈的各个层面和许多数字领域中实现了。
在我们继续之前,了解有关缓存主题的不同术语非常重要。
- 记录系统:存储数据的永久存储。很可能是数据库。也称为真实来源系统。
- 缓存未命中:当应用程序查询缓存但该特定记录在缓存中不存在时。
- 缓存命中:当记录确实存在于缓存中并按原样返回时。
- 缓存污染:当缓存中填充了未使用或未查询的值时。
- 缓存驱逐:从缓存中删除条目以释放内存的过程。
- 数据新鲜度:缓存中的记录与底层记录系统的同步程度。
- 缓存过期:作为驱逐过程的一部分或作为缓存失效的一部分,基于时间删除缓存记录,我们将在下面讨论。
缓存类型:
- 内存缓存 (RAM):将经常访问的数据存储在内存中,以实现闪电般的快速访问。
- 磁盘缓存:将更大的数据集存储在设备的本地存储器中,并在应用程序会话期间持久保存。
- 网络缓存:将数据存储在远程服务器或 CDN 上,对于不经常变化的动态内容很有用。
缓存失效策略:
- 基于时间的失效:根据预定义的生存时间 (TTL) 逐出缓存数据。
- 基于访问的无效:根据访问模式或使用频率逐出缓存数据。
- 基于令牌的失效:使用令牌控制对缓存数据的访问,增强安全性。
有五种主要的缓存模式,它们都与缓存的读取、写入和与底层记录系统同步的方式有关。
1、Cache-Aside 缓存策略
Cache-Aside 缓存策略可能是最流行的,也是大多数软件工程师最熟悉的。这种缓存方法将缓存写入和读取的控制权完全交给应用程序。在这里,应用程序既控制何时从数据库或缓存读取,又控制何时写入。
下面通过示例来详细说明其工作原理。
想象一下,您的应用程序收到用户的登录请求,并随后获取用户的邮寄地址。
- 应用程序首先检查用户的地址是否存在于缓存中。
- 如果该用户没有地址条目,应用程序将从数据库中检索数据。
- 然而,如果信息存在于缓存中,则会立即检索该数据,从而节省我们访问数据库的时间。
- 当获取到新的信息之后,应用程序也会将该数据写入缓存中。
在步骤 2 中,如果缓存中没有该特定项目的条目 — — 这通常被称为“缓存未命中”。
优点
- 易于实现
- 控制权完全掌握在应用程序手中
- 使用最少的内存(至少在理论上),因为仅在需要时才获取缓存项(延迟加载)
缺点
- 由于必须从较慢的存储中获取数据,因此缓存未命中延迟较高。缓存未命中次数过多,性能可能会受到影响。
- 应用程序逻辑变得更加复杂(尽管整体思路很容易实现)
何时使用
- 当您想要完全控制缓存的填充方式时。
- 当您没有可以管理数据库读/写的缓存产品时。
- 当缓存的访问模式不规则时
2、直写缓存Write-Through
直写缓存可确保缓存与底层持久数据存储之间的一致性。换句话说,当发生写入时,它会在同一事务中传播到缓存和数据库。
举个例子来说明一下:
- 财务应用程序收到更新用户帐户余额的请求。
- 用户账户余额在数据库和缓存中都存在。
- 数据库和缓存都在同一事务中使用新值进行更新。
- 另一个请求来了,这次是读取用户的余额。我们首先查看缓存并使用该值。由于缓存具有最新的值,因此不必担心该值可能与底层数据库不同步。
请注意,步骤 3可以通过应用程序逻辑完成。但是,通常实际的缓存产品将承担这一责任。例如,如果您使用的是 EHCache 或 Infinispan,则应用程序将更新 Redis 缓存,然后可以依次配置 Redis 缓存以更新数据库。
优点
- 确保缓存和底层数据存储之间的一致性
缺点
- 事务复杂性,因为我们现在需要某种两阶段提交逻辑来确保缓存和数据库都更新(如果不受缓存控制)
- 操作复杂性,如果上述之一失败,我们需要优雅地处理用户体验。
- 写入变得更慢,因为我们现在需要更新两个地方(缓存和数据存储),而不是只更新一个地方(数据存储)
何时使用
直写式缓存非常适合需要强数据一致性且无法提供过时数据的应用程序。它通常用于数据在写入后必须立即保持准确和最新的环境中。
3、写式缓存 Write-Around
此策略会填充底层存储,但不填充缓存本身。换句话说,写入会绕过缓存,仅写入底层存储。此技术与Cache-Aside之间有一些重叠。
不同之处在于, Cache-Aside的重点是读取和延迟加载 — 仅在首次从数据存储中读取数据时才将数据填充到缓存中。而 Write-Around 缓存的重点是写入性能。当数据经常被写入但不经常被读取时,这种技术通常用于避免缓存污染。
优点
- 减少缓存污染,因为缓存不会在每次写入时填充
缺点
- 如果某些记录经常被读取,性能就会受到影响,因此最好主动将这些记录加载到缓存中,以防止第一次访问数据库。
何时使用
这通常在写入量很大但读取量明显较低的情况下使用。
4、回写式(Write-Behind)缓存
写入操作首先填充缓存,然后写入数据存储区。这里的关键是写入数据存储区是异步进行的 — 因此无需两阶段事务提交。
Write-Behind 缓存策略通常由缓存产品处理。如果缓存产品具有此机制,则应用程序将写入缓存,然后缓存产品将负责将更改发送到数据库。如果缓存产品不支持此功能,则应用程序本身将触发对数据库的异步更新。
优点
- 写入速度更快,因为系统只需在初始事务中写入缓存。数据库将在稍后更新。
- 如果流程由缓存产品处理,则应用程序逻辑就不会那么复杂。
缺点
- 由于数据库和缓存在数据库收到新的更改之前将不同步,因此可能会出现不一致的情况。
- 当缓存最终尝试更新数据库时,可能会出现错误。如果发生这种情况,则需要更复杂的机制来确保数据库收到最新的数据。
何时使用
当写入性能至关重要时,可以使用后写缓存,数据库中的数据暂时与缓存略微不同步是可以接受的。它适用于写入量大但一致性要求不太严格的应用程序。可以使用它的一个例子是 CDN(内容分发网络),用于快速更新缓存内容,然后将其同步到记录系统。
5、通读read-through
从某种意义上说,直读缓存类似于缓存旁路模式,因为在这两种模式下,我们首先在缓存中查找记录。如果发生缓存未命中,我们会在数据库中查找。但是,虽然缓存旁路模式将查询缓存和数据库的责任放在应用程序上,但对于直读,这个责任就落在了缓存产品上(如果它有这种机制)
优点
- 简单——所有逻辑都封装在缓存应用程序中
缺点
- 缓存未命中时从数据库读取数据时可能会出现延迟。数据更新需要复杂的失效机制。
何时使用
当您想要简化访问数据的代码时,可以使用读取缓存。此外,当您想确保缓存始终包含来自数据存储的最新数据时,可以使用读取缓存。对于读取数据比写入数据更频繁的应用程序来说,它很有用。但这里的关键点是,您的缓存产品应该能够通过配置或本机从底层记录系统执行这些读取。
最难点:缓存失效
现在我们了解了填充缓存的不同方法,我们还需要了解如何使其与底层记录系统保持同步。
说到缓存失效,主要有两种方法:基于时间的方法和基于事件的方法。基于时间的失效方法可以通过大多数缓存产品中提供的生存时间 (TTL) 设置来控制。基于事件的方法需要应用程序或其他程序将新记录发送到缓存。
数据缓存的问题在于,它几乎总是与底层数据存储(记录系统)至少略有不同步。换句话说,它会变得陈旧。为了使缓存尽可能与记录系统保持同步,我们需要实施某种缓存失效策略。
换句话说,我们需要确保缓存内的数据“新鲜度”。
缓存失效会导致从记录系统检索新记录并将其放入缓存中。因此,了解缓存失效与其与我们上面讨论的缓存策略之间的关系非常重要。
缓存策略与如何从缓存中加载和检索数据有关。另一方面,缓存失效则与记录系统和缓存之间的数据一致性和新鲜度有关。
因此,这两个概念之间存在一些重叠,对于某些缓存策略,失效会比其他策略更简单。例如,使用缓存写入方法,缓存会在每次写入时更新,因此您无需额外实现这一点。但是,删除可能不会反映出来,因此可能需要明确处理这些删除的应用程序逻辑。
有两种方法可以使缓存条目无效:
1、事件驱动
使用事件驱动方法,您的应用程序会在底层记录存储发生更改时通知缓存。每次记录发生变化时,您都会触发缓存通知 — 无论是同步还是异步。
这可以通过应用程序来完成,您的代码将负责保持缓存最新。或者 — 对于某些缓存产品,可能存在发布/订阅功能,缓存产品可以订阅这些类型的通知。在这种情况下,应用程序需要做的工作可能会减少。但是,仍然需要某些东西来生成这些通知事件。
2、基于时间
对于基于时间的方法,所有缓存记录都会有一个与之关联的 TTL(生存时间)。记录的 TTL 到期后,该缓存记录将被删除。这通常由缓存产品控制。
缓存驱逐策略
缓存驱逐与缓存失效类似,因为在这两种情况下,我们都会删除旧的缓存记录。然而,两者的区别在于,当缓存已满且无法容纳更多记录时,需要驱逐缓存。
请记住,缓存的目的是存储最常访问的记录的子集。它不是复制整个事实来源系统。因此,缓存的大小通常比存储在数据库/事实来源/记录系统中的数据大小小几个数量级。
因此,我们需要一种可以“驱逐”或换句话说删除记录的机制。
同时,我们需要确保从应用程序最不可能需要的记录开始 - 否则缓存的全部意义就毫无意义了。
为了确保以最佳方式驱逐记录,我们可以利用多种驱逐策略:
1、最近最少使用 (LRU)
通过这种方法,我们可以删除一段时间内未使用的记录。
何时使用:在数据被访问的可能性随着上次访问记录的时间而降低的情况下,这种方法非常有效。这种方法非常适合通用缓存,因为访问的近期性是未来访问的有力指标。
何时不使用:对于数据访问模式与新近度不相关的工作负载来说并不理想。
2、先进先出 (FIFO)
逐出那些在其他记录之前保存在缓存中的记录。
何时使用:适用于数据使用年限比访问频率或新近度更重要的缓存。适合缓存具有可预测寿命的数据。
何时不使用:对于仍可能频繁访问旧数据的工作负载来说,这不是最理想的。
3、最不常用(LFU)
逐出那些不经常使用/访问的记录。
何时使用:最适合需要长期保留频繁访问的数据的情况。适用于具有稳定访问模式的应用程序。
何时不使用:在访问模式可能发生显著变化的环境中效果较差。不常访问的项目可能会污染缓存。
4、生存时间 (TTL)
根据预定的离开期限进行驱逐。
何时使用:适用于在一定时期后变得过时或陈旧的数据。
何时不使用:不适用于其有效性不会随着时间的推移而自然过期并且需要根据其他因素无限期地保留在缓存中的数据。
5、随机替换
随机驱逐记录。
何时使用:可用于复杂跟踪机制的成本超过其收益的情况,或访问模式不可预测且其他驱逐策略不太适合的情况。
何时不使用:在大多数实际场景中,当访问模式或多或少可预测时,通常效率低于其他策略。