第 5 篇:「Fluss 列式流处理」—— Arrow 格式与查询优化

第 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;
    }
}
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

相关推荐
头茬韭菜2 天前
第 9 篇:「Fluss 运维实战」—— 部署、监控与故障排查
运维·fluss
头茬韭菜2 天前
功能点 1:项目骨架与启动流程 —— 源码阅读笔记
运维·笔记·debian·fluss
头茬韭菜3 天前
第 10 篇:「Fluss 实战案例集」—— 完整解决方案与最佳实践
c#·linq·fluss
头茬韭菜5 天前
第 4 篇:「Fluss + Flink 集成实战」—— Catalog、Source 与 Sink
大数据·python·flink·fluss
头茬韭菜9 天前
第 2 篇:「Fluss 架构深入」—— CoordinatorServer、TabletServer 与存储引擎
架构·fluss
头茬韭菜10 天前
Apache Fluss 项目深度分析与技术文档系列计划
flink·spark·realtime·fluss
chaplinthink2 年前
基于Fluss 的流式湖仓架构
lakehouse·fluss·pamion