---
name: arrow
title: Apache Arrow 的简单介绍
date: 2026-02-10
---

本文将介绍一个有意思的项目 [Apache Arrow](https://arrow.apache.org/) ，和 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。

### 嵌套类型

**List**：`List<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。

**Struct**：`Struct<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 构建，它已经成为现代数据分析的基础设施。

（全文完）
