使用 Memcached 的分布式缓存

作者:Brad Fitzpatrick

Memcached 是一个高性能的分布式缓存系统。虽然它与应用程序无关,但最常见的用途是通过减轻数据库负载来加速动态 Web 应用程序。Memcached 被用于 LiveJournal、Slashdot、Wikipedia 和其他高流量站点。

动机

在过去的八年里,我一直在创建跨多台服务器的大型交互式数据库支持的网站。大约 70 台机器目前运行着 LiveJournal.com,这是一个拥有 250 万账户的博客和社交网络系统。除了典型的博客和朋友/兴趣/个人资料声明功能外,LiveJournal 还提供论坛、投票、每用户新闻聚合器、通过电话发布的音频帖子以及其他有助于人们聚集在一起的功能。

优化动态网站的速度始终是一个挑战,LiveJournal 也不例外。这项任务更具挑战性,因为系统中几乎任何内容项都可以具有关联的安全级别,并被聚合到许多不同的视图中。从之前使用动态、上下文感知内容的项目中,我从 LiveJournal 开发之初就知道预生成静态页面不是一种可行的优化技术。这是不可能的,因为组成对象的缓存性和生命周期差异很大,因此您会做出很多牺牲,并浪费大量时间预计算比请求频率更高的页面。

这并不是说缓存是一件坏事。恰恰相反,计算机性能的核心因素之一是其内存层次结构的速度、大小和深度。缓存绝对是必要的,但前提是您在正确的介质上以正确的粒度进行缓存。我发现最好分别缓存页面上的每个对象,而不是将整个页面作为一个整体进行缓存。这样,您就不会因为冗余缓存出现在多个页面上的对象和模板元素而浪费空间。

但归根结底,这一切都是一系列权衡。因为处理器不断变得更快,所以我发现最好消耗 CPU 周期而不是等待磁盘。现代磁盘不断增长且更便宜,但它们并没有变得更快。考虑到它们的速度有多慢且容易崩溃,我尽量避免使用磁盘。LiveJournal 的 Web 节点都是无盘的,通过通用的但冗余的 NFS 根映像进行网络启动。这不仅更便宜,而且需要的维护也明显减少。

当然,我们的数据库服务器需要磁盘,但在那里我们坚持使用带有快速 RAID 设置的快速磁盘。我们实际上有十个不同的数据库集群,每个集群都有两台或多台机器。九个集群是用户集群,包含特定于在它们之间分区的用户的数据。一个是我们的全局集群,包含非用户数据和将用户映射到其用户集群的表。独立集群的基本原理是分散写入。另一种选择是拥有一个包含数百个从属服务器的大型集群。这种单体集群的困难在于它只分散读取。随着每个新从属服务器的添加,回报递减的问题就会出现,并且越来越多的从属服务器被保持最新状态所需的写入所消耗。

此时,您可以看到 LiveJournal 的后端理念

  1. 避免磁盘:它们很麻烦。必要时,仅使用快速、冗余的 I/O 系统。

  2. 横向扩展,而不是纵向扩展:许多小型机器,而不是大型机器。

我对小型机器的定义更多的是关于可重用性而不是成本。我想要一台只要值得它的空间和热量输出就可以继续使用的机器。我不想通过每六个月扔掉机器并用更大的机器替换它们来进行扩展。

在哪里缓存?

在 Memcached 之前,我们的 Web 节点无条件地访问我们的数据库。这行得通,但它并没有达到最佳状态。我意识到,即使有 4G 或 8G 的内存,我们的数据库服务器缓存也是有限的,无论是原始内存大小还是 32 位机器上运行的数据库服务器进程可用的地址空间都有限制。是的,我可以将我们所有的数据库都替换为具有更多内存的 64 位机器,但请记住,我固执且节俭。

我想在我们的 Web 节点上缓存更多内容。不幸的是,因为我们使用的是 mod_perl 1.x,所以缓存很麻烦。每个进程,因此每个 Web 请求,都在自己的地址空间中,并且无法与其他进程共享数据。30-50 个进程中的每一个都可以自行缓存,但这样做会很浪费。

