我最近有机会研究扩展一个网络应用程序。它有一个相当传统的架构:一个位于快速本地代码网络服务器(例如 apache2 或 nginx)前面的 CDN,后面是一个使用慢速解释语言(例如 Python 或 Ruby)编写的应用代码,该代码与数据库(例如 Postgres 或 MySQL)进行通信,并且还有一个额外的内存 KV 存储(例如 redis 或 memcached)作为缓存。正如你可能想象的那样,扩展这个应用程序最终涉及到很多 “增加更多缓存”。
正如我之前所谈到的,我喜欢从硬件利用率的角度思考性能问题 —— 为了完成给定的工作单位,我们消耗了哪些硬件资源?因此,这似乎是一个很好的机会来写出一个在我脑海中浮现的模型:当我们在例如 memcached
中缓存东西时,我们实际上在使用底层物理资源方面做了什么?
缓存点查找#
我开始思考这个分类法的原因之一是为了回答这个问题:为什么我们可能通过主键缓存数据库点查找?数据库已经有自己的内存缓存来存储最近访问的数据,那么我们为什么要在它们前面放一个 额外 的缓存呢?尽管如此,一项高度不科学的 Twitter 投票确认,即使是对于主键查找,将 memcache 或类似技术放在数据库前面也是一种相当普遍的做法。在我接下来的分类中,我将触及缓存的一般每个视角和目的,然后也会提到它告诉我们关于通过主键缓存查找的信息。
缓存的功能#
我已经确定了缓存服务器在传统网络应用架构中可能扮演的三种不同的 “基本” 目的。这些是 “纯粹” 的视角;任何缓存部署都涉及这些的某种组合。但我发现将它们稍微分开思考是有帮助的,并且如果我尝试向某个端点添加缓存,我希望知道我希望哪种。
用内存换取 CPU#
这可能是应用缓存的最经典概念。你有一些昂贵的操作,而不是每次需要结果时都执行它,你会减少执行频率,并将结果存储在缓存中。最终结果是你的系统使用更少的 CPU,但更多的内存,因为它需要存储缓存的结果。这个模式在网络架构中的经典例子是整个页面缓存,你将整个渲染的页面缓存到某个地方(无论是在你的应用框架中还是在前端网络服务器或 CDN 中)。缓存复杂数据库查询的输出,以便可以用单个点查找替代,也是另一个例子。
当我们缓存的计算是昂贵的,而输出相对较小时(以减少我们需要的内存),这种权衡最有意义。网络应用程序通常用相对较慢的语言编写,如 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 中缓存的记录数量是数据库本身的几倍!