第 5 篇:「Fluss 列式流处理」------ Arrow 格式与查询优化
阅读本文你将了解: Apache Arrow 列式存储的基础原理、Fluss 的 Arrow Log 格式设计、列裁剪/谓词下推/分区裁剪三种优化技术的协同效应、以及与 Kafka 行式存储的性能对比。
5.1 Apache Arrow 列式格式基础
5.1.1 行式 vs 列式存储
行式存储(Kafka 风格):
Record 1: [order_id=1, user_id=100, amount=99.9, city='Beijing', status='paid', ...200列]
Record 2: [order_id=2, user_id=200, amount=49.9, city='Shanghai', status='new', ...]
Record 3: [order_id=3, user_id=300, amount=199.9, city='Beijing', status='paid', ...]
列式存储(Fluss/Arrow 风格):
order_id: [1, 2, 3 ]
user_id: [100, 200, 300 ]
amount: [99.9, 49.9, 199.9 ]
city: ['Beijing','Shanghai','Beijing']
status: ['paid', 'new', 'paid' ]
5.1.2 为什么列式更适合分析?
sql
-- 分析查询:只需要 3 列
SELECT user_id, SUM(amount)
FROM orders
WHERE status = 'paid'
GROUP BY user_id;
| 存储格式 | 需要读取的数据 | 有效数据占比 |
|---|---|---|
| 行式(Kafka) | 全部 200 列 × N 行 | 3/200 = 1.5% |
| 列式(Fluss) | 仅 user_id, amount, status 三列 | 100%(只读需要的列) |
I/O 对比:200 列的表,分析查询只读 3 列 → Fluss 只读取约 1.5% 的数据量,I/O 降低约 65 倍。
5.2 Fluss 的 Arrow Log 格式
5.2.1 Arrow 在 Fluss 中的角色
Fluss 使用 Apache Arrow 作为核心数据格式:
数据流路径:
Client (Flink) → [Arrow RecordBatch] → Network → TabletServer → [Arrow IPC] → Disk
关键优势:
├── 列式组织:天然支持列裁剪
├── 零拷贝:跨语言(Java/Python/C++)直接共享内存
├── SIMD 友好:列式数据布局适合向量化计算
└── 标准化:IPC 格式可直接被 Pandas、PyTorch 等读取
5.2.2 Arrow 写入流程
java
// 简化自 org.apache.fluss.record.ArrowLogWriter
public class ArrowLogWriter {
private final VectorSchemaRoot root; // Arrow 内存结构
private final ArrowStreamWriter writer;
/**
* 将一批行数据写入 Arrow IPC 格式
*/
public void writeBatch(List<RowData> rows) {
int rowCount = rows.size();
// 1. 为每列分配 Arrow Vector(列式内存布局)
for (int col = 0; col < schema.getFieldCount(); col++) {
FieldVector vector = root.getVector(col);
vector.allocateNew();
// 2. 按列写入数据(不是按行!)
for (int row = 0; row < rowCount; row++) {
vector.setSafe(row, rows.get(row).getField(col));
}
vector.setValueCount(rowCount);
}
// 3. 写入 IPC 格式到磁盘
writer.writeBatch();
}
}
5.2.3 Segment 文件中的 Arrow 布局
.log 文件物理布局:
┌──────────────────────────────────────────┐
│ Batch 1 Header (Arrow IPC metadata) │
│ - schema: [order_id: INT64, ...] │
│ - row_count: 1024 │
├──────────────────────────────────────────┤
│ Batch 1 Body (Arrow columnar data) │
│ order_id: [1, 2, 3, ..., 1024] │
│ user_id: [100, 200, ..., 102400] │
│ amount: [99.9, 49.9, ..., 299.9] │
│ ... (all columns) │
├──────────────────────────────────────────┤
│ Batch 2 Header ... │
│ Batch 2 Body ... │
└──────────────────────────────────────────┘
5.3 列裁剪(Column Projection)
5.3.1 原理
查询: SELECT user_id, amount FROM orders
无列裁剪(Kafka 模式):
→ 读取整个 RecordBatch(所有 200 列)
→ 网络传输全部数据
→ 客户端丢弃不需要的 198 列
列裁剪(Fluss 模式):
→ TabletServer 只读取 user_id 和 amount 两个 Arrow Vector
→ 网络只传输 2 列数据
→ 客户端直接使用
数据量对比:
200 列表,只查 2 列 → 网络传输减少 99%
5.3.2 源码支撑
java
// 简化自 org.apache.fluss.client.reader.LogScanner
public class LogScanner {
/**
* 根据查询需要的列,只读取对应的 Arrow Vector
*/
public ArrowRecordBatch scan(long startOffset, int[] projectedColumns) {
// 1. 定位到指定 offset 的 LogSegment
LogSegment segment = findSegment(startOffset);
// 2. 读取 Arrow IPC 数据
ArrowBuf buffer = segment.readBatch(startOffset);
// 3. 列裁剪:只提取需要的 Vector
VectorSchemaRoot fullRoot = ArrowStreamReader.read(buffer);
if (projectedColumns.length < fullRoot.getSchema().getFieldCount()) {
// 只保留查询需要的列
VectorSchemaRoot projected = VectorSchemaRoot.create(
fullRoot.getSchema().select(projectedColumns),
allocator
);
// 零拷贝引用(不复制数据)
for (int i = 0; i < projectedColumns.length; i++) {
projected.setVector(
i,
fullRoot.getVector(projectedColumns[i])
.getTransferPair(allocator).getTo()
);
}
return new ArrowRecordBatch(projected);
}
return new ArrowRecordBatch(fullRoot);
}
}
5.4 谓词下推(Predicate Pushdown)
5.4.1 原理
查询: SELECT * FROM orders WHERE amount > 1000 AND status = 'paid'
无谓词下推:
→ TabletServer 返回全部数据
→ 客户端(Flink Task)逐行过滤
→ 网络传输所有满足条件和不满足条件的数据
谓词下推:
→ 查询条件发给 TabletServer
→ TabletServer 对 Arrow Vector 进行向量化过滤
→ 只返回满足条件的行
→ 网络传输减少到满足条件的数据量
5.4.2 谓词下推实现
java
// 简化自 org.apache.fluss.predicate.PredicateEvaluator
public class PredicateEvaluator {
/**
* 对 Arrow RecordBatch 执行谓词过滤
*/
public SelectionVector4 evaluate(ArrowRecordBatch batch, List<Predicate> predicates) {
VectorSchemaRoot root = batch.getRoot();
int numRows = root.getRowCount();
// 初始全选
SelectionVector4 selection = SelectionVector4.createAllTrue(numRows);
for (Predicate predicate : predicates) {
String column = predicate.getColumn();
FieldVector vector = root.getVector(column);
switch (predicate.getOperator()) {
case EQUAL:
// 向量化比较(利用 SIMD)
for (int i = 0; i < numRows; i++) {
if (selection.isSelected(i)) {
Object value = vector.getObject(i);
selection.set(i, value.equals(predicate.getValue()));
}
}
break;
case GREATER_THAN:
for (int i = 0; i < numRows; i++) {
if (selection.isSelected(i)) {
double val = ((Float8Vector) vector).get(i);
selection.set(i, val > predicate.getDoubleValue());
}
}
break;
// ... 其他运算符
}
}
return selection;
}
}
5.4.3 谓词下推在 Flink SQL 中的触发
sql
-- 这些 WHERE 条件都会被下推到 Fluss TabletServer
SELECT user_id, SUM(amount)
FROM orders
WHERE amount > 100 -- 数值过滤 → 下推
AND status = 'paid' -- 等于过滤 → 下推
AND order_time >= '2026-08-01' -- 范围过滤 → 下推
GROUP BY user_id;
Flink SQL 查询计划:
LogicalFilter(amount > 100 AND status = 'paid')
└── FlussTableSourceScan
└── [Pushdown] predicates: [amount > 100, status = 'paid']
└── TabletServer 在读取时完成过滤
5.5 分区裁剪(Partition Pruning)
5.5.1 原理
orders 表分区结构:
├── dt=2026-08-06/
│ ├── bucket-0/
│ └── bucket-1/
├── dt=2026-08-07/
│ ├── bucket-0/
│ └── bucket-1/
└── dt=2026-08-08/
├── bucket-0/
└── bucket-1/
查询: SELECT * FROM orders WHERE dt = '2026-08-08'
分区裁剪:
→ Fluss 从 CoordinatorServer 获取分区信息
→ 只扫描 dt=2026-08-08 下的 Bucket
→ 跳过 8/6 和 8/7 的所有数据
→ 扫描量减少 66%(3 天数据中只读 1 天)
5.5.2 分区裁剪实现
java
// 简化自 org.apache.fluss.flink.source.FlussSourceEnumerator
public class FlussSourceEnumerator {
/**
* 根据分区条件过滤要读取的 Tablet
*/
public List<TabletSplit> getFilteredSplits(
TablePath tablePath,
List<Predicate> partitionPredicates) {
// 1. 从 Coordinator 获取所有分区信息
List<PartitionInfo> allPartitions = coordinatorClient.getPartitions(tablePath);
// 2. 应用分区谓词过滤
List<PartitionInfo> filteredPartitions = allPartitions.stream()
.filter(p -> partitionPredicates.stream()
.allMatch(pred -> pred.evaluate(p.getPartitionValues())))
.collect(Collectors.toList());
// 3. 只对过滤后的分区创建 Tablet 分片
List<TabletSplit> splits = new ArrayList<>();
for (PartitionInfo partition : filteredPartitions) {
for (long bucketId = 0; bucketId < partition.getBucketCount(); bucketId++) {
splits.add(new TabletSplit(
tablePath, partition.getPartitionId(), bucketId
));
}
}
return splits;
}
}
5.6 复合裁剪的协同效应
5.6.1 三层裁剪同时生效
sql
-- 一个同时触发三层裁剪的查询
SELECT user_id, amount
FROM orders
WHERE dt = '2026-08-08' -- ① 分区裁剪
AND status = 'paid' -- ② 谓词下推
AND amount > 100; -- ③ 列裁剪 (user_id, amount)
执行效果:
┌────────────────────────────────────────────────────────────┐
│ 原始数据: 200 列 × 3 天 × 1 亿行/天 = 3 亿行 │
│ │
│ ① 分区裁剪: 200 列 × 1 天 × 1 亿行 = 1 亿行 (↓67%) │
│ ② 谓词下推: 200 列 × 2000 万行 = 2000 万行 (↓80%) │
│ ③ 列裁剪: 2 列 × 2000 万行 (↓99% 列数) │
│ │
│ 最终网络传输: 2 列 × 2000 万行 │
│ vs 原始: 200 列 × 3 亿行 │
│ │
│ I/O 降低: ~99.3% │
└────────────────────────────────────────────────────────────┘
5.6.2 性能基准测试
| 查询类型 | Kafka (行式) | Fluss (列式+裁剪) | 提升倍数 |
|---|---|---|---|
| 全表扫描 | 100s | 100s | 1x |
| 10列查询 | 100s | 5s | 20x |
| 10列+过滤 | 100s | 2.5s | 40x |
| 2列+分区+过滤 | 100s | 0.5s | 200x |
以上数据为基于架构原理的估算值,实际性能取决于具体数据和硬件
5.7 与 Kafka 行式存储的性能对比
场景:实时分析大屏查询
表:用户行为表,150 列
Kafka 方案:
1. Kafka → Flink 全量消费 (150 列)
2. Flink SQL 在内存中过滤和聚合
3. 网络带宽: 150 列 × 100K rows/sec = ~120 MB/sec
4. 瓶颈:网络带宽和 Flink 内存
Fluss 方案:
1. Fluss → Flink 列裁剪 (只读 5 列)
2. 谓词下推到 TabletServer 执行
3. 网络带宽: 5 列 × 100K rows/sec = ~4 MB/sec
4. 优势:网络带宽降低 97%
5.8 Arrow 零拷贝的跨语言优势
Python ML Pipeline:
Fluss (Java) → Arrow IPC → Python Pandas (零拷贝)
import pyarrow as pa
import pandas as pd
# 从 Fluss Rust 客户端读取 Arrow 数据
reader = fluss.ArrowReader("orders")
batch = reader.read_batch() # Arrow RecordBatch
# 零拷贝转为 Pandas DataFrame
df = pa.Table.from_batches([batch]).to_pandas()
# 直接喂给 ML 模型
model.predict(df[['user_id', 'amount']])
5.9 总结与下一篇预告
| 优化技术 | 作用层 | 效果 |
|---|---|---|
| 列裁剪 | 存储+网络 | 200 列表只读 2 列,I/O 降低 99% |
| 谓词下推 | 计算 | WHERE 过滤在服务端执行,减少网络传输 |
| 分区裁剪 | 元数据 | 按分区粒度跳过大量数据,扫描量降低 50-90% |
| 三层叠加 | 全链路 | 协同效应可达 100-200x I/O 优化 |
下一篇我们将学习 Fluss Lookup Join------如何实现亚毫秒级维表关联,替代 Redis/HBase 维表方案。
本文基于 Apache Fluss 0.9.1 源码。项目 GitHub: https://github.com/apache/fluss