本文将介绍一个有意思的项目 Apache Arrow ,和 Parquet 一样,它也是一个被广泛使用的组件。

你完全可以只需要阅读第一节,知道它是干什么的就可以了。如果你还对它的设计感兴趣,再往下阅读就行了。

Arrow 是什么

假设你是一个数据工程师,日常工作涉及以下场景:

  1. Spark 做 ETL,把数据写成 Parquet 文件
  2. pandas 读取 Parquet 文件做数据分析
  3. 把分析结果导入 DuckDB 做进一步的 SQL 查询
  4. 最后用 Polars 做一些高性能的数据处理

看起来很常见对吧?但这里有个隐藏的性能杀手:每个系统都有自己的内存数据格式

当数据从一个系统传到另一个系统时,必须经历序列化和反序列化:系统 A 的内存格式 → 序列化 → 字节流 → 反序列化 → 系统 B 的内存格式。

这个过程非常昂贵,Wes McKinney(pandas 的创始人)观察到,在真实的分析工作负载中,80-90% 的计算时间花在了序列化和反序列化上,而不是实际的计算。

你大部分时间不是在算数据,而是在搬数据。如果有很多不同的系统需要互相交换数据,就会需要很多的转换器。而且每个转换器都要处理类型映射、NULL 语义、编码差异等问题,也非常容易出 bug。

这就是 Arrow 诞生的背景。

Arrow 定义了一种标准的列式内存格式

注意关键词:内存格式。它不是像 Parquet 那样的文件存储格式,而是规定数据在内存(RAM)中应该怎么排列。

到这里,大家基本就知道 Arrow 是干嘛用的了。Arrow 无非就是需要制定数据排列标准,并且实现各个语言的库。

各个系统在输出内容给下游系统的时候,不再仅仅使用 JSON、Parquet、数据库表或者其他格式文件,而是可以输出 Arrow 格式的数据流,下游拿到这个数据流,可以不用反序列化,直接就能高速解析。

更妙的是,有些系统直接用 Arrow 作为自己的内部格式(比如 Polars、DataFusion)。

这就是 Arrow 的核心价值:让不同系统用同一种方式理解内存中的数据,从而消除数据搬运的开销

Arrow 的列式内存格式

Arrow 到底是怎么把数据排列在内存中的?

定长类型

我们以 INT32 为例,假设有一列 INT32 数据:[1, 2, 3, 4, 5],Arrow 的内存布局非常直接:

Data Buffer (20 bytes):
+----+----+----+----+----+
| 01 | 02 | 03 | 04 | 05 |  (每个值 4 bytes)
+----+----+----+----+----+

5 个值紧密排列,访问第 i 个元素直接读偏移量 i × 4 处,O(1)。没有编码、没有压缩,直接就是原始值,这就是 CPU 友好布局。

处理 NULL:Validity Bitmap

Arrow 用一个位图来标记 NULL:

数据: [1, NULL, 3, NULL, 5]

Validity Bitmap (1 byte):
  bit 位:  7 6 5 4 3 2 1 0
  值:      0 0 0 1 0 1 0 1  = 0b00010101
                             (1=有值, 0=NULL)

Data Buffer:
+----+----+----+----+----+
| 01 | XX | 03 | XX | 05 |  (NULL 位置的值未定义,不会被读取)
+----+----+----+----+----+

这种设计非常紧凑,100 万个值的位图只需要约 125 KB。NULL 不影响数据对齐。如果没有 NULL,位图直接省略。

变长类型:Offsets + Data

字符串这种变长类型,Arrow 用 Offsets + Data 两个 Buffer 处理:

数据: ["hello", "arrow", "世界"]

Offsets:  [0, 5, 10, 16]    (N+1 个元素,最后一个数字标记数据的结束位置)
Data:     h e l l o a r r o w 世 界

第 i 个字符串 = Data[offsets[i] .. offsets[i+1]]

要获取第 i 个字符串,只需读 offsets[i]offsets[i+1] 之间的字节,依然是 O(1)。

可以看到,它使用一个非常紧凑的结构来存储 data,然后外挂一个”解析器”。

64 字节对齐

Arrow 要求所有 Buffer 按 64 字节对齐,这个很好理解,是为了做性能优化。因为 64 字节是 AVX-512 SIMD 寄存器的宽度,这样 CPU 可以直接把整块内存装入 SIMD 寄存器,没有边界问题。

对齐后,一条 SIMD 指令可以同时处理 16 个 INT32(4字节)或 8 个 INT64。这让上层计算引擎(DuckDB、Polars 等)可以充分利用向量化指令。

类型系统

Arrow 定义了一组精简的原始类型:

类别类型
整数Int8/16/32/64, UInt8/16/32/64
浮点数Float16, Float32, Float64
布尔Boolean(1 bit/值)
精确小数Decimal128, Decimal256
日期时间Date32, Date64, Time32, Time64, Timestamp
变长Utf8 (String), Binary

和 Parquet 一样,底层用少量原始类型,通过逻辑类型扩展语义。DATE 物理上是 INT32,STRING 物理上是 UTF-8 编码的 Binary。

嵌套类型

ListList<Int32> 表示每个元素是一个 Int32 数组,内存布局跟变长字符串一样,举个例子:

