MongoDB 慢查询日志中的聚合 Spilling 统计:logs_spilling 黄金测试与 SpillingStats 实现解析
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
本文以jstests/query_golden/expected_output/sbeDisabled/logs_spilling.md这份黄金测试期望输出文件为主线,完整讲解 MongoDB 聚合管道中各类阶段($sort、$group、$bucketAuto、$graphLookup、$setWindowFields等)发生内存溢出(spilling)时,慢查询日志中各 spilling 统计字段的含义、期望取值,以及它们在 logs_spilling_md.js 测试脚本与 SpillingStats 源码中的对应实现。读完后你将能够:看懂慢查询日志中usedDisk、*Spills、*SpilledRecords、*SpilledBytes等字段的语义,掌握通过setParameter强制各阶段溢出的验证手法,并理解同一测试在不同引擎变体(sbeDisabled/sbeFull等)下期望输出为何不同。
一、这份文档在仓库中的定位
expected_output/sbeDisabled/logs_spilling.md是黄金测试(golden test)框架下的期望输出文件,对应测试脚本 logs_spilling_md.js。该测试的声明目标是:"Tests the spilling statistics are a part of slow query logs"(验证 spilling 统计是慢查询日志的一部分),前置标签为:
requires_persistence:spilling 依赖持久化存储;requires_fcv_81:需要 8.1 及以上的功能兼容版本;requires_profiling:依赖 profiling 机制捕获慢查询日志。
目录名sbeDisabled表明这份期望文件适用于经典(Classic)查询引擎变体——即禁用 SBE(Slot-Based Execution)的 mongod 配置。同一测试脚本在其他引擎变体下会产生不同的期望输出(expected_output/下另有 sbeFull/ 等兄弟目录),因为 SBE 阶段(如 HashLookup、BlockHashAgg)与经典阶段的溢出行为、统计口径并不完全一致。
测试运行方式与整个query_golden套件一致(参见 README.plan_stability.md 中对黄金测试的通用说明,套件定义见 query-optimization/BUILD.bazel 中的query_golden_classic等 target):
buildscripts/resmoke.py run --suites=query_golden_classic \ jstests/query_golden/logs_spilling_md.js测试失败时,buildscripts/golden_test.py diff会展示期望输出与实际日志输出的差异;确认行为变更后用buildscripts/golden_test.py accept接受新的期望文件。
二、期望文件的结构约定:14 个用例如何组织
整个.md文件由 14 个编号用例(## 1. ...到## 14. ...)组成,每个用例包含:
### Pipeline:触发该场景的聚合管道(EJSON);### Slow query spilling stats:从慢查询日志中按规则抽取的 spilling 统计;- 少数用例(11、12、14)还多出
### Slow query spill storage stats一节,记录 spill 存储层的等待时间。
两个关键的解读规则:
"X"是占位符,不是字面值。测试脚本中的getSpillingAttrs()函数会把取值不确定(依赖数据布局、分配器行为)的字节类字段统一替换为"X"再与期望文件比对:- 以
SpilledBytes、SpilledDataStorageSize结尾的字段 → 写成"X"(字符串); spillStorage子文档中除data外的统计(当前只有timeWaitingMicros)→ 同样写成"X";- 而
usedDisk、以Spills/SpilledRecords结尾的计数字段是确定性的,期望文件中保留精确数值(如"sortSpilledRecords" : 8)。
- 以
- 空对象
{ }表示该管道没有产生任何 spilling 字段——即该阶段在这个引擎变体下要么没有溢出,要么根本不汇报 spilling 统计。这一点在用例 10/13(见第五节)中尤为重要。
三、完整用例与期望统计逐条解析
以下按原文件顺序完整收录 14 个用例,并补充每个用例在测试脚本中设置的强制溢出参数。
用例 1:大内存上限下的 $sort —— 不溢出
测试把internalQueryMaxBlockingSortMemoryUsageBytes设为 1000(字节),插入 3 条小文档后执行$sort,排序完全在内存中完成,因此日志中没有 spilling 字段:
[ { "$sort" : { "a" : 1 } } ]期望:
{ }用例 2:空集合上的 $sort —— 不溢出
空集合上执行同样的$sort,同样期望{ }。这两个用例验证了:没有实际发生 spilling 时,慢查询日志不应出现 spilling 统计,避免日志噪声。
用例 3:强制溢出的 $sort
把internalQueryMaxBlockingSortMemoryUsageBytes压到1 字节,任何数据都放不下,3 条文档全部落盘:
[ { "$sort" : { "a" : 1 } } ]{ "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 8, "sortSpills" : 5, "usedDisk" : true }注意sortSpilledRecords为 8 而非 3:从源码结构看(document_source_sort.cpp 及其统计汇聚逻辑),排序溢出的 record 计数把内部产生的记录(含 key/文档两侧)一并计入,故与输入文档数不同;sortSpills: 5表示触发溢出的批次数。这两个数值在测试数据固定时是确定性的,所以能写入期望文件。
用例 4:管道中多个 $sort
插入 3 条{a, b}文档,执行两段排序:
[ { "$sort" : { "a" : 1 } }, { "$limit" : 3 }, { "$sort" : { "b" : 1 } } ]期望:
{ "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 16, "sortSpills" : 10, "usedDisk" : true }关键字义:同一管道内多个排序阶段的 spilling 统计会被累加到同一组字段(两段各贡献约 8 records、5 spills)。这与SpillingStats::accumulate()的逐字段累加实现(spilling_stats.h)一致,也与 plan_summary_stats_visitor.h 中visit(SortStats)把多个 Sort 节点的spillingStats累入spillingStatsPerStage[SORT]的逻辑对应。
用例 5:时间序列集合上的 $sort
测试先用initTimeseriesColl()创建时间序列集合并写入 50 条文档(间隔为bucketMaxSpanSeconds / 10),再执行{ "$sort" : { "time" : 1 } }。即使内存上限保持 1 字节,期望是:
{ "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 50, "sortSpills" : 50, "usedDisk" : true }与用例 3 不同,这里是 50 条记录、50 次溢出——时间序列集合的读取路径产生了更细粒度的分批溢出,且 record 数与输入文档数一一对应。
用例 6:$group + $sort 同时溢出
测试同时把internalDocumentSourceGroupMaxMemoryBytes和internalQuerySlotBasedExecutionHashAggApproxMemoryUseInBytesBeforeSpill设为 1,插入 4 条两组的文档:
[ { "$group" : { "_id" : "$a", "b" : { "$sum" : "$b" } } }, { "$sort" : { "b" : 1 } } ]期望中同时出现 group 与 sort 两组统计:
{ "groupSpilledBytes" : "X", "groupSpilledDataStorageSize" : "X", "groupSpilledRecords" : 4, "groupSpills" : 4, "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 4, "sortSpills" : 3, "usedDisk" : true }这验证了 spilling 统计是按阶段前缀区分的(group*/sort*),同一条慢查询日志可以同时携带多个阶段的溢出信息。
用例 7:TextOr 投影(ExtendedAutoSpilling 特性)
该用例(及其后的用例 8)位于FeatureFlagUtil.isPresentAndEnabled(db, "ExtendedAutoSpilling")保护之下,仅当ExtendedAutoSpilling特性存在且启用时执行;测试将internalTextOrStageMaxMemoryBytes设为 1,并在文本索引集合上执行:
[ { "$match" : { "$text" : { "$search" : "black tea" } } }, { "$addFields" : { "score" : { "$meta" : "textScore" } } } ]期望:
{ "textOrSpilledBytes" : "X", "textOrSpilledDataStorageSize" : "X", "textOrSpilledRecords" : 4, "textOrSpills" : 4, "usedDisk" : true }说明sbeDisabled变体下该特性处于启用状态,否则期望文件中不会有 7、8 两节。对应地,plan_summary_stats_visitor.h 中visit(TextOrStats)会把溢出累入SpillingStage::TEXT_OR并置usedDisk = true。
用例 8:TextOr + 按 textScore 排序
[ { "$match" : { "$text" : { "$search" : "black tea" } } }, { "$sort" : { "_" : { "$meta" : "textScore" } } } ]期望同时携带textOr*(4/4)与sort*(8 records、5 spills)两组统计且usedDisk: true,进一步印证多阶段统计在同一日志行中共存的规则。
用例 9:$bucketAuto
internalDocumentSourceBucketAutoMaxMemoryBytes设为 1,插入 4 条两组文档:
[ { "$bucketAuto" : { "groupBy" : "$a", "buckets" : 2, "output" : { "sum" : { "$sum" : "$b" } } } } ]期望:
{ "bucketAutoSpilledBytes" : "X", "bucketAutoSpilledDataStorageSize" : "X", "bucketAutoSpilledRecords" : 13, "bucketAutoSpills" : 7, "usedDisk" : true }bucketAutoSpilledRecords: 13大于输入 4 条,从源码结构看(document_source_bucket_auto.cpp),bucket 划分过程中的中间记录也被计入溢出记录数。
用例 10:$lookup(HashLookup)在经典引擎下不上报溢出
测试把internalQuerySlotBasedExecutionHashLookupApproxMemoryUseInBytesBeforeSpill设为 1,准备 students/people 两个集合执行$lookup,但期望是空的:
[ { "$lookup" : { "from" : "logs_spilling_md_students", "localField" : "name", "foreignField" : "name", "as" : "matched" } } ]{ }这正是sbeDisabled目录的意义所在:HashLookup 溢出统计属于 SBE 阶段(sbe_hash_lookup_shared_test.h、hash_lookup.cpp),经典引擎执行$lookup时走的是不产生这类 spilling 统计的路径,因此即便参数被压低,经典变体的期望输出仍是空对象。对比sbeFull等变体的期望文件,此处应能看到hashLookup*字段——这是同一脚本在不同引擎下产生不同期望输出的典型例子。
用例 11:$graphLookup 溢出(含 spill storage 统计)
internalDocumentSourceGraphLookupMaxMemoryBytes设为 1,插入 7 条{_id, to}图数据:
[ { "$limit" : 1 }, { "$graphLookup" : { "from" : "coll", "startWith" : 1, "connectFromField" : "to", "connectToField" : "_id", "as" : "path", "depthField" : "depth" } } ]期望:
{ "graphLookupSpilledBytes" : "X", "graphLookupSpilledDataStorageSize" : "X", "graphLookupSpilledRecords" : 2, "graphLookupSpills" : 2, "usedDisk" : true }并额外多出一节 spill 存储层统计:
### Slow query spill storage stats { "timeWaitingMicros" : "X" }timeWaitingMicros记录执行期等待 spill 存储 I/O 的时间(微秒),因为依赖实际 I/O 时序所以取值为"X"。从 spillable_deque.cpp 等实现可见,spill 存储层(spillStorage)自身维护独立于阶段统计的等待时间指标。
用例 12:$graphLookup + $unwind + $sort
在图查询后追加解包与排序:
[ { "$limit" : 1 }, { "$graphLookup" : { "from" : "coll", "startWith" : 1, "connectFromField" : "to", "connectToField" : "_id", "as" : "path", "depthField" : "depth" } }, { "$unwind" : "$path" }, { "$sort" : { "path.depth" : 1 } } ]期望的 spilling 统计与用例 11 相同(graphLookup*:2 records / 2 spills),而$unwind后的排序因数据量极小未触发溢出,故没有sort*字段。这说明日志中只出现真正溢出的阶段字段——usedDisk与各阶段字段的存在本身就是"该阶段发生溢出"的判据。
用例 13:$lookup + $unwind(HashLookupUnwind)
internalQuerySlotBasedExecutionHashJoinApproxMemoryUseInBytesBeforeSpill设为 1,animals/locations 集合执行 lookup-unwind-project:
[ { "$lookup" : { "from" : "logs_spilling_md_locations", "localField" : "locationName", "foreignField" : "name", "as" : "location" } }, { "$unwind" : "$location" }, { "$project" : { "locationName" : false, "location.extra" : false, "location.coordinates" : false, "colors" : false } } ]与用例 10 同理,经典引擎下期望为空{ }(hashJoin*统计同样属于 SBE 路径,见 hash_join.cpp 与 plan_summary_stats_visitor.h 中visit(sbe::HashJoinStats)的累加逻辑)。
用例 14:$setWindowFields(两个变体)
测试先通过explain判断窗口阶段是否下推到 SBE(WINDOWstage),若是则把internalDocumentSourceSetWindowFieldsMaxMemoryBytes设为 1,否则设为 500(注释说明:经典引擎的DocumentSourceSetWindowFields在溢出后仍装不进内存上限时会失败,因此不能设 1 字节):
[ { "$setWindowFields" : { "partitionBy" : "$a", "sortBy" : { "b" : 1 }, "output" : { "sum" : { "$sum" : "$b" } } } } ]期望(第一个变体,无$limit):
{ "setWindowFieldsSpilledBytes" : "X", "setWindowFieldsSpilledDataStorageSize" : "X", "setWindowFieldsSpilledRecords" : 3, "setWindowFieldsSpills" : 2, "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 13, "sortSpills" : 7, "usedDisk" : true }外加 spill storage 统计{ "timeWaitingMicros" : "X" }。注意窗口阶段内部排序的溢出同样计入sort*字段(setWindowFields*与sort*并存)。
第二个变体在管道尾部追加{ "$limit" : 1 },期望变为:
{ "setWindowFieldsSpilledBytes" : "X", "setWindowFieldsSpilledDataStorageSize" : "X", "setWindowFieldsSpilledRecords" : 2, "setWindowFieldsSpills" : 1, "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 13, "sortSpills" : 7, "usedDisk" : true }$limit使窗口阶段提前结束,溢出记录数从 3 降到 2、溢出次数从 2 降到 1——下游 limit 会真实减少上游的溢出量,这是该用例的验证点。
四、测试脚本如何产生并比对这些日志
logs_spilling_md.js 的核心机制值得细读,它是"如何主动观测 spilling 统计"的可复制模板:
- 打开全量慢日志:
db.setProfilingLevel(1, {slowms: -1})——profiling 级别 1、阈值 -1 毫秒,任何查询都会记录"Slow query"日志;finally块中恢复原 profiling 配置,并逐一恢复所有改过的setParameter值(parametersToRestore列表保证不留脏状态)。 - 强制溢出:每个用例前用
saveParameterToRestore(knob)记录原值,再把对应内存上限setParameter到 1 字节(或 500 字节)。涉及的参数全集为:internalQueryMaxBlockingSortMemoryUsageBytes($sort)internalDocumentSourceGroupMaxMemoryBytes、internalQuerySlotBasedExecutionHashAggApproxMemoryUseInBytesBeforeSpill($group)internalTextOrStageMaxMemoryBytes(TextOr,受ExtendedAutoSpilling特性门控)internalDocumentSourceBucketAutoMaxMemoryBytes($bucketAuto)internalQuerySlotBasedExecutionHashLookupApproxMemoryUseInBytesBeforeSpill(SBE HashLookup)internalDocumentSourceGraphLookupMaxMemoryBytes($graphLookup)internalQuerySlotBasedExecutionHashJoinApproxMemoryUseInBytesBeforeSpill(SBE HashJoin)internalDocumentSourceSetWindowFieldsMaxMemoryBytes($setWindowFields)
- 定位日志行:每次执行
coll.aggregate(pipeline, {comment: comment})时携带唯一comment,随后db.adminCommand({getLog: "global"})拉取全局日志,用findMatchingLogLine()按msg: "Slow query"+comment+command: "aggregate"三重过滤定位目标行——comment过滤确保不会误抓子操作(如索引构建)产生的日志。 - 抽取并脱敏:
getSpillingAttrs()遍历日志行的attr字段:键为usedDisk、以Spills/SpilledRecords结尾的保留原值;以SpilledBytes/SpilledDataStorageSize结尾的替换为"X";spillStorage子文档去掉data后其余键也替换为"X"(其中留有 TODO:待spillStorage.data的bytesRead字段确定性出现后停止过滤data键)。 - 格式化输出:通过
pretty_md.js的section()/subSection()/code()输出## N. 标题+### Pipeline+### Slow query spilling stats的 Markdown 结构,交给黄金测试框架与期望文件逐字节比对。
五、源码级支撑:SpillingStats 四元组与 usedDisk 汇聚
期望文件里反复出现的四元组字段,其定义在 spilling_stats.h 的SpillingStats类中,四个私有成员语义明确:
| 字段 | 源码注释语义 | 对应日志字段 |
|---|---|---|
_spills | "The number of times the tracked entity spilled."(溢出发生次数) | *Spills |
_spilledBytes | 随溢出释放的内存字节数 | *SpilledBytes("X") |
_spilledDataStorageSize | 溢出占用的磁盘空间字节数 | *SpilledDataStorageSize("X") |
_spilledRecords | 写入 record store 的记录数 | *SpilledRecords |
两个实现细节解释了期望文件里的数值规律:
updateSpilledDataStorageSize()用std::max取历史最大值并返回增量(spilling_stats.h),说明该指标是"高水位"而非累加值,字节数天然依赖运行细节,故测试一律以"X"脱敏。accumulate()支持把多个执行节点的统计合并,这正是用例 4(多 $sort)、用例 6/8/14(多阶段)中同前缀字段数值相加的来源。
统计如何进入慢查询日志?plan_summary_stats_visitor.h 以树遍历方式访问执行计划各阶段的统计节点,对 SORT、GROUP、TEXT_OR、GEO_NEAR、SET_WINDOW_FIELDS、HASH_LOOKUP、HASH_JOIN、GRAPH_LOOKUP 等阶段统一执行同一模式:
if (stats->spillingStats.getSpills() > 0) { _summary.usedDisk = true; _summary.spillingStatsPerStage[PlanSummaryStats::SpillingStage::XXX].accumulate(...); }由此得出两条可验证结论:
usedDisk: true当且仅当至少一个阶段spills > 0——这就是用例 1/2/10/13 期望对象里连usedDisk都没有的原因;- 各阶段统计以阶段为键分桶累加,最终序列化为
sort*、group*、textOr*、bucketAuto*、graphLookup*、setWindowFields*等前缀字段进入慢查询日志的attr部分。
六、实战要点小结
- 在运维侧:看到慢查询日志中出现
usedDisk与sortSpilledRecords等字段,即代表聚合管道发生了内存到磁盘的溢出,可按阶段前缀定位是哪个阶段;spillStorage.timeWaitingMicros则量化了等待 spill I/O 的开销。 - 在测试侧:复现/验证某一阶段的溢出行为,只需把对应
internal*MaxMemoryBytes/*ApproxMemoryUseInBytesBeforeSpill参数压到极小,再配合setProfilingLevel(1, {slowms: -1})与comment标记抓取日志;期望文件中的确定性计数字段(*Spills、*SpilledRecords、usedDisk)可用于断言,字节类字段则应视为非确定性。 - 在引擎差异侧:
sbeDisabled变体中 $lookup/$lookup-unwind 用例期望为空,而 SBE 变体下同一段管道会汇报hashLookup*/hashJoin*统计——修改溢出逻辑或日志字段时,必须同步检查 expected_output/ 下所有引擎变体的期望文件并用buildscripts/golden_test.py accept更新,否则黄金测试将失败。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考