Paimon LSM Tree Compaction核心:堆和败者树

SortMergeReaderWithMinHeap

SortMergeReaderWithMinHeap 是 Paimon 合并排序(Merge-Sort)机制中最终执行多路归并(K-way Merge)的核心实现之一。

SortMergeReaderWithMinHeap 是 SortMergeReader 接口的一个具体实现。它的核心功能是接收多个已经排好序的 RecordReader(代表多个有序的数据流),并使用 最小堆(Min-Heap) 这种数据结构,将它们合并成一个单一的、全局有序的数据流。

在合并过程中,当遇到主键(user key)相同的多条记录时,它会调用一个 MergeFunction 来对这些记录进行合并(例如,去重、更新、聚合等)。这个类是 Paimon Compaction 和查询时数据读取的底层执行引擎。

要理解其工作原理,首先要看它的核心成员变量。

// ... existing code ...
public class SortMergeReaderWithMinHeap implements SortMergeReader {

    private final List> nextBatchReaders;
    private final Comparator userKeyComparator;
    private final MergeFunctionWrapper mergeFunctionWrapper;

    private final PriorityQueue minHeap;
    private final List polled;
// ... existing code ...
  • nextBatchReaders: 一个 RecordReader 列表。它存储了那些当前批次(batch)已经读完,需要等待下一次 readBatch() 调用时再去获取新批次的 RecordReader
  • userKeyComparator: 用户主键的比较器,用于比较 KeyValue 的 key 部分,是排序和分组的主要依据。
  • mergeFunctionWrapper: 合并函数的包装器。当从堆中取出主键相同的一组元素时,这些元素会被喂给 mergeFunctionWrapper 进行处理。
  • minHeap: 这是整个类的核心,一个 java.util.PriorityQueue。它被配置成一个最小堆,堆顶永远是所有输入流中最小的那个元素。
  • polled: 一个临时的 Element 列表。它用于存放上一次从堆中取出、用于合并的元素。这是理解其批处理和迭代逻辑的关键。

构造函数与初始化

构造函数负责初始化上述成员变量,其中最关键的是最小堆的初始化。

// ... existing code ...
    public SortMergeReaderWithMinHeap(
            List> readers,
            Comparator userKeyComparator,
            @Nullable FieldsComparator userDefinedSeqComparator,
            MergeFunctionWrapper mergeFunctionWrapper) {
        this.nextBatchReaders = new ArrayList<>(readers);
        this.userKeyComparator = userKeyComparator;
        this.mergeFunctionWrapper = mergeFunctionWrapper;

        this.minHeap =
                new PriorityQueue<>(
                        (e1, e2) -> {
                            // 1. 比较主键
                            int result = userKeyComparator.compare(e1.kv.key(), e2.kv.key());
                            if (result != 0) {
                                return result;
                            }
                            // 2. 如果主键相同,比较用户定义的 sequence 字段
                            if (userDefinedSeqComparator != null) {
                                result =
                                        userDefinedSeqComparator.compare(
                                                e1.kv.value(), e2.kv.value());
                                if (result != 0) {
                                    return result;
                                }
                            }
                            // 3. 如果仍然相同,比较 Paimon 内部的 sequence number
                            return Long.compare(e1.kv.sequenceNumber(), e2.kv.sequenceNumber());
                        });
        this.polled = new ArrayList<>();
    }
// ... existing code ...

堆的比较器定义了元素的排序规则,这是一个多级比较

  1. 首先按 userKeyComparator 比较主键。
  2. 如果主键相同,则按 userDefinedSeqComparator 比较用户指定的 sequence 字段(如果存在)。
  3. 如果以上都相同,则按 Paimon 内部的 sequenceNumber 比较。

这个精细的比较逻辑确保了数据合并的确定性和正确性。

readBatch() 方法

这是外部消费者调用以获取一个批次迭代器的入口。

// ... existing code ...
    @Nullable
    @Override
    public RecordIterator readBatch() throws IOException {
        // 1. 初始化阶段:从每个 reader 中读取第一条记录放入堆中
        for (RecordReader reader : nextBatchReaders) {
            while (true) {
                RecordIterator iterator = reader.readBatch();
                if (iterator == null) {
                    // reader 已耗尽,关闭并移除
                    reader.close();
                    break;
                }
                KeyValue kv = iterator.next();
                if (kv == null) {
                    // 空批次,释放并尝试下一个批次
                    iterator.releaseBatch();
                } else {
                    // 找到第一条记录,包装成 Element 放入堆中
                    minHeap.offer(new Element(kv, iterator, reader));
                    break;
                }
            }
        }
        nextBatchReaders.clear();

        // 2. 如果堆不为空,返回一个新的迭代器;否则返回 null 表示所有数据已读完
        return minHeap.isEmpty() ? null : new SortMergeIterator();
    }
// ... existing code ...

它的逻辑是:

  1. 初始化填充:遍历所有待处理的 RecordReader,从每个 reader 中获取其第一个可用批次的第一个 KeyValue 记录,并将其包装成一个 Element 对象放入 minHeapElement 对象包含了记录本身、记录所在的批次迭代器以及源 RecordReader
  2. 返回迭代器:完成初始化后,如果堆中有数据,就创建一个 SortMergeIterator 实例并返回。这个迭代器将负责从堆中消费数据。

SortMergeIterator 内部类

这是实际执行迭代和合并逻辑的地方。

next() 方法

next() 方法是迭代器的公共接口,它循环调用 nextImpl(),直到 mergeFunctionWrapper 产生一个结果。

// ... existing code ...
    private class SortMergeIterator implements RecordIterator {
// ... existing code ...
        @Override
        public T next() throws IOException {
            while (true) {
                boolean hasMore = nextImpl();
                if (!hasMore) {
                    return null; // 当前批次结束
                }
                T result = mergeFunctionWrapper.getResult();
                if (result != null) {
                    return result; // MergeFunction 产生了结果,返回
                }
                // MergeFunction 可能需要更多相同 key 的记录,循环继续
            }
        }
// ... existing code ...

nextImpl() 方法

这是整个算法最核心的部分,它执行一个“取-合并-补”的循环。

// ... existing code ...
        private boolean nextImpl() throws IOException {
// ... (precondition checks) ...

            // 1. 补充堆:处理上一轮合并过的元素 (polled list)
            for (Element element : polled) {
                if (element.update()) {
                    // 成功从其源 iterator 获取下一条记录,重新放入堆中
                    minHeap.offer(element);
                } else {
                    // 源 iterator 的当前批次已耗尽,将其 reader 放入 nextBatchReaders
                    element.iterator.releaseBatch();
                    nextBatchReaders.add(element.reader);
                }
            }
            polled.clear();

            // 2. 检查批次是否结束
            if (!nextBatchReaders.isEmpty()) {
                return false; // 有 reader 批次结束,本轮迭代器结束
            }

            // 如果堆已空,说明所有数据都处理完了
            if (minHeap.isEmpty()) {
                return false;
            }

            // 3. 合并阶段:处理下一个主键相同的组
            mergeFunctionWrapper.reset();
            InternalRow key = minHeap.peek().kv.key(); // 获取堆顶元素的 key

            // 循环取出所有 key 相同的元素
            while (!minHeap.isEmpty()) {
                Element element = minHeap.peek();
                if (userKeyComparator.compare(key, element.kv.key()) != 0) {
                    break; // key 不同,分组结束
                }
                minHeap.poll(); // 从堆中取出
                mergeFunctionWrapper.add(element.kv); // 交给 MergeFunction 处理
                polled.add(element); // 放入 polled 列表,等待下一轮补充
            }
            return true; // 成功处理了一个分组
        }
// ... existing code ...

nextImpl() 的逻辑可以分解为三步:

  1. 补充(Refill):遍历 polled 列表(即上一轮合并的元素),尝试从它们的源 iterator 中获取下一条记录(通过 element.update())。如果成功,将更新后的 element 重新放入堆中。如果失败(表示该 iterator 的当前批次已读完),则将其 reader 放入 nextBatchReaders,等待外部下一次调用 readBatch()
  2. 检查结束:如果 nextBatchReaders 不为空,说明至少有一个输入流的批次结束了,那么当前 SortMergeIterator 的生命周期也结束了,返回 false
  3. 合并(Merge)
    • 从堆顶 peek() 一个元素,确定当前要合并的 key
    • 进入一个循环,不断从堆中 poll() 出所有与当前 key 相同的元素。
    • 将取出的元素交给 mergeFunctionWrapper 处理。
    • 同时,将这些取出的 Element 对象存入 polled 列表,以便在下一次调用 nextImpl 时执行第1步的“补充”操作。

Element 静态内部类

这是一个简单的辅助类,但至关重要。

// ... existing code ...
    private static class Element {
        private KeyValue kv;
        private final RecordIterator iterator;
        private final RecordReader reader;

