第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) |
| QPS | 10万/秒 |
| 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-enabled | true | 启用Lookup表加速 |
file-index-read-enabled | true | 启用文件索引 |
scan-parallelism | CPU核数 | 扫描并行度 |
split-num-limit | 无限制 | Split最大数量 |