论文阅读: Facebook扩展Memcached之路
Scaling Memcache at Facebook, NSDI'13
摘要
Memcached 是一种广泛知名且简单的内存缓存解决方案。本文描述了 Facebook 如何利用 Memcached 作为构建块,构建并扩展一个分布式KV存储系统,以支持全球最大的社交网络。我们的系统处理每秒数十亿的请求,并存储数万亿的记录,以便为全球超过十亿的用户提供丰富的体验。
1. 引言
社交网络的基础设施需要满足以下要求:
- 允许接近实时的通信,
- 从多个来源动态汇聚内容,
- 能够访问和更新非常受欢迎的共享内容,
- 扩展到每秒处理数百万个用户请求。
我们描述了如何改进开源版本的 Memcached [14],并将其作为构建全球最大社交网络的分布式键值存储的基石。我们讨论了从单个服务器集群扩展到多个地理分布集群的过程。
本文重点讨论 Memcached——一个开源的内存哈希表实现——因为它提供了低延迟的访问和低成本的共享存储池。我们的应用中,网页通常会从 Memcached 服务器获取数千个键值对。
本文包含四个主要贡献:
- 描述了 Facebook 基于 Memcached 的架构演变。
- 识别了改进 Memcached 性能和内存效率的增强功能。
- 突出了提高我们在大规模操作系统能力的机制。
- 描述了施加在我们系统上的生产工作负载。
2. 总览
以下属性极大地影响了我们的设计:
- 用户消费的内容数量比他们创建的内容多一个数量级,这种行为导致工作负载主要由数据获取组成,并表明缓存可能具有显著优势。
- 其次,我们的读取操作从各种来源获取数据,例如 MySQL 、HDFS 和后端服务,这种异构性需要一种灵活的缓存策略,能够存储来自不同来源的数据。
Memcached 提供了一组简单的操作(set、get 和 delete),很适合作为大规模分布式系统中的基本组件。我们最初使用的开源版本提供了一个单机内存哈希表。本文讨论了我们如何在这个基本构建块的基础上进行改进,使其更高效,并用于构建一个可以处理每秒数十亿个请求的分布式键值存储。我们使用“memcached”来指代源代码或运行中的二进制文件,而“memcache”则描述分布式系统。
查询缓存(Query cache):我们通过 memcache 来减轻数据的读取负载。我们将memcache 作为 按需填充的旁路 (demand-filled look-aside)缓存。如图1,当一个 Web 服务器需要数据时,它首先通过提供一个字符串键向 memcache 请求值。如果该键指向的项目未被缓存,Web 服务器将从数据库或其他后端服务检索数据,并用键值对填充缓存。对于写请求,Web 服务器向数据库发出 SQL 语句,然后向 memcache 发送一个删除请求,使任何过时的数据无效。我们选择删除缓存的数据而不是更新它,因为删除是幂等(idempotent)的。Memcache 不是数据的权威来源,因此允许它驱逐缓存数据。

- 图1: Memcache 作为按需旁路的缓存,左边是当一个Web服务器在cache miss的情况下的读操作路径;右边是写操作流程
虽然有几种方法可以应对 MySQL 数据库的过度读取流量,但我们选择了使用 memcache。将缓存层与持久层分开,使我们可以根据工作负载的变化独立调整每一层。
通用缓存(Generic cache): 我们还利用 memcache 作为一个更通用的键值存储。例如,工程师使用 memcache 存储来自复杂机器学习算法的预计算结果,这些结果可以被其他各种应用程序使用。新服务可以很容易地利用现有的 memcache 基础设施,而无需担心调整、优化、提供和维护大规模的服务器群。
现有的 memcached 不提供服务器间的协调;它是在单个服务器上运行的内存哈希表。我们的系统提供了一套配置、聚合和路由服务,将 memcached 实例组织成一个分布式系统。