        // ... constructor ...

        // IMPORTANT: Must not call this for elements still in priority queue!
        private boolean update() throws IOException {
            KeyValue nextKv = iterator.next();
            if (nextKv == null) {
                return false;
            }
            kv = nextKv;
            return true;
        }
    }
}

它像一个指针,将一条数据 (kv) 与其来源 (iterator 和 reader) 绑定在一起。update() 方法的作用就是沿着这个来源向前移动一步,获取下一条数据。

总结

SortMergeReaderWithMinHeap 是一个设计精巧、高效的多路归并读取器。它通过以下机制实现了复杂但可靠的合并逻辑:

  • 核心算法:使用最小堆(PriorityQueue)实现经典的 K-way Merge 算法,保证了输出的全局有序性。
  • 批处理感知:通过 readBatch() 和 SortMergeIterator 的分离,以及 nextBatchReaders 列表,优雅地处理了多个输入流的批次边界问题。
  • 状态机迭代nextImpl 方法中的“补充-合并”循环,以及 polled 列表的使用,构成了一个精巧的状态机。它确保了每次从堆中取出的元素都能在处理后,从其源头获取后继元素并重新放入堆中,从而驱动整个合并过程持续进行。
  • 功能分离:排序逻辑由 Comparator 定义,合并逻辑由 MergeFunction 定义,SortMergeReaderWithMinHeap 本身则专注于高效地驱动整个流程,体现了良好的分层设计。

SortMergeReaderWithLoserTree 

这是一个非常有意思的实现,它和我们之前讨论的 SortMergeReaderWithMinHeap 做着同样的事情——对多个已排序的数据流进行归并,但它采用了另一种经典的数据结构:败者树(Loser Tree)

将从以下几个方面来为剖析这个类:

  1. 核心目的与定位:它在 Paimon 中扮演什么角色?
  2. 核心数据结构:败者树:什么是败者树?它和最小堆有什么不同?
  3. 代码实现详解:逐一分析其关键方法和逻辑。
  4. 与 MinHeap 实现的对比:为什么 Paimon 会提供两种实现?它们各自的优劣是什么?

SortMergeReaderWithLoserTree 和 SortMergeReaderWithMinHeap 一样,都是 SortMergeReader 接口的实现。它的核心目的完全相同:接收多个已经按 (userKey, sequenceNumber) 排好序的 RecordReader,然后将它们高效地归并成一个全局有序的流。

在 Paimon 中,这通常用于 Compaction(合并)过程,比如将多个 Level 的 SST 文件或者一个 Level 内的多个文件进行合并,生成新的、更大的、有序的文件。

你可以通过配置项 CoreOptions.SORT_ENGINE 来选择使用 MIN_HEAP 还是 LOSER_TREE 作为归并引擎。

// ... existing code ...
    static  SortMergeReader createSortMergeReader(
            List> readers,
            Comparator userKeyComparator,
            @Nullable FieldsComparator userDefinedSeqComparator,
            MergeFunctionWrapper mergeFunctionWrapper,
            SortEngine sortEngine) {
        switch (sortEngine) {
            case MIN_HEAP:
                return new SortMergeReaderWithMinHeap<>(
                        readers, userKeyComparator, userDefinedSeqComparator, mergeFunctionWrapper);
            case LOSER_TREE:
                return new SortMergeReaderWithLoserTree<>(
                        readers, userKeyComparator, userDefinedSeqComparator, mergeFunctionWrapper);
            default:
                throw new UnsupportedOperationException("Unsupported sort engine: " + sortEngine);
        }
    }
// ... existing code ...

核心数据结构:败者树 (Loser Tree)

败者树是多路归并排序中一种非常高效的数据结构,常用于外排序。

  • 结构:它是一棵完全二叉树。叶子节点代表 K 个输入流(即 K 个 RecordReader),每个非叶子节点则记录了在其两个子节点之间比赛的“败者”。而整棵树的树根(通常是一个额外的节点,位于树的上方)则记录了全局的“胜者”(即全局最小/最大的元素)。

  • 与最小堆的比较

    • 查找胜者:最小堆的胜者(最小值)始终在堆顶,获取时间是 O(1)。败者树的胜者也始终在树根,获取时间也是 O(1)。
    • 更新/调整:当一个胜者被取出后,需要从其对应的输入流中补充一个新元素。
      • 最小堆:新元素加入堆后,需要进行一次“上浮”操作来维持堆的性质,最坏情况下比较次数是 O(logK)。
      • 败者树:新元素加入后,只需要沿着其叶子节点到树根的路径进行比赛,更新路径上的“败者”即可。这个路径的长度也是 O(logK)。
  • 优势:虽然两者的时间复杂度都是 O(logK),但败者树的实际比较次数通常更少,且其结构固定,没有复杂的指针移动,缓存友好性可能更好。它的主要优势在于,每次调整时,新元素只需要和其父节点中的“败者”比较,而不需要和兄弟节点比较,减少了比较的次数。


让我们来看 SortMergeReaderWithLoserTree 的具体实现。

构造函数与初始化

// ... existing code ...
public class SortMergeReaderWithLoserTree implements SortMergeReader {

    private final MergeFunctionWrapper mergeFunctionWrapper;
    private final LoserTree loserTree;

    public SortMergeReaderWithLoserTree(
            List> readers,
            Comparator userKeyComparator,
            @Nullable FieldsComparator userDefinedSeqComparator,
            MergeFunctionWrapper mergeFunctionWrapper) {
        this.mergeFunctionWrapper = mergeFunctionWrapper;
        // 关键:初始化 LoserTree
        this.loserTree =
                new LoserTree<>(
                        readers,
                        // 注意这里的比较器是反向的,因为 LoserTree 的实现可能是找最大值作为胜者
                        (e1, e2) -> userKeyComparator.compare(e2.key(), e1.key()),
                        createSequenceComparator(userDefinedSeqComparator));
    }
// ... existing code ...
  • 构造函数的核心工作就是创建并初始化一个 LoserTree 实例。
  • 它将输入的 readers 和比较器传递给 LoserTreeLoserTree 内部会将每个 RecordReader 包装成一个叶子节点。

readBatch() 方法

// ... existing code ...
    /** Compared with heapsort, {@link LoserTree} will only produce one batch. */
    @Nullable
    @Override
    public RecordIterator readBatch() throws IOException {
        loserTree.initializeIfNeeded();
        return loserTree.peekWinner() == null ? null : new SortMergeIterator();
    }
// ... existing code ...
  • 这个方法的实现非常简洁。它与 MinHeap 版本有显著不同。
  • loserTree.initializeIfNeeded(): 首次调用时,这个方法会从每个 RecordReader 中读取第一条记录,并构建整个败者树,决出第一个全局胜者。
  • loserTree.peekWinner() == null ? null : new SortMergeIterator(): 如果初始化后连一个胜者都没有(说明所有输入流都为空),则直接返回 null。否则,返回一个新的 SortMergeIterator 实例。
  • 重要注释Compared with heapsort, {@link LoserTree} will only produce one batch. 这条注释揭示了一个核心设计差异。LoserTree 的实现是一次性将所有 RecordReader 的数据流完整地归并到底,不像 MinHeap 版本那样有“逻辑批次”的概念。它会持续从 reader 中拉取数据,直到所有 reader 都耗尽,因此它只会产生一个“大”的 RecordIterator

SortMergeIterator 内部迭代器

这是执行归并的核心逻辑。

// ... existing code ...
    private class SortMergeIterator implements RecordIterator {

        private boolean released = false;

        @Nullable
        @Override
        public T next() throws IOException {
            while (true) {
                // 1. 调整败者树,为下一轮做准备
                loserTree.adjustForNextLoop();
                // 2. 弹出当前的胜者
                KeyValue winner = loserTree.popWinner();
                if (winner == null) {
                    // 没有胜者了,说明所有流都已耗尽
                    return null;
                }
                // 3. 开始处理新的一组 key
                mergeFunctionWrapper.reset();
                mergeFunctionWrapper.add(winner);

                // 4. 合并所有 key 相同的记录
                T result = merge();
                if (result != null) {
                    return result;
                }
            }
        }