数据 list: [ [1,2], null, [3,4,5] ] // 第一个元素有 2 个数字,第二个元素是 null,第三个元素有 3 个数字

Validity Bitmap: 0b00000101 // 标识位置 0 和 2 有值

Offsets:     [0, 2, 2, 5]
              ↑  ↑  ↑  ↑
              │  │  │  └── 第 2 个 list 结束
              │  │  └── NULL, offsets[1]==offsets[2]
              │  └── 第 0 个 list 结束
              └── 第 0 个 list 开始

Child Array (Int32): [1, 2, 3, 4, 5] // 存储完整的数据

可以看到,它跟前面介绍的变长字符串的 Offsets+Data 模式完全一样,所有数据在 Child Array 紧凑存储,使用 Offsets 数组解释每个 list 的长度,使用位图存储是否是 null。

StructStruct<name: Utf8, age: Int32> 每个字段作为独立的子数组存储,因为 Arrow 是列式存储,比如:

数据: [{"Alice", 30}, NULL, {"Carol", 25}]

Child "name":  ["Alice", <未定义>, "Carol"]
Child "age":   [30, <未定义>, 25]
Struct Validity Bitmap: 0b00000101

查询只需要 name 时,不用碰 age 数组,嵌套结构中列裁剪依然有效。

Map:物理上等价于 List<Struct<key, value>>,我们用 Map<Utf8, Int32> 举个例子:

数据:
  - row 0 → {a:1, b:2}
  - row 1 → {c:3, d:4, e:5}
  
Offsets: [0, 2, 5]
Keys:    ["a", "b", "c", "d", "e"]
Values:  [1,    2,   3,   4,   5 ]

Dictionary 类型

Arrow 也支持字典编码,不过它不是压缩手段,而是一种数据类型

原始数据:  ["北京", "上海", "北京", "广州", "北京", "上海", "北京"]

Dictionary 编码后:
  Indices:     [0, 1, 0, 2, 0, 1, 0]   (Int8)
  Dictionary:  ["北京", "上海", "广州"]

好处:省内存(重复值只存一份)、比较快(整数比较替代字符串比较)、缓存友好(字典小到可以放在 CPU 缓存里)。

RecordBatch 和 Table

RecordBatch 是 Arrow 的基本数据单元:一组等长的 Array + Schema,类似 Parquet 的 Row Group 的内存版本。

RecordBatch (3 行, 3 列):
  Schema: {name: Utf8, age: Int32, city: Utf8}
  name:  ["张三", "李四", "王五"]
  age:   [ 28,     35,     42 ]
  city:  ["北京", "上海", "北京"]

Table 是多个 RecordBatch 的逻辑拼接。为什么不用一个巨大的 RecordBatch?因为 Arrow Array 是不可变的,新数据到来时直接追加新 RecordBatch,不需要复制已有数据。

数据交换

IPC 格式

Arrow 定义了两种 IPC 格式来传输数据,核心思想是传输格式和内存格式一致,接收方拿到数据直接可用。

Stream 格式:Schema → RecordBatch 1 → RecordBatch 2 → … → EOS。顺序读取,适合流式管道。

File 格式:首尾有 Magic Number(“ARROW1”),末尾有 Footer 包含所有 RecordBatch 的偏移量,支持随机访问。结构和 Parquet 文件很像。

关键区别在于:Arrow IPC 文件的数据 buffer 和内存布局完全一致,可以用 mmap 直接映射,不需要解码。一个 10GB 的 Arrow IPC 文件,mmap 后进程内存接近 0,访问到哪里才加载哪里。

Arrow Flight

Arrow IPC 解决本地进程间交换,Arrow Flight 解决网络传输。它基于 gRPC,直接在网络上传输 Arrow RecordBatch,不需要行列转换和序列化。

传统 JDBC/ODBC 的流程是:列式数据 → 转行式 → 序列化 → 传输 → 反序列化 → 再转列式。Flight 直接跳过这些步骤。

客户端                              服务端
  │── GetFlightInfo("SELECT ...") ──→│  查询元信息
  │←── FlightInfo (schema, 节点) ────│
  │── DoGet(ticket) ────────────────→│  拉取数据
  │←── RecordBatch 流 ──────────────│  直接返回 Arrow 格式

Flight 还支持分布式读取——GetFlightInfo 可以返回多个节点地址,客户端并行拉取。

Flight SQL 是 Flight 之上针对 SQL 场景的扩展,是 Arrow 原生的 ODBC/JDBC 替代品,分析场景下比传统方式快 10-100 倍。ClickHouse、Apache Doris、Dremio、InfluxDB 等已经支持。

小结

Arrow 定义了一种标准的列式内存格式,让不同系统用同一种方式理解数据,消除序列化/反序列化开销。

它和 Parquet 是搭档:Parquet 管磁盘存储,Arrow 管内存计算。内存布局为现代 CPU 优化(64 字节对齐、SIMD 友好),支持零拷贝数据交换。Arrow Flight 则把这种高效延伸到了网络传输层。

Polars、DuckDB、DataFusion、pandas 等主流工具都围绕 Arrow 构建,它已经成为现代数据分析的基础设施。

(全文完)