- 图2: 架构整体图
我们将论文结构化,以突出在三个不同的部署规模下出现的主题。
- 当我们有一个服务器集群时,我们的读取密集型工作负载和广泛的扇出(fan-out)是主要关注点
- 当需要扩展到多个前端集群时,我们解决了这些集群之间的数据复制问题
- 最后,我们描述了在全球范围内分布集群时提供一致用户体验的机制
在所有规模下,操作复杂性和容错性都是重要的。图 2 描述了最终架构,其中我们将同处一地的集群组织成一个区域(Region),并指定一个主区域,提供数据流以保持非主区域的最新状态。
在系统演进过程中,我们优先考虑两个主要设计目标
- 任何更改必须能够影响用户体验或操作效率。那些范围有限的优化(即影响较小或局限于某个小范围的优化)通常不被考虑
- 将读取瞬态过时数据的概率作为可调参数。系统设计允许在一定程度上读取稍微过时的数据,以换取降低后端存储服务的负载。允许在性能和数据一致性之间进行权衡。
3. 一个集群: 延迟和负载
想象一个集群中扩展到数千台服务器时面临的挑战。在这种规模下,我们聚焦在降低从缓存获取数据的延迟或由于缓存未命中而施加的负载上。
3.1 降低延迟
无论数据请求是否导致缓存命中或未命中,memcache 的响应延迟都是用户请求响应时间的关键因素。一个单个用户的 Web 请求通常会导致数百个单独的 memcache get 请求。例如,加载我们其中一个热门页面平均会导致从 memcache 获取的 521 个不同项。
我们在一个集群中提供了数百台 memcached 服务器,以减轻数据库和其他服务的负载。通过一致性哈希算法[22],数据项分布在 memcached 服务器上。因此,Web 服务器必须定期与许多 memcached 服务器通信,以满足用户请求。因此,所有 Web 服务器在短时间内与每个 memcached 服务器进行通信。这种全互连(all-in-all)通信模式可能会导致 incast 拥塞,或者让单个服务器成为许多 Web 服务器的瓶颈。数据复制通常可以缓解单个服务器的瓶颈问题,但在常见情况下会导致显着的内存浪费。
我们主要通过专注于运行在每台 Web 服务器上的 memcache 客户端来降低延迟。这个客户端提供一系列功能,包括序列化、压缩、请求路由、错误处理和请求批处理。客户端维护一个所有可用服务器的映射,该映射通过辅助配置系统进行更新。
并行请求和批处理:我们构建我们的 Web 应用程序代码以最小化响应页面请求所需的网络往返次数。我们构建一个表示数据之间依赖关系的有向无环图 (DAG)。Web 服务器使用此 DAG 来最大化可以同时获取的项目数量。平均而言,这些批次包含每个请求 24 个key
客户端-服务器通信:Memcached 服务器之间不进行通信。在适当的情况下,我们将系统的复杂性嵌入到一个无状态客户端中,而不是在 memcached 服务器中。这极大地简化了 memcached,并使我们能够专注于使其在更有限的用例中高效运行。保持客户端无状态可以在软件中进行快速迭代,并简化我们的部署流程。客户端逻辑由两个组件提供:一个可以嵌入到应用程序中的库,或者作为一个独立的代理命名为 mcrouter。该代理提供了一个 memcached 服务器接口,并将请求/响应路由到/来自其他服务器。
客户端使用 UDP 和 TCP 与 memcached 服务器进行通信。我们依赖 UDP 来进行 get 请求以减少延迟和开销。由于 UDP 是无连接的,Web 服务器中的每个线程都允许直接与 memcached 服务器通信,绕过 mcrouter,而无需建立和维护连接,从而减少了开销。UDP 实现检测到被丢弃或无序接收的数据包(使用序列号),并在客户端端将其视为错误,它不提供任何机制来尝试从这些错误中恢复。在我们的基础设施中,我们认为这个决定是实际可行的。在高负载下,memcache 客户端观察到有 0.25% 的 get 请求被丢弃。其中约 80% 的丢包是由于延迟或丢失的数据包,而其余的则是由于乱序传递。客户端将 get 错误视为缓存未命中,但 Web 服务器在查询数据后会跳过将条目插入 memcached,以避免给可能已超载的网络或服务器增加额外负载。
为了可靠性,客户端通过运行在与 Web 服务器相同的机器上的 mcrouter 实例,通过 TCP 执行 set 和 delete 操作。对于需要确认状态更改(更新和删除)的操作,TCP 可以减轻我们的 UDP 实现中添加重试机制的需求。
Web 服务器依赖于高度的并行性和超额订阅来实现高吞吐量。开放的 TCP 连接需要高内存,这使得在每个 Web 线程和 memcached 服务器之间的代价过高,除非通过 mcrouter 进行某种形式的连接合并。合并这些连接通过减少高吞吐量 TCP 连接所需的网络、CPU 和内存资源,提高了服务器的效率。图 3 显示了生产中 Web 服务器通过 UDP 和通过 mcrouter 通过 TCP 获取键的平均、中位数和第 95 百分位数的延迟

- 图3: 通过mcrouter的UDP、TCP的get延迟
Incast 拥塞:当客户请求大量键时,如果这些响应同时到达,可能会压垮机架和集群交换机等组件。因此,客户端使用滑动窗口机制来控制未完成请求的数量。当客户端收到响应时,下一个请求可以发送。类似于 TCP 的拥塞控制,这个滑动窗口的大小在成功请求时缓慢增长,在请求无回应时缩小。这个窗口适用于所有的 memcache 请求,并且与请求的目的地无关,反观 TCP 窗口仅适用于单个流。
图 4 显示了窗口大小对于用户请求等待被调度的时间的影响。窗口大小的变化会影响用户请求等待被处理的时间长度。数据是从一个前端集群中的多个机架中收集的。用户请求在每个 Web 服务器上表现出泊松到达过程。根据 Little 定律,队列中的请求数(L)与请求处理的平均时间(W)成正比,假设输入请求率是恒定的(对于我们的实验来说确实如此)。等待被调度的 Web 请求的时间是系统中 Web 请求数量的直接指标。当窗口大小较小时,应用程序将不得不串行地分派更多组 memcache 请求,从而增加 Web 请求的持续时间。当窗口大小变得过大时,大量同时的 memcache 请求会导致 incast 拥塞。结果将是 memcache 错误,并且应用程序会回退到持久存储以获取数据,这将导致 Web 请求的处理速度变慢。在这两个极端之间存在一个平衡,可以避免不必要的延迟,并尽量减少 incast 拥塞