        private T merge() {
            Preconditions.checkState(
                    !released, "SortMergeIterator#nextImpl is called after release");

            // 5. 循环查看下一个胜者,如果 key 相同,则持续弹出并合并
            while (loserTree.peekWinner() != null) {
                mergeFunctionWrapper.add(loserTree.popWinner());
            }
            // 6. 返回合并结果
            return mergeFunctionWrapper.getResult();
        }
// ... existing code ...

归并流程:

  1. loserTree.adjustForNextLoop(): 这是一个准备步骤,调整树的状态。
  2. loserTree.popWinner(): 弹出当前的全局胜者(最小的 KeyValue)。这个操作内部会自动从该胜者对应的 RecordReader 中补充下一条记录,并重新调整败者树,决出新的全局胜者。
  3. mergeFunctionWrapper.add(winner): 将第一个胜者添加到 MergeFunction 中。
  4. merge(): 这个私有方法是处理相同 key 的逻辑。它会通过 loserTree.peekWinner() 查看新的胜者。如果新胜者的 key 和刚才弹出的 winner 的 key 相同,就继续 popWinner() 并 add 到 mergeFunctionWrapper,直到遇到一个 key 不同的新胜者或者树为空。
  5. mergeFunctionWrapper.getResult(): 将收集到的所有相同 key 的记录进行合并处理,并返回最终结果。
  6. while(true) 循环确保了即使 mergeFunction 的结果是 null(例如,所有相同 key 的记录都被标记为删除),迭代器也会继续寻找下一个有效的记录。
    public T peekWinner() {
        return leaves.get(tree[0]).state != State.WINNER_POPPED ? leaves.get(tree[0]).peek() : null;
    }

标准败者树(Standard Loser Tree)与堆

会从以下几个方面展开:

  1. 核心思想对比:两者如何找到“最强者”?
  2. 数据结构形态:它们长什么样?
  3. 工作流程对比:一次完整的“取数-调整”过程。
  4. 性能与优劣势分析:为什么会有两种不同的选择?
  5. 与 Paimon 中 LoserTree 的联系:Paimon 的实现与标准版有何不同?

假设我们有 K 个已经排好序的输入流,需要将它们合并成一个大的有序流。

  • 最小堆 (Min-Heap)

    • 思想:直接、暴力。把 K 个输入流的当前元素全都扔进一个大小为 K 的最小堆里。
    • 如何找到最强者:堆的性质保证了堆顶元素永远是全局最小的。所以,每次需要下一个元素时,直接从堆顶取即可。
  • 败者树 (Loser Tree)

    • 思想:更像一个“锦标赛”。它不直接关注谁是最终的冠军(最小元素),而是记录每一轮比赛的“失败者”。
    • 如何找到最强者:通过一系列两两比较,失败者被留在树的内部节点上,胜利者则不断晋级。最终,全局的胜利者(最小元素)会到达树的顶端。所以,最强者也是在树的根部找到。

数据结构形态

  • 最小堆

    • 它是一棵完全二叉树,通常用一个数组来实现。
    • 核心性质:父节点的值总是小于或等于其子节点的值。
    • 关注点:维护父子之间的“小于等于”关系。

  • 标准败者树

    • 它也是一棵完全二叉树,同样用数组实现。
    • 它有 K 个叶子节点,代表 K 个输入流。树的内部有 K-1 个非叶子节点
    • 核心性质:非叶子节点存储的是其两个子节点之间比赛的败者
    • 特殊之处:通常会在树的根节点之上再增加一个全局胜者节点tree[0]),它存储的是整个锦标赛的最终冠军。
    • 关注点:记录比赛的“败者”。

工作流程对比 (K路归并)

这是两者最核心的区别所在。我们来模拟一下“取出一个最小元素,并从对应流补充一个新元素”的完整过程。

最小堆的工作流程

  1. 初始化:将 K 个输入流的第一个元素全部插入最小堆。建堆过程的时间复杂度是 O(K)。
  2. 取数 (Extract-Min)
    • 从堆顶取出最小元素 min_val。这是 O(1) 操作。
  3. 调整 (Heapify)
    • 从 min_val 所属的输入流中,读取下一个元素 new_val
    • 将堆顶元素替换为 new_val
    • 此时堆的性质被破坏,需要进行一次 “下沉 (sift-down)” 操作:将新的堆顶元素与其子节点比较,如果它比某个子节点大,就与较小的那个子节点交换位置,然后继续向下比较和交换,直到它不再大于其子节点,或者到达叶子节点。
    • 这个调整过程,比较的路径是从根到叶子,长度为 O(logK)。

标准败者树的工作流程

  1. 初始化:从 K 个输入流中各取一个元素,作为叶子节点。然后从下往上,两两比赛,败者留在父节点,胜者继续向上比赛,直到填满所有内部节点和最终的胜者节点 tree[0]。这个过程也是 O(K)。
  2. 取数 (Get-Winner)
    • 从 tree[0] 直接获得全局最小元素 min_val。这是 O(1) 操作。
  3. 调整 (Adjust)
    • 找到 min_val 所属的叶子节点 leaf_i
    • 从 leaf_i 对应的输入流中,读取下一个元素 new_val,更新 leaf_i 的值。
    • 关键区别:让 new_val (代表 leaf_i) 与其父节点中记录的败者进行一场新的比赛。
    • 胜者继续向上,与更上一层的父节点中的败者比赛。
    • 败者则留在当前父节点。
    • 这个过程一路向上,直到树的根。最终的胜者被放入 tree[0]
    • 这个调整过程,比较的路径是从叶子到根,长度也是 O(logK)。

核心结论

  • 从理论时间复杂度看,两者是同级的。
  • 但在实际工程中,当 K(归并路数)很大时,败者树的比较次数大约只有最小堆的一半,并且数据移动更少,因此通常认为败者树的常数时间因子更小,性能更优。这就是为什么在需要高性能外部排序的场景(如数据库、大数据处理引擎)中,败者树是更常见的选择。

为什么败者树常数更好

假设我们有 K 个输入流。


1. 最小堆 (Min-Heap) 的调整过程

最小堆的调整过程叫做 “下沉 (Sift-Down)”。

  1. 取出最小值:从堆顶(数组索引 0)取出全局最小值。这个操作本身不耗时。
  2. 补充新元素:从该最小值所属的输入流中,读取下一个新元素,并把它放到堆顶。
  3. 下沉调整:现在堆顶的元素很可能是个“闯入者”,不满足最小堆的性质(父节点小于等于子节点)。我们需要让它“下沉”到合适的位置。
    • 第1步比较:将当前节点(一开始是堆顶)与它的两个子节点进行比较,找出两个子节点中较小的那个。这里发生了 1 次比较。
    • 第2步比较:将当前节点与上一步找出的较小子节点进行比较。如果当前节点比它大,就交换位置。这里发生了 1 次比较。
    • 重复:当前节点“下沉”到新的位置后,继续与它的新子节点进行上述两步比较,直到它不再大于任何子节点,或者它自己成为了叶子节点。

关键点:在下沉的每一层,为了找到正确的交换对象,我们都需要 2 次比较(子节点之间比一次,父节点和胜出的子节点再比一次)。这个下沉路径的长度是 logK

所以,最小堆一次调整所需的总比较次数大约是 2 * logK


2. 败者树 (Loser Tree) 的调整过程

败者树的调整过程是 “向上比赛 (Tournament-Up)” 。

  1. 取出最小值:从全局胜者节点(tree[0])得知最小元素的位置,并取出它。
  2. 补充新元素:从该最小值所属的叶子节点,读取下一个新元素。
  3. 向上调整:现在这个叶子节点有了新值,它需要重新参加比赛,决定新的全局胜者。
    • 第1步比较:将这个更新后的叶子节点,与它的父节点中记录的败者进行比较。这里只发生了 1 次比较。
    • 胜者晋级:这次比较的胜者,继续向上移动到上一层。
    • 重复:晋级的胜者,再与更上一层父节点中记录的败者进行比较(又是 1 次比较),直到到达树根。

关键点:在向上调整的每一层,新晋级的选手只需要和一个固定的对手(父节点里存储的那个“败者”)进行比赛。因此,每一层只需要 1 次比较。这个比赛路径的长度也是 logK

所以,败者树一次调整所需的总比较次数大约是 logK

最小堆的数组:数组中直接存储的是参与比较的元素本身(或者是指向元素的引用)。例如 heap[0] 就是全局最小的那个 KeyValue 对象。


败者树的 tree 数组:数组中存储的不是 KeyValue 对象,而是指向 leaves 列表的整型索引 (int index)。

因此可以常数定位更新位置。


与 Paimon 中 LoserTree 的联系

现在回到 LoserTree.java。它和标准败者树最大的不同,就是状态机 (State)

