bigywater.tech
MongoDB · 37

$or:每个分支各自走哪个索引

$or 的每个分支各走哪个索引?一个分支没有索引会怎样?加了 sort 和别的条件之后呢?这篇用 20 万个文档和 explain 逐个情形验证。

240 对 200,000
$or 每个分支都有索引与有一个分支没有索引时检查的文档数
SORT_MERGE
每个分支的索引都能按排序字段有序时,$or + sort 不需要内存排序
240 → 200
两个分支重叠时检查的键数与最终返回的文档数,去重发生在 FETCH 之前

各走各的

实测 · 本机执行输出
  test> db.t.find({$or: [{a: 5}, {b: 17}]})
  {
    plan: 'SUBPLAN > FETCH > OR[IXSCAN(a_1) | IXSCAN(b_1)]',
    nReturned: 240,
    keysExamined: 240,
    docsExamined: 240,
    ms: 9
  }

每个分支有可用索引时,$or 的计划是 OR[IXSCAN | IXSCAN],各自按自己的索引读,合并后再 FETCH 并去重。

一个分支没有索引

实测 · 本机执行输出
  test> db.t.find({$or: [{a: 5}, {d: 17}]})    // d 没有索引
  {
    plan: 'SUBPLAN > COLLSCAN',
    nReturned: 202,
    keysExamined: 0,
    docsExamined: 200000,
    ms: 42
  }

只要有一个分支用不上索引,整个 $or 退化为 COLLSCAN,检查全部 200,000 个文档。

外层条件

实测 · 本机执行输出
  test> db.t.find({c: 7, $or: [{a: 5}, {b: 17}]})
  {
    plan: 'FETCH > OR[IXSCAN(a_1) | IXSCAN(b_1)]',
    nReturned: 0,
    keysExamined: 240,
    docsExamined: 240,
    ms: 1
  }
  test> 建了 {c, a} 和 {c, b} 两个复合索引之后,同一个查询
  {
    plan: 'FETCH > OR[IXSCAN(c_1_a_1) | IXSCAN(c_1_b_1)]',
    nReturned: 0,
    keysExamined: 0,
    docsExamined: 0,
    ms: 2
  }

$or 之外的条件要靠对应的复合索引({c, a}、{c, b})分配进每个分支;只有单字段索引时是 FETCH 后过滤。

$or + sort

实测 · 本机执行输出
  test> db.t.find({$or: [{a: 5}, {b: 17}]}).sort({d: 1})    // 有 {a, d} 和 {b, d}
  {
    plan: 'SUBPLAN > FETCH > SORT_MERGE[IXSCAN(a_1_d_1) | IXSCAN(b_1_d_1)]',
    nReturned: 240,
    keysExamined: 240,
    docsExamined: 240,
    ms: 8
  }
  test> 删掉 {b, d} 索引后同一个查询
  {
    plan: 'SUBPLAN > SORT > FETCH > OR[IXSCAN(b_1) | IXSCAN(a_1_d_1)]',
    nReturned: 240,
    keysExamined: 240,
    docsExamined: 240,
    ms: 0
  }

每个分支的索引都能按排序字段有序时,用 SORT_MERGE 归并,不需要 SORT 阶段;有一个分支不能就退化成 OR 之后再 SORT。

去重

实测 · 本机执行输出
  # 分支命中数、并集、交集
  test> db.t.countDocuments({a: 5}) + " + " + db.t.countDocuments({b: 35}) + " (两个分支各自的命中数);并集 " + db.t.countDocuments({$or: [{a: 5}, {b: 35}]}) + ";同时满足两个分支的 " + db.t.countDocuments({a: 5, b: 35})
  200 + 40 (两个分支各自的命中数);并集 200;同时满足两个分支的 40
  test> db.t.find({$or: [{a: 5}, {b: 35}]})
  {
    plan: 'SUBPLAN > FETCH > OR[IXSCAN(a_1) | IXSCAN(b_1)]',
    nReturned: 200,
    keysExamined: 240,
    docsExamined: 200,
    ms: 0
  }
  test> db.t.find({$or: [{a: 5}, {a: 5, b: 3}]})    // 第二个分支被第一个包含,优化器直接化简
  {
    plan: 'FETCH > IXSCAN(a_1)',
    nReturned: 200,
    keysExamined: 200,
    docsExamined: 200,
    ms: 0
  }

两个分支命中同一个文档只会返回一次;键数是两边之和,FETCH 的文档数是并集。被另一个分支包含的分支会被化简掉。

同一字段

实测 · 本机执行输出
  test> db.t.find({$or: [{a: 5}, {a: 6}, {a: 7}]})
  {
    plan: 'FETCH > IXSCAN(a_1)',
    nReturned: 600,
    keysExamined: 601,
    docsExamined: 600,
    ms: 0
  }
  test> db.t.find({a: {$in: [5, 6, 7]}})
  {
    plan: 'FETCH > IXSCAN(a_1)',
    nReturned: 600,
    keysExamined: 601,
    docsExamined: 600,
    ms: 0
  }
  同一字段:$or 三个分支 0.9 ms;$in 0.9 ms
  不同字段:一条 $or 查询 0.6 ms;应用端拆成两条查询再合并 0.7 ms

同一个字段上的多个 $or 分支和 $in 的计划一样,耗时也一样。

分步演示

$or 查询的索引使用
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MongoDB 9.0.2(Homebrew 的 mongod、mongos)/ mongosh 2.12.0 / pymongo 4.18.3;每个实验在临时目录里起全新的 mongod(独立 dbpath,端口 9900 以上,只监听 127.0.0.1,WiredTiger 缓存 0.25 GB),需要副本集或分片集群时在本机起多个进程;命令输出来自真实的 mongosh,批量写入、计时与多线程用 pymongo;计时用 time.perf_counter,数字每次运行会有小幅抖动,ObjectId、时间戳等每次运行都不同。20 万个文档;计划来自 explain("executionStats");耗时是 pymongo 的 9 次中位数
    • 没有验证:$or 在聚合 $match 里的行为;嵌套的 $or / $and;分支很多(几十个)时的计划与规划开销;$or 里含 $regex、$text 分支;SBE 引擎下 $or 的计划表示差异;三个以上分支其中某个分支结果极多时的合并开销
    京ICP备2026067502号-1京公网安备11010502063487号