System V 共享内存有太多奇怪的限制,而且不可移植。它也只能在一台机器上工作,而不能跨 40 多个 Web 节点工作。这些问题反映了我认为大多数缓存解决方案的主要问题。即使我们的应用程序平台是多线程的,并且数据可以在进程之间轻松共享,我们仍然只能在一台机器上缓存。我不希望所有 40 多个机器独立缓存并重复信息。

Memcached 的诞生

有一天,厌倦了在 mod_perl 应用程序中高效缓存的痛苦,我开始做梦。我意识到我们在网络周围有很多空闲内存可用,我想以某种方式使用它。如果您是一位在 CPAN 中漫步的 Perl 程序员,您会发现大量的 Cache::* 模块。几乎所有模块的接口都是字典。如果您有幸错过了计算机科学 101,那么字典就是将键映射到值的抽象数据类型的名称。Perl 人称之为关联数组或哈希,是哈希表的简称。哈希表是一种特定类型的数据结构,它提供字典接口。

我想要一个全局哈希表,所有机器上的所有 Web 进程都可以同时访问,立即看到彼此的更改。我将使用它作为我的缓存。并且因为内存很便宜,网络速度很快,而且我不相信服务器会一直存活,所以我希望它分布在我们所有的机器上。我快速搜索了一下,一无所获,然后开始构建它。

每个 Memcached 服务器实例都在用户定义的 IP 和端口上侦听。基本思想是您在网络中的任何有空闲内存的地方都运行 Memcached 实例,并且您的应用程序都使用它们。如果机器是 32 位的并且总内存超过内核可供单个进程使用的内存,那么在同一台机器上运行多个实例甚至很有用。例如,当我们在横向扩展而不是纵向扩展方面吸取教训时,我们购买了一台非常昂贵的机器,它恰好有 12GB 的内存。如今,我们将其用于许多杂项任务,其中一项是运行五个 2GB Memcached 实例。即使 32 位 Linux 上的每个进程通常只能寻址 3GB 的内存,这也为我们的全局缓存提供了 10GB 的额外内存。

Memcached 的诀窍在于,对于给定的键,它需要始终如一地选择相同的 Memcached 节点来处理该键,同时将存储(键)均匀地分布在所有节点上。将键 foo 存储在机器 1 上,然后稍后让另一个进程尝试从机器 2 加载 foo 是行不通的。幸运的是,这不是一个难以解决的问题。我们可以简单地将网络上的所有 Memcached 节点视为哈希表中的桶。

Memcached 的工作原理
Distributed Caching with Memcached

图 1. Memcached 客户端库负责将请求发送到正确的服务器。

步骤 1:应用程序使用客户端库请求键 foo、bar 和 baz,客户端库计算键哈希值,确定应接收请求的 Memcached 服务器。

步骤 2:Memcached 客户端向所有相关的 Memcached 服务器发送并行请求。

步骤 3:Memcached 服务器将响应发送到客户端库。

步骤 4:Memcached 客户端库聚合应用程序的响应。

如果您知道哈希表的工作原理,请略读。如果您是哈希新手,这里有一个快速概述。哈希表实现为桶数组。每个桶(数组元素)都包含一个节点列表,每个节点都包含 [键,值]。稍后搜索此列表以查找包含正确键的节点。大多数哈希表都从小处开始,并随着桶列表变得太长而随时间动态调整大小。

获取/设置具有值的键的请求需要通过哈希函数运行该键。哈希函数是一个单向函数,将键(无论是数字还是字符串)映射到将成为桶号的某个数字。一旦计算出桶号,就会搜索该桶的节点列表,查找具有给定键的节点。如果未找到,则可以将新节点添加到列表中。

那么这与 Memcached 有什么关系呢?Memcached 向用户呈现字典接口(键 -> 值),但它在内部实现为两层哈希。第一层在客户端库中实现;它通过将键哈希到虚拟桶列表上来决定将请求发送到哪个 Memcached 服务器,每个虚拟桶代表一个 Memcached 服务器。到达那里后,选定的 Memcached 服务器使用典型的哈希表。

每个 Memcached 实例都是完全独立的,并且不与其他实例通信。每个实例默认情况下都会删除最近最少使用的项目,以便为新项目腾出空间。服务器提供许多统计信息,您可以使用这些信息来查找整个 Memcached 场的查询/命中/未命中率。如果服务器发生故障,可以将客户端配置为绕过死机或多台机器,并使用剩余的活动服务器。此行为是可选的,因为应用程序必须准备好处理从抖动节点接收到的可能过时的信息。关闭时,对死机服务器上的键的请求只会导致应用程序的缓存未命中。在足够大的 Memcached 场上,在足够多的唯一主机上,一台死机不应对全局命中率产生太大影响。