  • 标准败者树:每次调整,都是一个全新的、独立的比赛。它不关心新元素和旧元素的 key 是否相同。
  • Paimon LoserTree:它必须关心 key 是否相同。当它从一个叶子节点补充新元素 new_val 后,它在 adjust 过程中,不仅要和父节点的败者比赛,还要判断 new_val 的 key 和上一轮全局胜者的 key 是否相同。
    • 如果 key 相同,它会进入 adjustWithSameWinnerKey 逻辑,只比较 sequenceNumber
    • 如果 key 不同,它会进入 adjustWithNewWinnerKey 逻辑,正常比较 key。
    • 并且通过 State 的转换,实现了“暂停”在某个 key 的功能。

所以,可以把 Paimon 的 LoserTree 理解为:一个增加了复杂状态管理以支持分组归并功能的、高度特化的败者树。它的核心归并思想和标准败者树是一致的,但为了适应 LSM-Tree 的业务场景,做了非常精巧的扩展。

LoserTree

LoserTree 这个类 是一个为高性能归并排序设计的核心数据结构,其实现比标准的败者树要复杂精巧得多,因为它需要处理 LSM-Tree 场景下的一个特殊问题:如何高效地合并来自多个数据流中主键(user key)相同的记录

将从以下几个方面来详细解读:

  1. 设计目标与挑战:这个 LoserTree 到底要解决什么问题?
  2. 核心数据结构tree 数组和 leaves 列表分别是什么?
  3. 关键状态管理:State 枚举:这是理解其复杂行为的钥匙。
  4. 核心算法流程
    • 初始化 (initializeIfNeeded)
    • 调整 (adjust)
    • 外部交互 (adjustForNextLooppopWinnerpeekWinner)
  5. 叶子节点:LeafIterator:数据源的直接管理者。

正如类注释所说,这个 LoserTree 是一个“变种”(a variant of the loser tree)。在标准的归并排序中,我们每次从败者树中取出一个最小元素后,就立即从其对应的数据流中补充一个新元素。

但 Paimon 的场景不同:

  • 存在重复 Key:多个输入流(RecordReader)中可能包含主键(user key)相同的记录。
  • 需要合并:这些主键相同的记录需要被收集到一起,交给 MergeFunction 进行合并(例如,取最新版本、累加等)。
  • 对象复用RecordReader 和 MergeFunction 可能会复用 KeyValue 对象,这意味着我们不能在一个 key 被 MergeFunction 完全处理完之前,就从 RecordReader 读取下一个 key,否则对象内容可能被覆盖。

核心挑战:如何在保持败者树高效决出全局最小 key 的同时,又能暂停下来,等待所有输入流中与该 key 相同的记录都被处理完毕后,再继续处理下一个 key?

LoserTree 通过引入一个精巧的状态机 (State) 和复杂的调整逻辑(adjust)来解决这个问题。


核心数据结构

// ... existing code ...
public class LoserTree implements Closeable {
    private final int[] tree;
    private final int size;
    private final List> leaves;
// ... existing code ...
  • leaves: 一个 List>,大小为 K(输入流的数量)。leaves.get(i) 代表第 i 个输入流。它是实际数据的持有者。
  • tree: 一个 int[] 数组,大小为 K。这是败者树的核心。tree[j] 存储的是一个索引,指向 leaves 列表。tree[0] 存储的是全局胜者在 leaves 中的索引,而 tree[1] 到 tree[K-1] 存储的是比赛中的败者索引。

关键状态管理:State 枚举

这是整个 LoserTree 的灵魂,理解了它就理解了一大半。每个 LeafIterator 都有一个 State

// ... existing code ...
    private enum State {
        LOSER_WITH_NEW_KEY(false),
        LOSER_WITH_SAME_KEY(false),
        LOSER_POPPED(false),
        WINNER_WITH_NEW_KEY(true),
        WINNER_WITH_SAME_KEY(true),
        WINNER_POPPED(true);

        private final boolean winner;
// ... existing code ...
  • winner 字段:true 表示这个节点在它参与的局部比赛中是胜者,false 表示是败者。
  • WINNER_WITH_NEW_KEY: 一个新来的挑战者,它的主键与当前败者树中的胜者们都不同。它需要进行完整的“主键比较”和“序列号比较”。
  • LOSER_WITH_NEW_KEY: WINNER_WITH_NEW_KEY 的失败者。
  • WINNER_WITH_SAME_KEY: 一个新来的挑战者,它的主键与当前败者树中的胜者们相同。它在向上比赛时,可以跳过主键比较,直接进行序列号比较,这是一种优化。
  • LOSER_WITH_SAME_KEY: WINNER_WITH_SAME_KEY 的失败者。它就是那个潜伏在树中,等待被 firstSameKeyIndex 快速找到的“兄弟”节点。
  • WINNER_POPPED: 刚刚被消费的胜者,是 adjust 流程的起点之一。
  • LOSER_POPPED: 一个被消费过的节点,在“皇位交接”后,它成为了败者,留在树中。

这个状态机将 key 的比较结果(相同/不同)和比赛结果(胜/负)组合起来,驱动着 adjust 方法的行为。

WINNER_WITH_NEW_KEY 和 WINNER_WITH_SAME_KEY

场景一:WINNER_WITH_NEW_KEY -> adjustWithNewWinnerKey

这个状态是游戏中最常见的状态,它代表一个 “全新的挑战者” 上场了。

什么时候会产生 WINNER_WITH_NEW_KEY

  1. 游戏开始时 (initializeIfNeeded): 每个玩家(LeafIterator)从牌堆(RecordReader)里摸第一张牌。这张牌对所有人来说都是未知的,所以每个玩家的状态都是 WINNER_WITH_NEW_KEY
  2. 玩家出完一张牌后 (advanceIfAvailable): 当一个玩家的牌被选为当前轮的胜者(被 popWinner),他需要再摸一张新牌。这张新牌的主键(比如'J')和上一张(比如'10')可能完全不同,所以这个玩家的状态会被重置为 WINNER_WITH_NEW_KEY,代表他带着一张“新牌”重新加入战局。

调用 adjustWithNewWinnerKey 意味着什么?

这意味着一个拿着“新牌”的挑战者 (winnerNode),正在和树中一个“老的失败者”(parentNode)进行比较。因为挑战者的牌是全新的,我们对它一无所知,所以必须进行最全面的比较

  • 先比主键 (firstComparator):
    • 如果主键不同(比如 'J' vs 'Q'),直接定胜负。
    • 如果主键相同(比如 'J' vs 'J'),说明我们首次发现了两个主键相同的节点。这时就要立即转入第二轮比较
  • 再比序列号 (secondComparator):
    • 根据序列号分出胜负。
    • 关键点:从这一刻起,这两个主键相同的节点的状态就会发生改变,变成 LOSER_WITH_SAME_KEY 和 WINNER_WITH_NEW_KEY(如果胜者是原父节点)或者 LOSER_WITH_SAME_KEY(如果胜者是挑战者,挑战者状态不变,但父节点状态改变)。这为后续的 adjustWithSameWinnerKey 埋下伏笔。

场景二:WINNER_WITH_SAME_KEY -> adjustWithSameWinnerKey

这个状态代表一个 “同主键的挑战者” 上场了。它不是一个全新的挑战者,而是我们已知的一个主键分组内的成员。

什么时候会产生 WINNER_WITH_SAME_KEY

这个状态不是被重置出来的,而是在 adjust 过程中由其他状态转化而来的。主要来源有两个:

  1. adjustWithNewWinnerKey 的转化: 当一个 WINNER_WITH_NEW_KEY 的挑战者遇到了一个主键相同的 LOSER_WITH_NEW_KEY 父节点,比较之后,如果父节点胜出,父节点的状态就会被更新为 WINNER_WITH_NEW_KEY,而挑战者会变为 LOSER_WITH_SAME_KEY。如果挑战者胜出,父节点会变为 LOSER_WITH_SAME_KEY,挑战者状态不变,但它已经进入了“同主键”的处理流程。

  2. adjustWithSameWinnerKey 内部的转化: 当一个 WINNER_WITH_SAME_KEY 的挑战者遇到了一个 LOSER_WITH_SAME_KEY 的父节点,比较之后,如果父节点胜出,父节点的状态就会从 LOSER_WITH_SAME_KEY 变成 WINNER_WITH_SAME_KEY,它就接替了挑战者的身份,继续向上晋级。

调用 adjustWithSameWinnerKey 意味着什么?

这意味着我们已经确认,挑战者 (winnerNode) 和当前全局的胜者 (tree[0]) 的主键是相同的。因此,当它和父节点 (parentNode) 比赛时,我们可以做出一个非常重要的优化:

