土法炼钢兴趣小组的算法知识备份

【数据湖与开放表格式】Apache Arrow 内存格式与零拷贝

文章导航

分类入口
databasestorage
标签入口
#arrow#ipc#arrow-flight#zero-copy#c-data-interface#columnar#parquet

目录

第 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 写的向量检索库。如果每一跳都用自己的内存表示,那么每一跳都要:

  1. 把上游格式反序列化成本系统的对象(行对象、PyObject、JVM 对象……)。
  2. 计算。
  3. 把结果序列化成下游能读的字节流。

这里有两类浪费。一是 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..int64floatdoublebooldatetimestamp
变长二进制 validity bitmap, offsets, values utf8binary(offset 为 int32)
大变长二进制 validity bitmap, offsets, values large_utf8large_binary(offset 为 int64)
定长列表 validity bitmap, (child) fixed_size_list
变长列表 validity bitmap, offsets, (child) listlarge_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]

逐行对上规范:


三、空值:validity bitmap

Arrow 用一个独立的 validity bitmap(也叫 null bitmap)表示哪些 slot 是空值,规则在规范 “Validity (Null) Bitmaps” 一节:

对第 \(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

嵌套类型靠组合这几种 buffer 表达,而不是新发明布局:

这与 第 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]

逐个对上:

可见嵌套就是「外层 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 10

values 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.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 有两种封装:

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

两个对工程有意义的设计:

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 里有几个容易混的容器,澄清一下层级:

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 作为内存模型,于是它们之间互通几乎免费:

它们与 列存引擎系列 里 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

读法很清楚:

这正是「内存格式 vs 磁盘格式」分工的量化体现:要长期存、要省空间、要扫描裁剪选 Parquet;要中间结果落盘、跨进程快速交换、读了立刻算选 Feather/IPC。 二者不是替代关系,是数据生命周期不同阶段的工具。


十二、边界与小结

边界

小结


上一篇ORC 文件格式与 Parquet 对照

下一篇列式编码与压缩

返回 系列目录


参考资料

规范

  1. Apache Arrow, Arrow Columnar Format(physical memory layout、validity bitmaps、variable-size binary/list、struct、buffer alignment)。
  2. Apache Arrow, Serialization and Interprocess Communication (IPC)(encapsulated message format、Schema/RecordBatch/DictionaryBatch、streaming vs file format、ARROW1 魔数)。
  3. Apache Arrow, The Arrow C data interfaceArrowSchemaArrowArray、release callback、ABI 约定)与 The Arrow C stream interfaceArrowArrayStream)。
  4. Apache Arrow, Arrow Flight RPCFlightServiceGetFlightInfo/DoGet/DoPutFlightData、多 endpoint)与 Arrow Flight SQL
  5. Apache Arrow, Flatbuffers 定义 Schema.fbsMessage.fbs

实现 / 实验

  1. pyarrow 24.0.0(pyarrow.Arraypyarrow.cffipyarrow.parquet),Python 3.14.5,Linux WSL2 x86_64。本章 buffer 布局与 C Data Interface 零拷贝输出均来自本机真实执行。
  2. Apache DataFusion(arrow-rs)、Polars(polars-arrow)、Acero(Arrow C++ execution engine)、DuckDB Arrow 互操作文档。

系列内

  1. 列存引擎内核Block/列向量与向量化执行。
  2. 本系列 第 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。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-29 · database / storage

【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解

拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。

2026-06-30 · database / storage

【数据湖与开放表格式】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 与编码。

2026-06-30 · database / storage

【数据湖与开放表格式】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。

2026-06-30 · database / storage

【数据湖与开放表格式】列式编码与压缩

拆解 Parquet 的两层缩减:专用编码(dictionary / RLE / DELTA_BINARY_PACKED / BYTE_STREAM_SPLIT)降熵,再用 zstd/snappy/lz4/gzip 压字节。用 pyarrow 在同一列上实测不同编码+压缩组合的体积与读取耗时(3M 行,7 轮中位数),并与 ClickHouse CODEC 做同思想不同落地的对照。


By .