一、引言
Apache Kafka 无处不在。它是分布式消息传递的首选,为全球众多公司提供各种用例服务,包括消息传递、日志聚合和流处理。
许多公司都在使用它,包括 PayPal、Uber 和 LinkedIn 等大型科技公司。然而,并非所有人都使用 Kafka;他们会使用其他可用的解决方案,或者……构建自己的系统。在今天的文章中,我们将探讨 Scribe,这是 Meta 构建的一个消息队列服务,用以支撑全球规模的流量。
二、术语
在继续之前,我们首先了解一些 Scribe 的概念:
首先是 Category。它向用户展示了一个逻辑流的概念。
与 Kafka 类似,向 Category 写入和读取数据的客户端也被称为生产者和消费者。两者都以库的形式实现。一个应用程序可以根据需要拥有多个生产者和消费者实例。
对于消费消息,一组消费者可以共同处理来自一个 Category 的消息。数据可以根据消息的键或值被分割成逻辑分片。一个消费者可以只读取来自一个逻辑分片的消息。Scribe 允许整个流量在消费者组之间任意分配,而无需依赖分片方案。
还有一个物理分片的概念,它们是包含与消息负载相关联的元数据的日志文件。我们将在后面更详细地探讨。
三、高层架构
从写入路径来看,生产者实例接收来自用户的消息,对它们进行批处理,然后将它们发送给 ScribeD。在这个阶段,一个批次可能包含来自不同 Category 的消息。然后,ScribeD 将这些批次发送给写入代理。
消息随后被拆分,使得来自同一 Category 的消息属于同一批次。写入代理然后将这些批次路由到批处理服务。批处理服务持久化这些批次并提交它们的元数据。
对于读取路径,消费者实例联系读取流服务,从元数据存储中检索元数据。然后,消费者使用元数据中的信息(例如,从哪里读取元数据)来形成对读取代理的请求。该代理管理所有相关的数据访问操作。
接下来,我们将更深入地探讨写入/读取操作的细节,以及数据是如何存储的。
四、写入
生产者库是入口点。可以在多个主机上启动多个生产者实例。当应用程序向生产者库发出写入请求时,它们将获得一个包含写入操作结果的对象。
为了节省内存占用,生产者将来自多个 Category 的消息在内存中批量聚合。生产者将消息批次刷新到 ScribeD,这是一个本地守护进程,它接收来自主机上所有生产者实例的消息,并最终将它们发送到写入代理。
ScribeD 的主要目标是通过将生产者刷新(在内存中)的消息缓冲到磁盘上,来确保这些消息的容错性。这种方法可以防止在写入持久化存储时发生故障导致数据丢失。
在接收消息批次后,写入代理首先对这些消息执行准入控制检查,然后将来自同一 Category 的消息拆分到同一批次中。这些批次被路由到批处理服务。
批处理服务压缩批次数据并将其刷新到临时数据存储和持久化负载存储。之后,批处理服务将这些批次的元数据(包括指向批次数据的指针)写入基于日志的元数据存储。这些元数据将帮助 Scribe 为消费者提供顺序的、流式抽象的读取模式。
五、存储
如前所述,Meta 将元数据和数据分离开。消息元数据存储在 LogDevice 中,这是一个为顺序数据读写操作优化的基于日志的存储系统。
1、元数据存储
对于每个 Category,LogDevice 将每个数据批次的元数据追加到一个称为 log 的文件中。每个日志都是该 Category 的一个物理分片。日志文件中的每个记录都有一个单调递增的序列号。
Meta 在 LogDevice 集群中存储了数百万个日志。每个 LogDevice 集群包含存储节点。每个记录可以在这些节点的任意子集上进行复制,确保数据冗余。
2、持久化数据存储(DDS)
所有消息负载都存储在 Tectonic 中,这是 Meta 构建的用于替代 HDFS 的分布式文件系统。这个文件系统也是其他系统的支柱,例如 Meta 的数据仓库。
Tectonic 不是基于一个因子完全复制负载,而是支持纠删码来确保数据可靠性。与简单的复制相比,存储占用空间会更小;然而,在发生故障需要数据重建时,这种方法将需要更多资源。
为了控制数据块的数量,Scribe 可以将来自多个 Category 的数据存储在同一个块中。写入代理累积大小达到几十兆字节级别的块,然后执行刷新操作到 Tectonic 存储节点的磁盘。在一个块中,来自同一 Category 的数据也会被累积起来,以更有效地服务读取操作,累积大小最高可达 2 兆字节。
3、临时数据存储(EDS)
除了持久化存储,消息负载也存在于区域性的临时数据存储中,这本质上是一个具有两层结构的缓存:本地缓存和远程缓存。
这一点很重要,因为 DDS 可能位于与读取用户不同的区域。一个 Category 通常从全球运行的生产者那里接收输入数据记录。这导致当消费者发出读取请求时,它从不同区域读取大部分数据。
EDS 在这里起到了救援作用,因为它允许用户从不同区域访问 DDS。其目标是尽量减少对包含实际负载数据的远程存储的访问。
除了降低延迟和避免对 Tectonic 的压力之外,EDS 的其他有趣职责将在本文后面讨论。
对于远程层,Meta 利用 Memcached 来存储消息负载,存储时长为 1-2 小时。Meta 认为这段时间对于想要消费被认为是“温”数据的用户来说已经足够。对于更“热”的数据,Meta 使用 Cachelib 来管理缓存,它利用读取代理主机的空闲内存资源。我们将在讨论读取路径时更深入地探讨 EDS。
六、读取
读取路径的入口点也是一个名为 Consumers 的库。应用程序通常启动一组消费者实例来从 Category 读取数据。当用户启动一个消费者实例时,会生成一个到读取流服务的有状态连接。
Meta 尝试以确保消费者实例和读取流服务位于同一区域的方式建立连接。该读取流服务负责(在元数据的帮助下)为用户提供流抽象。
该服务的一个实例拥有一个到 LogDevice 集群的连接池。当它收到消费者请求时,它会根据消费者的输入,识别所有必需的 LogDevice 集群、物理分片以及要读取的分片范围。
一个不同的实例(称为读取器)将处理从 LogDevice 集群实际读取数据的操作。这个读取流实例合并来自读取器的结果,为消费者提供单一的元数据流。
消费者使用这个元数据(包括数据指针)向读取代理发出 RPC 请求以获取负载。读取代理的主要工作是处理与访问负载存储相关的所有事情。
当收到来自消费者的请求时,读取代理尝试从区域临时数据存储中获取数据。数据可能存在于 Memcached 实例的内存缓存中。
如果读取代理必须访问持久化数据存储,则该请求被路由到与 DDS 中存储的负载位于同一区域的读取代理实例。
如果消费者指定了任何过滤或序列化选项,读取代理将对其访问的消息应用这些选项。
七、元数据如何管理
元数据是 Scribe 的核心。许多组件需要访问元数据存储来支持写入和读取操作。尽管 LogDevice 是一个事务性的、高可用的数据库,但数百万客户端尝试查询元数据则是另一回事。
为了解决这个问题,Meta 在元数据存储之上构建了一个缓存和分发层。有一个后台作业会扫描整个元数据数据库,并将内容填充到一个配置分发系统中。客户端可以从此处读取元数据。
对于更详细的元数据,例如包含指向所需数据的指针的物理分片,一个周期性作业会轮询 LogDevice 集群以检索集群、Category 和物理分片之间的最新映射关系,并将该映射关系暴露在一个高度复制的数据库中。
对于来自消费者的请求,读取流服务实例订阅相关的映射关系。当在运行时添加或删除物理分片时,映射关系可能会发生变化。基于这些信息,服务可以相应地启动或停止读取器实例。
八、流量如何管理
为了应对全球规模的流量,Scribe 在不同层级利用了多层流量管理系统。
集群内流量整形: Scribe 动态调整其内部资源以处理单个 LogDevice 集群内的流量。当特定 Category 出现流量激增时,一项服务会自动将其现有的物理分片拆分成更小的分片,以确保该 Category 的水平可扩展性。当流量下降时,系统会在多余的分片超过保留期后逐渐移除它们。
集群间流量整形: 控制平面持续监控每个 LogDevice 集群的写入和读取工作负载,以调整传入的写入工作负载限制,确保集群不会被压垮。
当达到限制时,写入代理可以将流量路由到同一区域内的另一个 LogDevice 集群。如果过载是全球性的,则使用优雅降级方法来保护系统。
主机级流量整形: 在单个服务器级别,Scribe 使用 Meta 的服务网格来路由流量。服务网格使用主机暴露的负载指标来将请求路由到负载最轻的服务器。
当服务器接受一个将在长时间内消耗大量资源的请求时,它会立即通告一个“虚假的”高负载指标。这个高值告诉系统的其余部分,该服务器已被“预留”,即使实际的资源使用尚未开始。
然后服务器指数级衰减这个负载值。负载指标随着时间的推移逐渐降低。等到请求的实际资源消耗变得明显时,人工设置的值已经衰减到能够反映服务器实际负载的程度。
这个机制至关重要,因为它可以防止服务网格将过多请求路由到已经在处理繁重工作负载的服务器。如果没有它,服务网格会在一个长时间请求的最初几秒内认为主机空闲,并假定该服务器能够处理新的请求。
对于特定任务,Scribe 使用一致性哈希将请求分配给特定主机,这有助于最大化效率。这在诸如写入代理将特定数据 Category 的消息组发送到同一个批处理服务主机,或者当请求需要路由到特定的读取代理主机以利用该主机上的内存缓存等用例中很有帮助。
九、临时数据如何管理
如上所述,Scribe 在临时数据存储中管理数据副本,目的是:
Scribe 围绕资源使用一种约束满足模型,例如持久化存储 IO 或 Memcached 网络利用率,来确定创建哪些副本以及创建在何处。系统的“控制平面”基于几个因素做出这些决策,旨在在资源限制内最大化收益。考虑的关键因素包括:
根据数据中心特定的资源限制,Scribe 将采用不同的缓存策略以充分利用可用资源。例如,一个 Memcached 出口流量有限的区域可能会使用读取代理内存中的副本作为“盾牌”,以防止过多请求冲击缓存。
Scribe 的控制平面不断重新计算这些策略,以适应波动的工作负载。
十、支持的保证
Scribe 支持几种消息传递保证:尽力而为、至少一次和可重复读取的至少一次。
1、尽力而为
尽力而为保证的总体思路是优先考虑吞吐量和可用性。这是为高容量、“发射后不管”的数据流设计的权衡,在这种情况下,快速传递大部分数据比确保每条消息都恰好传递一次且保持严格的顺序更重要。
从写入路径来看:
从读取路径来看:
2、至少一次
其主要目标是确保每条消息都成功存储。Scribe 通过以下方式实现这一点:
存储确认: 客户端只有在收到存储层确认消息已成功保存后,才认为消息已发送。
积极重试: 如果消息没有收到确认,它会被重新发送。这个过程在写入路径的每一步重复,直到确认消息已保存。
然而,积极的重试可能导致数据重复。为了最小化这种情况,Scribe 实施了多项优化:
3、可重复读取
不可重复读: 在一个事务过程中,如果事务在不同时间点看到同一数据具有不同的值,则此行为称为不可重复读。
通过可重复读取,Scribe 保证消费者读取数据流时,如果重新读取,将看到相同顺序的相同数据。这是比上述两种保证更强的保证,对于需要严格数据排序和可靠处理的用例(如变更数据捕获)至关重要。
Scribe 提供了两种不同的方法来实现这一点,每种方法都有其自身的权衡:写入路径变体和读取路径变体。
写入路径变体:
读取路径变体:
十一、结语
在本文中,我们首先探讨了 Scribe 的术语,然后深入研究了其架构,该架构包含许多在读写路径上职责清晰的组件。Scribe 还将元数据与数据分离,并引入了缓存层来改进读取路径。
然后我们更仔细地了解了 Scribe 如何管理流量、元数据以及缓存系统中的数据。最后,我们将通过研究 Scribe 提供的几种消息传递保证来结束本文。
>>>>
参考资料
作者丨Vu Trinh 编译丨Rio
来源丨网址:https://vutr.substack.com/p/how-did-meta-move-terabytes-of-data
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
如果字段的最大可能长度超过255字节,那么长度值可能…
只能说作者太用心了,优秀
感谢详解
一般干个7-8年(即30岁左右),能做到年入40w-50w;有…
230721