  • 跳过主键比较:我们知道它的对手 parentNode 的主键也必然是相同的(状态为 LOSER_WITH_SAME_KEY)。
  • 直接比较序列号 (secondComparator): 直接进入第二轮比较,快速决出胜负。

这极大地提升了处理大量相同主键数据时的效率。

一个节点的生命周期通常是: WINNER_WITH_NEW_KEY -> 参与 adjustWithNewWinnerKey 比赛 -> (如果遇到相同主键)进入“同主键”处理模式 -> (如果它在同主键内部比赛中胜出)状态可能变为 WINNER_WITH_SAME_KEY -> 参与 adjustWithSameWinnerKey 比赛 -> ... -> 直到它成为全局冠军或最终失败。


初始化 (initializeIfNeeded)

 
  
// ... existing code ...
    public void initializeIfNeeded() throws IOException {
        if (!initialized) {
            Arrays.fill(tree, -1);
            for (int i = size - 1; i >= 0; i--) {
                leaves.get(i).advanceIfAvailable();
                adjust(i);
            }
            initialized = true;
        }
    }
// ... existing code ...
  • 这是一个标准的败者树构建过程。
  • leaves.get(i).advanceIfAvailable(): 从每个输入流中读取第一条记录。
  • adjust(i): 将第 i 个叶子节点放入树中,并从该叶子节点开始,向上进行比赛,直到根节点,沿途更新败者。
  • 循环结束后,tree[0] 就指向了持有全局最小记录的那个 LeafIterator

T popWinner() 

这个函数是 LoserTree 最主要的“取数”接口。它的作用是:返回当前的全局最小元素,并立即开始调整树以决出下一个最小元素。

我们逐行分析:

// ... existing code ...
    public T popWinner() {
        // 1. 获取当前胜者节点(O(1) 操作)
        LeafIterator winner = leaves.get(tree[0]);

        // 2. 检查胜者状态
        if (winner.state == State.WINNER_POPPED) {
            // 如果胜者已经是“已消费”状态,说明当前 Key 分组的所有记录都已被处理完毕。
            // 此时应该返回 null,由上层逻辑(SortMergeIterator)决定下一步做什么。
            return null;
        }

        // 3. 弹出胜者数据
        T result = winner.pop();
        // winner.pop() 内部做了两件事:
        //   a. 将自己的状态设置为 State.WINNER_POPPED
        //   b. 返回它持有的数据 kv

        // 4. 调整败者树
        adjust(tree[0]);
        // 这是关键一步。因为胜者节点的状态变成了 WINNER_POPPED,
        // 所以需要调用 adjust(),让这个“已消费”的节点重新参与比赛。
        // adjust() 内部的 `case WINNER_POPPED:` 逻辑会被触发,
        // 可能会快速提升另一个相同 Key 的节点为胜者,或者开始新一轮的比较。

        // 5. 返回结果
        return result;
    }
// ... existing code ...

总结 popWinner():

  • 职责:获取一个元素,并让出位置。
  • 行为:它返回当前胜者,并将其标记为 WINNER_POPPED,然后立即调用 adjust() 来重新平衡树。adjust() 的结果可能是另一个相同 Key 的记录成为新胜者,也可能是一个全新 Key 的记录成为胜者。

 LeafIterator 类的 pop() 方法实现:

// ... existing code ...
    private static class LeafIterator implements Closeable {
// ... existing code ...
        public T pop() {
            // 在这里!状态被设置为 WINNER_POPPED
            this.state = State.WINNER_POPPED;
            return kv;
        }
// ... existing code ...
    }
// ... existing code ...

这个作为胜者(winner)的 LeafIterator 实例,在它自己的 pop() 方法内部,立即将自己的状态设置为 State.WINNER_POPPED


adjustForNextLoop()

这个函数是处理完一个 Key 分组后,进入下一个 Key 分组之前的 “清扫和准备” 工作。它的作用是:确保败者树顶端 tree[0] 的元素是一个全新的、可用的、未被消费的记录。

我们逐行分析:

// ... existing code ...
    public void adjustForNextLoop() throws IOException {
        // 1. 获取当前胜者节点
        LeafIterator winner = leaves.get(tree[0]);

        // 2. 循环检查并处理“已消费”状态
        while (winner.state == State.WINNER_POPPED) {
            // 只要胜者是“已消费”状态,就说明这个叶子节点的数据已经空了,需要补充。

            // 2a. 补充新数据
            winner.advanceIfAvailable();
            // `advanceIfAvailable()` 会从底层的 RecordReader 中读取下一条记录,
            // 并将叶子节点的状态重置为初始的 WINNER_WITH_NEW_KEY。

            // 2b. 调整败者树
            adjust(tree[0]);
            // 因为叶子节点补充了新数据,所以必须调用 adjust() 让它重新参与比赛,
            // 决出新的全局胜者。

            // 2c. 更新 winner 变量,为下一次 while 循环检查做准备
            winner = leaves.get(tree[0]);
        }
    }
// ... existing code ...

总结 adjustForNextLoop():

  • 职责:承上启下,开启新的一轮。
  • 行为:它是一个循环,不断地将树顶那些 WINNER_POPPED 的“空壳”叶子节点填入新数据,并重新调整树,直到树顶 tree[0] 站着一个真正可用的新胜者为止。

在 SortMergeReaderWithLoserTree 的迭代器 SortMergeIterator 中:

  1. next() 方法首先调用 adjustForNextLoop()。这确保了无论上一轮留下了什么状态,我们现在面对的都是一个全新的、可用的胜者。
  2. 然后调用 loserTree.popWinner() 取得这个新 Key 分组的第一个元素。
  3. 接着在一个 merge() 循环里,不断调用 loserTree.popWinner()。因为 adjust() 会优先提升相同 Key 的记录,所以这个循环会不断取出所有相同 Key 的记录,直到 popWinner() 返回 null,表示这个 Key 分组处理完毕。
  4. next() 方法返回合并结果后,下一次调用又会从第 1 步开始,adjustForNextLoop() 会再次清理战场,准备下一个 Key 分组。

调整 (adjust)

这是 LoserTree 实现中最核心、最复杂的方法。它的职责是:当一个叶子节点(由 winner 索引指定)的状态或数据发生变化后,从这个叶子节点开始,自底向上地重新进行比赛,调整败者树的内部结构,最终确定新的全局胜者

整个分析将分为以下几个部分:

