随着越来越多的用户到 Pinterest 寻求灵感,Pinterest 核心基础设施系统的需求增长的比以往任何时候都快。我们的核心存储系统之一是位于许多微服务和数据库前面的分布式缓存层。它处于 Pinterest 基础架构技术栈的底部,负责吸收由用户增长驱动的绝大多数后端流量。
Pinterest 的分布式缓存集群建立在 AWS 的 EC2 实例上,由数千台机器组成,缓存了数百 TB 的数据,高峰时,每秒可处理 1.5 亿个请求。该缓存层通过降低整个后端技术栈的延迟,来优化顶层性能,并通过减少昂贵的后端所需的容量来提供显著的成本效率。
本文中,我们将对支持 Pinterest 的大规模缓存集群的架构进行深入的技术研究。
应用数据缓存
每个对 Pinterest 的 API 请求都会在内部根据技术栈分发到复杂的 RPC 树,并在完成其关键路径前会涉及数十个服务。这可能包括查询关键数据(例如 Pinterest 的图片和收藏板)的服务,推送相关图片的推荐系统和垃圾内容检测系统。在这些服务中,只要其输入数据可以被唯一键值表示,就可以将该离散的查询单元的结果缓存在临时存储系统中,以便将来重用。
在 Pinterest,分布式缓存层的最常见用途是通过后备语义(look-aside semantics)来存储这类中间计算的结果。这使得缓存层能吸收一大部分流量。如果没有缓存层,这些流量会流向涉及复杂计算和昂贵存储的服务和数据库。凭借着毫秒级的尾延迟(tail latency),以及极低的单位请求基础架构成本,这个分布式缓存层提供了一个高性能低成本的后端扩展机制,以满足 Pinterest 不断增长的需求。
简化版的 Pinterest 的 API 请求生命周期:经过主要 API 服务,其依赖于后端以及分布式缓存层
通过提供分布式缓存层即服务,应用开发人员可以专注于实现业务逻辑,而不必担心分布式数据的一致性、高可用性或者内存容量。缓存层用户使用通用的路由抽象层,以确保应用程序具有容错性和一致的数据视图。此外,缓存服务端集群可以独立于应用层横向扩展,从而透明地调整内存或吞吐量,以适应资源使用情况的变化。
分布式缓存的骨干:Memcached 和 Mcrouter
Memcached 和 mcrouter 构成了 Pinterest 分布式缓存基础架构的骨干,并且在 Pinterest 的存储基础架构中起着至关重要的作用。 Memcached 是由纯 C 语言编写的开源且高效的内存键值存储。Mcrouter 是应用层的 Memcached 协议代理,位于 Memcached 集群的前面,并提供强大的高可用性和路由功能。
Memcached 是缓存解决方案中非常有吸引力的选择:
- 得益于其异步事件驱动的体系结构和多线程处理模型,memcached 非常高效且易于进行横向扩展以满足容量需求。
- Extstore 通过实例的 NVMe 闪存磁盘上的二级温存储(secondary warm storage)层,帮助实现了惊人的存储效率。
- Memcached 精心设计的简单体系结构提供了在其之上构建抽象层的灵活性,以及简单易行的水平可扩展性以满足日益增长的需求。一个 Memcached 进程本身只是一个简单的键值存储,根据设计,它对其它的 Memceched 进程的存在毫无了解,甚至没有 Memcached 集群的概念。
- Memcached 在数十年的开发过程中已经经过准确性和性能的严格测试,并拥有非常活跃的开源社区(该社区还将多个 Pinterest 提交的补丁合并至上游。)。
- Memcached 自带了对 TLS 终止功能的原生支持,从而使我们能通过 TLS 双向身份验证的流量(该过程还额外包括内部搭建的基于 SPIFFE 的授权访问控制)来保护整个集群。
Mcrouter 在 2014 年由 Facebook 开源,在扩展其 Memcached 部署方面发挥了关键作用。Mcrouter 也非常适合 Pinterest 的架构,原因如下:
- 通过为应用开发人员提供与整个缓存集群进行交互的单个终端节点,Mcrouter 充当了 Memcached 服务器集群的有效抽象。此外,将 mcrouter 用作整个系统的唯一接口可以确保 Pinterest 上所有服务和机器之间有通用及全局一致的流量行为。
- Mcrouter 提供了解耦的控制平面和数据平面:Memcached 服务器集群的整个拓扑结构被划分为多个“池”(逻辑集群),而管理客户端和服务器池之间交互的请求路由策略和行为均被独立管理。
- Mcrouter 的配置 API 为复杂的路由提供了强大的基础,包括区域亲和性路由,用于实现数据冗余的复制,多层缓存层和影子流量。
- 作为使用 memcached 的 ASCII 协议的应用层代理,mcrouter 开放了针对智能协议的功能,例如请求处理(TTL 修改、运行中压缩等)。
- Mcrouter 原生地提供了丰富的可观察性功能,并且对客户端应用来说不需要任何成本。这为我们整个基础架构中的 Memcached 流量提供了详细的可见性。对我们而言,其中最重要的指标包括百分位请求延迟,按单个客户端和服务器维度划分的吞吐量,与键前缀和键模式有关的请求趋势以及用于检测服务器行为异常的错误率。
从 mcrouter 到 Memcached 的请求路由总览。每个键前缀都与一个路由策略相关联,图中展示了两个例子。
在实践中,mcrouter 作为边车代理(proxy sidecar)被部署在和服务同一机器的单独进程。如图 2 所示,应用程序(可以由任何语言编写)在推送时将 Memcached 协议请求发送给 mcrouter,然后 mcrouter 作为代理将这些请求发送到数千个上游 memcached 服务器。这种架构能使我们在完全托管的缓存服务器集群中构建强大功能的同时,对客户端服务保持完全透明。
尽管从 Pinterest 早期开始,memcached 一直就是 Pinterest 基础架构的一部分,我们对其客户端的拓展策略在这些年来也在不断进化。具体来说,路由和服务发现在最开始是通过客户端库完成的(这其实很脆弱,而且它还与二进制部署紧密耦合)。然后,该方法被内部构建的一个路由代理取代(该路由代理没有提供用于高可用性的基础功能),最终被 mcrouter 取代。
计算和存储效率
Memcached 的效率很高:单个 r5.2xlarge EC2 实例每秒能支持超过 10 万个请求和数以万计的并发 TCP 连接,同时不会显着地增加客户端的延迟。这使 Memcached 成为 Pinterest 吞吐效率最高的生产服务。这部分归功于编写良好的 C 语言代码以及其体系结构。该体系结构利用了多个工作线程,每个工作线程独立地运行由”libevent“驱动的事件循环,来支持传入的连接。
在 Pinterest,Memcached 的 extstore 在存储效率方面取得了巨大的成功,具体的用例包括可视搜索以及个性化搜索推荐引擎。extstore 扩展了缓存数据容量,在 DRAM 之外增加了挂载在本地的 NVMe 闪存盘,从而将每个实例的可用存储容量从约 55 GB(r5.2xlarge)增加到将近 1.7 TB(i3.2xlarge),而实例成本只是略有增长。
在实践中,extstore 大大优化了数据用量受限的用例,尽管 DRAM 和 SSD 响应时间之间有几个数量级的差异,extstore 却没有牺牲端到端延迟。extstore 的内置调整工具使我们能找到一个平衡了磁盘 I/O、磁盘到内存的重新缓存速率、压缩频率和压缩程度以及客户端尾部响应时间的最佳平衡点。
高可用性
Pinterest 的所有基础架构系统都是高可用的,我们的缓存系统也不例外。 通过利用 mcrouter 提供的丰富的路由功能,我们的 memcached 集群有着一系列的容错功能:
- 针对部分失控或完全宕机的服务器的自动故障转移。网络本身就是不可靠且有损耗的。我们整个缓存架构假定这是不可改变的事实,在服务器不可用或速度缓慢时也可以保持可用性。幸运的是,缓存数据在本质上是瞬态的,这放宽了对数据持久性的要求,而持久性存储(例如数据库)对数据持久性的要求很高。在 Pinterest 中,mcrouter 会自动地在请求响应缓慢时,或者某个服务器宕机时故障转移到全局共享集群,并且 mcrouter 还会通过主动的运行状况检查将服务器加入服务池中。通过自动故障转移以及一系列的单个服务器故障的代理层检测,运维人员可以在最短的生产停机时间内识别并更换行为异常的服务器。
- 通过透明的跨区域复制实现数据冗余。我们的关键用例是跨不同的 AWS 可用区(AZ)进行多集群复制的。这样就可以在完全丢失可用区的情况下实现零停机时间:所有请求都将自动重定向到位于另一个可用区中的运行状况良好的副本节点(replica),在该副本节点中有完整的数据冗余副本。
- 与实际生产流量隔离的影子测试。 mcrouter 中的流量路由功能使我们可以进行各种弹性测试,包括集群到集群的暗流量以及在实际生产请求中人为加入的延迟和停机时间的测试,而不会影响生产。
负载均衡和数据分片
分布式系统的关键功能之一是水平可伸缩性,这是一种可以横向扩展而不是纵向扩展以适应额外的流量增长的能力。在 Pinterest,我们绝大多数的缓存工作量都是受吞吐量限制的,这需要集群中实例的数量与请求的数量大致呈线性比例关系。然而,memcached 本身是一个非常简单的键值存储,它本身并不会知道集群中的其他节点。那么每秒数亿个请求是如果通过网络发送到正确的服务器上的呢?
Mcrouter 通过对每个请求的缓存键运用哈希算法,来将请求确定性地发送到池中的某一个主机。这对于在服务器之间平均分配流量非常有帮助,但是 memcached 有一个独特的要求,即它的集群需要任意可伸缩性,也就是说运维人员要能够自由地根据不断变化的流量需求,来调整集群容量,同时最大程度地减少客户端的影响。
一致性哈希确保了在合格分片的总数增加或减少时,大多数键空间分区也可以映射到同一服务器。高度集中和可预测的命中率影响,允许系统在扩展时对客户端透明,从而防止容量的小范围变化导致集群命中率出现灾难性下降。
一致性哈希算法保证了当单一节点加入现有集群时,大多数键值空间所分配的服务器不变
客户端路由层将单个键值前缀映射到一个或多个这样的一致哈希池,这些一致哈希池位于某个路由策略之后,包括跨可用区复制集群的可用区亲和性偏好路由,针对位于基于闪存的容量集群后方的基于内存集群的 L1L2 路由(具有穿透)等。这样可以隔离流量,从而按客户端的用例情况来分配容量,并且可以确保来自 Pinterest 集群中任何客户端机器的一致缓存路由行为。
优劣权衡和我们的考虑
所有足够复杂的基础架构系统都具有一个共同特点:充满了(往往非常细微的)优劣权衡。在构建和扩展我们的缓存系统的过程中,我们权衡了许多方案的成本和收益。如下是最重要的几点:
- 中间代理层会产生大量的计算和 I/O 开销,特别是对于具有严格的延迟 SLO 并且注重性能的系统而言。但是,mcrouter 所提供的高可用性抽象,灵活的路由行为以及许多其他功能远远比性能损耗更重要。
- 全局共享的代理配置会给更改部署带来风险,因为在部署时,所有控制平面更改都会应用到 Pinterest 的含有数万台机器的整个集群中。然而,这也确保了全局一致的 memcached 集群拓扑和与之相关的路由策略,无论客户端通过何种方式在 Pinterest 内何处进行部署。
- 我们管理维护着约一百个不同的 Memcached 集群,其中,许多集群具有不同的租户(tenancy)特征(专用与共享)、硬件实例类型和路由策略。虽然这给团队带来了相当大的运维负担,但它也允许每个用例达到有效的性能和可用性隔离,同时还能通过选择最适合某个特定工作负载使用情况的参数和实例类型来达到效率优化。
- 在大多数情况下,一致性哈希方案在上游服务器池之间进行负载分配的效果很好,即使在键空间由类似前缀的键簇组成的情况下也是如此。但是,这不能解决热键问题——特定键集的请求量的异常增加仍然会产生因服务器集群中的热分片所导致的负载不平衡的问题。
展望
展望未来,我们希望继续提高 Pinterest 缓存基础架构的效率、可靠性和性能。我们的努力包括当前的一些实验性项目,例如将 memcached 核心直接嵌入到主机应用程序进程中,以处理性能关键的用例(这能使 memcached 与服务流程共享内存空间,并消除网络和 I/O 开销)。此外还有可靠性项目,例如设计一个稳健的多区域冗余解决方案。
如果觉得本文对你有帮助,可以转发关注支持一下