为文档关联构建哈希表,并合并数组键的匹配结果
MongoDB 的 SBE 哈希 lookup 先索引 foreign 文档,再为每条 local 文档查找匹配项;多个数组键命中同一 foreign 文档时按记录编号去重。
工程问题#
$lookup 需要为每条本地文档找到关联集合中的匹配文档。重复扫描关联集合代价高,而字段可能包含多个键:同一关联文档可能通过多个键被命中,不能因此在结果数组中重复出现。
实际实现#
选定哈希策略后,普通 $lookup 生成 HashLookupStage,并将 local 字段规范化为普通键数组。open 阶段缓存 foreign 文档,将键映射到缓存记录编号列表;getNext 逐条读取 local 文档并逐项探测键数组。各键对应的记录编号汇总后排序并 unique,再把这些编号指向的文档聚合为结果数组。这里不预先将 local 键数组转为集合,因为探测阶段已经按最终文档身份去重。哈希索引及文档缓冲区均有落盘路径。
处理流程#
- 执行计划选择哈希 lookup,并构造生成结果数组的聚合表达式
- 扫描 foreign 输入,缓存文档并建立键到记录编号列表的索引
- 读取一条 local 文档,将 local 字段规范化为键数组,逐项探测匹配编号
- 合并并去重匹配编号,取回文档并累加到该条结果数组
源码片段#
固定版本 · 原文摘录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));普通 $lookup 构造 HashLookupStage,并用 addToArray 累加匹配文档;无匹配时上层投影生成空数组。
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 }扫描 foreign 输入,缓存文档取得 bufferIndex,再把它登记到各个数组键名下;文档值和键索引分开保存。
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 }已有键追加记录编号;超过内存阈值时把编号列表写入记录存储并从内存哈希表移除,不是无限增长。
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每条 local 文档重置结果累加器及探测键数组,取得匹配编号集合后聚合对应文档;普通计划构造已将 local 字段规范化为数组。
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 }逐个数组元素查内存哈希表,未命中时检查落盘索引,并把各键的匹配编号汇入同一数组。
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同一文档可能被不同键重复命中,因此先按记录编号排序再 unique;这里去重的是文档身份,而不是键值。
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编号集合最终用于取回文档,并执行聚合字节码累加到输出数组,补全从索引到实际结果的链路。
片段保持固定提交的连续原文;完整源码与许可证保存在本地。每段说明交代所在分支,省略部分可通过出处查看。
收益与取舍#
工程分析带来的收益
- foreign 输入只需建立一次索引,避免每条 local 文档都扫描整个关联集合
- 键索引只引用缓存记录编号,同一文档可由多个键关联而不必复制整份文档
付出的代价
- 哈希构建是阻塞阶段,需要先处理 foreign 输入;哈希表和缓存会占内存,超过阈值的落盘会带来 I/O
- 数组键匹配汇总后还要排序去重;若产生 M 个候选编号,这部分为 O(M log M),不能笼统称整条关联为线性
- 该实现不负责决定哈希是否优于索引连接;规划器选择、嵌套路径与空值语义由前面的计划构造处理
可比较的选择
索引嵌套循环连接foreign 字段有合适索引且 local 输入较小时可避免完整哈希构建,但要付出多次索引探测。
普通嵌套循环连接无需建立哈希结构,但大量输入时可能重复扫描 foreign 集合。
版本与适用范围#
对应固定提交的普通 SBE 哈希 $lookup,不包含融合 $unwind 的 HashLookupUnwindStage。此计划构造用 KeyType::kArray 生成 local 键数组;底层迭代器另有标量分支,不能据此把本路径描述为直接探测原始标量字段。记录编号排序用于消除同一 foreign 文档的重复命中,不等于向用户承诺结果按字段排序。落盘是否允许及资源限制由执行环境控制。
参考来源#
源码、官方文档与原始论文src/mongo/db/query/stage_builder/sbe/gen_lookup.cpp · buildHashJoinLookupStage
src/mongo/db/exec/sbe/stages/hash_lookup.cpp · HashLookupStage::open
src/mongo/db/exec/sbe/stages/lookup_hash_table.cpp · LookupHashTable::addHashTableEntry
src/mongo/db/exec/sbe/stages/lookup_hash_table.h · getAllMatchingIndices 到标量或数组探测的分派
相关实现与比较#
4 个相关案例QEMU TCG 用 QHT 保存 TranslationBlock 索引,帮助复用与当前客体执行状态一致的翻译结果。
libvirt 为 Domain 对象维护 UUID 与名称两个哈希索引,两个入口指向同一个对象。
把上下文身份编码进块哈希,让重复请求共享已经计算的 KV cache。
Redis 有序集合既要按成员查分数,也要按分数和名次遍历;较大集合通过两种索引服务这些访问方式。