- 图4: Web服务器的请求等待被调度的平均等待时间
3.2 降低负载
我们的目标是减少在花销大的路径上fetch数据的频次,下面的小节描述了三种减少负载的技术。
3.2.1 租约
我们用租约(leases)的方法解决两个问题: 过期集(stale sets)和热点缓存(thundering herds)问题。
- 过期集是指当 Web 服务器在 memcache 中设置一个值时,该值不反映应该缓存的最新值,这可能发生在并发更新 memcache 时发生重新排序时。
- 热点缓存问题是指特定key经历了大量读写活动。由于写活动反复使最近设置的值无效(invalidate),许多读取将默认转到更昂贵的路径上。
从直觉上说,memcached 实例向客户端发出租约,以便在客户端遇到缓存未命中时将数据设置回缓存中。租约是与客户端最初请求的特定键绑定的 64 位token。在缓存中设置值时,客户端提供租约token。有了租约token,memcached 可以验证并确定是否应存储数据,从而调解并发写入。如果 memcached 由于收到该项的删除请求而使租约令牌无效,则验证可能失败。租约类似于MIPS指令中的load-link/store-conditional,可以防止过期集的发生。
"Load-link/store-conditional" 操作是一种早期并发编程(类似乐观并发)的机制,这种操作通常由处理器提供支持,有时候被视为"load-reserved/store-conditional"(LR/SC), 他们成对使用,实现多线程的同步问题。Load-link返回给定一个内存位置的当前值,即并将其加载到寄存器中。然后,处理器尝试以原子方式修改内存位置的值(store-conditional)。如果在此期间没有其他处理器修改了该内存位置,存储条件操作将成功,并且处理器可以继续执行后续操作。否则,如果在尝试修改时发现内存位置的值已经被修改,存储条件操作将失败,并且处理器需要重新执行整个操作。对租约进行轻微修改还有助于减轻缓存热点问题。默认情况下,这些服务器每个键每 10 秒只返回一个令牌。当一个键的值被写入后,该键在 10 秒内不会再次返回令牌,而是告知等待的客户端稍后再试。典型情况下,具有租约的客户端将在几毫秒内成功设置数据。因此,当等待的客户端重试请求时,数据通常已经存在于缓存中。使用租约,可以最小化某些用例中应用程序的等待时间。我们可以通过确定接受稍微过时数据的情况来进一步减少这个时间。
为了说明这一点,我们收集了一周内特定一组易受“惊群效应”影响的键的所有缓存未命中数据。在没有租约机制的情况下,所有缓存未命中导致数据库查询峰值达到每秒 17K。引入租约机制后,数据库查询峰值降至每秒 1.3K。由于我们的数据库容量规划是基于峰值负载进行的,因此租约机制带来了显著的效率提升。
过期数据:通过租约机制,我们可以在某些情况下最小化应用程序的等待时间。我们还可以进一步缩短等待时间,方法是识别出可以接受返回略微过期数据的情况。当某个键被删除时,其值会被转移到一个保存最近删除项的数据结构中,在被彻底清除前保留一段时间。随后对该键的 get 请求可以返回一个租约token,或者返回一个标记为过期的数据。那些在使用过期数据的情况下依然能继续正常运作的应用程序无需等待最新值从数据库中获取。我们的经验表明,由于缓存值通常是数据库的单调递增快照,大多数应用程序可以直接使用过期值,而不需要任何更改。
3.2.2 缓存池
将 memcache 用作通用缓存层要求各类负载共享基础设施,尽管它们的访问模式、内存占用和服务质量需求各不相同。不同应用的工作负载可能会产生负面干扰,导致命中率下降。
为了适应这些差异,我们将集群中的 memcached 服务器划分为不同的池。我们指定一个池(称为 wildcard)为默认池,并为那些在 wildcard 池中存储会引发问题的键单独设置其他池。例如,我们可以为那些访问频繁但缓存未命中代价较低的键设置一个小池,也可以为访问不频繁但缓存未命中代价非常高的键设置一个大池。
图5展示了两类不同项集的工作集情况,一类是低变化频率(low-churn)项集,另一类是高变化频率(high-churn)项集。工作集的近似值通过对每百万个项中抽样操作得到。对每个项,我们收集其最小、平均和最大项大小,将这些大小相加并乘以一百万,以估算整个工作集的大小。日工作集与周工作集的差异反映了变化的程度。具有不同变化特性的项会发生不良互动:重要的低变化频率键在高变化频率但已不再访问的键之前被驱逐。将这些键分配到不同的池中可以防止这种负面干扰,并让我们根据缓存未命中代价适当设置高变化频率池的大小。第7节将对此进行进一步分析。

