这个优化是通过对数据进行全局排序,从而让查询时能够跳过大量不相关的数据文件(Data Skipping),极大地减少 I/O,提升查询速度。只需要在执行 INSERT 语句时,通过 OPTIONS Hint 来启用和配置这个功能即可。
RangePartitionAndSortForUnawareBucketTableITCase 测试文件本身就是最好的例子。
比如测试中的这句 SQL:
INSERT INTO test_table /*+ OPTIONS('sink.clustering.by-columns' = 'col1', 'sink.parallelism' = '10') */ SELECT * FROM test_source
这里的 OPTIONS 就是在告诉 Flink/Paimon:
'sink.clustering.by-columns' = 'col1': 请在写入数据前,按照 col1 这一列对数据进行全局排序。
'sink.parallelism' = '10': 我希望最终有 10 个文件,也就是排序任务的并发度是 10。
只需要写这样的 SQL,底层的 Shuffle 和 Sort 就会被自动触发。
我们分三步来理解:
AppendOnlyFileStoreTable.java
文件定义了无主键表(Append-Only Table)的核心逻辑。
// ... existing code ...
/** {@link FileStoreTable} for append table. */
public class AppendOnlyFileStoreTable extends AbstractFileStoreTable {
// ... existing code ...
@Override
public TableWriteImpl newWrite(
String commitUser, ManifestCacheFilter manifestFilter) {
AppendOnlyFileStoreWrite writer = store().newWrite(commitUser, manifestFilter);
return new TableWriteImpl<>(
rowType(),
writer,
createRowKeyExtractor(),
(record, rowKind) -> {
Preconditions.checkState(
rowKind.isAdd(),
"Append only writer can not accept row with RowKind %s",
rowKind);
return record.row();
},
// ... existing code ...
从代码中可以看到,这种表只接受 ADD
(追加)类型的数据。数据被写入时,并没有按照任何特定列进行排序。
这就导致了两个主要问题:
WHERE order_amount > 1000
的订单。因为数据是乱序写入的,Paimon 不知道哪个数据文件里可能包含符合条件的记录。因此,它唯一的办法就是把所有的数据文件都读取一遍,然后逐条检查,这在数据量大时效率极低。这个过程的目标就是把乱序的数据,按照你指定的列(比如 order_amount
)重新整理成全局有序的。它分为两步:
这一步的目的是**“物以类聚”**,把值相近的数据发送到同一个计算节点上。
order_amount
的值大致分布在 1 到 10000 之间。order_amount
在 [1, 2500] 之间的数据行都会被发送到第一个排序任务,[2501, 5000] 的数据行发送到第二个任务,以此类推。RangeShuffle.java
中的逻辑就实现了类似的功能,通过二分查找等方式确定一个 Key 应该被分配到哪个范围分区。
// ... existing code ...
private int binarySearch(T key) {
int lastIndex = this.keyIndex.size() - 1;
int low = 0;
int high = lastIndex;
while (low <= high) {
final int mid = (low + high) >>> 1;
final Pair indexPair = keyIndex.get(mid);
final int result = keyComparator.compare(key, indexPair.getLeft());
if (result > 0) {
low = mid + 1;
} else if (result < 0) {
high = mid - 1;
} else {
return indexPair.getRight().get();
}
}
// ... existing code ...
这一步在每个计算节点内部进行。每个排序任务接收到属于自己范围的数据后,会在本地内存和磁盘上对这些数据进行排序。
SortUtils.java
中的 SortOperator
就是执行这个本地排序的核心算子。
// ... existing code ...
if (tableSortInfo.isSortInCluster()) {
return rangeShuffleResult
// ... existing code ...
// sort the output locally by `SortOperator`
.transform(
"LOCAL SORT",
internalRowType,
new SortOperator(
// ... existing code ...
当所有排序任务都完成后,数据就被写入到新的 Paimon 数据文件中。此时,整个表的数据就按照 order_amount
实现了全局有序。
经过 "Range Partition and Sort" 之后,数据文件的物理布局发生了根本性变化。
order_amount
在 [1, 2500] 的数据。order_amount
在 [2501, 5000] 的数据。Paimon 会为每个数据文件记录元数据,其中就包括排序列的最小值和最大值(Min/Max Index)。
现在,当再次执行查询 WHERE order_amount > 6000
时:
max(order_amount)
是 2500,小于 6000,所以整个文件 A 都可以被安全地跳过,无需读取。max(order_amount)
是 5000,也小于 6000,所以整个文件 B 也可以被跳过。max
值大于 6000 的文件才可能包含需要的数据,Paimon 只需要读取这些文件。这就是数据跳过(Data Skipping) 的魔力。通过一次性的排序重组,将原来需要全表扫描的查询,变成了只需要读取少数几个相关文件的精准查询,I/O 量可能减少几个数量级,查询速度自然就飞快了。
项目中的测试用例 RangePartitionAndSortForUnawareBucketTableITCase.java
很好地证明了这一点。它在排序后会去检查每个生成的数据文件,验证其内部数据是有序的,并且文件之间的 Min/Max 值是衔接有序的,这正是数据跳过能够生效的物理基础。
// ... existing code ...
minMaxOfEachFile.sort(Comparator.comparing(o -> o.f0));
Tuple2 preResult = minMaxOfEachFile.get(0);
for (int index = 1; index < minMaxOfEachFile.size(); ++index) {
Tuple2 currentResult = minMaxOfEachFile.get(index);
// ... existing code ...
// 验证后一个文件的最小值 >= 前一个文件的最大值,证明文件间是有序的
assertThat(currentResult.f0).isGreaterThanOrEqualTo(preResult.f1);
}
总结一下,"Range Partition and Sort" 通过牺牲一次性的写入/重组性能,换来了后续成千上万次查询性能的巨大提升,是典型的数据仓库优化思想。
paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/RangePartitionAndSortForUnawareBucketTableITCase.java
当Sort 过程完成后,数据被写入文件。在 Paimon 中,每个数据文件(Data File)都有一条对应的元信息记录,这条记录存储在清单文件(Manifest File)中。这条记录被称为 ManifestEntry
。
ManifestEntry
中除了记录文件名、大小等信息外,最关键的是它会记录这个文件中所有列的统计信息,包括最小值 (min value) 和 最大值 (max value)。
在 testRangePartition
测试中,这段代码就是为了验证这一点:
// ... existing code ...
List> minMaxOfEachFile = new ArrayList<>();
for (ManifestEntry file : files) {
// ... existing code ...
final AtomicInteger min = new AtomicInteger(Integer.MAX_VALUE);
final AtomicInteger max = new AtomicInteger(Integer.MIN_VALUE);
// 这里创建了一个 RecordReader 来读取文件内容
try (RecordReader reader =
testStoreTable.newReadBuilder().newRead().createReader(dataSplit)) {
reader.forEachRemaining(
internalRow -> {
int result = internalRow.getInt(0);
min.set(Math.min(min.get(), result));
max.set(Math.max(max.get(), result));
});
}
minMaxOfEachFile.add(Tuple2.of(min.get(), max.get()));
}
// ... existing code ...
这段代码模拟了 Paimon 在写入时会做的事情:计算每个文件的 min
和 max
值。虽然测试里是读出来再计算的,但实际写入时,Paimon Writer 会在写入过程中就计算好这些统计值,并最终保存在 ManifestEntry
中。
所以,Paimon 并不是通过某种特殊的标记知道文件“整体有序”,而是通过记录每个文件的**边界(Min/Max 值)**来间接了解数据的分布情况。
我们来看 testRangePartitionAndSortWithZOrderStrategy
这个测试,它模拟了一次查询:
// ... existing code ...
FileStoreTable testStoreTable = paimonTable("test_table");
PredicateBuilder predicateBuilder = new PredicateBuilder(testStoreTable.rowType());
// 1. 创建一个查询条件:WHERE col1 BETWEEN 100 AND 200
Predicate predicate = predicateBuilder.between(0, 100, 200);
// 2. 获取排序后的所有文件(10个)
List files = testStoreTable.store().newScan().plan().files();
assertThat(files.size()).isEqualTo(10);
// 3. 使用查询条件进行一次扫描,看看能过滤掉多少文件
List filesFilter =
((AppendOnlyFileStoreScan) testStoreTable.store().newScan())
.withFilter(predicate)
.plan()
.files();
// 4. 断言:过滤后的文件数一定小于总文件数
Assertions.assertThat(files.size()).isGreaterThan(filesFilter.size());
// ... existing code ...
当 Paimon 执行带有 WHERE col1 BETWEEN 100 AND 200
的查询时:
predicate
(查询条件)。ManifestEntry
。col1
的 min
和 max
值。
min
是 300,max
是 399。这个范围 [300, 399]
与查询的范围 [100, 200]
完全没有交集。因此,Paimon 知道这个文件里不可能有任何符合条件的数据,于是直接跳过这个文件,根本不会去读它的内容。min/max
范围与 [100, 200]
有交集的文件才会被选中读取。filesFilter
列表里只包含了那些需要被读取的文件。测试断言 files.size() > filesFilter.size()
,证明了数据跳过确实生效了。