流式数据湖Paimon探秘之旅 (七) 读取流程全解析

117 阅读10分钟

第7章:读取流程全解析

导言:数据从文件到应用的旅程

在前面的章节,我们讲了如何写入数据(第5章)和如何提交数据(第6章)。现在是最后一个关键问题:如何高效地读取数据?

读取的三个层次

应用程序(SQL)
    ↓
TableScan / TableRead(表层API)
    ↓
FileStoreScan(文件扫描)
    ├─ 规划:选择哪些文件?
    ├─ 分割:如何并行化?
    └─ 过滤:哪些分区/桶要扫?
    ↓
SplitRead(实际读取)
    ├─ 打开文件
    ├─ 应用谓词过滤
    └─ 返回结果行

第一部分:FileStoreScan(文件扫描规划)

1.1 FileStoreScan是什么

FileStoreScan负责规划读取哪些文件

public interface FileStoreScan {
    
    /**
     * 添加分区过滤器
     */
    FileStoreScan withPartitionFilter(Predicate filter);
    
    /**
     * 添加分区列表
     */
    FileStoreScan withPartitionFilter(List<BinaryRow> partitions);
    
    /**
     * 计划扫描,返回需要读取的Split
     */
    ScanPlan plan();
}

public class ScanPlan {
    // 待读取的文件组
    public List<DataFileMeta> files();
    
    // 已分割成的Split(可并行读取)
    public List<Split> splits();
}

1.2 ScanPlan的构成

Split是什么?

Split代表一个可并行读取的任务单位

原始数据:
├─ partition: {dt="2024-01-01"}, bucket=0
│  ├─ File_1.parquet (key 1-1000)
│  ├─ File_2.parquet (key 1001-2000)
│  └─ File_3.parquet (key 2001-3000)
│
├─ partition: {dt="2024-01-01"}, bucket=1
│  ├─ File_4.parquet (key 1-1000)
│  └─ File_5.parquet (key 1001-2000)

分割后的Split:
├─ Split_1: partition={dt="2024-01-01"}, bucket=0, files=[File_1, File_2, File_3]
├─ Split_2: partition={dt="2024-01-01"}, bucket=1, files=[File_4, File_5]

特点:
├─ 每个Split对应一个bucket(可以独立读取)
├─ 同一bucket的所有文件打包成一个Split
└─ 多个Split可以并行读取

1.3 扫描规划流程

┌──────────────────────────────────┐
│ 应用指定扫描条件:               │
│ ├─ 时间范围:2024-01-01          │
│ ├─ 条件:sales > 1000             │
│ └─ 列:order_id, sales            │
└───────┬──────────────────────────┘
        │
        ↓
┌──────────────────────────────────┐
│ Step 1: 分区剪枝                 │
│ ├─ 读取Snapshot的Manifest        │
│ ├─ 筛选分区:dt="2024-01-01" ✓   │
│ ├─ 排除分区:dt=其他日期 ✗       │
│ └─ 候选文件大幅减少              │
└───────┬──────────────────────────┘
        │
        ↓
┌──────────────────────────────────┐
│ Step 2: 文件选择                 │
│ ├─ 按bucket分组                  │
│ ├─ 生成Split                     │
│ └─ 预计需要读取的bucket个数      │
└───────┬──────────────────────────┘
        │
        ↓
┌──────────────────────────────────┐
│ Step 3: 下推谓词                 │
│ ├─ sales > 1000                  │
│ ├─ 检查文件统计:min/max sales    │
│ ├─ 完全过滤的文件排除            │
│ └─ 部分过滤的文件保留            │
└───────┬──────────────────────────┘
        │
        ↓
┌──────────────────────────────────┐
│ ScanPlan                         │
│ ├─ 待扫描的Split个数             │
│ ├─ 预计扫描的行数                │
│ └─ 预计读取的数据量              │
└──────────────────────────────────┘

第二部分:谓词下推(Predicate Pushdown)

2.1 什么是谓词下推

**谓词(Predicate)**是查询条件,如 sales > 1000

下推是指将过滤条件下推到文件读取层,尽早过滤数据:

不下推:
SELECT * FROM orders WHERE sales > 1000
    
读取100GB所有数据
    
内存中过滤 sales > 1000
    
结果:5GB(丢弃了95GB)

性能损耗:100GB的磁盘IO + 100GB的网络传输 

下推:
SELECT * FROM orders WHERE sales > 1000
    
谓词下推:sales > 1000
    
文件级别过滤:
  ├─ File_1: min_sales=100, max_sales=500  排除
  ├─ File_2: min_sales=800, max_sales=1500  包含
  └─ File_3: min_sales=1200, max_sales=2000  包含
    
