Paimon:Range Partition and Sort优化无主键表(Append-Only Table)查询

这个优化是通过对数据进行全局排序,从而让查询时能够跳过大量不相关的数据文件(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(追加)类型的数据。数据被写入时,并没有按照任何特定列进行排序。

这就导致了两个主要问题:

  1. 查询必须全表扫描:假设你有一个很大的订单表(无主键),你想查询 WHERE order_amount > 1000 的订单。因为数据是乱序写入的,Paimon 不知道哪个数据文件里可能包含符合条件的记录。因此,它唯一的办法就是把所有的数据文件都读取一遍,然后逐条检查,这在数据量大时效率极低。
  2. 压缩率低:列式存储(如 Parquet)在数据有序时压缩效果最好。乱序的数据会导致压缩率下降,浪费存储空间。

Range Partition and Sort 是如何工作的?

这个过程的目标就是把乱序的数据,按照你指定的列(比如 order_amount)重新整理成全局有序的。它分为两步:

第一步:Range Partition (范围分区)

这一步的目的是**“物以类聚”**,把值相近的数据发送到同一个计算节点上。

  1. 采样 (Sample):首先,系统会对输入数据进行一次快速采样,了解数据的分布情况。比如,发现 order_amount 的值大致分布在 1 到 10000 之间。
  2. 确定范围 (Determine Ranges):根据采样结果,系统会计算出几个范围边界。比如,如果下游排序任务的并发是 4,它可能会划分出 4 个范围:[1, 2500], [2501, 5000], [5001, 7500], [7501, 10000]。
  3. 数据分发 (Shuffle):然后,系统会根据这些范围,将所有数据重新分发(Shuffle)。所有 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 ...
第二步:Sort (排序)

这一步在每个计算节点内部进行。每个排序任务接收到属于自己范围的数据后,会在本地内存和磁盘上对这些数据进行排序。

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" 之后,数据文件的物理布局发生了根本性变化。

  • 文件 A 可能只包含 order_amount 在 [1, 2500] 的数据。
  • 文件 B 可能只包含 order_amount 在 [2501, 5000] 的数据。
  • ...

Paimon 会为每个数据文件记录元数据,其中就包括排序列的最小值和最大值(Min/Max Index)。

现在,当再次执行查询 WHERE order_amount > 6000 时:

  1. Paimon 查询协调器首先查看每个数据文件的元数据。
  2. 它看到文件 A 的 max(order_amount) 是 2500,小于 6000,所以整个文件 A 都可以被安全地跳过,无需读取。
  3. 它看到文件 B 的 max(order_amount) 是 5000,也小于 6000,所以整个文件 B 也可以被跳过
  4. 只有那些 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 的查询时:

  1. Paimon 的查询计划器(Planner)会拿到这个 predicate(查询条件)。
  2. 它会遍历所有 10 个文件的 ManifestEntry
  3. 对于每个文件,它会检查该文件的 col1 的 min 和 max 值。
    • 假设一个文件的 min 是 300,max 是 399。这个范围 [300, 399] 与查询的范围 [100, 200] 完全没有交集。因此,Paimon 知道这个文件里不可能有任何符合条件的数据,于是直接跳过这个文件,根本不会去读它的内容。
    • 只有那些 min/max 范围与 [100, 200] 有交集的文件才会被选中读取。
  4. 最终,filesFilter 列表里只包含了那些需要被读取的文件。测试断言 files.size() > filesFilter.size(),证明了数据跳过确实生效了。

你可能感兴趣的:(Paimon,数据库,大数据,数据结构,java,apache)