  1. 方法签名与核心循环:它在做什么?
  2. 初始化分支:树是如何从无到有建立起来的?
  3. 核心 switch 逻辑:如何处理不同状态的“挑战者”?
    • case WINNER_WITH_NEW_KEY:一个全新的挑战者。
    • case WINNER_WITH_SAME_KEY:一个与当前胜者 Key 相同的挑战者。
    • case WINNER_POPPED:一个“已消费”的挑战者(最特殊的分支)。
  4. 胜败交换:比赛结束后如何更新晋级图?
  5. 最终胜者:如何加冕新的全局冠军?

方法签名与核心循环

// ... existing code ...
    private void adjust(int winner) {
        for (int parent = (winner + this.size) / 2; parent > 0 && winner >= 0; parent /= 2) {
// ... existing code ...
  • private void adjust(int winner):

    • private: 这是内部核心逻辑,不希望外部直接调用。
    • int winner: 参数是发起本次调整的叶子节点的索引。这个节点是当前这一轮比赛的“挑战者”或“晋级者”。
  • for (int parent = (winner + this.size) / 2; ...):

    • 这是一个自底向上的循环,模拟了比赛的晋级之路。
    • parent = (winner + this.size) / 2: 这是计算完全二叉树中子节点对应父节点索引的经典公式。leaves 列表的索引范围是 0 到 size-1,它们在逻辑上被视为树的叶子节点,索引从 size 到 2*size-1。所以 winner + this.size 就是叶子节点在整个树形数组中的逻辑位置。
    • parent > 0: 循环一直持续到树的根节点(索引为 1)。tree[0] 是为最终胜者保留的特殊位置。
    • winner >= 0: 一个小技巧,用于在某些情况下提前终止循环(我们稍后会在 WINNER_POPPED 分支看到)。
    • parent /= 2: 每一轮比赛结束后,去往上一层的父节点。

初始化分支

// ... existing code ...
            LeafIterator winnerNode = leaves.get(winner);
            LeafIterator parentNode;
            if (this.tree[parent] == -1) {
                // initialize the tree.
                winnerNode.state = State.LOSER_WITH_NEW_KEY;
            } else {
// ... existing code ...
  • if (this.tree[parent] == -1): 这个条件只在 initializeIfNeeded() 方法首次构建树时成立。此时父节点还没有记录任何“败者”。
  • winnerNode.state = State.LOSER_WITH_NEW_KEY;: 既然父节点是空的,那么当前这个“挑战者”winnerNode 就自动成为了这个父节点的“败者”,并等待与它的兄弟节点进行比赛。它的状态被标记为 LOSER_WITH_NEW_KEY,因为它是一个新加入的节点。

核心 switch 逻辑

这是 adjust 方法的灵魂,它根据当前“挑战者”(winnerNode)的状态,来决定采用何种比赛策略。

// ... existing code ...
            } else {
                parentNode = leaves.get(this.tree[parent]);
                switch (winnerNode.state) {
                    case WINNER_WITH_NEW_KEY:
                        adjustWithNewWinnerKey(parent, parentNode, winnerNode);
                        break;
                    case WINNER_WITH_SAME_KEY:
                        adjustWithSameWinnerKey(parent, parentNode, winnerNode);
                        break;
                    case WINNER_POPPED:
                        if (winnerNode.firstSameKeyIndex < 0) {
                            // fast path, which means that the same key is not yet processed in the
                            // current tree.
                            parent = -1;
                        } else {
                            // fast path. Directly exchange positions with the same key that has not
                            // yet been processed, no need to compare level by level.
                            parent = winnerNode.firstSameKeyIndex;
                            parentNode = leaves.get(this.tree[parent]);
                            winnerNode.state = State.LOSER_POPPED;
                            parentNode.state = State.WINNER_WITH_SAME_KEY;
                        }
// ... existing code ...

case WINNER_WITH_NEW_KEY

  • 场景: 一个持有全新 Key 的节点(或者至少我们认为是全新 Key)向上晋级,遇到了父节点中记录的败者 parentNode
  • 动作: 调用 adjustWithNewWinnerKey()
  • 内部逻辑:
    • 比较 winnerNode 和 parentNode 的 Key (firstComparator)。
    • 如果 Key 不同,谁小谁就是胜者,败者留在父节点。状态更新为 WINNER_WITH_NEW_KEY 和 LOSER_WITH_NEW_KEY
    • 如果 Key 相同,则需要进一步比较序列号 (secondComparator)。此时,状态会发生关键变化,变为 WINNER_WITH_NEW_KEY 和 LOSER_WITH_SAME_KEY,并且会记录下相遇的位置 (setFirstSameKeyIndex),为后续的快速跳转做准备。

case WINNER_WITH_SAME_KEY 【adjust只会调节 全局胜者 或者 new ,因此之后判断某些情况意味着出错】

  • 场景: 一个已知与当前全局胜者 Key 相同的节点向上晋级。
  • 动作: 调用 adjustWithSameWinnerKey()
  • 内部逻辑:
    1. 不再比较 Key,而是直接比较 winnerNode 和 parentNode 的序列号 (secondComparator)。
    2. 这是一种优化,因为我们已经知道 Key 相同了,只需要在这些相同 Key 的记录内部排序。
    3. 状态会在 WINNER_WITH_SAME_KEY 和 LOSER_WITH_SAME_KEY 之间转换。

case WINNER_POPPED (最特殊的快速路径)

firstSameKeyIndex;这个字段记录在 LeafIterator 中,它的含义是:“当前叶子节点在向上晋级的过程中,第一次遇到和它主键相同的另一个节点时,那个相遇点的父节点索引”

  • 如果一个节点一路晋级到根节点都没有遇到和它主key相同的节点,那么它的 firstSameKeyIndex 就是初始值 -1
  • 一旦它在 parent 节点处遇到了一个主键相同的对手,它的 firstSameKeyIndex 就会被设置为 parent 的值。

这个字段就像一个 “传送门”的坐标 ,它标记了通往“同主键伙伴”的捷径。

现在我们来看case WINNER_POPPED: 分支下的逻辑。

场景: 当前发起 adjust 的节点 winnerNode 是一个刚刚被 popWinner() 方法消费掉的节点,其状态为 WINNER_POPPED。这意味着这个节点的数据已经被取走,它现在是一个“空壳”。我们需要为这个“空壳”找到一个“继任者”。

 
  
// ... existing code ...
                    case WINNER_POPPED:
                        if (winnerNode.firstSameKeyIndex < 0) {
                            // fast path, which means that the same key is not yet processed in the
                            // current tree.
                            parent = -1;
                        } else {
                            // fast path. Directly exchange positions with the same key that has not
                            // yet been processed, no need to compare level by level.
                            parent = winnerNode.firstSameKeyIndex;
                            parentNode = leaves.get(this.tree[parent]);
                            winnerNode.state = State.LOSER_POPPED;
                            parentNode.state = State.WINNER_WITH_SAME_KEY;
                        }
                        break;
// ... existing code ...

if (winnerNode.firstSameKeyIndex < 0) - 快速退出路径

  • 条件firstSameKeyIndex 为 -1
  • 含义: 这个刚被消费的 winnerNode,在它之前成为全局胜者的那次晋级之旅中,从未遇到过任何主键和它相同的对手。【如果有另外一个相同的,至少在根结点下会相遇】
  • 推论: 这意味着,在当前的败者树中,它是这个主键分组的唯一一个成员,或者是这个分组中最后一个被处理的成员
  • 动作parent = -1;
  • 目的: 这是一个非常巧妙的 “快速退出”机制。它直接修改了 for 循环的变量 parent,使得下一次循环的条件 parent > 0 立即不满足,从而终止整个 adjust 的向上调整过程 。因为这个主键分组已经处理完毕,没有必要再向上比较了,直接让这个“空壳”留在原位,等待 adjustForNextLoop 来为它补充新数据即可。

else - 快速跳转/传送路径

  • 条件firstSameKeyIndex 大于等于 0
  • 含义: 这个刚被消费的 winnerNode,在它之前成为全局胜者的晋级之旅中,曾经遇到过一个主键和它相同的对手,并且把相遇点的坐标记录在了 firstSameKeyIndex 里。
  • 推论: 这意味着,在败者树的某个角落(tree[winnerNode.firstSameKeyIndex] 的位置),还潜伏着一个同主键的“兄弟”节点,它就是下一个最优先的候选人。
  • 动作:
    1. parent = winnerNode.firstSameKeyIndex;启动传送门! 直接将 for 循环的 parent 指针跳转到记录好的那个相遇点。我们跳过了中间所有层级的比较,直达目的地。
    2. parentNode = leaves.get(this.tree[parent]);: 获取到那个潜伏的“兄弟”节点。
    3. winnerNode.state = State.LOSER_POPPED;: 当前节点(空壳)状态变为“已弹出的败者”。
    4. parentNode.state = State.WINNER_WITH_SAME_KEY;: “兄弟”节点的状态变为“同主键的胜者”,它被直接“加冕”为新的晋级者。
  • 目的: 这是一个**“快速继任”**机制。它利用预先记录的“传送门”坐标,避免了逐层向上的比较,直接在同主键的节点之间完成了一次“皇位交接”。这极大地提升了在同一个大的主键分组内部进行归并的效率。

  • 动作:
    • if (winnerNode.firstSameKeyIndex < 0): 如果这个被消费的节点之前没有遇到过和它 Key 相同的节点。这意味着它是这个 Key 分组的唯一一个或最后一个成员。
      • parent = -1;: 这是一个 “快速退出” 的技巧。它直接让 for 循环的条件 parent > 0 不满足,从而立即终止循环。因为这个 Key 分组已经结束了,没必要再向上比赛了。
    • else: 如果这个被消费的节点之前遇到过和它 Key 相同的节点(并且记录了相遇的位置 firstSameKeyIndex)。
      • parent = winnerNode.firstSameKeyIndex;: 这是**“快速跳转”**的优化!它直接把循环的 parent 指针,跳到之前记录的那个相同 Key 的“败者”所在的位置。
      • 状态交换: 然后,它直接让那个“败者”成为新的胜者 (WINNER_WITH_SAME_KEY),自己成为败者 (LOSER_POPPED),并交换它们在树中的位置。
      • 这个过程完全没有进行任何 Key 或序列号的比较,是最高效的调整路径,专门用于在同一个 Key 分组内部快速切换胜者。

胜败交换

// ... existing code ...
                }
            }

            // if the winner loses, exchange nodes.
            if (!winnerNode.state.isWinner()) {
                int tmp = winner;
                winner = this.tree[parent];
                this.tree[parent] = tmp;
            }
        }
// ... existing code ...
  • 在 switch 逻辑(比赛过程)结束后,winnerNode 的状态会被更新为胜者或败者。
  • if (!winnerNode.state.isWinner()): 检查当前的“挑战者”winnerNode 在这一轮比赛中是否输了。
  • 交换: 如果它输了,它就留在这个 parent 节点上(this.tree[parent] = tmp;),而原先的败者则成为新的“挑战者” (winner = this.tree[parent];),继续向上晋级。

最终胜者

// ... existing code ...
        }
        this.tree[0] = winner;
    }
// ... existing code ...
  • 当 for 循环正常结束后,变量 winner 中存储的就是一路过关斩将、到达树根的最终全局胜者的索引。
  • this.tree[0] = winner;: 将这位全局胜者的索引,记录在为冠军保留的 tree[0] 位置。

adjustWithNewWinnerKey 

这个函数是 LoserTree 调整逻辑的核心之一,它的职责是处理一个**“新键挑战者”** (winnerNode) 在败者树中向上晋级的过程。所谓“新键挑战者”,指的是这个节点刚刚从底层读取了一个新的数据记录,其状态为 WINNER_WITH_NEW_KEY

我们将按照函数内部的 switch 语句来逐个分析它可能遇到的情况。

private void adjustWithNewWinnerKey(
        int index, LeafIterator parentNode, LeafIterator winnerNode)
  • index: 当前正在比较的父节点在 tree 数组中的索引。这个索引很重要,因为如果挑战者获胜,它需要记录下第一个与它主键相同的败者的位置,以便后续进行快速跳转。
  • parentNode: “盘踞”在父节点位置的败者节点。
  • winnerNode: 正在向上发起挑战的胜者节点,它的状态是 WINNER_WITH_NEW_KEY

case LOSER_WITH_NEW_KEY:

这是最常见、也是最核心的逻辑分支。

