化與多計劃競爭(Multi-Planning):基于 `$unwind + $group` 重寫的 Golden 測試深度解析)
MongoDB DISTINCT_SCAN 優(yōu)化與多計劃競爭Multi-Planning基于$unwind $group重寫的 Golden 測試深度解析【免費下載鏈接】mongoThe MongoDB Database項目地址: https://gitcode.com/GitHub_Trending/mo/mongo本文以 MongoDB 查詢優(yōu)化器中的$unwind $group → DISTINCT_SCAN重寫優(yōu)化為主線結(jié)合倉庫中 golden 測試unwind_group_to_distinct_scan_multiplanning_md的期望輸出深入剖析多個合適索引如何產(chǎn)生單一 DISTINCT_SCAN 候選、hint 對重寫的影響以及全索引掃描候選與 DISTINCT_SCAN 的多計劃競爭四個核心場景。讀完本文你將掌握 DISTINCT_SCAN 的觸發(fā)條件、explain 輸出中各字段unwindsArrays、$groupByDistinctScan、rejectedPlans等的準(zhǔn)確含義以及如何用hint與服務(wù)器參數(shù)控制該優(yōu)化從而在真實業(yè)務(wù)中寫出能被查詢優(yōu)化器高效重寫的聚合管道。一、背景$unwind $group為什么能變成 DISTINCT_SCAN在 MongoDB 中DISTINCT_SCAN是一種僅掃描索引鍵的去重掃描它按索引順序讀取鍵值跳過重復(fù)項因此既能天然輸出有序的互不重復(fù)鍵序列又能避免回表取文檔fetch。傳統(tǒng)上它服務(wù)于distinct()命令與去重 排序類查詢。MongoDB 的聚合優(yōu)化器在特定條件下可以把形如[ { $unwind: { path: $a, preserveNullAndEmptyArrays: true } }, { $group: { _id: $a } } ]的管道整體重寫為一次對可能多鍵的索引的DISTINCT_SCAN再配合一個$groupByDistinctScan階段直接產(chǎn)出分組結(jié)果。從測試源碼 jstests/aggregation/optimization/unwind_group_to_distinct_scan.js 頂部的注釋可以確認(rèn)該優(yōu)化的語義約束只針對頂層字段的$unwind后緊跟同字段、無累加器的$group必須設(shè)置preserveNullAndEmptyArrays: true。原因在于不帶該選項時nullish 文檔不產(chǎn)生任何分組而索引無法區(qū)分值為 null與數(shù)組元素為 [null]的文檔只有保留空/null 數(shù)組時二者在分組語義上才等價管道中不能有其它中間 stage 修改該字段也不能存在$match過濾器當(dāng)前實現(xiàn)下任何$match都會阻斷重寫。當(dāng)該重寫生效時explain 中會出現(xiàn)兩個標(biāo)志性產(chǎn)物計劃樹中的stage : DISTINCT_SCAN節(jié)點并攜帶unwindsArrays : true管道側(cè)$unwind與$group兩個 stage 被合并為單一的$groupByDistinctScanstage。二、測試環(huán)境與數(shù)據(jù)準(zhǔn)備本文主體文檔 jstests/query_golden/expected_output/featureFlagSbeFull/unwind_group_to_distinct_scan_multiplanning.md 是 golden 測試的期望輸出。驅(qū)動它的測試腳本為 jstests/query_golden/unwind_group_to_distinct_scan_multiplanning_md.js其中主集合test.unwind_group_to_distinct_scan_multiplanning_md插入 5 條文檔{a: [1, 2], b: 1}、{a: [2, 3], b: 2}、{a: 7, b: 3}、{a: [], b: 4}、{b: 5}建立索引{a: 1}與復(fù)合索引{a: 1, b: 1}加上_id_共 3 個索引被測管道固定為[{$unwind: {path: $a, preserveNullAndEmptyArrays: true}}, {$group: {_id: $a}}]。測試帶featureFlagShardFilteringDistinctScan與requires_fcv_91兩個標(biāo)簽說明該行為依賴分片過濾型 DISTINCT_SCAN 特性開關(guān)并要求 FCV 至少為 91。注意a字段在 5 條文檔中既含數(shù)組如[1, 2]又含標(biāo)量如7與缺失值因此索引a_1會被標(biāo)記為多鍵索引isMultiKey: truemultiKeyPaths會列出a : [a]。由于$match尚不被該重寫支持見測試注釋管道沒有任何謂詞可供計劃器枚舉競爭計劃因此多數(shù)場景下查詢計劃器只會生成一個解。golden 輸出的 Summarized explain 正是圍繞重寫與 multi-planning 如何交互這一核心問題展開的。三、場景一多個合適索引只生成單一 DISTINCT_SCAN 候選3.1 場景設(shè)置與結(jié)果當(dāng)集合同時存在a_1與a_1_b_1兩個前綴為 a 的合適索引時理論上兩者都足以支撐對a的 DISTINCT_SCAN。但觀察 explainrejectedPlans : [ ], winningPlan : [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1 } }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ] }, indexName : a_1, isFetching : false, isMultiKey : true, stage : DISTINCT_SCAN, unwindsArrays : true } ]關(guān)鍵信息解讀rejectedPlans為空數(shù)組沒有發(fā)生多計劃競爭。原因正如測試注釋所述——重寫要求空過濾器沒有謂詞可供計劃器枚舉出不同的索引區(qū)間方案計劃器通常會直接產(chǎn)出一個解計劃器選擇了a_1單字段索引而非a_1_b_1因為 DISTINCT_SCAN 只關(guān)心a的鍵單字段索引即可覆蓋PROJECTION_COVEREDisFetching: false無需回表unwindsArrays: true告知執(zhí)行器該 DISTINCT_SCAN 掃描的是多鍵索引中的數(shù)組元素等價于在展開數(shù)組之后取去重鍵索引邊界[MinKey, MaxKey]表示全范圍掃描無謂詞約束下的默認(rèn)邊界。計劃樹之下聚合管道側(cè)變?yōu)閱我?stage{ $groupByDistinctScan : { newRoot : { _id : $a } } }也就是說$unwind與$group兩個階段被吸收進(jìn)掃描最終只輸出 5 個分組鍵{ _id: 1 / 2 / 3 / 7 / null }。其中null分組來自{a: []}空數(shù)組展開為空與{b: 5}字段缺失——在preserveNullAndEmptyArrays: true語義下它們都折疊進(jìn)null分組。3.2 從源碼看 DISTINCT_SCAN 的生成DISTINCT_SCAN 計劃節(jié)點的生成邏輯位于 src/mongo/db/query/distinct_access.cpp 與 src/mongo/db/query/canonical_distinct.h 一帶它負(fù)責(zé)從候選索引中挑出能夠以鍵序去重方式回答 distinct 語義的索引而$groupByDistinctScan這一 explain 階段的序列化與計劃解釋邏輯可在 src/mongo/db/query/plan_explainer_impl.cpp 與 src/mongo/db/query/compiler/physical_model/query_solution/query_solution.cpp 中找到對應(yīng)實現(xiàn)。可以推斷當(dāng)聚合優(yōu)化器識別出可重寫管道后會直接構(gòu)建單一候選的 distinct 計劃因而不會觸發(fā) multi-planning。四、場景二需要回表的 hinted 索引不會被轉(zhuǎn)換為 DISTINCT_SCAN4.1 場景設(shè)置如果通過 hint 顯式指定復(fù)合索引{a: 1, b: 1}{ hint : { a : 1, b : 1 } }結(jié)果仍然正確_id仍是1, 2, 3, 7, null但計劃形態(tài)完全不同winningPlan : [ { stage : PROJECTION_SIMPLE, transformBy : { _id : 0, a : 1 } }, { nss : test.unwind_group_to_distinct_scan_multiplanning_md, stage : FETCH }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ] }, indexName : a_1_b_1, isMultiKey : true, stage : IXSCAN } ]隨后是兩個未被合并的原始 stage{ $unwind : { path : $a, preserveNullAndEmptyArrays : true } }, { $group : { $willBeMerged : false, _id : $a } }4.2 為什么 hint 會阻斷重寫這是一個非常重要的實戰(zhàn)結(jié)論可以被重寫為 DISTINCT_SCAN 與被強(qiáng)制使用某個索引是兩回事。a_1_b_1雖然以a為前綴但它攜帶多余的b鍵DISTINCT_SCAN 要求投影完全被索引覆蓋isFetching: false。而a_1_b_1的鍵只有(a, b)若只取a掃描后仍需 FETCH 回表取原始文檔explain 中的FETCHstage 與PROJECTION_SIMPLE證實了這一點從重寫語義看$groupByDistinctScan需要的是恰好覆蓋分組鍵的去重掃描帶多余后綴鍵的復(fù)合索引會產(chǎn)生同一a值對應(yīng)多條b不同的索引項無法直接作為去重源因此優(yōu)化器放棄重寫退化為常規(guī)的IXSCAN FETCH $unwind $group執(zhí)行路徑。換句話說hint 只能約束用哪個索引掃描卻不能強(qiáng)制優(yōu)化器做不合規(guī)的語義重寫。優(yōu)化器寧可保留完整管道也要保證結(jié)果正確。五、場景三hint 可以強(qiáng)制合適的索引作為對照如果 hint 指定的是可覆蓋、鍵序恰好匹配的索引{ hint : { a : 1 } }計劃重新回到 DISTINCT_SCAN 形態(tài)winningPlan : [ { stage : PROJECTION_SIMPLE, transformBy : { _id : 0, a : 1 } }, { nss : test.unwind_group_to_distinct_scan_multiplanning_md, stage : FETCH }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ] }, indexName : a_1, isMultiKey : true, stage : IXSCAN } ]等等——這里出現(xiàn)的仍是IXSCAN而非DISTINCT_SCAN而且多了FETCH這恰恰是本場景最有價值的信息在 classic 引擎Execution Engine: classic下當(dāng)用戶顯式 hint 時計劃器對該管道生成的是普通IXSCAN計劃$unwind與$group原樣保留帶$willBeMerged : false標(biāo)記對比場景一的PROJECTION_COVERED這里即使是a_1classic 引擎仍選擇了FETCH PROJECTION_SIMPLE形態(tài)需要留意的是這份 golden 輸出保存在featureFlagSbeFull目錄下。同名的 sbeFull 版本期望輸出 中場景二、三的 explain 標(biāo)注為 Execution Engine: sbe投影字段布爾值形式_id : false, a : true也與 classic 的0/1形式不同——說明在完整 SBESlot-Based Execution引擎下$groupByDistinctScan的生成路徑有所差異但需要回表的 hint 阻斷重寫這一結(jié)論在兩個引擎中保持一致。因此將場景二與場景三放在一起讀出的完整結(jié)論是DISTINCT_SCAN 重寫不是一個hint 可強(qiáng)開的特性。hint 只能把掃描固定到指定索引是否重寫取決于該索引是否滿足鍵序可去重 投影可覆蓋這兩個硬性條件。實戰(zhàn)中若想穩(wěn)定獲得 DISTINCT_SCAN應(yīng)當(dāng)為分組鍵建立單獨的前綴索引如{a: 1}而不是依賴 hint 去拯救一個帶多余后綴的復(fù)合索引。六、場景四全索引掃描候選與 DISTINCT_SCAN 的多計劃競爭6.1 場景設(shè)置這是四個場景中唯一真正發(fā)生 multi-planning 的用例也是這份 golden 文檔存在的核心意義。數(shù)據(jù)被放入另一個集合test.unwind_group_to_distinct_scan_multiplanning_md_scalar全部為標(biāo)量數(shù)據(jù){a: 1, b: 1}、{a: 1, b: 2}、{a: 2, b: 3}、{a: 3, b: 4}、{b: 5}并建立索引{b: 1, a: 1}與{a: 1}。注意這里沒有{a: 1, b: 1}b_1_a_1是非多鍵索引。由于管道沒有謂詞正常不會產(chǎn)生多個計劃。測試腳本為此臨時打開了一個服務(wù)器參數(shù)const knob internalQueryPlannerGenerateCoveredWholeIndexScans; const priorKnobValue assert.commandWorked(db.adminCommand({getParameter: 1, [knob]: 1}))[knob]; assert.commandWorked(db.adminCommand({setParameter: 1, [knob]: true})); try { outputAggregationPlanAndResults(scalarColl, pipeline); } finally { assert.commandWorked(db.adminCommand({setParameter: 1, [knob]: priorKnobValue})); }internalQueryPlannerGenerateCoveredWholeIndexScans會讓計劃器額外生成整索引全掃描whole index scan的候選計劃從而人為制造 multi-planning 場景。測試特意不用runWithKnobs()之類的輔助函數(shù)是因為那些工具會經(jīng)由FixtureHelpers打開新連接新連接隱式會話的 id 會被打印進(jìn) golden 輸出破壞結(jié)果穩(wěn)定性——這個細(xì)節(jié)也說明了 golden 測試對輸出的精確性要求。6.2 競爭結(jié)果DISTINCT_SCAN 勝出explain 中第一次出現(xiàn)了非空的rejectedPlansrejectedPlans : [ [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1 } }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ], b : [ [MinKey, MaxKey] ] }, indexName : b_1_a_1, isMultiKey : false, stage : IXSCAN } ] ]被拒絕的計劃是對b_1_a_1的整索引掃描——它雖然可覆蓋PROJECTION_COVERED但必須掃過(b, a)全索引數(shù)據(jù)量明顯更大。勝出的計劃則是winningPlan : [ { stage : PROJECTION_COVERED, transformBy : { _id : 0, a : 1 } }, { direction : forward, indexBounds : { a : [ [MinKey, MaxKey] ] }, indexName : a_1, isMultiKey : false, stage : DISTINCT_SCAN, unwindsArrays : true } ]這里a_1是非多鍵索引isMultiKey: false、multiKeyPaths全空因為該集合數(shù)據(jù)全為標(biāo)量DISTINCT_SCAN只遍歷a的鍵并跳過重復(fù)值數(shù)據(jù)中a: 1出現(xiàn)兩次配合PROJECTION_COVERED全程不回表rejectedPlans的queryShapeHash為B1FC3E85...與前三場景的7369A25E...不同——因為集合/命名空間與索引集合不同query shape 編碼也會變化管道側(cè)同樣合并為$groupByDistinctScan$unwind/$group不再單獨出現(xiàn)。這一場景證明即使把 DISTINCT_SCAN 候選放進(jìn) multi-planning 的公開競爭中它依然能靠更小的掃描代價勝出。這正是該優(yōu)化對查詢性能的實戰(zhàn)價值——去重語義由索引掃描直接承擔(dān)無需展開數(shù)組、無需哈希聚合、無需回表。七、重寫的前提條件與失效邊界圍繞同一優(yōu)化的完整單元測試 jstests/aggregation/optimization/unwind_group_to_distinct_scan.js 系統(tǒng)性地覆蓋了重寫的邊界可作為本文四個場景之外的查漏補(bǔ)缺清單觸發(fā)條件$unwind必須設(shè)置preserveNullAndEmptyArrays: true且不能帶includeArrayIndex$group的_id必須與$unwind字段一致且不能有任何累加器支持多鍵索引與單字段/復(fù)合索引復(fù)合索引中展開字段須在前綴混合方向如{a: -1, b: 1}也可$unwind與$group之間不能插入任何修改該字段的 stage如$addFields$unwind之前也不能有$sort不支持虛線路徑如$foo.a即使只有葉子字段是多鍵的見 TODO SERVER-133204不支持任何$match見 TODO SERVER-133195包括展開前/后的等值、范圍、$in以及非數(shù)組前綴/后綴上的過濾。不適用/被拒絕的索引類型sparse 索引缺失值無索引項、partial 索引、wildcard 索引$**均不參與hashed 索引因鍵值非原始值而被拒絕但復(fù)合索引尾部帶 hashed 字段{a: 1, b: hashed}仍可用非 simple collation 的索引不可用索引鍵可能已被整理無法等價于分組鍵。已知偏差known deviations多鍵索引場景下頂層undefined值會被折疊進(jìn)null分組因為空數(shù)組[]與undefined產(chǎn)生相同的索引鍵而 BSONundefined類型已廢棄這種偏差可接受——控制組$natural全掃描結(jié)果與 DISTINCT_SCAN 結(jié)果在 unwind_group_to_distinct_scan.js 中被顯式對比斷言。八、如何親手復(fù)現(xiàn)與驗證復(fù)現(xiàn)本文四個場景只需兩步啟動 MongoDBFCV ≥ 91啟用featureFlagShardFilteringDistinctScan后運行 golden 測試腳本 jstests/query_golden/unwind_group_to_distinct_scan_multiplanning_md.js將腳本輸出與三份期望文件逐行比對默認(rèn)引擎jstests/query_golden/expected_output/unwind_group_to_distinct_scan_multiplanning.md完整 SBEjstests/query_golden/expected_output/sbeFull/unwind_group_to_distinct_scan_multiplanning.md特性開關(guān)組合jstests/query_golden/expected_output/featureFlagSbeFull/unwind_group_to_distinct_scan_multiplanning.md在 shell 中手工驗證某條管道是否被重寫可直接執(zhí)行db.coll.explain().aggregate([...])檢查計劃中是否出現(xiàn)stage : DISTINCT_SCAN與unwindsArrays : true以及管道中是否合并出$groupByDistinctScan。若計劃里出現(xiàn)FETCH、IXSCAN與獨立的$unwind/$group則說明重寫未生效可對照上文觸發(fā)條件與失效邊界逐項排查。九、總結(jié)通過這份 golden 期望輸出與配套測試源碼可以確認(rèn) MongoDB 查詢優(yōu)化器的如下行為當(dāng)$unwindpreserveNullAndEmptyArrays: true 同字段無累加器$group且無過濾謂詞時優(yōu)化器將管道重寫為對合適索引的DISTINCT_SCAN并在 explain 中以unwindsArrays: true與$groupByDistinctScan標(biāo)識多個合適索引并存時不會觸發(fā) multi-planning計劃器直接產(chǎn)出單一 DISTINCT_SCAN 候選優(yōu)先選擇可覆蓋、無需回表的索引hint 不能強(qiáng)制該重寫指向需要回表的復(fù)合索引的 hint 會使優(yōu)化器放棄重寫、退化為IXSCAN FETCH全管道執(zhí)行只有指向鍵序匹配索引的 hint 才可能維持去重掃描形態(tài)即使在internalQueryPlannerGenerateCoveredWholeIndexScans制造出的 multi-planning 競爭中DISTINCT_SCAN 仍憑借更小的掃描代價勝出。對應(yīng)用開發(fā)者而言最直接的實踐啟示是為高頻的按數(shù)組字段去重統(tǒng)計類管道建立以該字段為前綴的獨立索引{a: 1}并保持管道形態(tài)為展開 分組、無中間階段、無過濾器即可讓 MongoDB 自動以 DISTINCT_SCAN 的高效路徑執(zhí)行避免數(shù)組展開、回表與哈希聚合的開銷。【免費下載鏈接】mongoThe MongoDB Database項目地址: https://gitcode.com/GitHub_Trending/mo/mongo創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考