bigywater.tech
MongoDB · 38

正则查询:什么写法能用上前缀索引

什么样的正则能用上前缀索引?/i 为什么慢?这篇用 30 万个词和 explain 的 indexBounds 逐个验证,并给出大小写不敏感前缀查询的办法。

17 对 300,006
/^abc/ 与 /abc/ 检查的索引键数
0.2 对 100 ms
锚定前缀与不锚定的正则查询耗时
24 个键
用小写副本字段做不区分大小写的前缀查询检查的键数

前缀锚定

实测 · 本机执行输出
  /^abc/:     16 个
  /^abc$/:      1 个
  /abc/:    123 个
  /^abc.*/:     16 个
  /^(abc|abd)/:     27 个
  /^abc/i:     23 个

简单的 /^abc/ 被收窄成字符串范围 ["abc", "abd"),只检查命中的少数键;/^abc.*/ 同样。

不能收窄的写法

实测 · 本机执行输出
  # 锚定前缀
  test> db.t.find({name: /^abc/})
  {
    plan: 'FETCH > IXSCAN(name_1)',
    nReturned: 16,
    keysExamined: 17,
    docsExamined: 16,
    ms: 1
  }
  # 不锚定
  test> db.t.find({name: /abc/})
  {
    plan: 'FETCH > IXSCAN(name_1)',
    nReturned: 123,
    keysExamined: 300006,
    docsExamined: 123,
    ms: 111
  }
  # 不区分大小写
  test> db.t.find({name: /^abc/i})
  {
    plan: 'FETCH > IXSCAN(name_1)',
    nReturned: 23,
    keysExamined: 300006,
    docsExamined: 23,
    ms: 104
  }
  # ^ 后面是 .*,等于不锚定
  test> db.t.find({name: /^.*abc/})
  {
    plan: 'FETCH > IXSCAN(name_1)',
    nReturned: 123,
    keysExamined: 300006,
    docsExamined: 123,
    ms: 118
  }
  # 前缀后面跟 .*
  test> db.t.find({name: /^abc.*/})
  {
    plan: 'FETCH > IXSCAN(name_1)',
    nReturned: 16,
    keysExamined: 17,
    docsExamined: 16,
    ms: 0
  }

不锚定的 /abc/、不区分大小写的 /^abc/i、/^.*abc/、带分支的 /^(abc|abd)/ 的范围都是整个索引 ["", {}),在每个键上匹配正则;只有命中的文档才会 FETCH。

indexBounds

实测 · 本机执行输出
  test> /^abc/ 的 indexBounds
  {"name":["[\"abc\", \"abd\")","[/^abc/, /^abc/]"]}
  test> /^abc$/ 的 indexBounds
  {"name":["[\"abc\", \"abd\")","[/^abc$/, /^abc$/]"]}
  test> /^(abc|abd)/ 的 indexBounds
  {"name":["[\"\", {})","[/^(abc|abd)/, /^(abc|abd)/]"]}
  test> /abc/ 的 indexBounds
  {"name":["[\"\", {})","[/abc/, /abc/]"]}
  test> /^abc/i 的 indexBounds
  {"name":["[\"\", {})","[/^abc/i, /^abc/i]"]}

explain 的 indexBounds 里能直接看出范围有没有被收窄成字符串区间。

大小写不敏感

实测 · 本机执行输出
  # name_lc 是 name 的小写副本
  test> db.t.find({name_lc: /^abc/})
  {
    plan: 'FETCH > IXSCAN(name_lc_1)',
    nReturned: 23,
    keysExamined: 24,
    docsExamined: 23,
    ms: 3
  }
  命中 23 个(= /^abc/i 的 23 个)
  # ci 集合的索引带 collation
  test> db.ci.find({name: /^abc/i}).collation({locale: "en", strength: 2})
  {
    plan: 'COLLSCAN',
    nReturned: 8,
    keysExamined: 0,
    docsExamined: 100000,
    ms: 30
  }

另存一个小写副本字段并建索引,用小写前缀查询,只检查 24 个键;正则不使用 collation,带 collation 的索引帮不上忙。

耗时

实测 · 本机执行输出
  /^abc/                  0.2 ms
  /^abc$/(等值)             0.2 ms
  /abc/(不锚定)              100 ms
  /^abc/i                  98 ms
  name_lc /^abc/          0.3 ms
  {name: "abc"} 等值        0.2 ms

能收窄的写法 0.2~0.3 ms,扫整个索引的写法约 100 ms。

$in 与分支

实测 · 本机执行输出
  test> db.t.find({name: {$in: [/^abcd/, /^abce/]}})
  {
    plan: 'FETCH > IXSCAN(name_1)',
    nReturned: 2,
    keysExamined: 3,
    docsExamined: 2,
    ms: 0
  }
  test> /^(abcd|abce)/ 的 indexBounds
  {"name":["[\"\", {})","[/^(abcd|abce)/, /^(abcd|abce)/]"]}

把多个前缀分开写进 $in,每个都是前缀范围(检查 3 个键);写成一个带分支的正则就退化成整个索引。

分步演示

正则查询与前缀索引
本步的对象

    本文用到的工具与边界

    • 本机 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、时间戳等每次运行都不同。30 万个随机词(固定种子);耗时是 pymongo 的 9 次中位数
    • 没有验证:UTF-8 多字节字符的前缀范围;正则带 m、s、x 选项时的收窄;复合索引上的正则(正则字段不是第一个时);$text 与 Atlas Search 作为替代;$regex 在聚合 $match 里的表现;PCRE 对灾难性回溯的保护
    京ICP备2026067502号-1京公网安备11010502063487号