- 图5: 高变化频率(high-churn)集和低变化频率( low-churn)键集的日工作集和周工作集
3.2.3 缓存池复制
使用复制来提高memcached服务器的延迟和效率。当同时满足以下条件时,我们选择在数据池内复制一类键:
- 应用程序经常同时获取多个键。
- 整个数据集适合存储在一个或两个memcached服务器中。
- 请求速率远高于单个服务器的处理能力。
举个例子,假如一台 memcached 服务器可以存储 100 个数据项,每秒处理 50 万次请求,每次请求都会读取 100 个键。此时,如果我们想要系统每秒处理 100 万次请求,通常的做法是增加一台服务器,并把数据项分开存储到两台服务器上。但这样一来,客户端每次请求时需要从两台服务器分别获取大约一半的数据(每次获取 50 个键),结果是两台服务器依然要处理 100 万次请求/秒。
相比之下,复制方式则是让两台服务器都保存完整的 100 个数据项,这样每次请求都可以直接从任意一台服务器获取完整的数据,从而把每台服务器的负载降到 50 万次请求/秒。每个客户端会根据自身的 IP 地址选择不同的服务器。但这种方式要求确保每台服务器的数据保持同步,必要时要对所有副本进行数据更新。
3.3 失败的处理
如果从 memcache 无法获取数据,会导致后端服务负载过大,并引发连锁故障。对此,我们需要在两个层面上应对故障:
- 处理少数主机因网络或服务器故障而无法访问的情况。
- 应对集群大范围的宕机。如果整个集群需要下线,我们会将用户的请求转移到其他集群,这样就完全卸掉了该集群 memcache 的负载。
对于小规模的故障,我们有一个自动化的修复系统。然而,修复过程可能需要几分钟,在此期间会有连锁故障的风险。因此,我们设置了一个名为 "Gutter" 的小型服务器集群来接管部分故障服务器的任务。这些 Gutter 服务器占整个集群 memcached 服务器的约 1%。
当 memcached 客户端的请求无响应时,会认为服务器发生故障,然后将请求重定向到 Gutter 集群。如果在 Gutter 中也没有找到数据,客户端会从数据库中查询该键值对,并将其写入 Gutter 中。Gutter 中的缓存项会很快过期,避免数据过时。
这种设计与重新分配键到其他 memcached 服务器的方法不同,因为重新分配会导致负载分配不均,可能引发新的故障。例如,如果一个热键占了服务器请求量的 20%,新的服务器可能因热键负载过重而崩溃。通过将负载转移到空闲的 Gutter 服务器,我们减少了这种风险。
通常,每次请求失败都会增加后端存储的负担。使用 Gutter 后,我们将这些失败转为 Gutter 集群的命中,从而减少了后端的负载。实际中,该系统将用户可见的失败率降低了 99%,并将每天 10%-25% 的失败转化为成功命中。
总结来说,Gutter 是一种故障期间的缓冲方案,当 memcache 中部分服务器故障时,Gutter 承接了一部分请求,防止大量请求直接进入后端数据库,从而保护了后端系统的稳定性
4. 一个区域: 复制
随着用户的访问量继续增大,你可能会想要购买更多的机器来部署 web server 和 memcached server,实现横向扩容。然而简单地横向扩容不能解决所有问题。越来越多的用户会将原本不严重的问题暴露出来:
- 用户增多会导致热点数量增多、单个热点热度增大
- 由于 memcached client 需要与所有 memcached server 通信,incast congestion 问题会更严重
因此,我们将Web服务器和Memcached服务器拆分成多个前端集群(frontend clusters)。这些集群与数据库的存储集群一起定义了一个区域。这种区域架构具有更小的故障域和更易于管理的网络配置。我们通过数据复制来换取更多独立的故障域以及减少Incast拥塞。
4.1 区域失效
虽然区域中的存储集群持有数据的权威副本,但用户需求可能会将该数据复制到前端集群中。存储集群负责使缓存数据失效,以保持前端集群与权威版本的一致性。作为优化,当一个Web服务器修改数据时,它还会向其自身集群发送失效请求,以提供写后读(read-after-write)语义,并减少本地缓存中过时数据的存在时间。

- 图 6: 失效管道,展示需要通过守护进程(mcsqueal)删除的键
我们在每个数据库上部署了失效守护进程(名为mcsqueal)。每个守护进程会检查其数据库提交的SQL语句,提取出任何删除操作,并将这些删除操作广播到区域内的每个前端集群的memcache中。如图6所示。
4.1 区域失效
虽然区域中的存储集群持有数据的权威副本,但用户需求可能会将该数据复制到前端集群中。存储集群负责使缓存数据失效,以保持前端集群与权威版本的一致性。作为优化,当一个Web服务器修改数据时,它还会向其自身集群发送失效请求,以提供读取后写入语义,并减少本地缓存中过时数据的存在时间。
SQL语句修改权威状态时,会附带需要在事务提交后失效的memcache键 [7]。我们在每个数据库上部署失效守护进程(名为mcsqueal)。每个守护进程检查其数据库提交的SQL语句,提取任何删除操作,并将这些删除请求广播到该区域内每个前端集群的memcache部署中。如图6所示。我们认识到大多数失效操作并不删除数据,实际上,只有4%的删除操作会导致缓存数据的实际失效。
减少数据包率:虽然mcsqueal可以直接联系memcached服务器,但这样会导致从后端集群到前端集群发送的数据包速率过高。这种数据包率问题是由于有许多数据库和许多memcached服务器跨集群边界通信所造成的。失效守护进程将删除操作批量处理成更少的数据包,并将其发送到每个前端集群中运行mcrouter实例的专用服务器。这些mcrouter会解包每个批次中的单个删除操作,并将这些失效请求路由到正确的memcached服务器。批量处理使得每个数据包中的删除操作数量提高了18倍。
通过Web服务器进行失效处理:Web服务器广播失效请求到所有前端集群更为简单。然而,这种方法有两个问题。首先,由于Web服务器不如mcsqueal管道高效进行批量处理,导致更多的数据包开销。其次,当出现系统性失效问题(例如由于配置错误导致删除请求路由错误)时,Web服务器的方法没有什么补救措施。在过去,这通常需要对整个memcache基础设施进行滚动重启,这是一个缓慢且具有破坏性的过程,而我们希望避免这种情况。相比之下,将失效操作嵌入SQL语句中,数据库会提交并存储在可靠的日志中,这使得mcsqueal可以简单地重播可能丢失或被误路由的失效操作。
4.2 区域池
每个集群根据发送给它的用户请求的组合独立地缓存数据。如果用户的请求随机地路由到所有可用的前端集群,那么缓存的数据将在所有前端集群中大致相同。这使我们能够在进行维护时将集群下线,而不会导致缓存命中率下降。过度复制数据可能导致内存效率低下,特别是对于大型且访问频率较低的项目。我们可以通过让多个前端集群共享相同的memcached服务器来减少副本的数量,我们称之为区域池。
跨集群边界会增加延迟。此外,我们的网络在集群边界上的可用带宽比在单个集群内少40%。复制数据会增加memcached服务器的数量,以换取较少的跨集群带宽、更低的延迟和更好的容错性。对于某些数据,不复制数据而只在区域内拥有单一副本更加具有成本效益。扩展memcache时的主要挑战之一是决定一个键是否需要在所有前端集群中复制,还是只在一个区域内保留单一副本。当区域池中的服务器发生故障时,也会使用Gutter机制。

