我最近有机会在扩展一个 web 应用程序方面进行工作。它有一个相当传统的架构:一个 CDN 在一个快速的本地代码 web 服务器(例如 apache2 或 nginx)前面,后面是一个使用慢速解释语言(例如 Python 或 Ruby)编写的应用代码,它与一个数据库(例如 Postgres 或 MySQL)进行通信,并且还有一个额外的内存 KV 存储(例如 redis 或 memcached)作为缓存。正如你可能想象的那样,扩展这个应用程序最终涉及到很多 “增加更多缓存”。
正如我之前提到的,我喜欢从硬件利用率的角度思考性能问题 —— 为了完成给定的工作单位,我们消耗了哪些硬件资源?因此,这似乎是一个很好的机会来写出一个在我脑海中浮现的模型:当我们在 memcached
中缓存东西时,我们实际上在使用底层物理资源方面做了什么?
缓存点查找#
我开始思考这个分类法的原因之一是为了回答这个问题:为什么我们可能会通过主键缓存数据库点查找?数据库已经有自己的内存缓存来存储最近访问的数据,那么我们为什么要在它们前面放一个 额外 的缓存呢?尽管如此,一项高度不科学的 Twitter 投票确认,即使对于主键查找,将 memcache 或类似技术放在数据库前面也是一种相当普遍的做法。当我逐步讨论以下分类法时,我将触及每个视角和缓存的一般目的,然后也会提到它告诉我们关于通过主键缓存查找的内容。
缓存的功能#
我已经确定缓存服务器在传统 web 应用架构中可能扮演的三种不同的 “基本” 目的。这些是 “纯粹” 的视角;任何缓存部署都涉及到这些的某种组合。但我发现将它们稍微分开思考是有帮助的,并且在尝试为某个端点添加缓存时,了解我希望实现的目标。
用内存换取 CPU#
这可能是对应用缓存用途的最经典理解。你有一些昂贵的操作,而不是每次需要结果时都执行它,你会减少执行频率,并将结果存储在缓存中。最终结果是你的系统使用更少的 CPU,但更多的内存,因为它需要存储缓存的结果。这个模式在 web 架构中的经典例子是整个页面缓存,你在某处缓存整个渲染的页面(无论是在你的应用框架中还是在前端 web 服务器或 CDN 中)。缓存复杂数据库查询的输出,以便可以用单个点查找替代,也是另一个例子。
当我们缓存的计算成本高,而输出相对较小时(以减少所需的内存),这种权衡最有意义。Web 应用程序通常使用相对较慢的语言编写,如 Python 或 Ruby,这使得这种权衡更有价值。然而,更一般地说,只要我们在 CPU 受限而内存不受限时,这可能是一个合适的权衡。
值得注意的是,我们不需要一个单独的缓存服务器来进行这种权衡。如果我们将一些中间计算的结果存储在数据库本身中,我们可以进行类似的权衡;这种策略的版本通常被称为 “物化”。
这种缓存的观点可能是理解我们为什么可能缓存主键查找的最不具信息量的。缓存将存储与数据库大致相同的数据,因此不明显我们节省了什么计算。也就是说,我会注意到,内存缓存可能消耗的 CPU 少于通用数据库,以便服务点查找。在大多数情况下,数据库必须解析查询,检查表元数据,找到正确的索引,并遍历 B - 树(即使所有相关页面都在内存中),而像 memcached 这样的工具则有一个更简单的解析任务,接着基本上是一个单一的哈希表查找。因此,关于缓存点查找的一个观点是,我们在缓存服务器中用内存换取数据库服务器中的 CPU 使用。
当然,缓存节点通常是与数据库 不同 的硬件,这一点也很重要。这将我们带入内存缓存服务的第二个特征:
增加更多内存或 CPU#
传统的数据库架构相当单一:你有一台运行 MySQL 或 PostgreSQL 的机器,处理所有的读写流量。这限制了我们可以添加到服务器的 CPU 和 RAM 的数量(出于实际原因,我们通常无法或不愿意使用 金钱能买到的最大实例)。即使在分片架构中,添加更多分片通常也是一个耗时或重量级的操作。因此,在实践中,我们在将金钱转化为数据库中的更多 CPU 或更多内存的能力上受到限制。
然而,如果我们能将工作从数据库中移到缓存中,它实际上让我们 获得更多的总 CPU 使用或内存。重要的是要注意,以这种方式增加更多资源通常不会提高效率,按 “服务单个请求的成本” 来衡量。实际上,通常会降低效率,因为我们增加了在系统中不同节点之间移动数据的开销。如果我们在前面描述的情况下用 CPU 换取内存,我们可能实际上会降低每个请求的总成本。如果我们只是增加资源,使自己能够用金钱换取吞吐量,但通常不会使单个请求变得更便宜。
缓存在操作上通常也比数据库更容易扩展。通过单个缓存键查找进行分片在概念上非常简单,允许我们添加多个缓存节点。通过这种方式,缓存架构可以潜在地将可用于缓存的内存和处理这些请求的 CPU 乘以数据库服务器所能提供的数量。
在这里,我们还可以将缓存节点与数据库读取副本进行比较,后者是扩展数据库集群的另一种常见策略。读取副本也为系统增加了额外的 CPU 和 RAM,而不会实质性地改变每个单独查询的性能特征。然而,由于每个副本通常必须复制整个数据集,因此写入流量会消耗 每个 副本的一些 CPU 和 I/O 带宽,从而降低了扩展的有效性。将每个副本在缓存中保留的键空间的哪些部分进行分片也是困难或不可能的,这使得在分片缓存架构中几乎无法有效利用额外的 RAM。
这种观点很好地解释了缓存点查找的价值!即使缓存和数据库在服务单个查找时效率相同,添加缓存节点也使我们能够比扩展数据库集群更容易地水平扩展我们的内存和 CPU,从而增加内存中持有的记录总数和可用的总 QPS。
更高效的内存使用#
最后一点在某种程度上是最特定于领域的,但对我来说也是最有趣的,因为这是最令人惊讶的。
大多数数据库在 “页面” 级别实现它们的缓存,页面通常在 4k-16k 范围内。页面是磁盘上存储和访问的基本单元,数据库缓存位于 I/O 层之上并缓存整个页面。
这种架构的含义是,即使我们只想要一条记录,我们也必须缓存至少一整页的数据。如果频繁访问的记录的 “工作集” 远小于整个数据库集合,这可能导致可怕的内存效率!想象一个有 100 字节行的表,在最坏的情况下,我们可能会将整整 8k 的数据拉入内存 仅仅是为了缓存一行,这导致缓存我们的工作集所需的 RAM 增加了 80 倍!一般来说,我们的记录不太可能稀疏到达到那个极限,但开销仍然会相当显著。
相比之下,像 Redis 或 Memcache 这样的内存存储正确地缓存单个对象。缓存一个 100 字节的记录不可避免地会占用一些哈希表、分配器开销等的开销,但可能最多也就 2 倍的开销!因此,即使在相同的内存量下,基于对象的缓存通常可以缓存 更多 的对象,而不是数据库自己的页面缓存,这为我们提供了使用缓存而不仅仅依赖于数据库自身缓存的第三个优势。
当我第一次意识到这一点时,这个结果让我非常惊讶!本文档中概述的缓存的前两种用途对我来说相当直观,但我天真地从未预料到专用缓存会比数据库自己的缓存有如此巨大的效率提升。对我来说,这个原因是专用缓存服务器中缓存主键查找的一个极具说服力的案例,至少在某些操作模式下:你可能能够在每 MB 的 RAM 中缓存的记录数量是数据库本身的几倍!