F5 在 Apache Arrow 上的旅程(第一部分)
发布于 2023 年 4 月 11 日
作者 Laurent Quérel
Apache Arrow 是一种在大数据、分析和机器学习应用中广泛采用的技术。在本文中,我们将分享 F5 使用 Arrow 的经验,特别是其在遥测方面的应用,以及我们在优化 OpenTelemetry 协议以显著降低带宽成本时遇到的挑战。我们取得的喜人成果促使我们分享我们的见解。本文特别关注将相对复杂的数据结构从各种格式转换为高效的 Arrow 表示形式,以优化压缩比、传输和数据处理。我们还探讨了不同映射和规范化策略之间的权衡,以及使用 Arrow 和 Arrow Flight 进行流式传输和批处理通信的细微差别。迄今为止,我们的基准测试结果令人鼓舞,压缩比提高了 1.5 倍到 5 倍,具体取决于数据类型(指标、日志、跟踪)、分布和压缩算法。本文提出的解决这些挑战的方法也可能适用于其他 Arrow 领域。本文是两部分系列文章的第一部分。
什么是 Apache Arrow
Apache Arrow 是一个开源项目,它提供了一种标准化的、与语言无关的内存中格式,用于表示结构化和半结构化数据。这使得系统之间能够共享数据和零拷贝数据访问,从而在不同 CPU 架构和编程语言之间交换数据集时无需序列化和反序列化。此外,Arrow 库具有一整套高性能、并行和矢量化的内核函数,旨在高效处理大量列式数据。这些特性使 Arrow 成为大数据处理、数据传输、分析和机器学习应用的诱人技术。越来越多的 产品和开源项目 已经将 Apache Arrow 作为其核心或提供 Arrow 支持,这反映了对其优势的广泛认可和赞赏(请参阅这篇文章,以深入了解 Arrow 生态系统和采用情况)。超过 11,000 名 GitHub 用户支持该项目,840 多名贡献者使该项目取得了无可否认的成功。
人们经常询问 Arrow 与 Apache Parquet 或其他列式文件格式之间的区别。Arrow 旨在并优化用于内存处理,而 Parquet 则专为基于磁盘的存储而定制。实际上,这些技术是互补的,它们之间存在桥梁以简化互操作性。在这两种情况下,数据都以列的形式表示,以优化访问、数据局部性和可压缩性。然而,权衡略有不同。Arrow 优先考虑数据处理速度,而不是最佳数据编码。Arrow 通常不原生支持那些不能从 SIMD 指令集中受益的复杂编码,这与 Parquet 等格式不同。以 Parquet 格式存储数据,并以 Arrow 格式处理和传输数据,已成为大数据社区中的一种普遍模式。
图 1 说明了行式和列式方法之间内存表示的差异。列式方法将来自同一列的数据分组到连续的内存区域中,这有助于并行处理 (SIMD) 并提高压缩性能。
我们为什么对 Apache Arrow 感兴趣
在 F5,我们已将 OpenTelemetry (OTel) 作为所有产品(例如 BIGIP 和 NGINX)中所有遥测的标准。这些产品可能会由于各种原因(从性能评估到取证目的)生成大量指标和日志。这些系统生成的数据通常集中存储并由专用系统处理。传输和处理这些数据占遥测管道相关成本的很大一部分。在这种情况下,我们对 Apache Arrow 产生了兴趣。我们没有重新发明另一种遥测解决方案,而是决定投资 OpenTelemetry 项目,致力于改进协议以显著提高其在高遥测数据量下的效率。我们与 Lightstep 的 Joshua MacDonald 合作,将这些优化集成到 实验性 OTel 收集器 中,目前正在与 OTel 技术委员会讨论以敲定代码 捐赠。
该项目分为两个阶段。第一阶段即将完成,旨在提高协议的压缩比。第二阶段计划在未来进行,重点是通过在所有级别集成 Apache Arrow 来提高端到端性能,从而无需在旧协议和新协议之间进行转换。迄今为止,结果令人鼓舞,我们的基准测试显示压缩比提高了 1.5 倍到 5 倍,具体取决于数据类型(指标、日志、跟踪)、分布和压缩算法。对于第二阶段,我们的估计表明数据处理加速可能在 2 倍到 12 倍之间,同样取决于数据的性质和分布。有关更多信息,我们鼓励您查阅规范和参考实现。
Arrow 依赖模式来定义它处理和传输的数据批次的结构。后续部分将讨论可用于优化这些模式创建的各种技术。
如何利用 Arrow 优化网络传输成本
Apache Arrow 是一个复杂的项目,其生态系统发展迅速,有时会让新手感到不知所措。幸运的是,Arrow 社区发布了三篇入门文章 1、2 和 3,我们建议那些对探索此技术感兴趣的人阅读。
本文主要关注将数据从 XYZ 格式转换为高效的 Arrow 表示形式,以优化压缩比和数据处理。这种转换有多种方法,我们将研究这些方法如何影响转换过程中的压缩比、CPU 使用率和内存消耗等因素。
您初始模型的复杂性会显著影响您需要做出的 Arrow 映射选择。首先,确定您希望针对特定上下文进行优化的属性至关重要。压缩率、转换速度、内存消耗、最终模型的速度和易用性、兼容性和可扩展性都是可能影响您最终映射决策的因素。从那里,您必须探索多个替代模式。
每个字段的 Arrow 类型和数据编码的选择将影响您的模式的性能。有多种方法可以表示分层数据或高度动态的数据模型,需要与传输层的配置协调评估多种选项。还应仔细考虑此传输层。Arrow 支持可能默认不激活的压缩机制和字典增量。
经过几次迭代后,您应该会得到一个满足您最初设定的目标的优化模式。使用真实数据比较不同方法的性能至关重要,因为每个单独字段中的数据分布可能会影响您是否使用字典编码。
Arrow 数据类型选择
选择 Arrow 数据类型的原理与为数据库定义数据模型时使用的原理非常相似。Arrow 支持多种数据类型。其中一些类型受到所有实现的全面支持,而另一些类型仅适用于 Arrow 社区支持最强的语言(请参阅此 页面,以获取不同实现的比较矩阵)。对于原始类型,通常最好选择提供最简洁表示形式且最接近初始字段语义的类型。例如,虽然可以用 int64 表示时间戳,但使用原生 Arrow Timestamp 类型会更有优势。这种选择并非因为更高效的二进制表示,而是因为在您的管道中处理和操作它会更容易。DataFusion 等查询引擎为这种类型的列提供专用的时间戳处理函数。对于日期、时间、持续时间和间隔等原始类型,也可以做出相同的选择。但是,如果您的项目需要最大兼容性,在某些情况下,可能需要优先选择具有通用支持的类型,而不是在内存占用方面最优的类型。
选择 Arrow 数据类型时,务必考虑压缩前后的数据大小。两种不同类型的压缩后大小很可能相同,但实际内存大小可能大两倍、四倍甚至八倍(例如,uint8 与 uint64)。这种差异将影响您处理大量数据的能力,并且还将显著影响在内存中处理这些数据的速度(例如,缓存优化、SIMD 指令效率)。
也可以使用 扩展类型 机制来扩展这些类型,该机制基于当前支持的原始类型之一构建,同时添加特定语义。这种扩展机制可以简化您在自己的项目中使用此数据,同时对将此数据解释为基本原始类型的中间系统保持透明。
原始类型的编码有一些变化,我们接下来将探讨。
数据编码
优化 Arrow 模式的另一个关键方面是分析数据的基数。通常,仅具有有限数量值的字段将通过字典编码更有效地表示。
字段的最大基数决定了字典的数据类型特征。例如,对于表示 HTTP 事务状态码的字段,最好使用索引类型为“uint8”且值类型为“uint16”的字典(表示法:“Dictionary
字典编码在 Apache Arrow 中高度灵活,允许为任何 Arrow 原始类型创建编码。索引的大小也可以根据上下文进行配置。
通常,建议在以下情况下使用字典
- 枚举的表示
- 文本或二进制字段的表示,具有很高概率具有冗余值。
- 基数已知低于 2^16 或 2^32 的字段的表示。
有时,字段的基数事先未知。例如,将数据流从行式格式转换为一系列列式编码批次(例如,OpenTelemetry 收集器)的代理可能无法提前预测字段是否将具有固定数量的不同值。有两种方法可能
- 使用最大数据类型(例如,“int64”、“string”等,而不是字典)的保守方法,
- 根据观察到的字段基数动态修改模式的自适应方法。在第二种方法中,如果没有基数信息,您可以乐观地从使用“Dictionary
”字典开始,然后在转换过程中检测潜在的字典溢出,并在溢出时将模式更改为“Dictionary ”。这种字典溢出自动管理技术将在未来的文章中更详细地介绍。
Apache Arrow 的最新进展包括实现了运行结束编码,这是一种有效表示具有重复值序列的数据的技术。这种编码方法对于处理包含大量相同值的数据集特别有利,因为它提供了更紧凑和优化的表示。
总之,字典编码不仅在内存和传输过程中占用更少的空间,而且显著提高了压缩比和数据处理速度。但是,这种表示类型在提取初始值时需要间接(尽管在某些数据处理操作中并非总是必需)。此外,重要的是要管理字典索引溢出,尤其是在编码字段没有明确定义的基数时。
分层数据
基本的分层数据结构可以很好地转换为 Arrow。然而,正如我们将看到的,在更一般的情况下,需要处理一些复杂问题(参见图 6)。虽然 Arrow 模式确实支持嵌套结构、映射和联合,但 Arrow 生态系统的一些组件并不完全支持它们,这使得这些 Arrow 数据类型不适合某些场景。此外,与大多数语言和格式(如 Protobuf)不同,Arrow 不支持递归定义模式的概念。Arrow 模式的定义是静态的,其嵌套元素的深度必须事先已知。有多种策略可以解决此限制,我们将在以下部分中探讨这些策略。
自然表示
表示简单分层数据模型最直接和直观的方法是使用 Arrow 的列表、映射和联合数据类型。然而,重要的是要注意,其中一些数据类型并非在整个 Arrow 生态系统中都得到完全支持。例如,联合到 Parquet 的转换不直接支持,需要一个转换步骤(参见非规范化和扁平化表示以将稀疏联合分解为可为空的结构和类型 ID 列)。同样,列表和映射尚未支持在 DataFusion 版本 20 中(嵌套结构部分支持)。
以下示例是使用这些不同数据类型表示上述模型的 Go 程序片段,它使用了 Arrow 模式。
import "github.com/apache/arrow/go/v11/arrow"
const (
GaugeMetricCode arrow.UnionTypeCode = 0
SumMetricCode arrow.UnionTypeCode = 1
)
var (
// uint8Dictionary represent a Dictionary<Uint8, String>
uint8Dictionary = &arrow.DictionaryType{
IndexType: arrow.PrimitiveTypes.Uint8,
ValueType: arrow.BinaryTypes.String,
}
// uint16Dictionary represent a Dictionary<Uint16, String>
uint16Dictionary = &arrow.DictionaryType{
IndexType: arrow.PrimitiveTypes.Uint16,
ValueType: arrow.BinaryTypes.String,
}
Schema = arrow.NewSchema([]arrow.Field{
{Name: "resource_metrics", Type: arrow.ListOf(arrow.StructOf([]arrow.Field{
{Name: "scope", Type: arrow.StructOf([]arrow.Field{
// Name and Version are declared as dictionaries (Dictionary<Uint16, String>)).
{Name: "name", Type: uint16Dictionary},
{Name: "version", Type: uint16Dictionary},
}...)},
{Name: "metrics", Type: arrow.ListOf(arrow.StructOf([]arrow.Field{
{Name: "name", Type: uint16Dictionary},
{Name: "unit", Type: uint8Dictionary},
{Name: "timestamp", Type: arrow.TIMESTAMP},
{Name: "metric_type", Type: arrow.UINT8},
{Name: "data_point", Type: arrow.ListOf(arrow.StructOf([]arrow.Field{
{Name: "metric", Type: arrow.DenseUnionOf(
[]arrow.Field{
{Name: "gauge", Type: arrow.StructOf([]arrow.Field{
{Name: "data_point", Type: arrow.FLOAT64},
}...)},
{Name: "sum", Type: arrow.StructOf([]arrow.Field{
{Name: "data_point", Type: arrow.FLOAT64},
{Name: "is_monotonic", Type: arrow.BOOL},
}...)},
},
[]arrow.UnionTypeCode{GaugeMetricCode, SumMetricCode},
)},
}...))},
}...))},
}...))},
}, nil)
)
在此模式中,我们使用联合类型来表示继承关系。Arrow 联合有两种类型,针对不同情况进行了优化。密集联合类型具有相对简洁的内存表示,但不支持矢量化操作,使其在处理阶段效率较低。相反,稀疏联合支持矢量化操作,但会带来与联合中变体数量成正比的内存开销。密集联合和稀疏联合具有非常相似的压缩率,稀疏联合有时略有优势。此外,应普遍避免具有大量变体的稀疏联合,因为它们可能导致过度的内存消耗。有关联合内存表示的更多详细信息,您可以查阅此页面。
在某些情况下,使用多个模式(即每个子类型一个模式)来表示继承关系可能更符合惯例,从而避免使用联合类型。但是,将这种方法应用于上述模型可能不是最佳选择,因为继承关系之前的数据(即 ResourceMetrics、Scope 和 Metrics)可能会重复多次。如果 ResourceMetrics、Metrics 和 DataPoint 之间的关系是 0..1(零到一)关系,那么多模式方法可能是最简单和最符合惯例的解决方案。
非规范化和扁平化表示
如果您的遥测管道不支持 `List` 类型,您可以对数据模型进行非规范化。这个过程通常用于数据库领域,以出于优化目的删除两个表之间的连接。在 Arrow 世界中,非规范化用于通过复制一些数据来消除 `List` 类型。转换后,以前的 Arrow 模式变为。
Schema = arrow.NewSchema([]arrow.Field{
{Name: "resource_metrics", Type: arrow.StructOf([]arrow.Field{
{Name: "scope", Type: arrow.StructOf([]arrow.Field{
// Name and Version are declared as dictionaries (Dictionary<Uint16, String>)).
{Name: "name", Type: uint16Dictionary},
{Name: "version", Type: uint16Dictionary},
}...)},
{Name: "metrics", Type: arrow.StructOf([]arrow.Field{
{Name: "name", Type: uint16Dictionary},
{Name: "unit", Type: uint8Dictionary},
{Name: "timestamp", Type: arrow.TIMESTAMP},
{Name: "metric_type", Type: arrow.UINT8},
{Name: "data_point", Type: arrow.StructOf([]arrow.Field{
{Name: "metric", Type: arrow.DenseUnionOf(
[]arrow.Field{
{Name: "gauge", Type: arrow.StructOf([]arrow.Field{
{Name: "value", Type: arrow.FLOAT64},
}...)},
{Name: "sum", Type: arrow.StructOf([]arrow.Field{
{Name: "value", Type: arrow.FLOAT64},
{Name: "is_monotonic", Type: arrow.BOOL},
}...)},
},
[]arrow.UnionTypeCode{GaugeMetricCode, SumMetricCode},
)},
}...)},
}...)},
}...)},
}, nil)
列表类型在所有级别都被消除。模型的初始语义通过复制每个数据点值以下级别的数据来保留。内存表示通常会比以前的表示大得多,但不支持列表类型的查询引擎仍然能够处理此数据。有趣的是,一旦压缩,这种数据表示方式不一定比以前的方法大。这是因为当数据中存在冗余时,列式表示的压缩效果非常好。
如果您的管道的某些组件不支持联合类型,也可以通过合并联合变体来消除它们(嵌套结构“metric”被删除,见下文)。
Schema = arrow.NewSchema([]arrow.Field{
{Name: "resource_metrics", Type: arrow.StructOf([]arrow.Field{
{Name: "scope", Type: arrow.StructOf([]arrow.Field{
// Name and Version are declared as dictionaries (Dictionary<Uint16, String>)).
{Name: "name", Type: uint16Dictionary},
{Name: "version", Type: uint16Dictionary},
}...)},
{Name: "metrics", Type: arrow.StructOf([]arrow.Field{
{Name: "name", Type: uint16Dictionary},
{Name: "unit", Type: uint8Dictionary},
{Name: "timestamp", Type: arrow.TIMESTAMP},
{Name: "metric_type", Type: arrow.UINT8},
{Name: "data_point", Type: arrow.StructOf([]arrow.Field{
{Name: "value", Type: arrow.FLOAT64},
{Name: "is_monotonic", Type: arrow.BOOL},
}...)},
}...)},
}...)},
}, nil)
最终模式已演变为一系列嵌套结构,其中联合变体的字段合并为一个结构。这种方法的权衡与稀疏联合相似——变体越多,内存占用越高。Arrow 支持位图有效性概念,用于识别各种数据类型(包括没有唯一空表示的数据类型,例如原始类型)的空值(每个条目 1 位)。位图有效性的使用使查询部分更容易,并且 DataFusion 等查询引擎知道如何高效地使用它。包含大量空值的列通常压缩效率很高,因为底层数组通常用 0 初始化。在压缩时,这些大量的 0 序列会导致高压缩效率,尽管在稀疏联合的情况下,压缩前会有内存开销。因此,根据您的具体情况选择适当的权衡至关重要。
在某些极端情况下,当不支持嵌套结构时,可以使用扁平化方法来解决此问题。
Schema = arrow.NewSchema([]arrow.Field{
{Name: "scope_name", Type: uint16Dictionary},
{Name: "scope_version", Type: uint16Dictionary},
{Name: "metrics_name", Type: uint16Dictionary},
{Name: "metrics_unit", Type: uint8Dictionary},
{Name: "metrics_timestamp", Type: arrow.TIMESTAMP},
{Name: "metrics_metric_type", Type: arrow.UINT8},
{Name: "metrics_data_point_value", Type: arrow.FLOAT64},
{Name: "metrics_data_point_is_monotonic", Type: arrow.BOOL},
}, nil)
通过连接父结构的名称来重命名终端字段(叶),以提供适当的作用域。这种类型的结构受到 Arrow 生态系统所有组件的支持。如果兼容性是系统的重要标准,则此方法可能很有用。但是,它具有与其他替代非规范化模型相同的缺点。
Arrow 生态系统发展迅速,因此查询引擎对 List、Map 和 Union 数据类型的支持可能会迅速改善。如果内核函数对于您的应用程序足够或更优,通常可以使用这些嵌套类型。
自适应/动态表示
某些数据模型转换为 Arrow 模式可能更具挑战性,例如以下 Protobuf 示例。在此示例中,每个数据点都添加了一组属性。这些属性使用大多数语言和格式(如 Protobuf)支持的递归定义(请参见下面的“AnyValue”定义)。不幸的是,Arrow(像大多数经典数据库模式一样)不支持模式中的此类递归定义。
syntax = "proto3";
message Metric {
message DataPoint {
repeated Attribute attributes = 1;
oneof value {
int64 int_value = 2;
double double_value = 3;
}
}
enum MetricType {
UNSPECIFIED = 0;
GAUGE = 1;
SUM = 2;
}
message Gauge {
DataPoint data_point = 1;
}
message Sum {
DataPoint data_point = 1;
bool is_monotonic = 2;
}
string name = 1;
int64 timestamp = 2;
string unit = 3;
MetricType type = 4;
oneof metric {
Gauge gauge = 5;
Sum sum = 6;
}
}
message Attribute {
string name = 1;
AnyValue value = 2;
}
// Recursive definition of AnyValue. AnyValue can be a primitive value, a list
// of AnyValues, or a list of key-value pairs where the key is a string and
// the value is an AnyValue.
message AnyValue {
message ArrayValue {
repeated AnyValue values = 1;
}
message KeyValueList {
message KeyValue {
string key = 1;
AnyValue value = 2;
}
repeated KeyValue values = 1;
}
oneof value {
int64 int_value = 1;
double double_value = 2;
string string_value = 3;
ArrayValue list_value = 4;
KeyValueList kvlist_value = 5;
}
}
如果属性的定义是非递归的,则可以直接将它们转换为 Arrow Map 类型。
为了解决此类问题并进一步优化 Arrow 模式定义,您可以采用一种自适应和迭代的方法,该方法根据正在转换的数据自动构建 Arrow 模式。通过这种方法,字段根据其基数自动进行字典编码,未使用的字段被消除,并且递归结构以特定方式表示。另一种解决方案涉及使用多模式方法,其中属性在单独的 Arrow Record 中描述,并且继承关系由自引用关系表示。这些策略将在未来的文章中更深入地介绍。对于那些渴望了解更多信息的人,第一种方法在 OTel Arrow Adapter 的参考实现中得到使用。
数据传输
与 Protobuf 不同,参与交换的双方通常事先不知道 Arrow 模式。在能够以 Arrow 格式交换数据之前,发送方必须首先将模式以及数据中使用的字典内容传达给接收方。只有在完成此初始化阶段后,发送方才能以 Arrow 格式传输数据批次。此过程,称为 Arrow IPC 流,在系统之间传输 Arrow 数据中起着至关重要的作用。可以采用几种方法来通信这些 Arrow IPC 流。最简单的方法是使用 Arrow Flight,它将 Arrow IPC 流封装在基于 gRPC 的协议中。但是,也可以针对特定上下文使用自己的实现。无论选择哪种解决方案,都必须理解底层协议必须是有状态的才能充分利用 Arrow IPC 流方法。为了实现最佳压缩率,至关重要的是仅发送一次模式和字典,以便分摊成本并最大限度地减少批次之间的数据冗余。这需要支持流式通信的传输,例如 gRPC。
对于大型批次,使用无状态协议是可能的,因为模式的开销与使用字典编码和列式表示实现的压缩收益相比可以忽略不计。然而,每次批次都必须传输字典,这使得这种方法通常不如流式方法高效。
Arrow IPC Stream 还支持“增量字典”的概念,这进一步优化了批处理传输。当批处理向现有字典(在发送方)添加数据时,Arrow IPC 允许发送增量字典,然后发送引用它的批处理。在接收方,此增量用于更新现有字典,从而在发生更改时无需重新传输整个字典。此优化仅在有状态协议下才可能实现。
为了充分利用 Apache Arrow 的列式格式,考虑排序和压缩至关重要。如果您的数据模型简单(即扁平)并且有一列或多列代表数据的自然顺序(例如,时间戳),则对数据进行排序可能有助于优化最终压缩比。在实施此优化之前,建议对实际数据进行测试,因为效果可能有所不同。无论如何,在发送批处理时使用压缩算法是有利的。Arrow IPC 通常支持 ZSTD 压缩算法,它在速度和压缩效率之间取得了出色的平衡,特别是对于列式数据。
最后,一些实现(例如 Arrow Go)默认未配置为支持增量字典和压缩算法。因此,确保您的代码采用这些选项以最大限度地提高数据传输效率至关重要。
实验
如果您的初始数据很复杂,建议进行自己的实验,以根据您的数据和目标(例如,优化压缩比或增强 Arrow 格式数据的可查询性)来优化 Arrow 表示。在我们的案例中,我们为 Apache Arrow 开发了一个覆盖层,使我们能够轻松地进行这些实验,而无需处理 Arrow API 的固有复杂性。然而,与直接使用 Arrow API 相比,这会以较慢的转换阶段为代价。虽然此库目前不公开,但如果兴趣足够,它可能会公开。
我们还采用了“黑盒优化”方法,该方法会自动找到最佳组合以实现我们旨在优化的目标(有关此方法的描述,请参阅“使用 Google Vertex AI Vizier 优化您的应用程序”)。
结论和后续步骤
从本质上讲,Apache Arrow 背后的关键概念是它消除了序列化和反序列化的需要,实现了零拷贝数据共享。Arrow 通过定义一种与语言无关的内存中格式来实现这一点,该格式在各种实现中保持一致。因此,原始内存字节可以直接通过网络传输,而无需任何序列化或反序列化,从而显著提高数据处理效率。
将数据模型转换为 Apache Arrow 需要适应和优化工作,正如我们在这篇文章中已经开始描述的那样。必须考虑许多参数,建议进行一系列实验以验证在此过程中做出的各种选择。
使用 Arrow 处理高度动态数据可能具有挑战性。Arrow 需要定义静态模式,这有时会使表示这种类型的数据变得复杂或次优,尤其是当初始模式包含递归定义时。本文讨论了解决此问题的几种方法。下一篇文章将专门介绍一种混合策略,该策略涉及根据所表示的数据动态调整 Arrow 模式,以优化内存使用、压缩比和处理速度。这种方法非常独特,值得单独撰写一篇文章。