表1总结了我们应用中两种具有大值的项目。我们将其中一种(B)迁移到区域池,而另一个(A)保持不变。注意,客户端访问属于B类别的项目的频率比A类别低一个数量级。类别B的低访问率使其成为区域池的主要候选,因为它不会对跨集群带宽产生不利影响。类别B还会占用每个集群的25%的通配池,因此区域化提供了显著的存储效率。然而,类别A的项目较大且访问频率较高,因此不适合进行区域化。将数据迁移到区域池的决定目前是基于一套手动启发式方法,考虑了访问率、数据集大小和访问特定项目的独立用户数量。
4.3 集群预热
当我们上线新集群、现有集群发生故障或进行计划维护时,缓存的命中率会非常差,降低了隔离后端服务的能力。一个名为“冷集群预热”的系统缓解了这一问题,该系统允许“冷集群”(即缓存为空的前端集群)从“热集群”(即缓存命中率正常的集群)中获取数据,而不是从持久存储中获取。这利用了前端集群之间的上述数据复制。通过该系统,冷集群可以在几小时内恢复到满负荷状态,而不是几天。
必须注意避免因竞态条件而导致的不一致。例如,如果冷集群中的一个客户端执行数据库更新,随后另一个客户端的请求从热集群获取过时的值,而热集群还未收到失效请求,那么该项目将在冷集群中保持不一致。memcached删除操作支持非零的保留时间,这会拒绝在指定的保留时间内进行添加操作。默认情况下,所有删除操作都会在冷集群中执行两秒钟的保留时间。当在冷集群中检测到未命中的请求时,客户端会重新向热集群请求该键,并将其添加到冷集群中。如果添加失败,则表示数据库中有更新的数据,客户端会重新从数据库中获取该值。虽然理论上删除操作的延迟可能超过两秒,但在绝大多数情况下并不会发生。冷集群预热的操作性好处远大于偶尔出现的缓存一致性问题。当冷集群的命中率稳定后,我们会关闭该系统,因为其带来的好处已经减少。
5. 跨区域: 一致性
将 FB 的数据中心同步到不同区域 (region)有若干好处,1)将 web servers 推进到离用户最近的地方带来低延;2)FB 服务的容灾能力;3)在新的区域可能在各方面产生规模经济效应。因此 memcache 服务也需要能够被部署到多个区域。每个区域包括一个存储集群和几个前端集群。我们指定一个区域作为主数据库所在区域,其他区域则包含只读副本;我们依赖 MySQL 的复制机制来保持副本数据库与主数据库的同步。

