数据想自由:使用 Apache Arrow 实现快速数据交换


已发布 2025 年 2 月 28 日
作者 David Li, Ian Cook, Matt Topol

这是旨在揭开 Arrow 作为数据库和查询引擎的数据交换格式的神秘面纱的系列文章中的第二篇。

本系列文章

  1. Apache Arrow 格式如何加速查询结果传输
  2. 数据想自由:使用 Apache Arrow 实现快速数据交换

作为数据从业者,我们经常发现我们的数据被“挟持”。我们不得不花费时间——花费时间来解析和清理低效且混乱的 CSV 文件,花费时间等待过时的查询引擎艰难地处理几个 GB 的数据,以及花费时间等待数据通过套接字传输。而这最后一个问题是我们今天要关注的重点。在多千兆位网络的时代,为什么这仍然是一个问题呢?毫无疑问,这确实是一个问题——Mark Raasveldt 和 Hannes Mühleisen 在他们的 2017 年论文1中发现,某些系统需要超过 十分钟 才能传输一个只需 十秒2 的数据集。

为什么我们要多等待 60 倍的时间? 正如我们之前所说,序列化开销困扰着我们的工具——而 Arrow 可以帮助我们解决这个问题。因此,让我们更具体地说明:我们将比较 PostgreSQL 和 Arrow 如何编码相同的数据,以说明数据序列化格式的影响。然后,我们将了解使用 Arrow 构建协议的各种方法,例如 Arrow HTTP 和 Arrow Flight,以及您如何使用它们。

PostgreSQL vs Arrow:数据序列化

让我们比较一下 PostgreSQL 二进制格式Arrow IPC 在相同的数据集上,并展示 Arrow(凭借后见之明的优势)如何做出比其前辈更好的权衡。

当您使用 PostgreSQL 执行查询时,客户端/驱动程序使用 PostgreSQL 线路协议发送查询并取回结果。在该协议内部,结果集使用 PostgreSQL 二进制格式3进行编码。

首先,我们将创建一个表并填充数据

postgres=# CREATE TABLE demo (id BIGINT, val TEXT, val2 BIGINT);
CREATE TABLE
postgres=# INSERT INTO demo VALUES (1, 'foo', 64), (2, 'a longer string', 128), (3, 'yet another string', 10);
INSERT 0 3

然后我们可以使用 COPY 命令将原始二进制数据从 PostgreSQL 转储到文件中

postgres=# COPY demo TO '/tmp/demo.bin' WITH BINARY;
COPY 3

然后我们可以根据 文档 注释数据的实际字节

00000000: 50 47 43 4f 50 59 0a ff  PGCOPY..  COPY signature, flags,
00000008: 0d 0a 00 00 00 00 00 00  ........  and extension
00000010: 00 00 00 00 03 00 00 00  ........  Values in row
00000018: 08 00 00 00 00 00 00 00  ........  Length of value
00000020: 01 00 00 00 03 66 6f 6f  .....foo  Data
00000028: 00 00 00 08 00 00 00 00  ........
00000030: 00 00 00 40 00 03 00 00  ...@....
00000038: 00 08 00 00 00 00 00 00  ........
00000040: 00 02 00 00 00 0f 61 20  ......a 
00000048: 6c 6f 6e 67 65 72 20 73  longer s
00000050: 74 72 69 6e 67 00 00 00  tring...
00000058: 08 00 00 00 00 00 00 00  ........
00000060: 80 00 03 00 00 00 08 00  ........
00000068: 00 00 00 00 00 00 03 00  ........
00000070: 00 00 12 79 65 74 20 61  ...yet a
00000078: 6e 6f 74 68 65 72 20 73  nother s
00000080: 74 72 69 6e 67 00 00 00  tring...
00000088: 08 00 00 00 00 00 00 00  ........
00000090: 0a ff ff                 ...       End of stream

老实说,PostgreSQL 的二进制格式非常容易理解,而且乍一看很紧凑。它只是一系列带有长度前缀的字段。但仔细观察并不那么有利。 PostgreSQL 的开销与行数和列数成正比

  • 每一行都有一个 2 字节的前缀,表示该行中的值的数量。但是数据是表格形式的——我们已经知道这些信息,而且它不会改变!
  • 每一行的每个值都有一个 4 字节的前缀,表示以下数据的长度,如果该值为 NULL,则为 -1。但是我们知道数据类型,并且这些类型不会改变——此外,大多数类型的值都有一个固定的、已知的长度!
  • 所有值都是大端字节序。但是我们的大多数设备都是小端字节序,因此必须转换数据。

例如,一个 int32 值列每行将有 4 字节的数据和 6 字节的开销——60% 被“浪费”了!4 随着列数的增加,比例会稍微好一些(但不是行数);在极限情况下,我们接近“仅” 50% 的开销。然后每个值都必须转换(即使字节序交换是微不足道的)。值得赞扬的是,PostgreSQL 的格式至少解析起来既便宜又容易——其他格式 使用诸如“varint”编码之类的技巧,这些技巧非常昂贵。