  • 场景: 一个“新键挑战者”(winnerNode) 遇到了一个“新键失败者”(parentNode)。这意味着这两个节点的主键之前都没有在树中形成过“同主键分组”。它们是两个完全独立的竞争者。
  • 代码分析:
    // ... existing code ...
    private void adjustWithNewWinnerKey(
            int index, LeafIterator parentNode, LeafIterator winnerNode) {
        switch (parentNode.state) {
            case LOSER_WITH_NEW_KEY:
                // when the new winner is also a new key, it needs to be compared.
                T parentKey = parentNode.peek();
                T childKey = winnerNode.peek();
                int firstResult = firstComparator.compare(parentKey, childKey);
                if (firstResult == 0) {
                    // if the compared keys are the same, we need to update the state of the node
                    // and record the index of the same key for the winner.
                    int secondResult = secondComparator.compare(parentKey, childKey);
                    if (secondResult < 0) {
                        parentNode.state = State.LOSER_WITH_SAME_KEY;
                        winnerNode.setFirstSameKeyIndex(index);
                    } else {
                        winnerNode.state = State.LOSER_WITH_SAME_KEY;
                        parentNode.state = State.WINNER_WITH_NEW_KEY;
                        parentNode.setFirstSameKeyIndex(index);
                    }
                } else if (firstResult > 0) {
                    // the two keys are completely different and just need to update the state.
                    parentNode.state = State.WINNER_WITH_NEW_KEY;
                    winnerNode.state = State.LOSER_WITH_NEW_KEY;
                }
                return;
    // ... existing code ...
    
  • 逻辑拆解:
    1. firstComparator.compare(parentKey, childKey): 首先比较主键。
    2. if (firstResult == 0): 主键相同。这是首次发现这两个节点的主键相同。
      • secondComparator.compare(...): 必须立即用第二比较器(通常是序列号)来分出胜负。
      • 状态转换: 无论谁胜谁负,这两个节点的关系已经明确。败者的状态会被更新为 LOSER_WITH_SAME_KEY,标志着“同主键分组”的形成。胜者会记录下这个败者的位置 (setFirstSameKeyIndex(index)),为后续的快速跳转做准备。
      • 如果原 parentNode 胜出,它的状态会变成 WINNER_WITH_NEW_KEY,因为它战胜了挑战者,成为了新的“挑战者”继续向上晋级。
    3. else if (firstResult > 0)parentNode 的主键更“小”(根据比较器定义,返回值 > 0 代表第一个参数胜出)。
      • parentNode 胜出parentNode 成为新的挑战者,状态变为 WINNER_WITH_NEW_KEY
      • winnerNode 失败winnerNode 挑战失败,留在当前父节点位置,状态变为 LOSER_WITH_NEW_KEY
    4. else (即 firstResult < 0)winnerNode 的主键更“小”。
      • winnerNode 胜出,它什么也不用做,保持 WINNER_WITH_NEW_KEY 状态继续向上晋级。parentNode 保持 LOSER_WITH_NEW_KEY 状态不变。这个逻辑在 adjust 函数的末尾通过交换节点实现。

case LOSER_WITH_SAME_KEY:

  • 场景: 一个“新键挑战者”(winnerNode) 遇到了一个“同主键失败者”(parentNode)。
  • 代码分析:
     
    // ... existing code ...
            case LOSER_WITH_SAME_KEY:
                // A node in the WINNER_WITH_NEW_KEY state cannot encounter a node in the
                // LOSER_WITH_SAME_KEY state.
                throw new RuntimeException(
                        "This is a bug. Please file an issue. A node in the WINNER_WITH_NEW_KEY "
                                + "state cannot encounter a node in the LOSER_WITH_SAME_KEY state.");
    // ... existing code ...
    
  • 逻辑拆解:
    • 这是一个不可能发生的场景,因此直接抛出异常。
    • 原因LOSER_WITH_SAME_KEY 状态意味着 parentNode 属于一个已经建立的“同主键分组”。根据败者树的归并原则,只要这个分组没有被完全处理完(即全局最小键仍然是这个分组的键),任何一个“新键挑战者”(WINNER_WITH_NEW_KEY) 都不可能在比赛中胜出并遇到这个 parentNode。如果遇到了,说明归并逻辑出现了严重错误。

case LOSER_POPPED:

  • 场景: 一个“新键挑战者”(winnerNode) 遇到了一个“已被弹出”的失败者 (parentNode)。
  • 代码分析:
     
    // ... existing code ...
            case LOSER_POPPED:
                // this case will only happen during adjustForNextLoop.
                parentNode.state = State.WINNER_POPPED;
                parentNode.firstSameKeyIndex = -1;
                winnerNode.state = State.LOSER_WITH_NEW_KEY;
                return;
    // ... existing code ...
    
  • 逻辑拆解:
    • 这个场景非常特殊,只在 adjustForNextLoop 函数中可能发生。adjustForNextLoop 的作用是处理完一个主键的所有记录后,找到下一个主键的第一个记录。
    • 当一个主键的所有记录都被 popWinner 弹出后,这些叶子节点的状态会变成 WINNER_POPPED。在 adjustForNextLoop 中,这些节点会重新加载数据 (advanceIfAvailable),状态变回 WINNER_WITH_NEW_KEY,然后调用 adjust
    • 此时,这个新的 winnerNode 就可能遇到一个之前同组的、但已经处理完毕的节点,其状态为 LOSER_POPPED
    • 处理方式: 这是一场“新老交替”的比赛。winnerNode 自动失败,状态变为 LOSER_WITH_NEW_KEY,留在原地。而 parentNode 则将自己的 WINNER_POPPED 状态传递上去,最终将整个“已弹出”的状态传递到树顶,表示这个分组彻底结束。

adjustWithSameWinnerKey

这个函数是 LoserTree 状态机中一个非常关键的优化环节。理解它,就能明白 LoserTree 是如何高效处理“同主键(user key)数据分组”的。

private void adjustWithSameWinnerKey(
        int index, LeafIterator parentNode, LeafIterator winnerNode)
  • 调用时机: 当一个“挑战者” (winnerNode) 向上晋级时,它的状态是 WINNER_WITH_SAME_KEY。这个状态意味着,winnerNode 的主键和当前全局胜者(tree[0])的主键是相同的。
  • 核心职责: 处理一个“同主键挑战者” (winnerNode) 和一个树中已有的“败者” (parentNode) 之间的比赛。由于主键相同,这场比赛可以跳过主键比较,直接进入第二轮比较(通常是比较序列号 sequenceNumber),从而提升效率。
  • 参数:
    • index: 当前比赛发生的父节点在 tree 数组中的索引。
    • parentNode: 树中该位置的原“败者”节点。
    • winnerNode: 发起挑战的“挑战者”节点。

函数的主体是一个 switch 语句,根据“败者” (parentNode) 的状态,决定如何进行比赛。

case LOSER_WITH_SAME_KEY:

这是最常见、也是最核心的场景。

  • 场景描述:

    • 挑战者 winnerNode 的主键与全局胜者相同 (WINNER_WITH_SAME_KEY)。
    • 它遇到的对手 parentNode 的主键也与全局胜者相同 (LOSER_WITH_SAME_KEY)。
    • 结论winnerNode 和 parentNode 这两个节点的主键必然是相同的。它们是同一个主键分组内的两个成员。
  • 代码逻辑:

    // ... existing code ...
                // the key of the previous loser is the same as the key of the current winner,
                // only the sequence needs to be compared.
                T parentKey = parentNode.peek();
                T childKey = winnerNode.peek();
                int secondResult = secondComparator.compare(parentKey, childKey);
                if (secondResult > 0) {
                    parentNode.state = State.WINNER_WITH_SAME_KEY;
                    winnerNode.state = State.LOSER_WITH_SAME_KEY;
                    parentNode.setFirstSameKeyIndex(index);
                } else {
                    winnerNode.setFirstSameKeyIndex(index);
                }
                return;
    // ... existing code ...
    
    1. secondComparator.compare(parentKey, childKey)直接进行第二轮比较(比如序列号)。这是关键的优化点。
    2. if (secondResult > 0): 这个条件表示 parentKey > childKey,即 parentNode 在第二轮比较中胜出。
      • parentNode.state = State.WINNER_WITH_SAME_KEY;: 胜者 parentNode 更新状态,准备继续向上晋级。
      • winnerNode.state = State.LOSER_WITH_SAME_KEY;: 败者 winnerNode 更新状态,留在当前位置。
      • parentNode.setFirstSameKeyIndex(index);非常重要。胜者 parentNode 记录下这次相遇的地点 (index)。如果它未来被 pop 掉,就可以通过这个“传送门”快速找到它的同主键兄弟 winnerNode
    3. else: 表示 winnerNode 在第二轮比较中胜出(或相等,保持原位)。
      • winnerNode 保持 WINNER_WITH_SAME_KEY 状态,继续向上晋级。
      • parentNode 保持 LOSER_WITH_SAME_KEY 状态,留在原地。
      • winnerNode.setFirstSameKeyIndex(index);: 同样,胜者 winnerNode 记录下相遇地点,以便未来快速回溯。

case LOSER_WITH_NEW_KEY: 和 case LOSER_POPPED:

  • 场景描述:

    • 挑战者 winnerNode 的主键与全局胜者相同 (WINNER_WITH_SAME_KEY)。
    • 但它遇到的对手 parentNode 的主键是一个新的、不同的主键 (LOSER_WITH_NEW_KEY),或者是一个已经被消费过的节点 (LOSER_POPPED)。
  • 逻辑矛盾: 这个场景在理论上不应该发生。因为 该节点在该组内已经是最小的key了,之前在这个节点失败了,意味着父节点一定小于等于。

  • 代码逻辑:

    // ... existing code ...
            case LOSER_WITH_NEW_KEY:
            case LOSER_POPPED:
                return;
    // ... existing code ...
    
    • return;: 代码直接返回,不做任何状态改变。这意味着 winnerNode 自动获胜,继续向上晋级。这是一种防御性的处理方式,它假设 winnerNode 应该获胜,但这种情况本身暗示了逻辑上的不一致性。在 adjustWithNewWinnerKey 中,类似的情况会直接抛出异常,这里的处理相对柔和一些。

外部交互

这三个 public 方法构成了 LoserTree 与外部 SortMergeReader 的接口。

  • popWinner():

    • 获取 tree[0] 指向的胜者记录。
    • 将其状态更新为 WINNER_POPPED
    • 调用 adjust(tree[0]) 来重新调整树,决出下一个胜者。
    • 返回刚才获取的记录。
  • peekWinner():

    • 只是“窥视”一下 tree[0] 指向的胜者记录,不改变任何状态
    • 如果胜者状态是 WINNER_POPPED,则返回 null。这正是它能阻止 merge() 循环的关键。
  • adjustForNextLoop():

    • 这是处理完一组相同 key 之后,进入下一组 key 之前的准备工作
    • 它的 while 循环会确保 tree[0] 指向的胜者不是 WINNER_POPPED 状态。它会不断地从被 pop 过的叶子节点中拉取新数据(advanceIfAvailable),并调用 adjust,直到树顶出现一个全新的、可用的胜者。

叶子节点:LeafIterator

这是一个内部类,是对 RecordReader 的封装,并附加了 LoserTree 所需的状态。

// ... existing code ...
    private static class LeafIterator implements Closeable {
        private final RecordReader reader;
        private RecordReader.RecordIterator iterator;
        private T kv;
        private boolean endOfInput;
        private int firstSameKeyIndex;
        private State state;
// ... existing code ...
        public void advanceIfAvailable() throws IOException {
            this.firstSameKeyIndex = -1;
            this.state = State.WINNER_WITH_NEW_KEY;
            // ... 核心的读数据逻辑 ...
            // 如果当前批次耗尽,就调用 reader.readBatch() 获取新批次
        }
// ... existing code ...
  • reader: 底层的 RecordReader
  • iterator: 当前正在读取的物理批次。
  • kv: 当前持有的记录。
  • state: 当前节点在败者树中的状态。
  • firstSameKeyIndex: 用于 WINNER_POPPED 快速路径优化的索引。
  • advanceIfAvailable(): 核心的数据拉取方法。它负责处理批次迭代,当一个批次(iterator)耗尽时,它会自动调用 reader.readBatch() 来获取下一个批次,实现了无缝的数据流。

总结

Paimon 的 LoserTree 是一个为解决“多路归并中合并相同主键”这一特定问题而深度定制的高级数据结构。它通过引入一个精巧的六状态状态机,并将其与败者树的调整逻辑深度融合,实现了以下目标:

  1. 高效排序:保持了败者树 O(K * logK) 的归并效率。
  2. 分组聚合:能够暂停在某个 key,等待所有流中该 key 的记录都被消费。
  3. 逻辑清晰:将复杂的控制逻辑内聚在 LoserTree 内部,使得上层调用者(SortMergeReaderWithLoserTree)的实现可以非常简洁。

它无疑是 Paimon compaction 模块中一个设计得非常出色的核心组件。

败者树是否能够合并所有重复key

如下案例:

  1. 场景: 有 N 个输入流(即 LoserTree 有 N 个叶子节点),但某个主键 Key_A 的记录总共有 2N 条,平均分布在这些流里。
  2. 初始化LoserTree 从 N 个输入流中各读取 1 条记录。此时树里有 N 条 Key_A 的记录。
  3. next() 第一次调用:

    a. while(true) 循环开始。
    b. popWinner() 弹出第一个 Key_A
    c. mergeFunctionWrapper.reset() 清空状态,然后 add() 这第一个 Key_A
    d. merge() 方法被调用。其内部的 while (loserTree.peekWinner() != null) 循环会把树里剩下 N-1Key_A 全部 pop 出来并 add 进去。
    e. 当 N 条记录全部 pop 完,树里所有的叶子节点都处于 WINNER_POPPED 状态,peekWinner() 返回 nullmerge() 的循环结束。
    f. mergeFunctionWrapper.getResult() 基于这 N 条记录计算出一个部分合并的结果,然后 next() 方法将这个结果返回。

  4. 问题出现next() 方法已经返回了,但关于 Key_A 的合并工作只完成了一半。另外 N 条 Key_A 的记录还静静地躺在文件里。
  5. next() 第二次调用: a. adjustForNextLoop() 会从某个输入流里补充一条新的 Key_A 记录。 b. popWinner() 弹出这条新的 Key_A。 c. mergeFunctionWrapper.reset() 被再次调用! 这就彻底清除了上一次部分合并的状态。 d. 最终,MergeFunction 在丢失状态的情况下处理了后续的记录,导致最终结果完全错误。

所以Paimon依赖每个输入Sort Run的key是不重复的,当每个流不同key,之后的key只会更大,因此现在在败者树的key 不会占据不必要的位置。【实际上这里的输入的Sort Run划分是由上层Compaction调用做的,保证Section之间独立Compaction,Section之间没有重叠;Section内部又分为多个没有重叠key的输入流】

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