当跨多个区域扩展时,保持 memcache 数据与持久存储之间的一致性成为主要技术挑战。这些挑战源于一个单一问题:副本数据库可能会滞后于主数据库(复制延迟)。 我们提供了“尽力而为”的最终一致性,但强调性能和可用性。
来自主区域的的写入:数据库更新后不是由 Web 服务器直接去发缓存失效通知(invalidation),而是交给 存储集群的后台 daemon(守护进程)。这个机制避免了一个关键的竞态问题,即失效请求比主区域复制数据请求之前到达。考虑一个主区域的网络服务器,它在修改数据库后希望使现在过时的数据失效。在主区域内部发送失效请求是安全的。然而,让web服务器在副本区域发送数据失效请求可能会过早,因为数据的更改可能尚未传播到副本数据库。紧接着来自副本区域查询数据请求会与复制流竞争,从而增加将过时数据写入 memcache 的概率。历史上,我们在扩展到多个区域后实现了 mcsqueal。
来自非主区域的写入:现在考虑一个用户在非主区域更新其数据时,复制延迟会很大,需要时间才能同步到主区。当他立刻刷新页面时,如果副本区的数据库还没同步到最新版本,他就会看到旧数据,产生“刚改完却没改成功”的错觉。在复制还没追上主区前,不能用副本区数据库去更新缓存。否则就会把旧数据重新塞进缓存里,让缓存“回退”
我们采用了远程标记(remote marker)机制,以最小化读取过时数据的概率。远程标记的存在表明本地副本数据库中的数据可能已经过时,因此查询应该被重定向到主区域。当一个Web服务器希望更新影响某个Key的数据时,该服务器:
- 设置标记。 当 web 服务器要修改一个数据项
k时,在区域中设置一个远程标记,表示本地数据可能过期。 - 写入主区。然后发写请求到主区,SQL语句会同时包含
和 ,确保主区更新完成后,这个 会失效(被删除)。 - 删除本地缓存。 删除本地集群中的k。
在随后对k的请求中,Web服务器将无法找到缓存中的数据,检查
我们通过使用区域池( regional pool)来实现远程标记。但是,如果两个请求同时修改同一个 key, 可能会导致其中一个误删了另一个的标记,从而又暴露出旧数据。这里在强调:普通缓存删除是“安全的”——最坏只是多查一次数据库;但不会损害一致性, 相反,远程标记的存在有助于区分非主数据库是否持有过时数据。在实际操作中,我们发现远程标记的驱逐和并发修改的情况比较少见。
想象以下这个场景:
- 副本区域中的 web server A 写入数据到 主区域的 DB
- A 将本地 memcache 中的数据删除
- 副本区域中的 web server B 从 memcache 中读取数据发生 cache miss,从本地 DB 中获取数据
- A 写入的数据从 主区域的 DB 中同步到 副本区域 DB,并通过 mcsqueal 将本地 memcache 中的数据删除
- web server B 将其读到的数据写入 memcache 中

此时,DB 与 memcache 中的数据将再次出现不一致,且必须等待数据过期之后才能恢复。如何解决这个问题?FB 在 memcache 上引入 remote marker 机制,以最小化读取过时数据的概率。标记的存在表示本地副本数据库中的数据可能过时,查询应重定向到主区域(本质上标记了 数据写入 master DB 但尚未同步到 replica DB 的中间状态)。当web服务器想要更新键k的数据时,该服务器
- 在本地 memcache 上打上 remote marker, 标记为
- 将 k 写入到 master DB中
- 将 k 从 memcache 中删除 (
不删除) - 等待 master DB 将数据同步到本地 replica DB 中,并且在 SQL 语句中埋入 k 和
的信息 - 本地 replica DB 通过 mcsqueal 解析 SQL 语句中,删除 remote marker