我们的设置

LiveJournal.com 目前在我们的网络上的十个唯一主机上运行着 28 个 Memcached 实例,缓存了最流行的 30GB 数据。我们的命中率约为 92%,这意味着我们访问数据库的频率比以前低得多。

在我们的具有 4GB 内存的 Web 节点上,我们运行三个每个 1GB 的 Memcached 实例,然后使用 500MB 的 mod_perl,留下 500MB 的喘息空间。在与 mod_perl 相同的机器上运行 Memcached 效果很好,因为我们的 mod_perl 代码是 CPU 密集型的,而 Memcached 几乎不占用 CPU。当然,我们可以购买专用于 Memcached 的机器,但我们发现将 Memcached 实例部署到我们碰巧有多余内存的任何地方,并为任何可以容纳额外内存的旧机器购买额外内存更经济。

您甚至可以运行一个所有实例大小都不同的 Memcached 场。我们运行 512MB、1GB 和 2GB 实例的混合。您可以在客户端配置中指定实例及其大小,Memcached 连接对象会适当地进行加权。

速度

当然,缓存的主要动机是速度,因此 Memcached 被设计为尽可能快。Memcached 的初始原型是用 Perl 编写的。虽然我喜欢 Perl,但原型慢得可笑且臃肿。Perl 为了所有东西都牺牲了内存使用率,因此浪费了大量宝贵的内存,并且 Perl 无法一次处理大量的网络连接。

当前版本是用 C 编写的,作为一个单进程、单线程、异步 I/O、基于事件的守护程序。为了可移植性和速度,我们使用 libevent(请参阅在线资源部分)进行事件通知。libevent 的优势在于它可以选择运行时处理文件描述符的最佳可用策略。例如,它在 BSD 上选择 kqueue,在 Linux 2.6 上选择 epoll,这在处理数千个并发连接时非常有效。在其他系统上,libevent 会退回到传统的 poll 和 select 方法。

在 Memcached 内部,所有算法都是 O(1)。也就是说,算法的运行时和使用的 CPU 永远不会随着并发客户端的数量而变化,至少在使用 kqueue 或 epoll 时,或者随着数据大小或任何其他因素而变化。

值得注意的是,Memcached 使用 slab 分配器进行内存分配。早期版本的 Memcached 使用 glibc 中的 malloc,并在大约一周后以失败告终,由于地址空间碎片化而占用了大量 CPU 空间。slab 分配器仅分配大块内存,将其切成小块用于特定类别的项目,然后在每次释放对象时为每个类别维护空闲列表。有关更多详细信息,请参阅资源中的 Bonwick 论文。Memcached 当前为从 64 字节到 1MB 的所有 2 的幂大小生成 slab 类,并且它分配可以容纳提交项目的最小大小的对象。由于使用了 slab 分配器,我们可以保证任何时间长度的性能。实际上,我们的生产 Memcached 服务器已经运行了 4-5 个月,平均每秒 7,000 个查询,没有问题,并且保持持续的低 CPU 使用率。

Memcached 的另一个关键要求是它是无锁的。所有对象在内部都是多版本的并且引用计数,因此没有客户端可以阻止任何其他客户端的操作。如果一个客户端正在更新存储在 Memcached 中的对象,而十几个其他客户端正在下载它,即使一个客户端在丢包一半的数据包的丢失网络连接上,也没有人需要等待任何人。

最后一个值得注意的优化是该协议允许一次获取多个键。如果您的应用程序知道它需要加载数百个键,这将非常有用。应用程序可以一次请求获取所有键,而不是按顺序检索所有键,这将花费一小部分秒的网络往返时间。必要时,客户端库会自动将应用程序的多键加载拆分为单独的并行多键加载到 Memcached 实例。或者,应用程序可以提供带有键的显式哈希值,以将数据组保留在同一实例上。这也节省了客户端库一些 CPU 时间,而无需计算哈希值。

客户端库

Memcached 的客户端/服务器接口简单轻巧。因此,有用于 Perl、PHP、Python 和 Java 的客户端库。我还听说我的一个同事一直在开发 Ruby 客户端,即将推出。

