MongoDB/SBE $lookup 哈希连接

为文档关联构建哈希表,并合并数组键的匹配结果

MongoDB 的 SBE 哈希 lookup 先索引 foreign 文档,再为每条 local 文档查找匹配项;多个数组键命中同一 foreign 文档时按记录编号去重。

e3974e633检索与计算核验 2026-10-12
相关概念

工程问题#

$lookup 需要为每条本地文档找到关联集合中的匹配文档。重复扫描关联集合代价高,而字段可能包含多个键:同一关联文档可能通过多个键被命中,不能因此在结果数组中重复出现。

实际实现#

选定哈希策略后,普通 $lookup 生成 HashLookupStage,并将 local 字段规范化为普通键数组。open 阶段缓存 foreign 文档,将键映射到缓存记录编号列表;getNext 逐条读取 local 文档并逐项探测键数组。各键对应的记录编号汇总后排序并 unique,再把这些编号指向的文档聚合为结果数组。这里不预先将 local 键数组转为集合,因为探测阶段已经按最终文档身份去重。哈希索引及文档缓冲区均有落盘路径。

处理流程#

  1. 执行计划选择哈希 lookup,并构造生成结果数组的聚合表达式
  2. 扫描 foreign 输入,缓存文档并建立键到记录编号列表的索引
  3. 读取一条 local 文档,将 local 字段规范化为键数组,逐项探测匹配编号
  4. 合并并去重匹配编号,取回文档并累加到该条结果数组

源码片段#

固定版本 · 原文摘录
src/mongo/db/query/stage_builder/sbe/gen_lookup.cppe3974e633bb5acdec501155cdb08334502ef39f8 · L1399–L1419
查看原文件
1399        // Plain $lookup without $unwind: use HashLookupStage.1400        // Aggregator to assemble the matched foreign documents into an array.1401        SbBlockAggExpr agg{SbExpr{} /*init*/,1402                           SbExpr{} /*blockAgg*/,1403                           b.makeFunction(sbe::EFn::kAddToArray, foreignRecordSlot) /*agg*/};14041405        auto [hl, lookupStageOutputSlot] = b.makeHashLookup(std::move(localKeysSetStage),1406                                                            std::move(foreignKeyStage),1407                                                            localKeysSetSlot,1408                                                            foreignKeySlot,1409                                                            foreignRecordSlot,1410                                                            std::move(agg),1411                                                            boost::none /* optOutputSlot */,1412                                                            state.getCollatorSlot());14131414        // Add a projection that returns an empty array in the "as" field if no foreign row matched.1415        auto [emptyArrayTag, emptyArrayVal] = sbe::value::makeNewArray();1416        auto emptyArrayConstant = b.makeConstant(emptyArrayTag, emptyArrayVal);14171418        SbExpr innerResultProjection =1419            b.makeFillEmpty(lookupStageOutputSlot, std::move(emptyArrayConstant));
buildHashJoinLookupStage

普通 $lookup 构造 HashLookupStage,并用 addToArray 累加匹配文档;无匹配时上层投影生成空数组。

src/mongo/db/exec/sbe/stages/hash_lookup.cppe3974e633bb5acdec501155cdb08334502ef39f8 · L150–L170
查看原文件
150    // Insert the inner side into the hash table.151    innerChild()->open(false);152    value::FixedSizeRow<1 /*N*/> value{1};153    while (innerChild()->getNext() == PlanState::ADVANCED) {154        // Copy the projected value.155        value.reset(0, _inInnerProjectAccessor->getCopyOfValue());156157        // This where we put the value in here. This can grow need to spill.158        size_t bufferIndex = _hashTable.bufferValueOrSpill(value);159        _memoryTracker.value().set(_hashTable.getMemUsage());160161        auto [tagKeyView, valKeyView] = _inInnerMatchAccessor->getViewOfValue();162        if (value::isArray(tagKeyView)) {163            value::ArrayAccessor arrayAccessor;164            arrayAccessor.reset(_inInnerMatchAccessor);165166            while (!arrayAccessor.atEnd()) {167                _hashTable.addHashTableEntry(&arrayAccessor, bufferIndex);168                _memoryTracker.value().set(_hashTable.getMemUsage());169                arrayAccessor.advance();170            }
HashLookupStage::open