当 replica 区域的 web server 想要读取数据 k 发生 cache miss 时:
- 如果 memcache 中数据 k 带了
,则从 master DB 中读取数据 - 如果 memcache 中数据 k 没有
,则直接从本地的 replica DB 中读取数据
操作考虑:由于跨区域通信的成本较高,因为数据必须经过大范围的地理距离(例如横跨整个美国大陆),通过共享删除流与数据库复制的相同通信渠道,我们在低带宽连接下提高了网络效率。我们在第4.1节中介绍的删除管理系统也部署在副本数据库中,用于将删除操作广播到副本区域的memcached服务器。当下游组件无法响应时,数据库和mcrouter会缓冲删除操作。当任何组件发生故障或延迟时,读取过时数据的概率将增加。缓冲的删除操作将在下游组件恢复可用后重新播放。与此相对的替代方案是将集群下线,或在发现问题时对前端集群进行过度失效操作。这些方法会导致比带来更多中断的问题更多的干扰,因此对于我们的工作负载来说,缓冲删除是更优的选择。
6. 单机改进
all-in-all 通信模式的意味着单个服务器可能成为集群的瓶颈。提升单个服务器缓存性能是一个活跃的研究领域。
6.1 性能优化
我们从一个单线程的 memcached 开始,使用固定大小的哈希表。最初的几个重要优化包括:
- 允许哈希表自动扩展,避免查找时间变为 O(n)。
- 使服务器多线程,使用全局锁来保护多个数据结构。
- 为每个线程分配一个独立的 UDP 端口,以减少在发送回复时的竞争,进而分散中断处理的开销。 前两个优化已贡献回开源社区,下面的内容探讨了更多的优化,这些优化尚未在开源版本中实现。
我们的实验主机配备了一台英特尔 Xeon CPU(X5650,2.67GHz,12 核心,12 个超线程),英特尔 82574L 千兆以太网控制器和 12GB 内存。生产服务器具有更多内存。更多细节已在先前的文献中发布 [4]。性能测试设置由 15 个客户端组成,生成 memcache 流量到单个 memcached 服务器,服务器有 24 个线程。客户端和服务器位于同一机架上,并通过千兆以太网连接。这些测试测量了在持续负载下 2 分钟内 memcached 响应的延迟。
获取性能:我们首先研究了将原始的多线程单锁实现替换为细粒度锁定的效果。我们通过在缓存中预先填充 32 字节的值,然后发出包含 10 个键的 memcached 请求来衡量命中的性能。图 7 显示了不同版本的 memcached 在子毫秒平均响应时间下所能维持的最大请求速率。第一组条形图是使用粗粒度锁的 memcached,第二组是我们当前版本的 memcached,最后一组是独立实现了较粗粒度锁定策略的开源版本 1.4.10。
采用细粒度锁定将命中的最大获取速率从 600k 提升至 1.8M 次每秒。未命中的性能也从 2.7M 次每秒提升至 4.5M 次每秒。命中操作比未命中操作更昂贵,因为返回值必须构建并传输,而未命中操作只需要一个静态的响应(END),表示所有键均未命中。
我们还研究了使用 UDP 替代 TCP 对性能的影响。图 8 显示了我们能够维持的最大请求速率,平均延迟低于 1 毫秒,适用于单个获取和 10 键的多重获取。我们发现,UDP 实现相比 TCP 实现,在单个获取上性能提升了 13%,在 10 键多重获取上提升了 8%。 由于多重获取比单个获取能打包更多数据,它们使用更少的包来完成相同的工作。图 8 显示了 10 键多重获取的性能是单个获取的四倍左右。
6.2 自适应 slab 分配器
Memcached 使用 slab 分配器来管理内存。该分配器将内存组织成多个 slab 类,每个类包含预分配的统一大小的内存块。Memcached 将项存储在能容纳其元数据、键和值的最小 slab 类中。Slab 类从 64 字节开始,大小按 1.07 的比例指数增加,最大到 1 MB,并对齐至 4 字节边界。每个 slab 类维护一个可用块的空闲列表,当其空闲列表为空时,向系统请求更多的 1MB 内存块。当 memcached 服务器无法分配更多的空闲内存时,它会通过逐出该 slab 类中的最近最少使用(LRU)项来存储新项。
然而,当工作负载发生变化时,每个 slab 类原先分配的内存可能不再足够,这会导致命中率下降。为了解决这一问题,我们实现了一种自适应分配器,定期重新平衡 slab 类的内存分配,以适应当前的工作负载。它会通过检测是否存在正在逐出的项,以及下一个将要逐出的项是否比其他 slab 类中最久未使用的项使用频率高出至少 20%,来识别哪些 slab 类需要更多的内存。如果发现这样的 slab 类,则会将其最久未使用的项所在的 slab 空间释放并转移到需要更多内存的 slab 类中。
需要注意的是,开源社区独立实现了一个类似的分配器,该分配器在 slab 类之间平衡逐出率,而我们的算法侧重于平衡各类中最老项的年龄。通过平衡年龄,能够更好地模拟整个服务器的全局 LRU 逐出策略,而不是仅仅调整逐出率,这在访问模式的影响下可能会变化。6.3 短暂项缓存
虽然 memcached 支持过期时间,但条目可能在过期后仍然存在于内存中。Memcached 会通过在处理该项的获取请求时或当它们到达 LRU 队列末尾时,延迟逐出这些过期条目。尽管这种方式对于常见情况很高效,但它允许短生命周期的键(即那些只经历单次活动峰值的键)在直到它们到达 LRU 队列末尾之前浪费内存。
因此,我们引入了一种混合方案,该方案对大多数键采用延迟逐出策略,并在短生命周期键过期时主动将其逐出。我们将短生命周期项放入一个循环缓冲区,该缓冲区由链表组成(按过期时间的秒数进行索引),称为短暂项缓存(Transient Item Cache)。每秒钟,缓冲区头部的所有项都会被逐出,随后头部向前推进。当我们将短过期时间添加到一个频繁使用的键集合中时,这些项的有效生命周期较短,结果是该键集合在 memcache 池中所占的比例从 6% 降低到 0.3%,而命中率没有受到影响。
6.4 软件升级
频繁的软件更新可能是由于升级、修复 bug、临时诊断或性能测试的需要。Memcached 服务器可以在几小时内达到其峰值命中率的 90%。因此,升级一组 memcached 服务器可能需要超过 12 小时,因为需要小心管理由此产生的数据库负载。我们修改了 memcached,使其将缓存值和主要数据结构存储在 System V 共享内存区域中,以便数据能够在软件升级过程中保持活跃,从而最小化升级带来的中断。
7. Memcache 工作负载
我们现在通过运行在生产环境中的服务器数据来描述 memcache 的工作负载。
7.1 Web 服务器的测量
我们记录了少量用户请求的所有 memcache 操作,并讨论了工作负载的扩展、响应大小和延迟特征。
扩展: 图 9 显示了一个 web 服务器在响应页面请求时可能需要联系的不同 memcached 服务器的分布情况。如图所示,56% 的页面请求仅联系不到 20 个 memcached 服务器。从请求量来看,用户请求通常会请求少量的缓存数据。然而,这个分布有一个长尾。图中还展示了我们一些较受欢迎页面的分布,这些页面更好地体现了“全对全”通信模式。大多数这类请求会访问超过 100 个不同的服务器;访问数百个 memcached 服务器并不罕见。
响应大小: 图 10 显示了 memcache 请求的响应大小。中位数(135 字节)与平均值(954 字节)之间的差异表明缓存项的大小有很大的变化。此外,似乎存在三个明显的峰值,分别出现在大约 200 字节和 600 字节处。较大的项通常存储数据列表,而较小的项则存储单个内容。
延迟: 我们测量了请求数据时的往返延迟,这包括路由请求和接收回复的成本、网络传输时间、反序列化和解压缩的成本。经过 7 天的测试,中位请求延迟为 333 微秒,而 75 分位数和 95 分位数分别为 475 微秒和 1.135 毫秒。我们从空闲 web 服务器的端到端中位延迟为 178 微秒,75 分位数和 95 分位数分别为 219 微秒和 374 微秒。
7.2 池统计
现在我们讨论四个 memcache 池的关键指标。这些池分别是:wildcard(单词为,通配符)池(默认池)、应用池(为特定应用设置的池)、复制池(为频繁访问的数据设置的池)和区域池(为很少访问的信息设置的池)。在每个池中,我们每 4 分钟收集一次平均统计数据,并在表 2 中报告一个月收集期内的最高平均值。此数据大致反映了这些池的峰值负载。表中显示了不同池之间 get、set 和 delete 请求速率的巨大差异。表 3 则展示了每个池的响应大小分布。不同的特征再次表明,我们希望将这些工作负载从彼此之间隔离开来。