所有客户端都支持使用其本机序列化方法进行对象序列化。Perl 使用 Storable,PHP 使用 serialize,Python 使用 Pickle,Java 使用 Serializable 接口。大多数客户端还支持透明压缩,可以选择仅在超过某个大小阈值时进行压缩。序列化和压缩都是可能的,因为 Memcached 允许客户端模块在存储的项目旁边存储不透明标志,指示它们应该如何处理传出的数据。

使用 Memcached

仅安装 Memcached 并非万能药;您必须做一些工作才能使用它。分析您的应用程序和数据库查询,以查看您在哪些地方花费的时间最多,然后从那里进行缓存。您还必须处理更新和清除缓存,因为即时缓存一致性对于大多数应用程序来说很重要。如果您的应用程序的内部 API 已经非常清晰,并且您不会随意地在代码中访问数据库,那么添加 Memcached 支持应该很容易。在您的 getter 函数中,首先尝试 Memcached。如果未命中,请访问数据库,然后填充 Memcached。在您的 setter 函数中,同时更新数据库和 Memcached。您可能会发现需要处理竞争条件和缓存一致性问题,但 Memcached API 提供了处理它们的方法。

Memcached 也可用于存储您实际上不需要放在磁盘上的数据。例如,LiveJournal 使用它来防止意外重复提交请求,方法是将事务的结果代码存储在 Memcached 中,并以事务签名作为键。Memcached 作为主要数据存储(而不是缓存)的另一个示例是抵御愚蠢和/或恶意机器人,通常是垃圾邮件发送者。通过跟踪每个 IP 地址和会话的最后时间和操作,我们的代码可以自动检测模式并及早通知我们攻击,并在必要时采取自动操作。将此信息存储在数据库中是浪费的,会不必要地加重磁盘负担。但是,将其放在内存中是可以的,因为如果 Memcached 节点发生故障,数据丢失是安全的。

我询问了邮件列表,他们正在使用 Memcached 做哪些有趣的事情,以下是他们的回答

  • 许多人像我们在 LiveJournal 上那样使用它,作为小型 Web 对象的典型缓存。

  • 一个站点正在使用它将当前播放的歌曲从他们的 Java 流媒体服务器传递到他们的 PHP 网站。他们过去使用数据库来完成此操作,但他们报告说访问 Memcached 要好得多。

  • 很多人都在缓存身份验证信息和会话密钥。

  • 一位人士报告说,通过缓存已知的好主机和坏主机以及身份验证详细信息,加快了邮件服务器的速度。

我继续收到有趣的电子邮件和建议,所以我很高兴人们找到了它的良好用途。

替代方案

如果您可以设法在单台机器上运行您的线程应用程序,并且不需要全局缓存,那么您可能不需要 Memcached。同样,如果您坐在单台机器上,SysV 共享内存可能会对您有所帮助。

一些人建议,MySQL 4.x 的查询缓存可能会消除对 Memcached 的需求。每次以任何方式更新相关表时,MySQL 查询缓存都会被清空。它主要是一个对只读站点有用的功能。LiveJournal 的写入量非常大,如今大多数高流量站点也是如此。此外,与其他数据库一样,MySQL 缓存加在一起不能超过内核提供的最大地址空间,通常在 32 位机器上为 3GB,这变得很局促。

对于某些人来说,另一种选择是 MySQL 的内存表处理程序。它对我的用途没有吸引力,因为它仅限于固定长度的记录,不允许 BLOB 或 TEXT 列。您可以存储在其中的数据总量也有限,因此我们仍然需要运行许多数据表并在它们之间分散键。

致谢

我要感谢 Anatoly Vorobey 为 Memcached 服务器所做的所有辛勤工作,感谢 Lisa Phillips 容忍早期容易崩溃的版本,并感谢邮件列表中的所有用户发送补丁、问题和建议。

本文的资源: /article/7559

Brad Fitzpatrick 从事数据库驱动的网站黑客工作已有八年。除了骑自行车,Brad 还喜欢尝试想出其他解决方案来解决原本可能需要销售人员参与的问题。除非您推销蓝色药丸或告知他死机服务器,否则 Brad 欢迎您发送邮件至 brad@danga.com

加载 Disqus 评论