只读取File_2和File_3(30GB)
    
磁盘IO:30GB(节省70%)✓

2.2 多层次的谓词下推

┌─────────────────────────────────────┐
│ 层级1:分区剪枝                    │
│ WHERE dt = '2024-01-01'             │
│ → 只读取该日期的分区                │
│ 效果:90%的分区直接排除             │
└─────────────────────────────────────┘
                ↓
┌─────────────────────────────────────┐
│ 层级2:文件级统计剪枝               │
│ WHERE sales > 1000                  │
│ 文件统计:min_sales, max_sales      │
│ → 排除max_sales < 1000的文件        │
│ 效果:50%的文件排除                 │
└─────────────────────────────────────┘
                ↓
┌─────────────────────────────────────┐
│ 层级3:行组级过滤(Parquet)       │
│ 每个行组1MB,有min/max统计         │
│ → 排除max < 1000的行组              │
│ 效果:20%的行组排除                 │
└─────────────────────────────────────┘
                ↓
┌─────────────────────────────────────┐
│ 层级4:行级过滤                    │
│ WHERE sales > 1000                  │
│ → 最后一道过滤,处理边界记录       │
│ 效果:实际过滤业务定义的行         │
└─────────────────────────────────────┘

总体效果:4层过滤可以减少99%+的数据读取!

2.3 AppendOnlyFileStoreScan vs KeyValueFileStoreScan

AppendOnlyFileStoreScan

特点:
├─ 无主键,所以无去重问题
├─ 读取相对简单
└─ 能充分利用谓词下推

配置:
└─ bucket-key:用于文件级过滤
   例如:bucket-key = order_id
   过滤:order_id 的某个范围

KeyValueFileStoreScan

特点:
├─ 有主键,需要多版本合并
├─ 读取相对复杂
└─ 需要特殊处理(Lookup表、DeletionVector)

配置:
├─ bucket-key:与AppendOnly相同
├─ 删除向量:DV_enabled = true
└─ Lookup表:lookup-enabled = true

第三部分:SplitRead(实际读取)

3.1 SplitRead的执行

public interface SplitRead<T> {
    
    /**
     * 为给定的Split创建读取器
     */
    RecordReader<T> createReader(Split split);
}

// 使用示例
SplitRead<KeyValue> read = fileStore.newRead();
for (Split split : scanPlan.splits()) {
    // 并行执行,每个线程处理一个Split
    RecordReader<KeyValue> reader = read.createReader(split);
    while (reader.nextRecord() != null) {
        // 处理记录
    }
}

3.2 RecordReader的工作流程

┌──────────────────────────────────────┐
│ RecordReader创建                    │
│ ├─ Split: partition, bucket, files   │
│ ├─ 打开所有文件的读取器              │
│ └─ 初始化合并器(如果有多个文件)   │
└───────┬──────────────────────────────┘
        │
        ↓
┌──────────────────────────────────────┐
│ 对于每个文件:                      │
│ ├─ 打开ParquetFile                   │
│ ├─ 读取行组(Row Group)             │
│ ├─ 应用谓词过滤                      │
│ └─ 返回过滤后的行                   │
└───────┬──────────────────────────────┘
        │
        ↓
┌──────────────────────────────────────┐
│ 多文件场景:合并                    │
│ ├─ 按主键合并(KeyValue表)         │
│ ├─ 去重(同主键保留最新)           │
│ └─ 返回合并后的行                   │
└───────┬──────────────────────────────┘
        │
        ↓
┌──────────────────────────────────────┐
│ 返回结果                            │
│ ├─ 一条记录
│ ├─ 满足所有谓词条件
│ └─ 是最新版本(KeyValue表)
└──────────────────────────────────────┘

3.3 AppendOnly表的读取

简单情况(单个文件):
┌─────────────────────────────────┐
│ File_1.parquet                  │
├─────────────────────────────────┤
│ Row 1: {id=1, name="Alice"}     │
│ Row 2: {id=2, name="Bob"}       │
│ Row 3: {id=3, name="Charlie"}   │
└─────────────────────────────────┘
        ↓
    按顺序返回

复杂情况(多个文件):
┌──────────────────┐  ┌──────────────────┐
│ File_1.parquet   │  │ File_2.parquet   │
├──────────────────┤  ├──────────────────┤
│ Row 1: id=1      │  │ Row 1: id=3      │
│ Row 2: id=2      │  │ Row 2: id=4      │
└──────────────────┘  └──────────────────┘
        ↓                      ↓
  返回 id=1           返回 id=3
  返回 id=2           返回 id=4
                ↓
        应用无需关心文件数,按顺序返回

