第 2 章 讲了 Parquet 怎么把列存到磁盘,第 3 章 对照了 ORC。但磁盘格式只是数据生命周期的一端:数据被读出来后,要进 CPU 算、要在 Spark / DuckDB / Trino / pandas 之间传、要跨语言(Python 调 Rust 写的引擎)。这一段路径上,长期以来每个系统都有自己的内存表示,跨系统就得序列化 + 反序列化,CPU 和内存都花在格式转换上,而不是计算上。
Apache Arrow 要解决的就是这一端:定义一套与语言无关的列式内存格式,让不同系统、不同语言共享同一份内存表示,从而把跨边界的成本压到接近零拷贝。本章拆 Arrow 的内存布局、零拷贝的来源,以及它向外传递数据的三层接口——进程内的 C Data Interface、跨进程/落盘的 IPC、跨网络的 Flight,最后讲清 Arrow 与 Parquet 的「内存 vs 磁盘」分工。
证据以 Apache Arrow 官方格式规范(Arrow Columnar Format、IPC、C Data Interface、Flight)为准;实测部分标注 pyarrow 24.0.0 / Python 3.14.5,输出来自真实执行。
一、问题:内存格式的「巴别塔」
设想一条普通的分析链路:对象存储上的 Parquet 文件 → Spark 读出来做 join → 结果给 Python 端用 pandas 做特征 → 再喂给一个 Rust 写的向量检索库。如果每一跳都用自己的内存表示,那么每一跳都要:
- 把上游格式反序列化成本系统的对象(行对象、
PyObject、JVM 对象……)。 - 计算。
- 把结果序列化成下游能读的字节流。
这里有两类浪费。一是 CPU:序列化/反序列化常常是分析作业里仅次于实际计算的开销,尤其是行式协议(一行一个对象、一个字段一次装箱)。二是内存:同一份数据在内存里存了好几份不同布局的副本。
Arrow 的设计前提写在 Columnar Format 规范开头:定义一个标准化的、与语言无关的列式内存格式,支持在硬件(CPU/GPU)上做高效的分析运算,并且零拷贝地在系统间共享。只要生产者和消费者都认这套布局,跨边界就不再需要逐元素转换——传一组指针即可。
flowchart LR
subgraph BEFORE["各自为政"]
A1[系统A 内存格式] -->|序列化| W1[字节流] -->|反序列化| A2[系统B 内存格式]
end
subgraph AFTER["共享 Arrow"]
B1[系统A] -->|共享 buffer 指针| B2[系统B]
end
这套思路和 列存引擎 里
ClickHouse 的
Block/列向量是同源的——都是「在批量列向量上算」。区别在于:ClickHouse
的 Block 是 ClickHouse 私有的内部表示,Arrow
是公开规范,目标就是被任意系统实现并互通。
二、列式内存布局:一切都是 buffer
Arrow 把一个数组(array,规范里叫 array 或 column)拆成若干个连续内存块,规范称之为 buffer。一个数组的完整描述 = 数据类型(type)+ 长度(length)+ 空值数(null count)+ 一组 buffer + (嵌套时)子数组(child arrays)。
每种类型用几个 buffer、各自含义是什么,由规范的 “Physical Memory Layout” 一节固定。下面这张表是核心:
| 物理布局 | buffer 序列 | 典型类型 |
|---|---|---|
| 定长原始类型 | validity bitmap, values | int8..int64、float、double、bool、date、timestamp |
| 变长二进制 | validity bitmap, offsets, values | utf8、binary(offset 为
int32) |
| 大变长二进制 | validity bitmap, offsets, values | large_utf8、large_binary(offset
为 int64) |
| 定长列表 | validity bitmap, (child) | fixed_size_list |
| 变长列表 | validity bitmap, offsets, (child) | list、large_list |
| 结构体 | validity bitmap, (children) | struct |
| 字典编码 | validity bitmap, indices;外加 dictionary | dictionary<...> |
| 联合体 | type_ids, (offsets,) children | dense_union /
sparse_union |
关键点:buffer
是裸内存块,没有逐元素的对象头。一个 100 万行的
int64 列就是一块 8 MB 的连续内存(外加一个 validity
bitmap),可以直接喂给 SIMD 指令,也可以直接
memcpy
或共享指针。这正是零拷贝和向量化的物理基础。
用 pyarrow 把布局打出来(本机 pyarrow 24.0.0 实测):
import pyarrow as pa
def desc(a):
bufs = a.buffers()
print(a.type, "nbuffers=", len(bufs),
[(b.size if b else None) for b in bufs])
desc(pa.array([1, 2, None, 4], type=pa.int32()))
desc(pa.array(["a", "bb", None, "dddd"], type=pa.string()))
desc(pa.array(["a", "bb", None, "dddd"], type=pa.large_string()))
desc(pa.array([1, 2, 3, 4], type=pa.int32())) # 无空值
desc(pa.array([True, False, None, True]))实际输出:
int32 nbuffers= 2 [1, 16]
string nbuffers= 3 [1, 20, 7]
large_string nbuffers= 3 [1, 40, 7]
int32(无空值) nbuffers= 2 [None, 16]
bool nbuffers= 2 [1, 1]
逐行对上规范:
int324 个元素:validity bitmap 1 字节(4 bit 够用,向上取整到 1 字节),values \(4\times4=16\) 字节。string:validity 1 字节 + offsets \(20\) 字节(int32,\(n+1=5\) 个偏移,\(5\times4=20\))+ values \(7\) 字节(a+bb+dddd= 1+2+4)。large_string:只有 offsets 变成 int64,\(5\times8=40\) 字节,values 不变。- 无空值的 int32:validity buffer 直接是
None——规范允许在没有空值时省略 validity bitmap,这一条后面第三节展开。 bool:values 也是 bit-packed 的,4 个布尔值占 1 字节,不是 1 字节存 1 个 bool。
三、空值:validity bitmap
Arrow 用一个独立的 validity bitmap(也叫 null bitmap)表示哪些 slot 是空值,规则在规范 “Validity (Null) Bitmaps” 一节:
- 每个 slot 占 1 个 bit:
1表示有值(valid),0表示空值(null)。 - bit 在字节内按 least-significant-bit(LSB) 排列:第 \(i\) 个值对应第 \(\lfloor i/8 \rfloor\) 字节的第 \((i \bmod 8)\) 位。
- 空值位置在 values buffer 里仍然占位(保留一个槽),值是未定义的——所以 values buffer 的长度和逻辑长度对齐,按下标随机访问是 \(O(1)\)。
- 数组带一个 null count。当 null count 为 0 时,validity bitmap 可以整块省略(指针为空),消费者据此知道没有空值,连 bitmap 都不必读。
对第 \(i\) 个值是否为空的判定,可以写成位运算:
\[ \text{is\_valid}(i) = \left( \text{bitmap}[\lfloor i/8 \rfloor] \;\gg\; (i \bmod 8) \right) \,\&\, 1 \]
这种「定长 bitmap + 定长 values」的组合让两件事同时成立:null 检查可以向量化(一次读一个机器字判 64 个 slot),随机访问不需要解析。代价是空值也占 values 的空间——对极稀疏的宽表,这一点要和 Parquet 在磁盘上对 null 的紧凑表示(definition level)区分开,二者是不同层(内存 vs 磁盘)的取舍。
四、变长与嵌套:offset buffer
定长类型的第 \(i\) 个值在 \(i \times \text{width}\) 处,直接算得出。变长类型(字符串、二进制、列表)做不到这点,于是引入 offset buffer:
- offset buffer 有 \(n+1\) 个偏移量。第 \(i\) 个元素的内容是 values buffer 上的区间 \([\text{offset}[i],\ \text{offset}[i+1])\)。
utf8/binary/list用 int32 偏移,单个数组的总字节数因此受 2 GiB 上限约束;large_utf8/large_binary/large_list用 int64 偏移,没有这个限制(上面实测里 offsets 从 20 字节变 40 字节就是这个差别)。- 长度由相邻偏移相减得到:\(\text{len}(i) = \text{offset}[i+1] - \text{offset}[i]\)。空值的处理是把它当长度 0 的区间(值未定义)。
嵌套类型靠组合这几种 buffer 表达,而不是新发明布局:
list<int32>:外层是 validity + offsets,child 是一个独立的int32数组。外层第 \(i\) 个 list 是 child 上 \([\text{offset}[i], \text{offset}[i+1])\) 这一段。struct<a:int32, b:utf8>:外层只有一个 validity bitmap,两个字段各是一个独立 child 数组,按下标对齐。- 嵌套可以任意套(
list<struct<...>>),每一层都只是「buffer + child」的递归。
这与 第 2 章 里 Parquet 用 Dremel 的 repetition/definition level 编码嵌套是两套不同的表达:Parquet 把嵌套压平成带 level 的列存到磁盘,Arrow 在内存里用 offset + child 直接表达层级。从 Parquet 读到 Arrow,本质是把 level 还原成 offset 结构(后面第九节)。
把嵌套类型的 buffer 打出来看(pyarrow
Array.buffers() 递归返回整棵树的所有
buffer,None 表示被省略的 validity):
desc("list", pa.array([[1,2],[3],None,[4,5,6]], type=pa.list_(pa.int32())))
desc("struct", pa.array([{"a":1,"b":"x"},{"a":2,"b":"y"},None],
type=pa.struct([("a",pa.int32()),("b",pa.string())])))实测输出:
list list<item: int32> nbuf=4 [1, 20, None, 24]
struct struct<a: int32, b: string> nbuf=6 [1, None, 12, None, 16, 2]
逐个对上:
list<int32>(4 个 list、1 个 null):外层 validity 1 字节 + offsets 20 字节(5 个 int32 偏移0,2,3,3,6);child 是 int32 数组,validity 被省略(None,child 无空值)+ values 24 字节(6 个 int32:1,2,3,4,5,6)。外层 list 只管「切片范围」,真正的值在 child 里。struct<a,b>(3 行、1 个 null):struct 只有一个 validity 1 字节,没有自己的 values;两个 child 各自独立——a是 int32(validity 省略 + values 12=3×4),b是 string(validity 省略 + offsets 16=4×4 + values 2 字节xy)。
可见嵌套就是「外层 buffer + child 数组」的递归组合,没有为嵌套发明新的物理布局。这也是为什么 Arrow 能零拷贝处理复杂嵌套:每一层都还是连续 buffer。
五、零拷贝从何而来
「零拷贝」不是一句口号,它是上面这套布局的直接推论。具体有三处:
切片(slice)共享 buffer。 取一个数组的子区间,不复制数据,只记一个 offset 和新 length,底层 buffer 指针不变。实测:
base = pa.array(list(range(100)), type=pa.int64())
sl = base.slice(10, 5)
print(base.buffers()[1].address == sl.buffers()[1].address, sl.offset)
# True 10values buffer 地址相同,sl 只是带了
offset=10。这意味着
filter/limit/分块这类操作在很多场景下只动元数据,不动数据。
没有逐元素对象。 一列 int64 在内存里就是一块连续字节,不存在「100 万个 Integer 对象」。把这块内存交给别人,传的是指针 + 长度,不是 100 万次拷贝。
跨边界共享而非转换。 既然布局是公开规范,生产者分配的 buffer,消费者可以直接按同一套规则解读。问题只剩一个:怎么安全地把「这块内存归你用了」这件事告诉另一个运行时,并约定谁来释放。这正是 C Data Interface 要解决的,下一节展开。
需要说清边界:零拷贝是同一进程地址空间内的概念。跨进程(共享内存除外)或跨网络时,数据终究要过一次字节边界,Arrow 在那两层(IPC、Flight)追求的是「最少拷贝 + 不做格式转换」,而不是字面意义的零拷贝。
六、C Data Interface:进程内跨运行时的零拷贝
同一个进程里跑着多个运行时是常态:Python 进程里既有 pyarrow(C++ 实现),又通过扩展加载了 DuckDB、Polars、或一个 Rust 库。它们都认 Arrow 布局,但各自有自己的对象包装。要在它们之间传一个数组而不拷贝,需要一个稳定的 ABI——不依赖某个库的版本、不依赖序列化。这就是 C Data Interface(规范 “The Arrow C data interface”)。
它只定义两个 C 结构体(外加流式的第三个):
struct ArrowSchema {
const char* format; // 类型的字符串编码,如 "i"=int32, "u"=utf8
const char* name;
const char* metadata;
int64_t flags;
int64_t n_children;
struct ArrowSchema** children;
struct ArrowSchema* dictionary;
void (*release)(struct ArrowSchema*); // 释放回调
void* private_data;
};
struct ArrowArray {
int64_t length;
int64_t null_count;
int64_t offset;
int64_t n_buffers;
int64_t n_children;
const void** buffers; // 指向上文那些 buffer 的裸指针数组
struct ArrowArray** children;
struct ArrowArray* dictionary;
void (*release)(struct ArrowArray*); // 释放回调
void* private_data;
};设计要点:
ArrowSchema描述类型,ArrowArray携带数据指针。buffers里就是第二节那些 buffer 的地址——没有拷贝,没有序列化。- release callback
是所有权移交的关键。生产者填好结构体后,消费者用完调用
release(),由生产者的回调按自己的方式释放底层内存。这样跨库的内存所有权有了明确协议,不会双重释放或泄漏。 - 结构体是 C ABI 稳定的:DuckDB、Polars、pyarrow、arrow-rs、R 的 arrow 包都实现了它,互相之间传数据不经过任何中间格式。
- 流式数据另有
ArrowArrayStream(C stream interface),用一个get_next()回调连续吐ArrowArray,语义同 IPC streaming,但同样是进程内零拷贝。
ArrowSchema.format
用一串紧凑的字符串编码类型,规范固定了这套 format
string,常见的几个:
| format | 类型 | format | 类型 |
|---|---|---|---|
n |
null | i |
int32 |
c / C |
int8 / uint8 | l |
int64 |
s / S |
int16 / uint16 | f / g |
float32 / float64 |
b |
boolean | u / U |
utf8 / large_utf8 |
z / Z |
binary / large_binary | +l / +L |
list / large_list |
+s |
struct | +m |
map |
tsu:UTC |
timestamp(us, UTC) | d:P,S |
decimal(P,S) |
消费者拿到 ArrowSchema 先解析 format string
得知类型,再按 ArrowArray.buffers 解读各
buffer——全程不反序列化数据本体。
第十一节用 pyarrow 实测了一次完整的 export → import,并验证两端 value buffer 地址相同,证实确实零拷贝。
七、IPC:跨进程与落盘
C Data Interface 只在同一进程内有效(传的是指针)。要把 Arrow 数据写文件、走管道、过 socket,就需要一个自描述的字节流格式,这就是 Arrow IPC(规范 “Serialization and Interprocess Communication (IPC)”),也是 Feather V2 文件的底层。
IPC 把数据切成一串 message,每条 message 用「封装格式(encapsulated message format)」框定:
<continuation: 0xFFFFFFFF> (4 字节)
<metadata_size: int32> (4 字节, little-endian)
<metadata flatbuffer> (Flatbuffers 编码的 Message)
<padding 到 8 字节对齐>
<message body> (实际的 buffer 数据)
message 有几类(定义在 Message.fbs /
Schema.fbs):
| message 类型 | 内容 |
|---|---|
| Schema | 字段名、类型、metadata;流的第一条 |
| RecordBatch | 一批数据:FieldNode(每个字段的
length/null_count)+ Buffer(每个 buffer 在
body 里的 offset/length)+ 紧接的 body |
| DictionaryBatch | 字典内容(字典编码列单独传,可增量 delta) |
关键设计:RecordBatch 的 metadata 里只记每个 buffer 在 body 中的 offset 和 length,body 是各 buffer 顺序拼接、按对齐填充后的整块。读取方按 offset 切 body,就能重建第二节那套 buffer 布局——所以从 IPC 反序列化到内存 Arrow 数组,主要是「把 body 切成 buffer」,不需要逐元素解析。规范建议 buffer 按 64 字节对齐(利于 SIMD),IPC 中至少 8 字节对齐。
IPC 有两种封装:
- Streaming 格式:
Schema开头,后面一串RecordBatch/DictionaryBatch,以 EOS(end-of-stream)标记结尾。适合管道、socket、不断产生的数据。 - File 格式(Feather V2):在 streaming
内容外加了文件头尾的魔数
ARROW1和一个 footer,footer 里有每个 RecordBatch 的位置索引。因此 file 格式支持随机访问任意 batch,streaming 格式只能顺序读。
IPC 也支持可选的 body
压缩:规范允许对每个 buffer 用 LZ4_FRAME 或 ZSTD
压缩(在 message metadata 里标注),这就是 Feather V2 的
compression="zstd" 选项。但即便压缩,IPC
也不做 Parquet
那套类型感知编码(字典/delta/RLE)——它只是把原始
buffer 字节压一遍。所以 Feather 压缩后仍比 Parquet
大、读取仍比 Parquet 快(第十一节实验二量化了这一点)。
和 Parquet 区分清楚:IPC/Feather 存的是Arrow 布局(不压缩时可直接 mmap 使用,几乎不解码),追求读取速度;Parquet 存的是编码 + 压缩后的紧凑形态(见 第 5 章),追求体积和扫描裁剪。一个偏「快」,一个偏「省」。
八、Arrow Flight:跨网络
跨机器传大批 Arrow 数据,如果用普通 RPC(每行一个 message、JSON/Protobuf 逐字段编码),序列化又会成为瓶颈。Arrow Flight(规范 “Arrow Flight RPC”)是建在 gRPC 上的数据传输协议,让网络传输也直接走 Arrow IPC 而非逐字段编码。
核心方法(gRPC service FlightService):
| 方法 | 作用 |
|---|---|
GetFlightInfo |
按
FlightDescriptor(路径或命令)查询数据集的
schema 与 endpoints |
DoGet |
凭 Ticket 拉取一个 endpoint 的数据,返回
FlightData 流(即 Arrow IPC 流) |
DoPut |
上传一个 Arrow 流 |
DoExchange |
双向流 |
ListFlights / GetSchema |
发现与 schema |
两个对工程有意义的设计:
FlightData直接承载 Arrow IPC message,body 就是 Arrow buffer。所以接收端从网络字节到内存 Arrow 数组,路径和第七节一样短,不经过行式编码。- endpoint
可以有多个:
GetFlightInfo可以返回一组 endpoint(可能指向不同节点),客户端并行DoGet多个 endpoint。这让分布式数据源天然支持并行拉取,而不是单连接串行。
Flight 之上还有 Flight SQL(用 Flight 传 SQL 请求/结果,定位类似 JDBC/ODBC 但走 Arrow)和 ADBC(Arrow Database Connectivity,统一的数据库客户端 API,结果直接是 Arrow)。这一层超出本系列文件格式的主线,知道它们都把 Arrow 作为「线上格式 + 内存格式」统一即可。
flowchart TB
subgraph SAME["同进程"]
CDI["C Data Interface\n传指针, 零拷贝"]
end
subgraph PROC["跨进程/落盘"]
IPC["IPC / Feather\n自描述字节流"]
end
subgraph NET["跨网络"]
FL["Flight (gRPC)\nIPC over the wire"]
end
CDI --> IPC --> FL
三层是同一套内存布局向外的三种边界:进程内传指针,跨进程传字节流,跨网络传 IPC over gRPC。共同点是消费侧都不做格式转换。把三层并排对照:
| 维度 | C Data Interface | IPC / Feather | Flight |
|---|---|---|---|
| 边界 | 同进程不同运行时 | 跨进程 / 落盘 | 跨网络 |
| 传的是什么 | 裸指针 + release callback | 自描述字节流 | gRPC 上的 IPC 流 |
| 拷贝 | 零(共享内存) | 一次(序列化/反序列化字节) | 网络传输不可避免 |
| 是否解析数据本体 | 否 | 否(只切 body 成 buffer) | 否 |
| 典型用途 | pyarrow↔︎DuckDB↔︎Polars↔︎R | 中间结果、快速落盘 | 分布式数据服务、Flight SQL |
| 随机访问 | 是 | file 格式支持,streaming 不支持 | 取决于 endpoint 划分 |
共性是:三者都不把数据「翻译」成行式或其它格式,区别只在跨越的边界类型和因此付出的拷贝代价。
九、Arrow 与 Parquet:内存 vs 磁盘的分工
初学者最容易混的就是 Arrow 和 Parquet 的关系。它们是互补的两层,不是竞品:
| 维度 | Apache Arrow | Apache Parquet |
|---|---|---|
| 定位 | 内存计算格式 | 磁盘存储格式 |
| 优化目标 | 随机访问、SIMD、零拷贝传递 | 体积小、扫描时裁剪 |
| 编码/压缩 | 不编码不压缩(直接可算) | dictionary/RLE/delta + zstd/snappy(第 5 章) |
| 随机访问第 i 个值 | \(O(1)\) | 需先解码 page |
| 典型存活时长 | 一次查询的内存生命周期 | 长期持久化 |
| 落地形态 | RecordBatch / Table / IPC | .parquet 文件 |
二者在读路径上首尾相接。一次「读 Parquet 做分析」大致是:
flowchart LR
PQ["Parquet 文件\n编码+压缩的 page"] -->|解压+解码| AB["Arrow RecordBatch\n内存列向量"]
AB --> ENG["查询/计算引擎\n向量化算子"]
实现上,Arrow C++ 自带 Parquet 读写器,pyarrow 的
pq.read_table() 直接把 Parquet 解码成 Arrow
Table——Parquet 的 column chunk/page
解压解码后,填进 Arrow 的
buffer。两边的概念有对应关系:Parquet 一个 row group ≈ Arrow
若干 RecordBatch;Parquet 列的 null(definition
level)还原成 Arrow 的 validity bitmap;Parquet 的嵌套 level
还原成 Arrow 的 offset + child。
一句话记法:Parquet 是数据「躺着」的样子,Arrow 是数据「被算」的样子,读取就是从前者变到后者。
RecordBatch、Table 与 ChunkedArray
实际 API 里有几个容易混的容器,澄清一下层级:
- RecordBatch:一组等长的 Array(每列一个),是第二节那套 buffer 布局的最小批量单位,对应 IPC 的一条 RecordBatch message。
- ChunkedArray:逻辑上一列,物理上由多个 Array「块」拼成(读大文件时一列会分成多块,避免单块过大或受 int32 offset 上限约束)。
- Table:一组等长的 ChunkedArray,是「多列 + 分块」的逻辑表。
pyarrow 的 pq.read_table() 返回的就是
Table,每列是 ChunkedArray:
rt = pq.read_table(buf)
print(type(rt).__name__, type(rt.column(0)).__name__, rt.column(0).num_chunks)
# Table ChunkedArray 1小表读成 1 个 chunk,大表会有多个。算子通常在 RecordBatch 粒度上流式处理(Acero 风格),而不是一次把整列加载成单块。
十、以 Arrow 为基座的引擎
Arrow 的价值在生态里才完全体现。一批现代引擎直接把 Arrow 作为内存模型,于是它们之间互通几乎免费:
- Apache DataFusion(Rust):基于
arrow-rs,算子在 ArrowRecordBatch上向量化执行,是 Rust 生态里搭查询引擎的常用底座。 - Polars(Rust/Python):内存采用 Arrow
列式布局(
polars-arrow,源自 arrow2 的实现),因此与 pyarrow / DuckDB 经 Arrow 互转开销很低。 - Acero:Arrow C++ 自带的流式执行引擎,直接在 Arrow 数据上做 filter/project/join/aggregate。
- DuckDB:可经 C Data Interface 与 pyarrow / Polars 零拷贝互换数据——把一个 pyarrow 表当成 DuckDB 的表来查,反之亦然。
它们与 列存引擎系列 里
ClickHouse 的 Block 是同一谱系的「批量列向量 +
向量化算子」,差别在于 Arrow
把这套表示标准化并公开,于是「换引擎」不再意味着「换数据格式」。对
lakehouse 来说,这正是查询引擎能百花齐放又能读同一份湖(第 18
章 展开)的内存层前提。
十一、实验
环境(两个实验共用):
| 项 | 值 |
|---|---|
| CPU | 12th Gen Intel Core i9-12900K |
| OS | Linux 6.6.87.2-microsoft-standard-WSL2 x86_64(glibc 2.43) |
| Python / pyarrow | 3.14.5 / 24.0.0 |
| cffi | 已安装(pyarrow C Data Interface 依赖) |
实验一:C Data Interface 零拷贝验证
前面说 C Data Interface
是真零拷贝,这里实测。思路:在同一进程内,用一个 pyarrow
数组当「生产者」,通过 C Data Interface 的
ArrowArray/ArrowSchema
结构体导出,再导入成一个新的「消费者」数组,比对两者底层
value buffer
的内存地址是否相同。地址相同即证明没有拷贝、共享同一块内存。
脚本:
import pyarrow as pa
from pyarrow.cffi import ffi
# 生产者:构造一个 Arrow 数组
producer = pa.array([10, 20, 30, None, 50], type=pa.int64())
src_addr = producer.buffers()[1].address # value buffer 地址
# 在 C 侧分配两个 C Data Interface 结构体
c_array = ffi.new("struct ArrowArray*")
c_schema = ffi.new("struct ArrowSchema*")
ptr_array = int(ffi.cast("uintptr_t", c_array))
ptr_schema = int(ffi.cast("uintptr_t", c_schema))
# 导出:把结构体指向生产者的 buffer(不拷贝)
producer._export_to_c(ptr_array)
producer.type._export_to_c(ptr_schema)
# 消费者:从同一组结构体导入成新数组
consumer = pa.Array._import_from_c(ptr_array, ptr_schema)
dst_addr = consumer.buffers()[1].address
print("[producer] value buffer = 0x%x" % src_addr)
print("[consumer] value buffer = 0x%x" % dst_addr)
print("equals:", consumer.equals(producer))
print("same buffer address (zero-copy):", src_addr == dst_addr)真实输出(本机执行,未删减关键行):
[producer] type = int64
[producer] value buffer = 0x79487e9e00c0
[producer] null_count = 1
[consumer] type = int64
[consumer] value buffer = 0x79487e9e00c0
[consumer] equals producer values: True
[result] same buffer address (zero-copy): True
生产者和消费者的 value buffer 地址都是
0x79487e9e00c0,完全相同;值也相等。这证明 C
Data Interface
的导入导出没有复制底层数据,只是通过 C
结构体移交了对同一块内存的引用,释放则由 release callback
协调。这就是「在同进程不同运行时之间零拷贝传
Arrow」的真实机制。
说明:本实验在单进程内用 pyarrow 自身的
_export_to_c/_import_from_c演示 C Data Interface 的 ABI 与零拷贝语义。真正的跨语言场景(如 pyarrow → DuckDB、pyarrow → R)走的是同一组 C 结构体,只是两端换成不同库的实现;本机未安装第二个运行时,故以同进程导入导出验证「地址不变」这一核心性质,不臆造跨库输出。读者要复现跨库零拷贝,可安装duckdb,用duckdb.from_arrow(pyarrow_table)与con.execute(...).arrow()互转,两端同样经 C Data Interface。
实验二:IPC/Feather 与 Parquet 的「快 vs 省」
第九节说 Arrow IPC(Feather V2)偏「快」、Parquet 偏「省」,这里量化。同一张 3 列、300 万行的表(int64 自增列 + 低基数字符串列 + double 列),分别写成 Parquet(zstd)、Feather(不压缩)、Feather(zstd),比体积与读取耗时(读取 7 轮取中位数,读自内存 buffer 规避磁盘缓存抖动):
| 格式 | size (MiB) | read (ms) |
|---|---|---|
| parquet / zstd | 26.37 | 33.0 |
| feather / uncompressed | 89.18 | 2.3 |
| feather / zstd | 36.13 | 25.9 |
读法很清楚:
- Feather 不压缩读得最快(2.3 ms),因为它存的就是未解码的 Arrow 布局,读取近乎把 buffer 摆好即可用,几乎不解码;代价是体积最大(89 MiB)。
- Parquet/zstd 最省(26 MiB),但读取要解压 + 解码,33 ms,是 Feather 不压缩的约 14 倍耗时。
- Feather/zstd 居中——加了压缩省了体积,但读取又得解压,速度优势随之缩小。
这正是「内存格式 vs 磁盘格式」分工的量化体现:要长期存、要省空间、要扫描裁剪选 Parquet;要中间结果落盘、跨进程快速交换、读了立刻算选 Feather/IPC。 二者不是替代关系,是数据生命周期不同阶段的工具。
十二、边界与小结
边界:
- 零拷贝是同进程概念。跨进程/网络(IPC、Flight)追求最少拷贝、不做格式转换,但字节终究要过边界。
- Arrow 是内存格式,不负责持久化的体积优化;长期存储仍用 Parquet/ORC,临时高速落盘用 Feather/IPC。
- 本章不展开 Arrow 的计算内核(compute kernels)、GPU(cuDF)与具体引擎实现细节。
小结:
- Arrow 把一个数组表示成「validity bitmap + value buffer +(变长时)offset buffer + 子数组」的裸 buffer 组合,没有逐元素对象,这是向量化与零拷贝的物理前提。
- 零拷贝来自三处:切片共享 buffer、无逐元素对象、跨边界共享布局而非转换。
- 向外传递有三层:进程内 C Data Interface(传指针,实测同地址零拷贝)、跨进程/落盘 IPC/Feather(自描述字节流)、跨网络 Flight(IPC over gRPC,支持并行 endpoint)。
- Arrow(内存、可算)与 Parquet(磁盘、紧凑)首尾相接:读 Parquet 就是解压解码后填进 Arrow buffer。下一章进入 Parquet 这一侧的编码与压缩。
下一篇:列式编码与压缩
返回 系列目录
参考资料
规范
- Apache Arrow, Arrow Columnar Format(physical memory layout、validity bitmaps、variable-size binary/list、struct、buffer alignment)。
- Apache Arrow, Serialization and Interprocess
Communication (IPC)(encapsulated message
format、Schema/RecordBatch/DictionaryBatch、streaming vs
file format、
ARROW1魔数)。 - Apache Arrow, The Arrow C data
interface(
ArrowSchema、ArrowArray、release callback、ABI 约定)与 The Arrow C stream interface(ArrowArrayStream)。 - Apache Arrow, Arrow Flight
RPC(
FlightService、GetFlightInfo/DoGet/DoPut、FlightData、多 endpoint)与 Arrow Flight SQL。 - Apache Arrow, Flatbuffers 定义
Schema.fbs、Message.fbs。
实现 / 实验
- pyarrow
24.0.0(
pyarrow.Array、pyarrow.cffi、pyarrow.parquet),Python 3.14.5,Linux WSL2 x86_64。本章 buffer 布局与 C Data Interface 零拷贝输出均来自本机真实执行。 - Apache
DataFusion(
arrow-rs)、Polars(polars-arrow)、Acero(Arrow C++ execution engine)、DuckDB Arrow 互操作文档。
系列内
- 列存引擎内核:
Block/列向量与向量化执行。 - 本系列 第 2 章 Parquet 格式、第 5 章 列式编码与压缩、第 18 章 查询引擎如何读湖。
附录、工程注记
validity bitmap 与 null_count 的省略
null_count 为 0 时 validity buffer 可省略(指针为 NULL)。消费者读到 null_count==0 即可跳过 bitmap 检查,这是常见的快路径。但跨库传递时要正确处理「buffer 存在但全 1」与「buffer 省略」两种等价表示。
offset 的 int32 上限
utf8/binary/list
用 int32 offset,单数组 values 上限约 2 GiB。超大列要用
large_utf8/large_binary/large_list,代价是
offset buffer 翻倍。RecordBatch
通常切得足够小(如每批几万到几十万行),很少触及上限。
REE 与 StringView(版本边界)
较新的 Arrow 规范加入了 run-end encoded(REE)布局与
StringView/BinaryView(变长视图)等布局,用于内存内的轻量编码与减少小字符串拷贝。是否可用取决于具体实现版本与对端支持,跨库传递前需确认双方都实现,否则回退到经典布局。
Dictionary 编码的内存表示
Arrow 的 dictionary<index, value>
在内存里是「indices 数组 + 一个 dictionary 数组」。IPC
用单独的 DictionaryBatch 传字典,支持 delta 增量。注意这与
Parquet 的字典编码是两套东西:一个是内存逻辑类型,一个是磁盘
page 编码(第
5 章),互转时需要映射。
64 字节对齐与 SIMD
规范建议 buffer 起始地址按 64 字节对齐、长度按 64 字节填充,便于 AVX-512 等宽 SIMD 一次处理整块且不越界。IPC 至少要求 8 字节对齐。自己分配 buffer 给 Arrow 用时,应使用 Arrow 的分配器或保证对齐,否则部分向量化路径会退化。
Flight 的 DoExchange 与背压
DoExchange
是双向流,适合需要客户端与服务端来回交互的场景(如增量上传 +
服务端返回处理结果)。gRPC 的流控天然提供背压;批大小(每个
RecordBatch 行数)直接影响吞吐与内存峰值,过小会让 message
头部与 RPC 开销占比上升。
与 pandas 的零拷贝边界
pyarrow ↔︎ pandas
转换不总是零拷贝:定长数值无空值的列可零拷贝映射成
numpy;含空值或字符串列通常要转换(pandas 经典 object/NaN
表示与 Arrow 不一致)。用 pandas 的 Arrow-backed
扩展类型(dtype_backend="pyarrow")可减少这类转换。
Feather V2 用途
Feather V2 即 Arrow IPC file 格式,适合中间结果落盘、跨进程快速交换——读时几乎不解码,可 mmap。它不替代 Parquet:没有 Parquet 那套编码压缩与统计裁剪,长期存储和扫描型分析仍选 Parquet。
IPC 里的字典:DictionaryBatch
字典编码列在 IPC 流里,字典本身用单独的 DictionaryBatch message 传,RecordBatch 里只放下标。一个字典可被多个 RecordBatch 引用(按 dictionary id 关联)。规范支持两种更新:replacement(后续 DictionaryBatch 整体替换)和 delta(只追加新增字典项)。跨库传字典编码数据时,要确认对端支持 delta 字典,否则可能只认 replacement。
字节序
Arrow 规范以 little-endian 为默认与主流。IPC 在 schema 里标注字节序,跨架构(大端机器)传输时需要转换。绝大多数现代部署是小端,通常无需关心,但写跨平台工具时要按 schema 字段判断,不假设。
mmap 与 Feather
不压缩的 Feather 文件可以 pa.memory_map() +
ipc.open_file()
打开,操作系统按需分页,进程「打开即用」而不预读全量。这对反复读的中间结果很省内存。一旦开了
body 压缩,就失去 mmap 直用的优势(要解压)。
Tensor 与 extension 类型
Arrow 除了表状数据,还定义了 Tensor / SparseTensor 的 IPC 表示,以及 extension type(在标准类型上挂 metadata 表达自定义语义,如 UUID、地理类型)。extension type 的物理布局仍是标准 buffer,未知的消费者可按底层类型读,不会因为不认识扩展而失败。
schema 与字段 metadata
Arrow 的 Field/Schema 可携带 key-value metadata。lakehouse 里常用它传字段级元信息(如来源、单位)。注意这套 metadata 是 Arrow schema 层的,和 Parquet 文件的 key-value metadata、Iceberg 的 schema(第 8 章)是不同层,互转时需要显式映射,不会自动贯通。
nanoarrow 与轻量实现
完整的 Arrow C++/pyarrow 较重。nanoarrow
是官方的极简 C 库,只实现内存布局 + C Data Interface + 基础
IPC,适合给小型库加 Arrow 互操作能力而不引入大依赖。要给一个
C/Rust 库接 Arrow 生态,常从 nanoarrow 或 arrow-rs
起步。
跨实现一致性测试
Arrow 项目维护 integration test:各语言实现(C++/Java/Go/Rust/JS)按同一组 IPC/C Data Interface 用例互读,保证「同一份字节,各语言解读一致」。这是 Arrow 能当跨语言公共格式的工程保障——格式规范 + 强制互测,而非各自实现各自的方言。
CUDA / GPU 上的 Arrow
Arrow 布局同样适用于 GPU 显存(cuDF 用 Arrow 列布局)。validity bitmap + 连续 value buffer 对 GPU 的 coalesced 访问同样友好。CPU↔︎GPU 之间也可经 CUDA IPC 共享,减少拷贝,但跨设备终究要过 PCIe,不是字面零拷贝。
ADBC 的定位
ADBC(Arrow Database Connectivity)是面向数据库客户端的统一 API,类比 JDBC/ODBC,但结果直接是 Arrow,省掉「数据库行格式 → 客户端对象 → Arrow」的两次转换。它可以用 Flight SQL 作后端,也可包装原生驱动。对 lakehouse,ADBC 让上层应用用一套 API 读不同引擎,且结果天然是列式 Arrow。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解
拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。
【数据湖与开放表格式】Parquet 文件格式深拆
拆 Parquet 的物理结构:file → row group → column chunk → page,footer 里的 FileMetaData(Thrift)与 PAR1 magic。讲清 PLAIN/RLE-bitpacking/字典/DELTA_BINARY_PACKED/BYTE_STREAM_SPLIT 各自压谁,Dremel 的 repetition/definition level 如何表达嵌套,column index/offset index 与 split-block bloom filter 怎样让谓词在读盘前裁掉 page。基于本机 pyarrow 24.0.0 真实 dump footer 与编码。
【数据湖与开放表格式】ORC 文件格式与 Parquet 对照
ORC 用 stripe 而非 row group、用三级统计(file/stripe/row-group index)而非独立 page index、用 PRESENT/DATA 等 stream 而非 page 组织一列。本文按 ORC 规范拆其文件尾(postscript + footer)、stripe 内部结构与 RLEv2 整数编码,并用本机 pyarrow 24.0.0 把同一份 30 万行数据写成 ORC 与 Parquet,对比真实体积与物理布局,最后给出什么场景仍用 ORC。
【数据湖与开放表格式】列式编码与压缩
拆解 Parquet 的两层缩减:专用编码(dictionary / RLE / DELTA_BINARY_PACKED / BYTE_STREAM_SPLIT)降熵,再用 zstd/snappy/lz4/gzip 压字节。用 pyarrow 在同一列上实测不同编码+压缩组合的体积与读取耗时(3M 行,7 轮中位数),并与 ClickHouse CODEC 做同思想不同落地的对照。