Arrow 如何比较? 我们可以使用 ADBC 将 PostgreSQL 表拉入 Arrow 表中,然后像以前一样对其进行注释

>>> import adbc_driver_postgresql.dbapi
>>> import pyarrow.ipc
>>> conn = adbc_driver_postgresql.dbapi.connect("...")
>>> cur = conn.cursor()
>>> cur.execute("SELECT * FROM demo")
>>> data = cur.fetchallarrow()
>>> writer = pyarrow.ipc.new_stream("demo.arrows", data.schema)
>>> writer.write_table(data)
>>> writer.close()
00000000: ff ff ff ff d8 00 00 00  ........  IPC message length
00000008: 10 00 00 00 00 00 0a 00  ........  IPC schema(208 bytes)
000000e0: ff ff ff ff f8 00 00 00  ........  IPC message length
000000e8: 14 00 00 00 00 00 00 00  ........  IPC record batch(240 bytes)
000001e0: 01 00 00 00 00 00 00 00  ........  Data for column #1
000001e8: 02 00 00 00 00 00 00 00  ........
000001f0: 03 00 00 00 00 00 00 00  ........
000001f8: 00 00 00 00 03 00 00 00  ........  String offsets
00000200: 12 00 00 00 24 00 00 00  ....$...
00000208: 66 6f 6f 61 20 6c 6f 6e  fooa lon  Data for column #2
00000210: 67 65 72 20 73 74 72 69  ger stri
00000218: 6e 67 79 65 74 20 61 6e  ngyet an
00000220: 6f 74 68 65 72 20 73 74  other st
00000228: 72 69 6e 67 00 00 00 00  ring....  Alignment padding
00000230: 40 00 00 00 00 00 00 00  @.......  Data for column #3
00000238: 80 00 00 00 00 00 00 00  ........
00000240: 0a 00 00 00 00 00 00 00  ........
00000248: ff ff ff ff 00 00 00 00  ........  IPC end-of-stream

乍一看,Arrow 看起来非常……令人生畏……。 有一个巨大的标头,似乎与我们的数据集完全无关,还有神秘的填充,似乎仅仅是为了占用空间而存在。 但重要的是,开销是固定的。 无论您传输一行还是一百亿行,开销都不会改变。 并且与 PostgreSQL 不同,不需要按值解析

Arrow 没有在每个地方都放置值的长度,而是将同一列(以及相同类型)的值组合在一起,因此它只需要缓冲区的长度5。 开销不会在不需要的地方添加。 字符串仍然具有每个值的长度。 空值性改为存储在位图中,如果没有任何 NULL 值(就像这里一样),则省略位图。 因此,更多的数据行不会增加开销; 相反,您拥有的数据越多,您支付的费用就越少。

即使是标头实际上也不是看起来那么不利。 标头包含 schema,这使得数据流具有自描述性。 使用 PostgreSQL,您需要从其他地方获取 schema。 因此,我们首先没有进行同类比较:PostgreSQL 仍然必须传输 schema,它只是不属于我们在这里看到的“二进制格式”6

实际上 PostgreSQL 还存在另一个问题:对齐。 每行开头的 2 字节字段计数意味着它之后的所有 8 字节整数都是未对齐的。 这需要额外的努力才能正确处理(例如,显式的未对齐加载习惯用法),否则您将遭受 未定义的行为、性能损失,甚至运行时错误。 另一方面,Arrow 会有策略地添加一些填充以保持数据对齐,并允许您根据您的数据使用小端字节序或大端字节序。 而且 Arrow 不会对需要进一步解析的数据应用昂贵的编码。 因此,通常,您可以按原样使用 Arrow 数据,而无需解析每个值

这就是 Arrow 作为标准化数据格式的好处。 通过使用 Arrow 进行序列化,从线路上传输的数据已经采用 Arrow 格式,并且可以进一步直接传递给 DuckDBpandaspolarscuDFDataFusion 或任何数量的系统。 与此同时,即使 PostgreSQL 格式解决了这些问题——添加填充以对齐字段、使用小端字节序或使字节序可配置、修剪开销——您最终仍然必须将数据转换为另一种格式(可能是 Arrow)才能在下游使用。

即使您真的想在任何地方都使用 PostgreSQL 二进制格式7,遗憾的是,该文档将您指向 C 源代码作为文档。 另一方面,Arrow 有一个 规范文档 以及十几种语言的多个 实现(包括第三方实现),供您在自己的应用程序中选择和使用。

