MongoDB 聚合管道 DISTINCT_SCAN 多计划竞争(Multiplanning)原理与 Golden 测试深度解读
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
导读
本文以 MongoDB 开源仓库中的 golden 测试文档 featureFlagSbeFull/distinct_aggregation_multiplanning.md 为主体,系统讲解聚合管道在可被重写为 distinct 场景时,查询规划器如何通过**多计划竞争(multiplanning)**在多个 DISTINCT_SCAN 候选、以及 DISTINCT_SCAN 与普通 IXSCAN/COLLSCAN 候选之间挑选最优执行计划。读完本文,你将掌握 DISTINCT_SCAN 的适用前提、$sort/$group/$top/$bottom与索引方向的关系、hint对计划选择的强制作用、根级$or的边界约束、multikey 索引对去重扫描的限制,以及如何读懂 classic 与 sbe 两种执行引擎的 explain 输出。
一、背景:distinct 重写与 DISTINCT_SCAN
在 MongoDB 中,$group聚合如果满足特定形态(如_id直接引用某个字段),其语义等价于对某个字段求"去重后的值"。此时查询规划器可以把管道重写为 distinct 场景,进而考虑使用DISTINCT_SCAN:一种沿索引顺序扫描、通过跳过重复键来直接产出去重结果的访问路径,无需构建哈希表或进行内存排序。
从 测试源文件 的注释可以看到,该测试的目标是:
Tests that the aggregation will go through the process of multiplanning when the pipeline can be rewritten for the distinct case.
即:验证"管道可被重写为 distinct 场景时,聚合会进入多计划竞争流程"。测试带有featureFlagShardFilteringDistinctScan与requires_fcv_82两个标签,说明该行为与分片过滤去重扫描特性标志及 FCV 8.2 相关。
DISTINCT_SCAN 的底层执行实现在 classic 引擎中位于 src/mongo/db/exec/classic/distinct_scan.cpp,其规划层的 distinct 访问路径构造见 src/mongo/db/query/distinct_access.h 与 canonical_distinct.h。
1.1 Golden 测试的产出机制
这份 md 文件并非手写文档,而是 golden 测试框架自动生成的期望输出。核心调用链如下:
- 测试脚本调用
outputAggregationPlanAndResults()(定义于 jstests/libs/query/golden_test_utils.js),它对每个管道同时执行coll.aggregate(pipeline, options)与coll.explain().aggregate(pipeline, options),再借助formatExplainRoot(来自 jstests/libs/query/analyze_plan.js)整理出稳定的 explain 结构; section()/subSection()(定义于 jstests/libs/query/pretty_md.js)负责按## 编号. 标题与### 小节的格式输出 Markdown;outputAvailableIndexes()会打印集合上全部索引名称,用于对照规划器实际考虑的候选。
同一测试在不同特性组合下还有多份期望输出,分别位于expected_output/下的sbeFull、sbeRestricted、sbeDisabled、internalEnableJoinOptimization等目录,本文解读的是featureFlagSbeFull变体。
1.2 测试数据集与索引
测试在集合test.distinct_aggregation_multiplanning_md(shell 变量jsTestName())上先创建如下 11 个索引:
[ "_id_", "a_1", "b_1", "a_1_b_1", "a_-1_b_1", "a_1_b_-1", "a_1_b_1_c_1", "a_1_b_1_d_1", "b_1_a_1", "b_1_c_1", "d_1_c_-1" ]第一批数据(3 条文档,用于大部分场景):
{ "_id" : 1, "a" : 4, "b" : 2, "c" : 3, "d" : 4 } { "_id" : 2, "a" : 4, "b" : 3, "c" : 6, "d" : 5 } { "_id" : 3, "a" : 5, "b" : 4, "c" : 7, "d" : 5 }二、场景一:仅 DISTINCT_SCAN 候选被考虑
当管道形态($sort+$group,或直接$group)与$first/$last/$top/$bottom累加器组合能够完全由一次去重扫描满足时,规划器产生的候选计划全部是 DISTINCT_SCAN,竞争只发生在"选哪个索引、什么方向扫描"之间。
2.1$sort {a:1,b:1}+$group _id:$a, accum:$first($b)
管道:
[ { "$sort" : { "a" : 1, "b" : 1 } }, { "$group" : { "_id" : "$a", "accum" : { "$first" : "$b" } } } ]结果:
{ "_id" : 4, "accum" : 2 } { "_id" : 5, "accum" : 4 }explain(Execution Engine: classic)核心:rejectedPlans中被淘汰的是a_1_b_1_c_1与a_1_b_1_d_1两个 DISTINCT_SCAN(均带PROJECTION_COVERED投影_id:0,a:1,b:1,indexBounds为全范围[MinKey, MaxKey]),最终winningPlan选择索引a_1_b_1:
"winningPlan" : [ { "stage" : "PROJECTION_COVERED", "transformBy" : { "_id" : 0, "a" : 1, "b" : 1 } }, { "direction" : "forward", "indexBounds" : { "a" : [ "[MinKey, MaxKey]" ], "b" : [ "[MinKey, MaxKey]" ] }, "indexName" : "a_1_b_1", "isFetching" : false, "isMultiKey" : false, "keyPattern" : { "a" : 1, "b" : 1 }, "stage" : "DISTINCT_SCAN" } ]管道末尾接$groupByDistinctScan(newRoot为{_id: "$a", accum: "$b"}),说明 classic 引擎用"去重扫描 + 逐组取首值"的方式直接完成了分组,PROJECTION_COVERED保证a、b两列均从索引覆盖,无需回表(isFetching: false)。
关键点:DISTINCT_SCAN 的 indexBounds 总是全范围
[MinKey, MaxKey],它不靠谓词过滤,而是靠索引有序性跳过重复键。
2.2$sort {a:1,b:-1}+$first($b):方向反转
[ { "$sort" : { "a" : 1, "b" : -1 } }, { "$group" : { "_id" : "$a", "accum" : { "$first" : "$b" } } } ]结果:
{ "_id" : 4, "accum" : 3 } { "_id" : 5, "accum" : 4 }winningPlan使用a_1_b_-1(方向 forward,indexBounds中 b 为[MaxKey, MinKey]),rejectedPlans只有a_1_b_1(b 区间[MinKey, MaxKey])。这印证了测试源码中的注释:"Ensure the planner correctly reverses the DISTINCT_SCAN direction for $first and $top"——规划器会根据$sort的降序要求,把 DISTINCT_SCAN 放到能正向产出该顺序的索引键上,而不是在扫描后做一次反向排序。
2.3$sort {a:-1,b:-1}+$last($b):升序索引反向扫描
[ { "$sort" : { "a" : -1, "b" : -1 } }, { "$group" : { "_id" : "$a", "accum" : { "$last" : "$b" } } } ]结果与 2.1 一致({_id:4, accum:2}、{_id:5, accum:4})。有趣的是winningPlan选了a_-1_b_1(directionbackward),rejectedPlans含a_1_b_1_c_1(forward)与a_1_b_1_d_1(backward,isMultiKey: true,stage 为 IXSCAN)。$last语义要求取组内最后一个值,通过反向扫描直接让组内首个遇到的值即最大值方向的末值,从而仍用"每键取首"的方式实现$last。
2.4$group+$top(sortBy a:1,b:1, output c)
[ { "$group" : { "_id" : "$a", "accum" : { "$top" : { "sortBy" : { "a" : 1, "b" : 1 }, "output" : "$c" } } } } ]结果:
{ "_id" : 4, "accum" : 3 } { "_id" : 5, "accum" : 7 }winningPlan选择a_1_b_1_c_1(DISTINCT_SCAN,PROJECTION_COVERED投影_id:0,a:1,b:1,c:1,isFetching: false)。注意$top的输出字段是c,因此只有含c键的a_1_b_1_c_1能全覆盖投影;rejectedPlans中的a_1_b_1、a_1_b_1_d_1均因无法覆盖c而需要回表(isFetching: true)。这直接引出场景三的索引选择规则。
2.5$bottom(sortBy a:-1,b:-1)与$bottom(sortBy a:1,b:-1)
管道(sortBy a:-1,b:-1):
[ { "$group" : { "_id" : "$a", "accum" : { "$bottom" : { "sortBy" : { "a" : -1, "b" : -1 }, "output" : "$c" } } } } ]结果同样是{_id:4, accum:3}、{_id:5, accum:7},winningPlan仍为a_1_b_1_c_1(forward、全覆盖)。当sortBy变为{a:1, b:-1}时,winningPlan变为a_-1_b_1(backward,isFetching: true,需回表取c),说明降序组合下索引方向会随之翻转,且在无法覆盖c时接受回表。
2.6 按_id分组:唯一索引直通
[ { "$group" : { "_id" : "$_id", "accum" : { "$first" : "$b" } } } ]结果:
{ "_id" : 1, "accum" : 2 } { "_id" : 2, "accum" : 3 } { "_id" : 3, "accum" : 4 }rejectedPlans为空数组,winningPlan直接使用_id_索引(isUnique: true)做 DISTINCT_SCAN(isFetching: true回表取b)。_id天然唯一,去重扫描与唯一索引组合没有竞争余地。
2.7 其余方向组合
$sort {a:1,b:-1}+$last($b):winningPlan为a_-1_b_1(forward,b 区间[MinKey, MaxKey])。$sort {a:-1,b:1}+$last($b):winningPlan为a_-1_b_1(backward,a 区间[MaxKey, MinKey])。- 无
$sort的$group {_id:$a, accum:$first($b)}:rejectedPlans为空,直接选a_1_b_1(forward,全覆盖)。 $group {_id:$d, accum:$top(sortBy:{d:-1}, output:$c)}与$sort {d:-1}+$first($c):两者winningPlan均为d_1_c_-1(directionbackward,d 区间[MaxKey, MinKey])。$top的sortBy:{d:-1}与$sort {d:-1}表达同样的降序需求,规划器把该索引反向扫描,从而复用"首键即结果"的 distinct 语义。
2.8 用 hint 强制指定 DISTINCT_SCAN
当自动多计划竞争被hint覆盖时,规划器不再竞争,直接使用指定索引(即使它与自动选择不同,甚至更大):
hint: "a_1_b_1"与hint: "a_1_b_1_c_1"作用于同一管道($sort {a:1,b:1}+$first($b)),分别产出a_1_b_1与a_1_b_1_c_1两个 DISTINCT_SCAN,rejectedPlans均为空;两者queryShapeHash相同(384E008C...),说明查询形状不受 hint 影响;hint: "a_1_b_1"作用于$top/$bottom管道时,同样强制使用该索引(即使需要isFetching: true回表取c)。
实战结论:hint可以"锁定"某个 DISTINCT_SCAN,代价是可能放弃索引覆盖带来的回表开销。
三、场景二:DISTINCT_SCAN 与非 DISTINCT_SCAN 候选并存
在第二批数据中,测试插入了一条d为数组的文档{a: 4, b: 2, c: 3}、{a: 4, b: 3, c: 6}、{a: 5, b: 4, c: 7, d: [1, 2, 3]}。由于d是 multikey 字段,a_1_b_1_d_1变为 multikey 索引,DISTINCT_SCAN 与普通 IXSCAN 同时进入候选池。
3.1 平局时倾向 DISTINCT_SCAN
$sort {a:-1,b:-1}+$last($b)的winningPlan仍是a_1_b_1(DISTINCT_SCAN、forward、全覆盖),rejectedPlans包括:
a_1_b_1_c_1(DISTINCT_SCAN,forward);a_1_b_1_d_1(multikey,isMultiKey: true,stage 为 IXSCAN,方向 backward)。
而$sort {a:-1,b:-1}+$first($b)时,winningPlan为a_1_b_1(DISTINCT_SCAN、backward),rejectedPlans中a_1_b_1_c_1与a_1_b_1_d_1均为 DISTINCT_SCAN。说明只要去重扫描可行,规划器在竞争平局时会优先 DISTINCT_SCAN;multikey 索引则降级为 IXSCAN 参与竞争。
3.2 用 hint 强制非 DISTINCT_SCAN
hint: "a_1_b_1_d_1":执行引擎切换为sbe,winningPlan为GROUP+PROJECTION_COVERED+ IXSCAN(a_1_b_1_d_1,backward,isMultiKey: true,nss: "test.distinct_aggregation_multiplanning_md"),rejectedPlans为空。即 hint 让管道放弃 distinct 重写,退化为普通分组 + 索引扫描;hint: { "$natural" : 1 }:执行引擎仍为sbe,winningPlan为GROUP+SORT(memLimit: 104857600,sortPattern: {a:-1, b:-1},type: "simple")+PROJECTION_SIMPLE+COLLSCAN。天然顺序扫描无法保证排序,因此引入显式SORT阶段,内存上限 100 MB。
从源码结构看,这两个 case 也是文档中"Execution Engine: sbe"的代表:当管道无法(或被迫不)走 distinct 重写时,聚合在 SBE 引擎下以
GROUP/SORT/IXSCAN/COLLSCAN原样执行。
四、场景三:索引选择规则——覆盖投影优先,否则最小索引
该场景用三个对照实验总结 DISTINCT_SCAN 候选内部的择优逻辑:
| 管道 | 结果 | 选中的索引 | 原因 |
|---|---|---|---|
{$group: {_id: "$a"}}(无投影) | {_id:4}、{_id:5} | a_1 | 最小索引:只需a键去重,a_1键最少 |
{$group: {_id: "$a", accumB: $first(b), accumC: $first(c)}} | {_id:4, accumB:2, accumC:3}、{_id:5, accumB:4, accumC:7} | a_1_b_1_c_1 | 覆盖投影:a/b/c全部从索引读出(isFetching: false) |
同上有accumD: $first(d) | {_id:4, accumB:2, accumC:3, accumD:4}、{_id:5, accumB:4, accumC:7, accumD:5} | a_1(isFetching: true) | 任何索引都无法覆盖d,退回最小索引并回表 |
第三个 case 的 explain 明确显示winningPlan为a_1DISTINCT_SCAN + 回表,且$groupByDistinctScan的newRoot同时携带accumB/accumC/accumD四个字段。规则可总结为:优先找能全覆盖投影的最短索引;若不存在覆盖索引,则选键数最少的索引,用 FETCH 回表补齐剩余字段。
五、场景四:根级$or与 DISTINCT_SCAN 的边界
规则:根级$or只有在所有分支谓词都能合并进同一个索引扫描(mergeable bounds)时,才能使用 DISTINCT_SCAN。
5.1 可合并:单字段多区间
[ { "$match" : { "$or" : [ { "a" : { "$lte" : 5 } }, { "a" : { "$gt" : 8 } } ] } }, { "$group" : { "_id" : "$a" } } ]结果{_id:4}、{_id:5}。winningPlan为a_1DISTINCT_SCAN(forward,indexBounds 为两个区间[-inf, 5.0]、(8.0, inf],isFetching: false)。rejectedPlans中既有a_1_b_1、a_1_b_-1、a_1_b_1_c_1、a_1_b_1_d_1等 DISTINCT_SCAN 候选,也有OR+ 多路 IXSCAN 的普通候选(PROJECTION_DEFAULT+OR+ 两个 IXSCAN)。最终单索引合并区间的 DISTINCT_SCAN 胜出。
5.2 不可合并一:同一字段上的$and区间
{ "$match" : { "$or" : [ { "a" : { "$lte" : 5 } }, { "a" : { "$gt" : 6, "$lt" : 7 } }, { "a" : { "$gt" : 8 } } ] } }中间的{a: {$gt: 6, $lt: 7}}是一个区间交叠,无法与其余分支合并为单一连续区间。执行引擎切换为sbe,winningPlan为GROUP+OR+ 两个 IXSCAN(a_1上[-inf,5.0]、(8.0,inf]与(6.0,7.0)分别扫描),DISTINCT_SCAN 被禁用。
5.3 不可合并二:不同字段的$or
{ "$match" : { "$or" : [ { "a" : { "$gt" : 0 } }, { "b" : { "$lt" : 10 } } ] } }sbe引擎下winningPlan为GROUP+OR+ IXSCAN(b_1_a_1上b:[-inf,10.0))+ IXSCAN(a_1上(0.0,inf])。$or分支落在不同字段上时,DISTINCT_SCAN 完全不可用。另一个变体{$or: [{a: {$gt: 0}, b: {$lt: 10}}, {a: {$lt: 10}}]}同样退化:GROUP+OR+ IXSCAN(a_1上[-inf,10.0))+ IXSCAN(a_1_b_1上a:(0.0,inf], b:[-inf,10.0))。
5.4 平局:DISTINCT_SCAN 优先于 IXSCAN
新集合coll2(test.distinct_aggregation_multiplanning_md-2)只有_id_、a_-1_b_1、a_1_b_1三个索引,5 条数据{a: i, b: -i},管道为$match {a: {$gt: 0}}+$group _id:$a, accum:$top(sortBy:{a:1,b:1}, output:$b)。winningPlan为a_1_b_1DISTINCT_SCAN(PROJECTION_COVERED,boundsa:(0.0,inf]),rejectedPlans中唯一的a_-1_b_1是 IXSCAN(nss: "test.distinct_aggregation_multiplanning_md-2")。DISTINCT_SCAN 与 IXSCAN 竞争平局时,去重扫描胜出。
5.5 选择性谓词更偏好 FETCH + filter + IXSCAN
集合coll3(...-3)有 20 条数据,索引a_1_b_1与b_1_c_1,管道为$match {a: {$gt: 0}, b: {$gt: -18}}+$group _id:$a, accum:$top(sortBy:{a:1,b:1}, output:$b)。虽然存在 DISTINCT_SCAN 候选(a_1_b_1,rejectedPlans中可见),但winningPlan选择了PROJECTION_SIMPLE+FETCH(filter 保留a > 0)+ IXSCAN(b_1_c_1上b:(-18.0, inf]),管道最终以$group($willBeMerged: false、$top累加器原样执行)收尾。当b上的谓词选择性更强、能大幅缩小扫描范围时,优化器放弃"全范围去重扫描 + 事后过滤",改走"窄区间索引扫描 + FETCH 过滤"——这正是成本模型在多计划竞争中发挥作用的例证。
六、场景五:排序规格冲突导致无 DISTINCT_SCAN 候选
当管道中$sort与$top/$bottom的sortBy无法由同一个索引同时满足时,DISTINCT_SCAN 整体退出竞争:
$sort {a:1,b:1}+$top(sortBy:{b:1,a:1}, output:$c):$sort与sortBy键序相反(a,bvsb,a),sbe引擎下rejectedPlans含a_1_b_1(IXSCAN)与a_1_b_1_d_1(IXSCAN、multikey),winningPlan为GROUP+PROJECTION_COVERED+ IXSCAN(a_1_b_1_c_1,forward);$sort {a:1,b:1}+$bottom(sortBy:{a:-1,b:-1}, output:$c):方向完全相反,同样退化,winningPlan为GROUP+PROJECTION_COVERED+ IXSCAN(a_1_b_1_c_1)。测试源码注释指出该查询"could (and after SERVER-94369, possibly will) be answered by a forward distinct scan on a_1_b_1",暗示后续版本可能进一步放宽此限制。
七、场景六:multikey 索引禁用 DISTINCT_SCAN
第三批数据加入了{a: [1, 2, 3], b: 4, c: 7, d: 5},使a成为 multikey 字段,所有以a为前缀的索引均变为 multikey 索引。
$sort {a:1,b:1}+$first($b):结果中出现{_id: [1,2,3], accum: 4}。执行引擎为sbe:rejectedPlans中a_1_b_1_c_1、a_1_b_1_d_1均以IXSCAN(isMultiKey: true,multiKeyPaths.a: ["a"])出现;winningPlan为GROUP+FETCH+ IXSCAN(a_1_b_1,multikey);- 无
$sort的{$group: {_id: "$a"}}:winningPlan直接退化为GROUP+COLLSCAN(filter: {}); - "No available indexes" 场景(
$top管道):同样GROUP+COLLSCAN。
结论:multikey 索引无法承载 DISTINCT_SCAN(数组键无法保证"有序去重"语义),规划器会退化为 IXSCAN+FETCH 甚至 COLLSCAN。
八、场景七:按非 multikey 字段分组、累加器读取 multikey 字段
只要$group的_id字段本身不是 multikey,即使$first/$last累加器读取的是 multikey 字段,DISTINCT_SCAN 依然可用(通过回表获取累加器字段):
{$group: {_id: "$b", accum: {$first: "$a"}}}:winningPlan为b_1DISTINCT_SCAN(forward、isFetching: true),结果{_id:2, accum:4}、{_id:3, accum:4}、{_id:4, accum:5};{$group: {_id: "$b", accum: {$last: "$a"}}}:winningPlan为b_1DISTINCT_SCAN(directionbackward、isFetching: true),结果{_id:2, accum:4}、{_id:3, accum:4}、{_id:4, accum:[1,2,3]}($last取到数组值本身)。
8.1 收尾实验:DISTINCT_SCAN 平局时选择键数最少的索引
新数据集 5 条,创建 5 个前缀索引(a_1_b_1_c_1_d_1_e_1、a_1_b_1_c_1_d_1、a_1_b_1_c_1、a_1_b_1、a_1),管道为$match {a: {$gt: 0}}+{$group: {_id: "$a"}}:
[ "_id_", "a_1_b_1_c_1_d_1_e_1", "a_1_b_1_c_1_d_1", "a_1_b_1_c_1", "a_1_b_1", "a_1" ]rejectedPlans依次列出a_1_b_1_c_1_d_1、a_1_b_1_c_1、a_1_b_1、a_1_b_1_c_1_d_1_e_1四个 DISTINCT_SCAN 候选,winningPlan最终选择a_1(键数最少、PROJECTION_COVERED、boundsa:(0.0,inf])。这是场景三规则的再次印证:无额外投影需求时,最短索引即最优。
九、explain 输出速查:classic 与 sbe 引擎的形态差异
汇总本 golden 文件全部案例,可以建立如下对照表:
| 形态 | Execution Engine | 结构特征 | 典型场景 |
|---|---|---|---|
$cursor(含winningPlan/rejectedPlans)+$groupByDistinctScan | classic | 管道被重写为 distinct,底层走 DISTINCT_SCAN;rejectedPlans中并列展示多个候选 DISTINCT_SCAN | 场景一、二(DISTINCT_SCAN 选中)、三、四(可合并 $or)、七 |
GROUP+PROJECTION_COVERED/PROJECTION_SIMPLE+IXSCAN/COLLSCAN/OR/SORT | sbe | 未走 distinct 重写,按普通聚合执行 | hint 强制非 DISTINCT_SCAN(3.2)、不可合并$or(5.2/5.3)、排序冲突(六)、multikey(七) |
其中queryShapeHash是查询形状的稳定哈希,同一管道即使 hint 不同也保持一致(如场景一hint实验中两次输出均为384E008C...);isShardFiltering、isPartial、isSparse、isUnique、multiKeyPaths等字段用于描述索引属性,nss字段在 sbe 的扫描节点中标识集合全名(test.distinct_aggregation_multiplanning_md、-2、-3后缀对应测试中的多个辅助集合)。
十、总结:DISTINCT_SCAN 多计划竞争的完整决策模型
综合 7 大场景、30 余条管道的期望输出,MongoDB 对可重写为 distinct 的聚合管道执行如下决策流程:
- 形态判定:管道能否被重写为 distinct(
$sort+$group,或$group含$first/$last/$top/$bottom,_id直接引用字段); - 候选生成:生成所有可用的 DISTINCT_SCAN 候选;若
$or谓词可合并到单一索引区间,则区间合并的 DISTINCT_SCAN 也入池; - 资格排除:multikey 索引不可作 DISTINCT_SCAN;
$sort与sortBy冲突时全部排除;根级$or分支无法合并时全部排除; - 竞争择优:DISTINCT_SCAN 与 IXSCAN 平局时优先 DISTINCT_SCAN;多个 DISTINCT_SCAN 竞争时优先能全覆盖投影的索引,否则选键数最少的索引;当普通索引谓词选择性显著更强时,成本模型可能反选
FETCH + filter + IXSCAN; - hint 覆盖:
hint指定索引后跳过竞争直接执行,指定 multikey 索引或$natural时管道退化为 sbe 普通聚合执行。
阅读本 golden 输出时,建议同时对照测试源码 distinct_aggregation_multiplanning_md.js、生成工具 golden_test_utils.js、底层执行器 distinct_scan.cpp,并可横向对比同目录下 distinct_command_multiplanning.md、distinct_query_planner.md、distinct_index_eligibility.md 等姊妹 golden 文件,以及sbeFull/sbeRestricted/sbeDisabled/internalEnableJoinOptimization变体,获得不同特性开关下的完整行为画像。
【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考