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 ...
堆的比较器定义了元素的排序规则,这是一个多级比较:
userKeyComparator
比较主键。userDefinedSeqComparator
比较用户指定的 sequence 字段(如果存在)。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 ...
它的逻辑是:
RecordReader
,从每个 reader 中获取其第一个可用批次的第一个 KeyValue
记录,并将其包装成一个 Element
对象放入 minHeap
。Element
对象包含了记录本身、记录所在的批次迭代器以及源 RecordReader
。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()
的逻辑可以分解为三步:
polled
列表(即上一轮合并的元素),尝试从它们的源 iterator
中获取下一条记录(通过 element.update()
)。如果成功,将更新后的 element
重新放入堆中。如果失败(表示该 iterator
的当前批次已读完),则将其 reader
放入 nextBatchReaders
,等待外部下一次调用 readBatch()
。nextBatchReaders
不为空,说明至少有一个输入流的批次结束了,那么当前 SortMergeIterator
的生命周期也结束了,返回 false
。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)。
将从以下几个方面来为剖析这个类:
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 ...
败者树是多路归并排序中一种非常高效的数据结构,常用于外排序。
结构:它是一棵完全二叉树。叶子节点代表 K 个输入流(即 K 个 RecordReader
),每个非叶子节点则记录了在其两个子节点之间比赛的“败者”。而整棵树的树根(通常是一个额外的节点,位于树的上方)则记录了全局的“胜者”(即全局最小/最大的元素)。
与最小堆的比较:
优势:虽然两者的时间复杂度都是 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
和比较器传递给 LoserTree
。LoserTree
内部会将每个 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 ...
归并流程:
loserTree.adjustForNextLoop()
: 这是一个准备步骤,调整树的状态。loserTree.popWinner()
: 弹出当前的全局胜者(最小的 KeyValue
)。这个操作内部会自动从该胜者对应的 RecordReader
中补充下一条记录,并重新调整败者树,决出新的全局胜者。mergeFunctionWrapper.add(winner)
: 将第一个胜者添加到 MergeFunction
中。merge()
: 这个私有方法是处理相同 key 的逻辑。它会通过 loserTree.peekWinner()
查看新的胜者。如果新胜者的 key 和刚才弹出的 winner
的 key 相同,就继续 popWinner()
并 add
到 mergeFunctionWrapper
,直到遇到一个 key 不同的新胜者或者树为空。mergeFunctionWrapper.getResult()
: 将收集到的所有相同 key 的记录进行合并处理,并返回最终结果。while(true)
循环确保了即使 mergeFunction
的结果是 null
(例如,所有相同 key 的记录都被标记为删除),迭代器也会继续寻找下一个有效的记录。 public T peekWinner() {
return leaves.get(tree[0]).state != State.WINNER_POPPED ? leaves.get(tree[0]).peek() : null;
}
会从以下几个方面展开:
LoserTree
的联系:Paimon 的实现与标准版有何不同?假设我们有 K 个已经排好序的输入流,需要将它们合并成一个大的有序流。
最小堆 (Min-Heap)
败者树 (Loser Tree)
最小堆
标准败者树
tree[0]
),它存储的是整个锦标赛的最终冠军。这是两者最核心的区别所在。我们来模拟一下“取出一个最小元素,并从对应流补充一个新元素”的完整过程。
最小堆的工作流程
min_val
。这是 O(1) 操作。min_val
所属的输入流中,读取下一个元素 new_val
。new_val
。标准败者树的工作流程
tree[0]
。这个过程也是 O(K)。tree[0]
直接获得全局最小元素 min_val
。这是 O(1) 操作。min_val
所属的叶子节点 leaf_i
。leaf_i
对应的输入流中,读取下一个元素 new_val
,更新 leaf_i
的值。new_val
(代表 leaf_i
) 与其父节点中记录的败者进行一场新的比赛。tree[0]
。核心结论:
假设我们有 K 个输入流。
1. 最小堆 (Min-Heap) 的调整过程
最小堆的调整过程叫做 “下沉 (Sift-Down)”。
0
)取出全局最小值。这个操作本身不耗时。关键点:在下沉的每一层,为了找到正确的交换对象,我们都需要 2 次比较(子节点之间比一次,父节点和胜出的子节点再比一次)。这个下沉路径的长度是 logK
。
所以,最小堆一次调整所需的总比较次数大约是 2 * logK。
2. 败者树 (Loser Tree) 的调整过程
败者树的调整过程是 “向上比赛 (Tournament-Up)” 。
tree[0]
)得知最小元素的位置,并取出它。关键点:在向上调整的每一层,新晋级的选手只需要和一个固定的对手(父节点里存储的那个“败者”)进行比赛。因此,每一层只需要 1 次比较。这个比赛路径的长度也是 logK
。
所以,败者树一次调整所需的总比较次数大约是 logK。
最小堆的数组:数组中直接存储的是参与比较的元素本身(或者是指向元素的引用)。例如 heap[0] 就是全局最小的那个 KeyValue 对象。
败者树的 tree 数组:数组中存储的不是 KeyValue 对象,而是指向 leaves 列表的整型索引 (int index)。因此可以常数定位更新位置。
LoserTree
的联系现在回到 LoserTree.java
。它和标准败者树最大的不同,就是状态机 (State
)。
LoserTree
:它必须关心 key 是否相同。当它从一个叶子节点补充新元素 new_val
后,它在 adjust
过程中,不仅要和父节点的败者比赛,还要判断 new_val
的 key 和上一轮全局胜者的 key 是否相同。
adjustWithSameWinnerKey
逻辑,只比较 sequenceNumber
。adjustWithNewWinnerKey
逻辑,正常比较 key。State
的转换,实现了“暂停”在某个 key 的功能。所以,可以把 Paimon 的 LoserTree
理解为:一个增加了复杂状态管理以支持分组归并功能的、高度特化的败者树。它的核心归并思想和标准败者树是一致的,但为了适应 LSM-Tree 的业务场景,做了非常精巧的扩展。
LoserTree
LoserTree
这个类 是一个为高性能归并排序设计的核心数据结构,其实现比标准的败者树要复杂精巧得多,因为它需要处理 LSM-Tree 场景下的一个特殊问题:如何高效地合并来自多个数据流中主键(user key)相同的记录。
将从以下几个方面来详细解读:
LoserTree
到底要解决什么问题?tree
数组和 leaves
列表分别是什么?State
枚举:这是理解其复杂行为的钥匙。initializeIfNeeded
)adjust
)adjustForNextLoop
, popWinner
, peekWinner
)LeafIterator
:数据源的直接管理者。正如类注释所说,这个 LoserTree
是一个“变种”(a variant of the loser tree)。在标准的归并排序中,我们每次从败者树中取出一个最小元素后,就立即从其对应的数据流中补充一个新元素。
但 Paimon 的场景不同:
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
表示是败者。这个状态机将 key 的比较结果(相同/不同)和比赛结果(胜/负)组合起来,驱动着 adjust
方法的行为。
WINNER_WITH_NEW_KEY 和
WINNER_WITH_SAME_KEY
场景一:WINNER_WITH_NEW_KEY
-> adjustWithNewWinnerKey
这个状态是游戏中最常见的状态,它代表一个 “全新的挑战者” 上场了。
什么时候会产生 WINNER_WITH_NEW_KEY
?
initializeIfNeeded
): 每个玩家(LeafIterator
)从牌堆(RecordReader
)里摸第一张牌。这张牌对所有人来说都是未知的,所以每个玩家的状态都是 WINNER_WITH_NEW_KEY
。advanceIfAvailable
): 当一个玩家的牌被选为当前轮的胜者(被 popWinner
),他需要再摸一张新牌。这张新牌的主键(比如'J')和上一张(比如'10')可能完全不同,所以这个玩家的状态会被重置为 WINNER_WITH_NEW_KEY
,代表他带着一张“新牌”重新加入战局。调用 adjustWithNewWinnerKey
意味着什么?
这意味着一个拿着“新牌”的挑战者 (winnerNode
),正在和树中一个“老的失败者”(parentNode
)进行比较。因为挑战者的牌是全新的,我们对它一无所知,所以必须进行最全面的比较:
firstComparator
):
secondComparator
):
LOSER_WITH_SAME_KEY
和 WINNER_WITH_NEW_KEY
(如果胜者是原父节点)或者 LOSER_WITH_SAME_KEY
(如果胜者是挑战者,挑战者状态不变,但父节点状态改变)。这为后续的 adjustWithSameWinnerKey
埋下伏笔。场景二:WINNER_WITH_SAME_KEY
-> adjustWithSameWinnerKey
这个状态代表一个 “同主键的挑战者” 上场了。它不是一个全新的挑战者,而是我们已知的一个主键分组内的成员。
什么时候会产生 WINNER_WITH_SAME_KEY
?
这个状态不是被重置出来的,而是在 adjust
过程中由其他状态转化而来的。主要来源有两个:
adjustWithNewWinnerKey
的转化: 当一个 WINNER_WITH_NEW_KEY
的挑战者遇到了一个主键相同的 LOSER_WITH_NEW_KEY
父节点,比较之后,如果父节点胜出,父节点的状态就会被更新为 WINNER_WITH_NEW_KEY
,而挑战者会变为 LOSER_WITH_SAME_KEY
。如果挑战者胜出,父节点会变为 LOSER_WITH_SAME_KEY
,挑战者状态不变,但它已经进入了“同主键”的处理流程。
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
中:
next()
方法首先调用 adjustForNextLoop()
。这确保了无论上一轮留下了什么状态,我们现在面对的都是一个全新的、可用的胜者。loserTree.popWinner()
取得这个新 Key 分组的第一个元素。merge()
循环里,不断调用 loserTree.popWinner()
。因为 adjust()
会优先提升相同 Key 的记录,所以这个循环会不断取出所有相同 Key 的记录,直到 popWinner()
返回 null
,表示这个 Key 分组处理完毕。next()
方法返回合并结果后,下一次调用又会从第 1 步开始,adjustForNextLoop()
会再次清理战场,准备下一个 Key 分组。adjust
)这是 LoserTree
实现中最核心、最复杂的方法。它的职责是:当一个叶子节点(由 winner
索引指定)的状态或数据发生变化后,从这个叶子节点开始,自底向上地重新进行比赛,调整败者树的内部结构,最终确定新的全局胜者。
整个分析将分为以下几个部分:
switch
逻辑:如何处理不同状态的“挑战者”?
case WINNER_WITH_NEW_KEY
:一个全新的挑战者。case WINNER_WITH_SAME_KEY
:一个与当前胜者 Key 相同的挑战者。case WINNER_POPPED
:一个“已消费”的挑战者(最特殊的分支)。方法签名与核心循环
// ... 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
parentNode
。adjustWithNewWinnerKey()
。winnerNode
和 parentNode
的 Key (firstComparator
)。WINNER_WITH_NEW_KEY
和 LOSER_WITH_NEW_KEY
。secondComparator
)。此时,状态会发生关键变化,变为 WINNER_WITH_NEW_KEY
和 LOSER_WITH_SAME_KEY
,并且会记录下相遇的位置 (setFirstSameKeyIndex
),为后续的快速跳转做准备。case WINNER_WITH_SAME_KEY 【adjust只会调节 全局胜者 或者 new ,因此之后判断某些情况意味着出错】
adjustWithSameWinnerKey()
。winnerNode
和 parentNode
的序列号 (secondComparator
)。WINNER_WITH_SAME_KEY
和 LOSER_WITH_SAME_KEY
之间转换。case WINNER_POPPED
(最特殊的快速路径)firstSameKeyIndex;
这个字段记录在 LeafIterator
中,它的含义是:“当前叶子节点在向上晋级的过程中,第一次遇到和它主键相同的另一个节点时,那个相遇点的父节点索引”。
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]
的位置),还潜伏着一个同主键的“兄弟”节点,它就是下一个最优先的候选人。parent = winnerNode.firstSameKeyIndex;
: 启动传送门! 直接将 for
循环的 parent
指针跳转到记录好的那个相遇点。我们跳过了中间所有层级的比较,直达目的地。parentNode = leaves.get(this.tree[parent]);
: 获取到那个潜伏的“兄弟”节点。winnerNode.state = State.LOSER_POPPED;
: 当前节点(空壳)状态变为“已弹出的败者”。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
),并交换它们在树中的位置。// ... 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 ...
firstComparator.compare(parentKey, childKey)
: 首先比较主键。if (firstResult == 0)
: 主键相同。这是首次发现这两个节点的主键相同。
secondComparator.compare(...)
: 必须立即用第二比较器(通常是序列号)来分出胜负。LOSER_WITH_SAME_KEY
,标志着“同主键分组”的形成。胜者会记录下这个败者的位置 (setFirstSameKeyIndex(index)
),为后续的快速跳转做准备。parentNode
胜出,它的状态会变成 WINNER_WITH_NEW_KEY
,因为它战胜了挑战者,成为了新的“挑战者”继续向上晋级。else if (firstResult > 0)
: parentNode
的主键更“小”(根据比较器定义,返回值 > 0 代表第一个参数胜出)。
parentNode
胜出: parentNode
成为新的挑战者,状态变为 WINNER_WITH_NEW_KEY
。winnerNode
失败: winnerNode
挑战失败,留在当前父节点位置,状态变为 LOSER_WITH_NEW_KEY
。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 ...
secondComparator.compare(parentKey, childKey)
: 直接进行第二轮比较(比如序列号)。这是关键的优化点。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
。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()
:
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
是一个为解决“多路归并中合并相同主键”这一特定问题而深度定制的高级数据结构。它通过引入一个精巧的六状态状态机,并将其与败者树的调整逻辑深度融合,实现了以下目标:
LoserTree
内部,使得上层调用者(SortMergeReaderWithLoserTree
)的实现可以非常简洁。它无疑是 Paimon compaction 模块中一个设计得非常出色的核心组件。
如下案例:
LoserTree
有 N 个叶子节点),但某个主键 Key_A
的记录总共有 2N 条,平均分布在这些流里。LoserTree
从 N 个输入流中各读取 1 条记录。此时树里有 N 条 Key_A
的记录。next()
第一次调用: a. while(true)
循环开始。
b. popWinner()
弹出第一个 Key_A
。
c. mergeFunctionWrapper.reset()
清空状态,然后 add()
这第一个 Key_A
。
d. merge()
方法被调用。其内部的 while (loserTree.peekWinner() != null)
循环会把树里剩下 N-1
条 Key_A
全部 pop
出来并 add
进去。
e. 当 N
条记录全部 pop
完,树里所有的叶子节点都处于 WINNER_POPPED
状态,peekWinner()
返回 null
,merge()
的循环结束。
f. mergeFunctionWrapper.getResult()
基于这 N
条记录计算出一个部分合并的结果,然后 next()
方法将这个结果返回。
next()
方法已经返回了,但关于 Key_A
的合并工作只完成了一半。另外 N 条 Key_A
的记录还静静地躺在文件里。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的输入流】