现在,我们并不是要挑剔 PostgreSQL。 显然,PostgreSQL 是一个功能齐全的数据库,具有悠久的历史、一组与 Arrow 不同的目标和约束,以及许多快乐的用户。 Arrow 并不是试图在该领域竞争。 但它们的领域确实有交叉。 PostgreSQL 的线路协议已经 成为事实上的标准,甚至像 Google 的 AlloyDB 这样的全新产品也在使用它,因此它的设计会影响许多项目8。 事实上,AlloyDB 是一个很好的例子,说明了一个闪亮的新型列式查询引擎被锁定在 90 年代的面向行的客户端协议后面。 因此 阿姆达尔定律 再次抬头——当中间环节阻碍您前进时,优化数据管道的“前端”和“后端”是没有意义的。

一组 Arrow(项目)

因此,如果 Arrow 如此出色,我们实际上如何使用它来构建我们自己的协议? 幸运的是,Arrow 附带了适用于不同情况的各种构建块。

  • 我们之前刚讨论过 Arrow IPC。Arrow 是一种内存格式,定义了数据数组在内存中的布局方式。Arrow IPC 定义了如何序列化和反序列化 Arrow 数据,以便将其发送到其他地方——无论是写入文件、套接字、共享缓冲区还是其他方式。Arrow IPC 将数据组织成一系列消息,使其易于通过您喜欢的传输方式(例如 WebSockets)进行流式传输。
  • Arrow HTTP “仅仅是” 通过 HTTP 流式传输 Arrow IPC。 Arrow 社区正在努力将其标准化,以便不同的客户端就如何执行此操作达成一致。这里有一些客户端和服务器的例子,涵盖多种语言,以及如何使用 HTTP Range 请求,使用 multipart/mixed 请求发送组合的 JSON 和 Arrow 响应等等。虽然它本身不是一个完整的协议,但在构建 REST API 时它可以很好地融入其中。
  • Disassociated IPC (分离式 IPC) 将 Arrow IPC 与高级网络传输(如 UCXlibfabric)结合使用。对于那些需要绝对最佳性能并拥有专用硬件的用户,这允许您以全速发送 Arrow 数据,从而利用 scatter-gather、Infiniband 等技术。
  • Arrow Flight SQL 是一个完全定义的协议,用于访问关系数据库。您可以将其视为完整 PostgreSQL 线协议的替代方案:它定义了如何连接到数据库、执行查询、获取结果、查看目录等等。对于数据库开发人员,Flight SQL 提供了一个完全原生的 Arrow 协议,具有多种编程语言的客户端以及 ADBC、JDBC 和 ODBC 的驱动程序——所有这些您都不必自己构建。
  • 最后,ADBC 实际上不是一个协议。相反,它是一个用于处理数据库的 API 抽象层(就像 JDBC 和 ODBC 一样——您肯定没想到吧),它是 Arrow 原生的,不需要来回转置或转换列式数据。ADBC 为您提供了一个单一的 API 来访问来自多个数据库的数据,无论它们在底层使用 Flight SQL 还是其他东西,如果绝对需要转换,ADBC 会处理这些细节,这样您就不必自己构建十几个连接器了。

总结一下:

  • 如果您正在使用数据库或其他数据系统,您需要 ADBC
  • 如果您正在构建一个数据库,您需要 Arrow Flight SQL
  • 如果您正在使用专业的网络硬件(如果您正在使用,您就会知道——这些东西可不便宜),您需要 Disassociated IPC (分离式 IPC) 协议
  • 如果您正在设计一个 REST 风格的 API,您需要 Arrow HTTP
  • 否则,您可以使用 Arrow IPC 自行构建。

A flowchart of the decision points.

结论

现有的客户端协议可能会造成浪费。 Arrow 提供了更高的效率,并避免了过去的许多设计缺陷。Arrow 通过各种标准(如 Arrow IPC、Arrow HTTP 和 ADBC)简化了数据 API 的构建和使用。通过在协议中使用 Arrow 序列化,每个人都可以从更简单、更快、更方便的数据访问中受益,并且我们可以避免不小心将数据限制在缓慢而低效的接口后面。


  1. 该论文可从 VLDB 免费获取。 

  2. 论文中的图 1 显示,Hive 和 MongoDB 耗时超过 600 秒,而 netcat 传输 CSV 文件的基线为 10 秒。当然,这意味着比较并不完全公平,因为 CSV 文件没有被解析,但它可以让您了解涉及的规模大小。 

  3. 也有文本格式,通常是许多客户端使用的默认格式。我们这里就不讨论它了。 

  4. 当然,这并不是完全浪费,因为 null/not-null 也是数据。但出于核算目的,我们将保持一致,并将长度、填充、位图等称为“开销”。 

  5. 这就是存储在那个巨大的标头中的内容(以及其他内容)——所有缓冲区的长度。 

  6. 相反,PGCOPY 标头特定于我们执行的 COPY 命令,以获得批量响应。 

  7. 确实有人这样做... 

  8. 我们也有一些使用 PostgreSQL 线协议的经验。