谓词过滤:WHERE id > 2
├─ File_1: 排除 id=1, 排除 id=2 → 空
├─ File_2: 返回 id=3, 返回 id=4
└─ 结果:id=3, id=4

3.4 KeyValue表的读取

复杂情况(多个文件,存在重复key):

Level 0(最新):
┌──────────────────┐
│ File_1.parquet   │
├──────────────────┤
│ key=1, val="v3"  │ ← 最新版本
│ key=2, val="v2"  │
└──────────────────┘

Level 1(旧):
┌──────────────────┐
│ File_2.parquet   │
├──────────────────┤
│ key=1, val="v1"  │ ← 旧版本
│ key=3, val="v1"  │
└──────────────────┘

读取流程:
Step 1: 打开File_1和File_2的读取器

Step 2: 多路归并(按key排序)
key=1: (File_1="v3", File_2="v1")
       → 选File_1(新)
       → 返回 (key=1, val="v3")

key=2: (File_1="v2")
       → 返回 (key=2, val="v2")

key=3: (File_2="v1")
       → 返回 (key=3, val="v1")

最终结果:
(key=1, val="v3")
(key=2, val="v2")
(key=3, val="v1")

第四部分:分布式扫描与并行化

4.1 扫描的并行化

本地读取(单机):
Split_1 → RecordReader_1(线程1)
Split_2 → RecordReader_2(线程2)
Split_3 → RecordReader_3(线程3)
...
Split_N → RecordReader_N(线程N)

同时进行 → 吞吐提升N倍!

限制:
├─ 线程池大小(通常等于Split个数)
└─ 磁盘IO能力

4.2 Flink/Spark中的并行读取

Flink Source

Flink数据源读取流程:
SourceOperator
    ├─ Split Source(生成Split)
    │   ├─ ScanPlan plan()
    │   ├─ 按bucket返回多个Split
    │   └─ 总计:numBuckets个Split
    │
    ├─ SourceReader(并行读取)
    │   ├─ Task 1读取 Split 1
    │   ├─ Task 2读取 Split 2
    │   └─ Task N读取 Split N
    │
    └─ 结果合并(框架自动)
        └─ 用户获得完整结果

并行度 = min(numBuckets, parallelism)

Spark DataSource

Spark读取流程:
DataFrame
    ├─ scan() 返回ScanPlan
    │   ├─ Split_1 → Partition_1
    │   ├─ Split_2 → Partition_2
    │   └─ Split_N → Partition_N
    │
    ├─ 为每个Partition创建Task
    │   ├─ Task 1处理Partition_1
    │   ├─ Task 2处理Partition_2
    │   └─ Task N处理Partition_N
    │
    └─ Shuffle(如果需要)
        └─ 最终结果

并行度 = numSplits(动态调整)

第五部分:实战案例

5.1 案例1:高并发点查(KeyValue表)

场景

  • 用户维表,主键:user_id
  • 每秒10万次查询
  • 查询:SELECT * FROM users WHERE user_id = ?

执行流程

应用发起点查:user_id = 1000
    ↓
FileStoreScan.plan()
    ├─ 读取Snapshot
    ├─ 查Manifest:user_id=1000在哪个分区/桶?
    └─ 返回单个Split(对应的bucket)
    ↓
RecordReader.createReader(split)
    ├─ 打开该bucket的所有文件(可能5-10个)
    ├─ 使用Lookup表快速定位user_id=1000可能所在文件
    │   └─ 过滤:仅打开2-3个文件(而非所有)
    ├─ 多路归并搜索user_id=1000
    └─ 返回结果
    ↓
耗时:<50ms(包括网络延迟)

性能指标

指标实际值
查询延迟<50ms(p99)
QPS10万/秒
CPU占用20%
缓存命中率95%(Lookup表缓存)

5.2 案例2:范围扫描(AppendOnly表)

场景

  • 日志表,无主键
  • 每秒1000万条日志写入
  • 查询:SELECT * FROM logs WHERE ts >= ? AND ts <= ? AND level = 'ERROR'

执行流程

应用发起范围扫描:ts in [1704067200000, 1704153600000], level='ERROR'
    ↓
分区剪枝:
├─ 时间范围 → 确定日期分区
├─ 日期2024-01-01至2024-01-02 → 选择这两个分区
└─ 其他分区排除 → 减少60%数据
    ↓
文件选择:
├─ 选择这两个分区的所有文件
├─ 按bucket分组 → 生成多个Split
└─ 预计16个Split(8个bucket × 2个分区)
    ↓
谓词下推:ts和level条件传递到reader
    ↓
