MongoDB 聚合溢出(Spilling)诊断指南:慢查询日志中的 Spilling 统计与 Golden Test 验证
2026/9/13 13:18:14 网站建设 项目流程

MongoDB 聚合溢出(Spilling)诊断指南:慢查询日志中的 Spilling 统计与 Golden Test 验证

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

导读

本文以 MongoDB 仓库中jstests/query_golden/expected_output/internalEnableJoinOptimization/logs_spilling.md这份 Golden Test 期望输出为骨架,系统讲解聚合管道在执行$sort$group$lookup$graphLookup$setWindowFields等内存敏感阶段时,数据"溢出(spilling)"到磁盘后如何以统计字段的形式出现在慢查询日志(Slow query log)中。读完本文,你将掌握 spilling 统计字段的命名规律与含义、通过内部 Server 参数强制触发溢出的调试方法、Golden Test 的验证机制,以及如何从日志中定位聚合内存压力。

一、背景:什么是聚合阶段的 Spilling

MongoDB 聚合框架中有多个阶段需要将中间结果保存在内存中:排序($sort)、分组($group)、桶自动分箱($bucketAuto)、哈希连接($lookup的哈希实现)、图查询($graphLookup)、窗口函数($setWindowFields)、文本查询的 OR 合并($text匹配)等。当这些阶段的中间数据量超过内存阈值时,执行引擎会把部分数据临时写入磁盘,处理完成后再读回,这一过程称为spilling(溢出/落盘)

spilling 是 MongoDB 应对"内存放不下"时的兜底手段:它保证查询不会因为内存不足而失败,但代价是引入额外的磁盘 I/O,显著拉长查询耗时。因此,spilling 是否发生、溢出多少次、溢出了多少记录和字节,是诊断聚合查询性能问题的关键指标——这也是 MongoDB 将这类统计写入慢查询日志的原因。

jstests/query_golden/logs_spilling_md.js的注释中明确指出本测试的意图:"Tests the spilling statistics are a part of slow query logs"(验证 spilling 统计是慢查询日志的组成部分)。

二、Golden Test 验证机制:期望输出如何产生

logs_spilling.md并非手写的文档,而是由测试运行时自动生成的期望输出(expected output)。其生成与校验流程记录在驱动测试 jstests/query_golden/logs_spilling_md.js 中,核心步骤如下:

  1. 开启 profile 与全局日志:测试调用db.setProfilingLevel(1, {slowms: -1}),将 profiling 阈值设为-1(即每条命令都算慢查询),从而保证每条 aggregate 命令都会产生 "Slow query" 日志。
  2. 执行聚合并打上注释outputPipelineAndSlowQueryLog()对目标集合执行coll.aggregate(pipeline, {comment: comment}),通过comment字段为每次聚合打上唯一标识(如"one sort""$lookup")。
  3. 从全局日志精确抓取日志行:调用db.adminCommand({getLog: "global"})读取内存日志,再用findMatchingLogLine()(来自 jstests/libs/log.js)按msg: "Slow query"commentcommand: "aggregate"三个条件过滤,避免误抓子操作产生的日志。
  4. 提取 spilling 统计并归一化getSpillingAttrs()遍历日志行attr对象,按命名规律分类——usedDisk、以Spills/SpilledRecords结尾的字段原样保留(数量稳定,可作为黄金值);以SpilledBytes/SpilledDataStorageSize结尾的字段替换为占位符"X"(字节数随运行环境波动,不适合做精确断言);spillStorage子对象中的键(除data外,参见代码中TODO SERVER-109672)同样归一化为"X"
  5. 输出 Markdown:借助 jstests/libs/query/pretty_md.js 的section/subSection/code/linebreak工具,将PipelineSlow query spilling stats(必要时还有Slow query spill storage stats)渲染成标准 Markdown,与期望文件比对。