如 3.2.3 节所述,我们在池内复制数据,并利用批量处理来应对高请求速率。观察到复制池的 get 请求速率最高(大约是下一个池的 2.7 倍),尽管它的项大小最小,但它的字节与数据包的比例却是最高的。这与我们的设计一致,我们利用复制和批量处理来提高性能。在应用池中,由于数据的快速更替,导致自然较高的缺失率。该池中的数据通常在访问几小时后就会逐渐失去热度,新的内容会取而代之。区域池中的数据通常较大且不常访问,这从请求速率和数据大小分布中得到了体现。
7.3 无效化延迟
我们认识到,无效化的时效性是决定过时数据暴露概率的关键因素。为了监控这一健康状况,我们从一百万个删除请求中抽样,记录删除请求的发起时间。随后,我们定期查询 memcache 中前端集群的内容,对于那些本应被删除的项,如果它们仍然存在于缓存中,我们将记录错误。
图 11 使用这种监控机制报告了我们在 30 天内的无效化延迟数据。我们将数据分为两个组件:(1)删除请求来源于主区域的 web 服务器,并且目标是主区域的 memcached 服务器;(2)删除请求来源于副本区域,并且目标是副本区域的 memcached 服务器。如图所示,当删除请求的源和目标都位于主区域时,我们的成功率要高得多,在 1 秒内可达到四个 9 的可靠性,而在 1 小时内则可达到五个 9 的可靠性。然而,当删除请求来自主区域外的地方时,可靠性在 1 秒内降至三个 9,且在 10 分钟内降至四个 9。根据我们的经验,我们发现如果在几秒钟内未能完成无效化,最常见的原因是第一次尝试失败,而后续的重试通常能够解决问题。
8. 相关工作
其他大型网站也认识到键值存储的实用性(utility)。。DeCandia等人[[12]](Dynamo: amazon’s highly available key-value store.)介绍了一种高度可用的键值存储Dynamo,用于Amazon.com的多种应用服务。虽然他们的系统针对写操作繁重的工作负载进行了优化,而我们系统的目标是以读取操作为主的工作负载。很多互联网产品都会用到K/V存储,我们之所以选择部署和扩展memcached,是因为它的设计更为简单。
Gribble等人【19】提出了一个早期的键值存储系统版本,适用于互联网规模的服务。Ousterhout等人【29】也提出了大规模内存键值存储系统的案例。与这些解决方案不同,memcache不保证持久性。我们依赖其他系统来处理持久数据存储。
Ports等人【31】提供了一个库来管理事务性数据库查询的缓存结果。我们的需求需要更灵活的缓存策略。我们对租约("Leases")[18]和陈旧读取(”stale reads“)【23】的使用,借鉴了高性能系统中缓存一致性和读取操作的先前研究。Ghandeharizadeh和Yap【15】的工作也提出了一种基于时间戳而非显式版本号解决陈旧集问题的算法。
虽然软件路由器更易于定制和编程,但通常在性能上不如硬件路由器。Dobrescu等人【13】通过利用多核、多内存控制器、多队列网络接口以及通用服务器上的批处理来应对这些问题。将这些技术应用于mcrouter的实现仍是未来的工作。Twitter也独立开发了类似于mcrouter的memcache代理。
在Coda【35】中,Satyanarayanan等人展示了如何同步因断开操作而分歧的数据集。Glendenning等人【17】利用Paxos【24】和仲裁【16】构建Scatter,一种具有线性化语义【21】、能承受变更的分布式哈希表。Lloyd等人【27】研究了COPS,一种在广域存储系统中实现因果一致性的存储系统。
TAO【37】是Facebook的另一个系统,它依赖缓存来服务大量低延迟查询。TAO与memcache有两个根本性区别:(1)TAO实现了图数据模型,其中节点由固定长度的持久标识符(64位整数)标识。(2)TAO将其图模型特定地映射到持久存储上,并负责持久性。我们的许多组件,如客户端库和mcrouter,都被两个系统使用。
9. 结论
在本文中,我们展示了如何扩展基于 memcached 的架构,以满足 Facebook 日益增长的需求。讨论的许多权衡并不是根本性的,而是源于在持续的产品开发中平衡工程资源的现实问题。在构建、维护和演进我们的系统过程中,我们总结出了以下几点经验教训:
- 分离缓存和持久存储系统 可以让我们独立地扩展它们。
- 改进监控、调试和运营效率的功能 与性能一样重要。
- 管理有状态组件 比无状态组件的运营要复杂。因此,将逻辑保持在无状态客户端有助于功能迭代,并最小化系统干扰。
- 系统必须支持新功能的逐步发布和回滚,即使这会导致特性集的暂时异质性。
- 简洁性至关重要。