本文探讨了各种缓存策略,包括Cache-Aside、Read-Through、Write-Through、Write-Behind和客户端缓存,以及分布式缓存。每种策略在延迟、复杂性和数据一致性方面都有不同的权衡。
译自:6 Caching Strategies: Latency vs. Complexity Tradeoffs
作者:Pekka Enberg
编者注:本文节选自 Manning 出版的书籍《延迟》。本书将帮助你更好地诊断延迟问题,并掌握目前主要属于“部落知识”的低延迟技术。你可以从 ScyllaDB 免费下载三个章节。并可以在即将到来的“构建低延迟应用程序大师班”(免费和虚拟)中向作者 Pekka Enberg 学习更多内容。
在向应用程序添加缓存时,你必须首先考虑你的缓存策略,该策略决定了如何从缓存和底层后备存储(如数据库或服务)进行读取和写入。
从宏观上讲,你需要决定在缓存未命中时,缓存是被动的还是主动的。换句话说,当你的应用程序从缓存中查找一个值,但该值不存在或已过期时,缓存策略会规定是由你的应用程序还是缓存从后备存储中检索该值。与往常一样,不同的缓存策略在延迟和复杂性方面有不同的权衡,所以让我们直接进入正题。
Cache-Aside 缓存
Cache-Aside 缓存可能是你将遇到的最典型的缓存策略。当缓存命中时,数据访问延迟主要由通信延迟决定,通信延迟通常很小,因为你可以在缓存服务器上,甚至在你的应用程序内存空间中找到附近的缓存。
但是,当使用 Cache-Aside 缓存时发生缓存未命中时,缓存是被应用程序更新的被动存储。也就是说,缓存只报告未命中,应用程序负责从后备存储中获取数据并更新缓存。
图 1 显示了 Cache-Aside 缓存的实际示例。应用程序通过缓存键从缓存中查找一个值,该缓存键决定了应用程序感兴趣的数据。
如果缓存中存在该键,则缓存返回与该键关联的值,应用程序可以使用该值。但是,如果该键在缓存中不存在或已过期,则会发生缓存未命中,应用程序必须处理该未命中。应用程序从后备存储中查询该值,并将该值存储在缓存中。
假设你正在缓存用户信息,并使用用户 ID 作为查找键。在这种情况下,应用程序通过用户 ID 执行查询,以从数据库中读取用户信息。然后,从数据库返回的用户信息被转换为你可以存储在缓存中的格式。然后,缓存会使用用户 ID 作为缓存键,并将信息作为值进行更新。例如,执行这种缓存的典型方法是将从数据库返回的用户信息转换为 JSON,并将其存储在缓存中。
图 1. 使用 Cache-Aside 缓存时,客户端首先从缓存中查找键。在缓存未命中时,客户端查询数据库并更新缓存。
Cache-Aside 缓存很受欢迎,因为它很容易设置缓存服务器(如 Redis),并使用它来缓存数据库查询和服务响应。使用 Cache-Aside 缓存时,缓存服务器是被动的,不需要知道你使用哪个数据库,或者如何将结果映射到缓存。你的应用程序会完成所有的缓存管理和数据转换。
在许多情况下,Cache-Aside 缓存是降低应用程序延迟的一种简单而有效的方法。你可以通过在靠近应用程序的缓存服务器中存储最相关的信息来隐藏数据库访问延迟。
但是,如果你有数据一致性或新鲜度要求,Cache-Aside 缓存也可能存在问题。例如,如果你有多个并发读取器正在缓存中查找一个键,你需要在应用程序中协调如何处理并发缓存未命中;否则,你可能会最终进行多次数据库访问和缓存更新,这可能会导致后续的缓存查找返回不同的值。
但是,使用 Cache-Aside 缓存,你会失去事务支持,因为缓存和数据库互不了解,应用程序负责协调对数据的更新。最后,Cache-Aside 缓存可能会有显著的尾部延迟,因为某些缓存查找在缓存未命中时会遇到数据库读取延迟。也就是说,虽然在缓存命中的情况下,访问延迟很快,因为它来自附近的缓存服务器;但遇到缓存未命中的缓存查找的速度与数据库访问的速度一样快。这就是为什么即使你正在进行缓存,到数据库的地理延迟仍然非常重要,因为在许多场景中,尾部延迟发生的频率惊人地高。
Read-Through 缓存
Read-Through 缓存是一种策略,与 Cache-Aside 缓存不同,当发生缓存未命中时,缓存是一个主动组件。当发生缓存未命中时,Read-Through 缓存会自动尝试从后备存储中读取该键的值。延迟与 Cache-Aside 缓存类似,尽管后备存储检索延迟是从缓存到后备存储,而不是从应用程序到后备存储,这可能更小,具体取决于你的部署架构。
图 2 显示了一个 Read-Through 缓存的实际示例。应用程序对一个键执行缓存查找,如果发生缓存未命中,缓存会对数据库执行读取操作,以获取该键的值。然后,缓存更新自身并将该值返回给应用程序。从应用程序的角度来看,缓存未命中是透明的,因为无论是否发生缓存未命中,缓存总是返回一个键(如果存在)。
图 2. 使用 Read-Through 缓存时,客户端从缓存中查找一个键。与 Cache-Aside 缓存不同,缓存查询数据库并在缓存未命中时更新自身。
Read-Through 缓存的实现更加复杂,因为缓存需要能够读取后备存储,但它还需要将数据库结果转换为缓存的格式。例如,如果后备存储是一个 SQL 数据库服务器,你需要将查询结果转换为 JSON 或类似的格式,以便将结果存储在缓存中。因此,缓存与你的应用程序逻辑的耦合度更高,因为它需要更多地了解你的数据模型和格式。
但是,由于缓存通过 Read-Through 缓存协调更新和数据库读取,因此它可以为应用程序提供事务保证,并确保并发缓存未命中时的一致性。此外,尽管从应用程序集成的角度来看,Read-Through 缓存更加复杂,但它确实消除了应用程序中的缓存管理复杂性。
当然,尾部延迟的相同警告适用于 Read-Through 缓存,就像它们适用于 Cache-Aside 缓存一样。一个例外:作为主动组件,Read-Through 缓存可以通过例如提前刷新缓存来更好地隐藏延迟。在这里,缓存在值过期之前异步更新缓存,因此完全向应用程序隐藏了数据库访问延迟(当值在缓存中时)。
Write-Through 缓存
Cache-Aside 和 Read-Through 缓存是围绕缓存读取的策略,但有时,你也希望缓存支持写入。在这种情况下,缓存提供了一个接口,用于更新应用程序可以调用的键的值。在 Cache-Aside 缓存的情况下,应用程序是唯一与后备存储通信的应用程序,因此会更新缓存。但是,对于 Read-Through 缓存,有两种处理写入的选项:Write-Through 和 Write-Behind 缓存。
Write-Through 缓存是一种策略,其中对缓存的更新会立即传播到后备存储。每当缓存更新时,缓存会同步地使用缓存的值更新后备存储。Write-Through 缓存的写入延迟主要由对后备存储的写入延迟决定,这可能非常显著。如图 3 所示,应用程序使用缓存提供的接口,使用键值对更新缓存。缓存使用新值更新其状态,使用新值更新数据库,并等待数据库提交更新,直到向应用程序确认缓存更新。
图 3. 使用 Write-Through 缓存时,客户端将键值对写入缓存。缓存立即更新缓存和数据库。
Write-Through 缓存旨在使缓存和后备存储保持同步。但是,对于非事务性缓存,在出现错误时,缓存和后备存储可能会失去同步。例如,如果写入缓存成功,但写入后备存储失败,则两者将失去同步。当然,Write-Through 缓存可以通过牺牲一些延迟来提供事务保证,以确保缓存和数据库要么都已更新,要么都没有更新。
与 Read-Through 缓存一样,Write-Through 缓存假定缓存可以连接到数据库并将缓存值转换为数据库查询。例如,如果你正在缓存用户数据,其中用户 ID 用作键,JSON 文档表示值,则缓存必须能够将用户信息 JSON 表示形式转换为数据库更新。
使用 Write-Through 缓存,最简单的解决方案通常是将 JSON 存储在数据库中。Write-Through 缓存的主要缺点是与缓存更新相关的延迟,这本质上等同于数据库提交延迟。这可能非常显著。
Write-Behind 缓存
与 Write-Through 缓存不同,Write-Behind 缓存立即更新缓存,后者会延迟数据库更新。换句话说,使用 Write-Behind 缓存,缓可以在更新后备存储之前接受多个更新,如图 4 所示,其中缓在更新数据库之前接受了三个缓存更新。
图 4. 使用 Write-Behind 缓存时,客户端将键值对写入缓存。但是,与 Write-Through 缓存不同,缓存更新缓存,但会延迟数据库更新。相反,Write-Behind 缓存会将多个缓存更新批处理到单个数据库更新中。
Write-Behind 缓存的写入延迟低于 Write-Through 缓存,因为后备存储是异步更新的。也就是说,缓存可以立即向应用程序确认写入,从而产生低延迟写入,然后在后台执行后备存储更新。但是,Write-Behind 缓存的缺点是你失去了事务支持,因为缓存不再能保证缓存和数据库是同步的。此外,Write-Behind 缓存会降低持久性,即你不丢失数据的保证。如果缓在将更新刷新到后备存储之前崩溃,你可能会丢失更新。
客户端缓存
客户端缓存策略意味着在应用程序内的客户端层拥有缓存。虽然像 Redis 这样的缓存服务器使用内存缓存,但应用程序必须通过网络使用 Redis 协议才能访问缓存。
如果应用程序是在数据中心运行的服务,则缓存服务器非常适合缓存,因为数据中心内的网络往返速度很快,并且缓存复杂性在于缓存本身。但是,最后一英里的延迟仍然是设备上用户体验的一个重要因素,这就是为什么客户端缓存如此有利可图的原因。你可以选择在应用程序中拥有缓存,而不是使用缓存服务器。
对于客户端缓存,从延迟的角度来看,Read-Through 和 Write-Behind 缓存的组合是最佳的,因为读取和写入都很快。当然,你的客户端通常无法直接与数据库连接,而是通过代理或 API 服务器间接访问数据库。由于数据库访问间接层和延迟,客户端缓存也使得事务难以保证。
对于许多需要低延迟客户端缓存的应用程序来说,本地优先的复制方法可能更实用。但对于简单的读取缓存,客户端缓存可能是实现低延迟的一个好解决方案。当然,客户端缓存也有一个权衡:它会增加应用程序的内存消耗,因为你需要空间来存储缓存。
分布式缓存
到目前为止,我们只讨论了缓存,就好像存在单个缓存实例一样。例如,你使用应用程序内缓存或单个 Redis 服务器来缓存来自 PostgreSQL 数据库的查询。但是,你通常需要数据的多个副本,以减少跨各种位置的地理延迟或横向扩展以适应你的工作负载。
对于这种分布式缓存,你有许多缓存实例,它们可以独立工作,也可以在缓存集群中工作。分布式缓存带来与复制和分区相关的复杂性和注意事项。使用分布式缓存,你不想将所有缓存的数据都放在每个实例上,而是希望在节点之间对缓存的数据进行分区。同样,你可以在多个实例上复制分区,以实现高可用性和降低访问延迟。
总的来说,分布式缓存是缓存、分区和复制的优点和问题的交集,所以如果你要使用它,请注意。
要继续阅读,请从 ScyllaDB 免费下载“延迟”的三个章节摘录 或从 Manning 购买完整书籍。此外,你还可以从即将到来的“构建低延迟应用程序大师班”(免费和虚拟)中向作者 Pekka Enberg 学习。



