本文将介绍一个有意思的项目 Apache Arrow ,和 Parquet 一样,它也是一个被广泛使用的组件。
你完全可以只需要阅读第一节,知道它是干什么的就可以了。如果你还对它的设计感兴趣,再往下阅读就行了。
Arrow 是什么
假设你是一个数据工程师,日常工作涉及以下场景:
- 用 Spark 做 ETL,把数据写成 Parquet 文件
- 用 pandas 读取 Parquet 文件做数据分析
- 把分析结果导入 DuckDB 做进一步的 SQL 查询
- 最后用 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 构建,它已经成为现代数据分析的基础设施。
(全文完)
0 条评论