扫描 foreign 输入,缓存文档取得 bufferIndex,再把它登记到各个数组键名下;文档值和键索引分开保存。

src/mongo/db/exec/sbe/stages/lookup_hash_table.cppe3974e633bb5acdec501155cdb08334502ef39f8 · L188–L205
查看原文件
188    } else {189        // The key is already present in '_memoryHt' so the memory will only grow by one size_t. If190        // we reach the memory limit, the key/value in '_memoryHt' will be evicted from memory and191        // spilled to '_recordStoreHt' along with the new index.192        htIt->second.push_back(valueIndex);193        _computedTotalMemUsage += sizeof(size_t);194        if (_computedTotalMemUsage > _memoryUseInBytesBeforeSpill) {195            if (!hasSpilledHtToDisk()) {196                makeInternalRecordStore();197            }198199            _computedTotalMemUsage -= size_estimator::estimate(keyView.tag, keyView.value);200201            // Evict the hash table value.202            _computedTotalMemUsage -= htIt->second.size() * sizeof(size_t);203            spillIndicesToRecordStore(_recordStoreHt.get(), keyView, htIt->second);204            _memoryHt->erase(htIt);205        }
LookupHashTable::addHashTableEntry

已有键追加记录编号;超过内存阈值时把编号列表写入记录存储并从内存哈希表移除,不是无限增长。

src/mongo/db/exec/sbe/stages/hash_lookup.cppe3974e633bb5acdec501155cdb08334502ef39f8 · L192–L206
查看原文件
192PlanState HashLookupStage::getNext() {193    auto optTimer(getOptTimer(_opCtx));194195    PlanState state = outerChild()->getNext();196    if (state == PlanState::ADVANCED) {197        // We just got this outer doc, so reset the $lookup "as" result array accumulator to nothing198        // and the hash table iterator to the outer key.199        _lookupStageOutput.reset(0, value::TagValueView::nothing());200        _hashTable.htIter.reset(_inOuterMatchAccessor->getViewOfValue());201202        // Accumulate all the matching inner docs for the outer key(s).203        accumulateFromValueIndices(_hashTable.htIter.getAllMatchingIndices());204    }205    return trackPlanState(state);206}  // HashLookupStage::getNext
HashLookupStage::getNext

每条 local 文档重置结果累加器及探测键数组,取得匹配编号集合后聚合对应文档;普通计划构造已将 local 字段规范化为数组。

src/mongo/db/exec/sbe/stages/lookup_hash_table.cppe3974e633bb5acdec501155cdb08334502ef39f8 · L24–L43
查看原文件
24    value::ArrayEnumerator enumerator(_outerKey.tag, _outerKey.value);25    while (!enumerator.atEnd()) {26        auto elemView = enumerator.getViewOfValue();2728        auto hashTableMatchIter = _hashTable._memoryHt->find(elemView);29        if (hashTableMatchIter != _hashTable._memoryHt->end()) {30            _hashTableMatchVector.insert(_hashTableMatchVector.end(),31                                         hashTableMatchIter->second.begin(),32                                         hashTableMatchIter->second.end());33        } else if (_hashTable._recordStoreHt) {34            // The key wasn't in memory. Check the '_hashTable._recordStoreHt' disk spill.35            auto elemColl = _hashTable.normalizeStringIfCollator(elemView);3637            auto indicesFromRS = _hashTable.readIndicesFromRecordStore(38                _hashTable._recordStoreHt.get(), elemColl.view());39            if (indicesFromRS) {40                _hashTableMatchVector.insert(41                    _hashTableMatchVector.end(), indicesFromRS->begin(), indicesFromRS->end());42            }43        }
LookupHashTableIter::initSearchArray:探测

逐个数组元素查内存哈希表,未命中时检查落盘索引,并把各键的匹配编号汇入同一数组。

src/mongo/db/exec/sbe/stages/lookup_hash_table.cppe3974e633bb5acdec501155cdb08334502ef39f8 · L47–L56
查看原文件
47    // Different array elements (or the memory and disk portions of the hash table) may map to the48    // same inner buffer index, so duplicates must be removed.49    std::sort(_hashTableMatchVector.begin(), _hashTableMatchVector.end());50    _hashTableMatchVector.erase(51        std::unique(_hashTableMatchVector.begin(), _hashTableMatchVector.end()),52        _hashTableMatchVector.end());5354    _hashTableMatchVectorIdx = 0;55    _hashTableSearched = true;56}  // LookupHashTableIter::initSearchArray
LookupHashTableIter::initSearchArray:去重

同一文档可能被不同键重复命中,因此先按记录编号排序再 unique;这里去重的是文档身份,而不是键值。

src/mongo/db/exec/sbe/stages/hash_lookup.cppe3974e633bb5acdec501155cdb08334502ef39f8 · L181–L190
查看原文件
181void HashLookupStage::accumulateFromValueIndices(const RecordIndexCollection* bufferIndices) {182    for (const size_t bufferIdx : *bufferIndices) {183        boost::optional<value::TagValueView> innerMatch = _hashTable.getValueAtIndex(bufferIdx);184        tassert(10801300, "Expected non-empty innerMatch", innerMatch);185        _outInnerProjectAccessor.reset(*innerMatch);186187        // Run the VM code to "accumulate" the current inner doc into the lookup output array.188        _lookupStageOutput.reset(0 /* column */, _bytecode.run(_aggCode.get()));189    }190}  // HashLookupStage::accumulateFromValueIndices
HashLookupStage::accumulateFromValueIndices

编号集合最终用于取回文档,并执行聚合字节码累加到输出数组,补全从索引到实际结果的链路。

片段保持固定提交的连续原文;完整源码与许可证保存在本地。每段说明交代所在分支,省略部分可通过出处查看。

收益与取舍#

工程分析

带来的收益

  • foreign 输入只需建立一次索引,避免每条 local 文档都扫描整个关联集合
  • 键索引只引用缓存记录编号,同一文档可由多个键关联而不必复制整份文档

付出的代价

  • 哈希构建是阻塞阶段,需要先处理 foreign 输入;哈希表和缓存会占内存,超过阈值的落盘会带来 I/O
  • 数组键匹配汇总后还要排序去重;若产生 M 个候选编号,这部分为 O(M log M),不能笼统称整条关联为线性
  • 该实现不负责决定哈希是否优于索引连接;规划器选择、嵌套路径与空值语义由前面的计划构造处理

可比较的选择

索引嵌套循环连接foreign 字段有合适索引且 local 输入较小时可避免完整哈希构建,但要付出多次索引探测。

普通嵌套循环连接无需建立哈希结构,但大量输入时可能重复扫描 foreign 集合。

版本与适用范围#

对应固定提交的普通 SBE 哈希 $lookup,不包含融合 $unwind 的 HashLookupUnwindStage。此计划构造用 KeyType::kArray 生成 local 键数组;底层迭代器另有标量分支,不能据此把本路径描述为直接探测原始标量字段。记录编号排序用于消除同一 foreign 文档的重复命中,不等于向用户承诺结果按字段排序。落盘是否允许及资源限制由执行环境控制。

参考来源#

源码、官方文档与原始论文
01
MongoDB · buildHashJoinLookupStage

src/mongo/db/query/stage_builder/sbe/gen_lookup.cpp · buildHashJoinLookupStage

源码e3974e633bb5acdec501155cdb08334502ef39f8
02
MongoDB · HashLookupStage::open

src/mongo/db/exec/sbe/stages/hash_lookup.cpp · HashLookupStage::open

源码e3974e633bb5acdec501155cdb08334502ef39f8
03
MongoDB · LookupHashTable::addHashTableEntry

src/mongo/db/exec/sbe/stages/lookup_hash_table.cpp · LookupHashTable::addHashTableEntry

源码e3974e633bb5acdec501155cdb08334502ef39f8
04
MongoDB · getAllMatchingIndices 到标量或数组探测的分派

src/mongo/db/exec/sbe/stages/lookup_hash_table.h · getAllMatchingIndices 到标量或数组探测的分派

源码e3974e633bb5acdec501155cdb08334502ef39f8

相关实现与比较#

4 个相关案例
QEMU·TCG 翻译块v9.2.0

QEMU TCG 用 QHT 保存 TranslationBlock 索引,帮助复用与当前客体执行状态一致的翻译结果。

附源码片段 · 缓存与复用
19 个匹配案例