该测试带有requires_persistencerequires_fcv_81requires_profiling标签,意味着它要求持久化存储、FCV ≥ 8.1,且当前环境支持 profiling。测试结束时(finally块)会逐一恢复被改动的 Server 参数与 profiling 级别,保证不影响其他用例。

三、Spilling 统计字段命名规律与源码映射

logs_spilling.md中 14 组输出可以看出明确的命名约定:统计字段以触发溢出的阶段名为前缀,后接指标名:

字段模式含义示例
<stage>Spills溢出到磁盘的次数(稳定的黄金值)"sortSpills" : 5
<stage>SpilledRecords溢出涉及的记录总数(稳定的黄金值)"sortSpilledRecords" : 8
<stage>SpilledBytes溢出写入的字节数(测试中归一化为"X""sortSpilledBytes" : "X"
<stage>SpilledDataStorageSize溢出数据在磁盘存储中占用的空间(归一化为"X""sortSpilledDataStorageSize" : "X"
usedDisk本次聚合是否使用了磁盘,布尔值"usedDisk" : true
spillStorage溢出存储子对象的统计(如等待磁盘 I/O 的微秒数){ "timeWaitingMicros" : "X" }

文档中出现的阶段前缀包括:sortgrouptextOrbucketAutohashLookupgraphLookupsetWindowFields

这些字段在源码中有直接的序列化实现。以排序为例,src/mongo/db/pipeline/document_source_sort.cpp 中在序列化统计时:

  • mutDoc["usedDisk"] = stats.spilledRanges() > 0(Classic 执行路径,按溢出区间数判断是否落盘);
  • mutDoc["spills"] = stats.spilledRanges()mutDoc["spilledBytes"] = _sortExecutor->spilledBytes()mutDoc["spilledRecords"] = ...(另一路径从spillingStats与排序执行器取数)。

同样,spilledDataStorageSize在 src/mongo/db/exec/agg/bucket_auto_stage.cpp、src/mongo/db/exec/agg/graph_lookup_stage.cpp 等处均通过spillingStats.getSpilledDataStorageSize()输出。而底层落盘容器实现在 src/mongo/db/pipeline/spilling/spillable_deque.cpp 与 src/mongo/db/pipeline/spilling/spillable_map.cpp,它们负责把内存中的双端队列/哈希表按需换页到磁盘。

四、如何强制触发 Spilling:内存阈值 Server 参数全表

驱动测试通过把各阶段的内存上限压到极小值(通常为 1 字节)来强制溢出。下表汇总了测试用到的全部 Server 参数及其作用阶段(均通过setParameter管理):

Server 参数影响的阶段/场景测试中的触发值
internalQueryMaxBlockingSortMemoryUsageBytes阻塞排序$sort(含多排序、时间序列排序)1000(不溢出)/ 1(强制溢出)
internalDocumentSourceGroupMaxMemoryBytesClassic 执行引擎的$group内存上限1
internalQuerySlotBasedExecutionHashAggApproxMemoryUseInBytesBeforeSpillSBE 哈希聚合($group)溢出阈值1
internalTextOrStageMaxMemoryBytes$text查询的 OR 合并阶段(需ExtendedAutoSpilling特性标志启用)1
internalDocumentSourceBucketAutoMaxMemoryBytes$bucketAuto1
internalQuerySlotBasedExecutionHashLookupApproxMemoryUseInBytesBeforeSpillSBE 哈希$lookup1
internalDocumentSourceGraphLookupMaxMemoryBytes$graphLookup1
internalQuerySlotBasedExecutionHashJoinApproxMemoryUseInBytesBeforeSpillSBE 哈希连接($lookup+$unwind1
internalDocumentSourceSetWindowFieldsMaxMemoryBytes$setWindowFields1(SBE)/ 500(Classic,见下文)

注意两点细节:

  • TextOr 场景有条件:测试代码先通过FeatureFlagUtil.isPresentAndEnabled(db, "ExtendedAutoSpilling")判断特性标志是否启用,只有启用时才执行第 7、8 节并保存/恢复internalTextOrStageMaxMemoryBytes
  • SetWindowFields 的阈值需探测:测试先用coll.explain().aggregate(pipeline)检查执行计划中是否存在WINDOW阶段(getPlanStage(getWinningPlanFromExplain(explain), "WINDOW"))来判断$setWindowFields是否下推到了 SBE。若下推到 SBE 则阈值可设为 1 字节;否则保持 Classic 路径,此时若 1 字节无法容纳,DocumentSourceSetWindowFields在溢出后仍不满足内存限制会直接报错,因此 Classic 下取 500。

生产环境中若要复现或验证 spilling,可在测试实例上执行例如:

db.adminCommand({setParameter: 1, internalQueryMaxBlockingSortMemoryUsageBytes: 1})

然后运行聚合并观察慢查询日志。注意这些均为内部参数,修改后应立即恢复,以免影响其他操作。

五、14 个测试场景逐一解读(继承期望输出全文)

以下逐节重现logs_spilling.md的全部内容,并结合驱动测试说明每个场景的数据构造与观察点。

场景 1:Sort with large memory limit(大内存上限排序)

内存上限设为 1000 字节,集合仅 3 条{a: 1..3}文档,排序完全在内存中完成。

Pipeline

[ { "$sort" : { "a" : 1 } } ]

Slow query spilling stats

{ }

统计为空对象,说明没有发生任何溢出。这是"健康"基线:usedDisk不出现、各*Spills字段缺失。

场景 2:Sort with empty collection(空集合排序)

对空集合执行同样的排序,同样没有溢出。

Pipeline

[ { "$sort" : { "a" : 1 } } ]

Slow query spilling stats

{ }

场景 3:Sort with spilling(强制溢出的排序)

internalQueryMaxBlockingSortMemoryUsageBytes压到 1 字节,同样的 3 条文档触发 5 次溢出、涉及 8 条记录(排序中间结果被反复换页)。

Pipeline

[ { "$sort" : { "a" : 1 } } ]

Slow query spilling stats

{ "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 8, "sortSpills" : 5, "usedDisk" : true }

usedDisk: truesortSpills: 5是判断"排序落盘"最直接的证据;字节数与存储大小因平台波动,黄金测试中以"X"占位。

场景 4:Multiple sorts(管道内多个排序)

数据为 3 条{a, b}组合文档,管道先按a升序、$limit 3、再按b升序,两个排序都溢出。

Pipeline

[ { "$sort" : { "a" : 1 } }, { "$limit" : 3 }, { "$sort" : { "b" : 1 } } ]

Slow query spilling stats

{ "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 16, "sortSpills" : 10, "usedDisk" : true }

可以看到统计中只有一个sort*前缀,说明同一阶段类型(这里两个$sort共用sort前缀)的多次溢出被汇总计数。

场景 5:Timeseries sort(时间序列集合排序)

通过createCollection(..., {timeseries: {timeField: "time", metaField: "meta"}})创建时间序列集合,并按bucketMaxSpanSeconds / 10的间隔插入 50 条{time, meta: 1}文档;按time排序时对底层 bucket 进行排序并全部溢出。

Pipeline

[ { "$sort" : { "time" : 1 } } ]

Slow query spilling stats

{ "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 50, "sortSpills" : 50, "usedDisk" : true }

50 条记录对应 50 次溢出,说明在 1 字节上限下每条记录都被单独换页,是最极端的溢出形态。

场景 6:Group(分组 + 排序)

数据为 4 条{a: 1|2, b: 1|2}文档,$groupa分组求和,再按b排序。同时压低internalDocumentSourceGroupMaxMemoryBytesinternalQuerySlotBasedExecutionHashAggApproxMemoryUseInBytesBeforeSpill,使 Group 与 Sort 双双溢出。

Pipeline

[ { "$group" : { "_id" : "$a", "b" : { "$sum" : "$b" } } }, { "$sort" : { "b" : 1 } } ]

Slow query spilling stats

{ "groupSpilledBytes" : "X", "groupSpilledDataStorageSize" : "X", "groupSpilledRecords" : 4, "groupSpills" : 4, "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 4, "sortSpills" : 3, "usedDisk" : true }

Slow query spill storage stats

{ "timeWaitingMicros" : "X" }

本场景首次出现Slow query spill storage stats小节:spillStorage子对象中的timeWaitingMicros表示等待溢出存储(磁盘 I/O)的微秒时间,归一化为"X"。同时group*sort*两组字段并存,证明一个管道内多个不同阶段可以各自记录溢出。

场景 7:TextOr and projection(文本查询 + 投影)

数据为green tea/black tea/black coffee三条文档并在a字段建文本索引,$text搜索"black tea"后用$addFields注入textScore元数据。仅当ExtendedAutoSpilling特性标志启用时才执行(内存上限 1 字节)。

Pipeline

[ { "$match" : { "$text" : { "$search" : "black tea" } } }, { "$addFields" : { "score" : { "$meta" : "textScore" } } } ]

Slow query spilling stats

{ "textOrSpilledBytes" : "X", "textOrSpilledDataStorageSize" : "X", "textOrSpilledRecords" : 4, "textOrSpills" : 4, "usedDisk" : true }

textOr前缀对应文本查询的 OR 合并阶段(TextOr 算子),说明$text内部的多 term 合并也会溢出。

场景 8:TextOr and sort(文本查询 + 按评分排序)

同样的文本搜索,随后按textScore元数据排序($sort的键使用{$meta: "textScore"})。Sort 与 TextOr 同时溢出。

Pipeline

[ { "$match" : { "$text" : { "$search" : "black tea" } } }, { "$sort" : { "_" : { "$meta" : "textScore" } } } ]

Slow query spilling stats

{ "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 8, "sortSpills" : 5, "textOrSpilledBytes" : "X", "textOrSpilledDataStorageSize" : "X", "textOrSpilledRecords" : 4, "textOrSpills" : 4, "usedDisk" : true }

场景 9:BucketAuto(自动分箱)

$bucketAutoa分成 2 个桶并输出sum(对b求和),内存上限 1 字节触发分箱过程溢出。

Pipeline

[ { "$bucketAuto" : { "groupBy" : "$a", "buckets" : 2, "output" : { "sum" : { "$sum" : "$b" } } } } ]

Slow query spilling stats

{ "bucketAutoSpilledBytes" : "X", "bucketAutoSpilledDataStorageSize" : "X", "bucketAutoSpilledRecords" : 13, "bucketAutoSpills" : 7, "usedDisk" : true }

场景 10:HashLookup(哈希$lookup

$lookupname为连接键关联logs_spilling_md_students集合(8 条学生文档),通过internalQuerySlotBasedExecutionHashLookupApproxMemoryUseInBytesBeforeSpill: 1强制哈希构建表溢出。

Pipeline

[ { "$lookup" : { "from" : "logs_spilling_md_students", "localField" : "name", "foreignField" : "name", "as" : "matched" } } ]

Slow query spilling stats

{ "hashLookupSpilledBytes" : "X", "hashLookupSpilledDataStorageSize" : "X", "hashLookupSpilledRecords" : 14, "hashLookupSpills" : 16, "usedDisk" : true }

Slow query spill storage stats

{ "timeWaitingMicros" : "X" }

场景 11:Graph lookup(图查询)

$graphLookup_id: 1为起点、沿to -> _id遍历 7 条节点文档,depthField: "depth"记录深度,内存上限 1 字节。

Pipeline

[ { "$limit" : 1 }, { "$graphLookup" : { "from" : "coll", "startWith" : 1, "connectFromField" : "to", "connectToField" : "_id", "as" : "path", "depthField" : "depth" } } ]

Slow query spilling stats

{ "graphLookupSpilledBytes" : "X", "graphLookupSpilledDataStorageSize" : "X", "graphLookupSpilledRecords" : 2, "graphLookupSpills" : 2, "usedDisk" : true }

Slow query spill storage stats

{ "timeWaitingMicros" : "X" }

场景 12:Graph lookup with unwind and sort(图查询 + 展开 + 排序)

在场景 11 的$graphLookup之后追加$unwind: "$path"与按path.depth升序的$sort。本场景下graphLookup*溢出统计与场景 11 一致(2/2),但排序部分没有出现在统计中。

Pipeline

[ { "$limit" : 1 }, { "$graphLookup" : { "from" : "coll", "startWith" : 1, "connectFromField" : "to", "connectToField" : "_id", "as" : "path", "depthField" : "depth" } }, { "$unwind" : "$path" }, { "$sort" : { "path.depth" : 1 } } ]

Slow query spilling stats

{ "graphLookupSpilledBytes" : "X", "graphLookupSpilledDataStorageSize" : "X", "graphLookupSpilledRecords" : 2, "graphLookupSpills" : 2, "usedDisk" : true }

Slow query spill storage stats

{ "timeWaitingMicros" : "X" }

场景 13:HashLookupUnwind(哈希连接 + 展开)

$lookuplocationName连接logs_spilling_md_locations集合(3 条位置文档),随后$unwind$project裁剪字段。测试还刻意在两侧各建一个包含dummy前缀的复合索引({dummy: -1, locationName: -1}{dummy: 1, name: -1}),为连接优化提供 multikeyness 信息。该场景通过internalQuerySlotBasedExecutionHashJoinApproxMemoryUseInBytesBeforeSpill: 1触发哈希连接溢出。

Pipeline

[ { "$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 } } ]

Slow query spilling stats

{ "hashLookupSpilledBytes" : "X", "hashLookupSpilledDataStorageSize" : "X", "hashLookupSpilledRecords" : 6, "hashLookupSpills" : 6, "usedDisk" : true }

Slow query spill storage stats

{ "timeWaitingMicros" : "X" }

场景 14:SetWindowFields(窗口函数)

$setWindowFieldsa分区、按b排序,对分区内b累加求和。其内存阈值在 Classic(500)与 SBE(1)路径下不同(通过 explain 探测WINDOW阶段决定)。本场景实际包含两个管道变体:纯$setWindowFields,以及其后追加$limit: 1

Pipeline

[ { "$setWindowFields" : { "partitionBy" : "$a", "sortBy" : { "b" : 1 }, "output" : { "sum" : { "$sum" : "$b" } } } } ]

Slow query spilling stats

{ "setWindowFieldsSpilledBytes" : "X", "setWindowFieldsSpilledDataStorageSize" : "X", "setWindowFieldsSpilledRecords" : 3, "setWindowFieldsSpills" : 2, "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 13, "sortSpills" : 7, "usedDisk" : true }

Slow query spill storage stats

{ "timeWaitingMicros" : "X" }

Pipeline

[ { "$setWindowFields" : { "partitionBy" : "$a", "sortBy" : { "b" : 1 }, "output" : { "sum" : { "$sum" : "$b" } } } }, { "$limit" : 1 } ]

Slow query spilling stats

{ "setWindowFieldsSpilledBytes" : "X", "setWindowFieldsSpilledDataStorageSize" : "X", "setWindowFieldsSpilledRecords" : 2, "setWindowFieldsSpills" : 1, "sortSpilledBytes" : "X", "sortSpilledDataStorageSize" : "X", "sortSpilledRecords" : 13, "sortSpills" : 7, "usedDisk" : true }

Slow query spill storage stats

{ "timeWaitingMicros" : "X" }

注意两个变体都同时携带setWindowFields*sort*两组统计:窗口函数内部隐含的排序(sortBy)单独计入sort前缀,而窗口本身的溢出计入setWindowFields前缀;追加$limit后窗口溢出次数从 3/2 降为 2/1,说明提前终止优化减少了窗口阶段的数据量。

六、执行引擎变体:同一测试在不同特性标志下的差异

logs_spilling.md并不仅存在于internalEnableJoinOptimization目录。仓库中同一份期望输出还出现在 jstests/query_golden/expected_output/featureFlagSbeFull、jstests/query_golden/expected_output/sbeDisabled、jstests/query_golden/expected_output/sbeFull、jstests/query_golden/expected_output/sbeRestricted 各目录下。同一测试在开启/关闭不同特性标志(SBE 全量、SBE 受限、SBE 禁用、内部连接优化等)时运行,得到各自的期望输出,用于回归捕获执行引擎行为差异。

这一点在场景 14 的数值上体现得尤其直观:internalEnableJoinOptimization变体中纯$setWindowFields的溢出为3 条 / 2 次,而sbeFull变体中对应数值为4 条 / 4 次。可见相同管道在不同执行路径下溢出行为不同,而 Golden Test 正是用来"锁住"这些行为,防止执行引擎改动时统计悄然漂移。如果你在某个特性标志组合下发现 spilling 统计与期望不符,这通常意味着执行计划或溢出策略发生了变化。

七、实践:如何用 Spilling 统计诊断线上聚合

结合文档与源码,可以沉淀出以下可落地的排查方法:

  1. 开启慢查询日志中的 spilling 信息:慢查询日志默认即包含 spilling 统计,无需额外开关;可通过setProfilingLevel(1, {slowms: 阈值})调整慢查询门槛,或直接用db.adminCommand({getLog: "global"})检索"msg": "Slow query""command": "aggregate"的日志行。
  2. 按字段定位瓶颈阶段:看日志中的*Spills*SpilledRecords前缀即可知道是哪类阶段在落盘——sortSpills对应$sort/$setWindowFields.sortBygroupSpills对应$grouphashLookupSpills对应$lookupgraphLookupSpills对应$graphLookuptextOrSpills对应$text匹配,bucketAutoSpills对应$bucketAutousedDisk: true是总开关。
  3. 评估溢出代价timeWaitingMicrosspillStorage子对象)给出了等待溢出存储 I/O 的时间,字节数(*SpilledBytes*SpilledDataStorageSize)体现落盘数据规模。若溢出频繁且等待时间长,说明中间结果远超内存预算。
  4. 对症优化:为$sort/$group的字段建索引以利用索引有序流式处理;为$lookupforeignField建索引以走非阻塞执行路径;为$text查询控制$search词项数量;必要时调整对应内部内存参数(如internalQueryMaxBlockingSortMemoryUsageBytes)放宽内存预算——但注意这些是内部参数,需评估实例内存余量后再谨慎调整。
  5. 结合 explain 交叉验证:测试中通过coll.explain().aggregate(...)检查WINDOW等执行阶段来判断下推路径;生产排查同样可以用explain确认计划形态,再与慢查询日志中的溢出统计互相印证。

八、总结

jstests/query_golden/expected_output/internalEnableJoinOptimization/logs_spilling.md表面上只是一份"管道 + 统计 JSON"的期望输出,实质上是一份完整的聚合溢出行为规格:它定义了 MongoDB 聚合框架中各内存敏感阶段的溢出统计字段(*Spills*SpilledRecords*SpilledBytes*SpilledDataStorageSizeusedDiskspillStorage.timeWaitingMicros),记录了 14 种典型管道在极端内存限制下的溢出形态,并通过 Golden Test 机制(jstests/query_golden/logs_spilling_md.js)与源码实现(document_source_sort.cpp、bucket_auto_stage.cpp、graph_lookup_stage.cpp、spillable_deque.cpp 等)相互印证。掌握了这套字段命名规律与触发参数,你就拥有了在慢查询日志中快速识别聚合内存瓶颈的实战能力。

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询