并行读取:
├─ 16个任务并行执行
├─ 每个任务处理一个Split
├─ 应用行级过滤:level='ERROR'
└─ 合并结果
    ↓
结果:所有匹配的日志行
耗时:<1秒(16个Split并行)

5.3 案例3:聚合查询(KeyValue表)

场景

  • 订单表,主键:order_id
  • 查询:SELECT user_id, SUM(amount) FROM orders GROUP BY user_id

执行流程

应用发起聚合:GROUP BY user_id
    ↓
全表扫描规划:
├─ FileStoreScan.plan()
├─ 生成N个Split(按bucket)
└─ 预计扫描所有数据
    ↓
并行读取:
├─ Split_1读取:bucket_0的所有订单
├─ Split_2读取:bucket_1的所有订单
└─ Split_N读取:bucket_N的所有订单
    ↓
应用端合并:
├─ 每个任务返回部分聚合结果
├─ 框架合并这些部分结果
└─ 最终返回完整GROUP BY结果
    ↓
性能:
├─ 顺序扫描,充分利用磁盘带宽
├─ 并行化:N个bucket → N个并行任务
└─ 耗时:~10秒(扫描50GB数据)

第六部分:性能优化

6.1 读取性能优化技巧

1. 启用谓词下推
   ├─ 减少数据扫描量(90%+)
   └─ WHERE子句尽可能具体

2. 充分利用分区
   ├─ 查询时指定分区
   └─ 避免全表扫描

3. 列剪枝(Column Pruning)
   ├─ 仅读取需要的列
   ├─ 减少IO(30-50%)
   └─ SELECT col1, col2而非SELECT *

4. 并行度调整
   ├─ parallelism = min(numBuckets, available_cores)
   └─ 避免过度并行化(线程竞争)

6.2 缓存策略

Lookup表缓存:
├─ 缓存Level 0的文件索引
├─ 加速点查(4-10倍)
├─ 内存占用:~100MB(100万条记录)
└─ 启用:lookup-enabled=true

Manifest缓存:
├─ 缓存Manifest内容
├─ 加速元数据查询
└─ 大小:自动管理

查询缓存:
├─ 应用层缓存查询结果
├─ 仅对完全相同的查询有效
└─ 推荐与时间戳结合使用

6.3 读取成本分析

单次点查成本:

CPU:
├─ Manifest查询:1ms
├─ 文件打开:10ms
├─ 数据扫描:10ms
└─ 总计:21ms

磁盘IO:
├─ Manifest读取:1MB
├─ 文件数据:10MB(1个文件)
└─ 总计:11MB

网络(如果是分布式):
└─ 往返延迟:20-50ms

总延迟:50-100ms(受网络影响)

第七部分:常见问题

Q1: 为什么有时查询很慢?

现象:同一个查询,有时<100ms,有时>1s

原因:
├─ Compaction进行中,文件数多
├─ 缓存miss(重启后)
├─ 磁盘IO堵塞
└─ 其他job同时读取

解决:
1. 检查Compaction状态
2. 预热缓存(重复查询相同数据)
3. 检查磁盘IO(iostat)

Q2: 为什么扫描结果不完整?

现象:count(*)与预期不符

原因:
├─ 未提交的数据不可见(正常)
├─ 仅指定了部分分区
├─ 表存在未完成的Compaction

解决:
1. 等待写入提交
2. 检查分区过滤条件
3. 运行ANALYZE检查一致性

Q3: 并行化没有提升性能

现象:4个并行任务,吞吐没有4倍提升

原因:
├─ 磁盘IO成为瓶颈(单盘顺序读已达上限)
├─ CPU核数不足
├─ 网络带宽受限

解决:
1. 检查实际IO带宽(iostat)
2. 调整并行度不超过实际核数
3. 考虑增加硬件资源

总结

读取流程的关键步骤

扫描规划(FileStoreScan)
    ├─ 分区剪枝
    ├─ 文件选择
    └─ 谓词下推
        ↓
生成Split和ScanPlan
        ↓
并行读取(SplitRead)
    ├─ 每个Split对应一个读取任务
    ├─ 任务可并行执行
    └─ 返回结果行
        ↓
应用获得最终结果

性能优化checklist

  • 启用谓词下推
  • 充分使用分区过滤
  • 列剪枝(仅读需要的列)
  • 设置合理的并行度
  • 启用Lookup表缓存
  • 定期预热缓存
  • 监控扫描延迟

关键参数

参数默认值说明
lookup-enabledtrue启用Lookup表加速
file-index-read-enabledtrue启用文件索引
scan-parallelismCPU核数扫描并行度
split-num-limit无限制Split最大数量