1. 从正则到倒排:为什么会慢,以及文本索引到底做了什么
1.1 一个真实的性能翻车现场
我接手过一个电商后台的搜索需求,商品表大概几百万条记录,SKU名称、品牌、卖点都堆在一个集合里。最初的实现特别简单:前端传关键词,后端用{ name: { $regex: keyword, $options: 'i' } }去查。刚开始数据量小的时候一点问题没有,但数据涨到百万级之后,一次搜索经常要两三秒,并发一上来CPU直接被打满。
用explain()看了一眼,全集合扫描,从头到尾一条条拿正则去匹配。当时还在想,给 name 字段加个普通 B-tree 索引不就行了吗?加了之后发现,$regex虽然能用索引做前缀匹配,但只要关键词不是从头开始匹配,^一旦放在中间,索引就废了。比如用户搜“手机壳”,结果商品名是“XX手机壳限量版”,正则必须写成name: /手机壳/,没有前缀锚点,索引帮不上忙。
这个时候,直接上 MongoDB 文本索引才是正路。文本索引不是为了替代专业的搜索引擎(比如 Elasticsearch),它解决的是"不想额外引入一套搜索组件,但在 MongoDB 里就能获得不错的关键词检索能力"这个场景。如果你的数据量在千万级以内,实时性要求没那么变态,文本索引完全够用。
1.2 倒排索引的朴素原理
文本索引的核心是倒排索引。你可以把 B-tree 索引想象成一本按拼音排序的字典目录,你查一个字,得知道大概在哪一页;而倒排索引更像是一本书最后面的"关键词-页码对照表",每个关键词后面挂着所有出现过的文章ID。
具体到 MongoDB:当你给某个字段创建了 text 索引,后台会对这个字段的字符串做分词、转小写、去除停用词(比如英文里的 the、a、an,中文里一般不做停用词过滤),然后为每个词项建一个词项到文档ID的映射。查询的时候,输入的关键词同样走一遍分词,然后快速查这个词项对应的文档ID集合,再做合并、排序。
这个过程和正则的全表扫描完全不是一个量级。因为在 B-tree 里,你要比对每一个字符串的每一个位置;而倒排索引直接通过哈希或者树结构找到词项,然后拿文档ID列表即可。这就是为什么加一个 text 索引之后,搜索从秒级降到毫秒级的根本原因。
需要特别说明的是,MongoDB 文本索引默认的倒排表并没有存储词频、逆文档频率这些完整的相关性打分模型,它使用的是简化的评分算法,加权逻辑你可以在创建索引时通过weights来控制。这个我们后面详细展开。
2. 创建文本索引前必须想清楚的几个配置
2.1 单字段还是多字段,以及怎么用wildcard覆盖全字段
先看最基本的创建语法:
db.products.createIndex( { name: "text" } )给name字段创建了文本索引。如果想多个字段一起参与搜索,比如商品名、品牌、描述,可以这样:
db.products.createIndex( { name: "text", brand: "text", description: "text" } )这样查询时$text会同时搜索这三个字段。但要注意:$text的搜索条件不能指定具体某字段,它就是全索引字段一起搜。如果你只想过 name 字段,不该把它们都塞进同一个文本索引。
还有一种偷懒但又极其好用的方式:通配文本索引。比如你想搜索一个文档里所有字符串字段,又不想一个个列出来,可以这样:
db.products.createIndex( { "$**": "text" } )$**代表所有字符串字段。这个方案我实际用过,很方便,但有两个坑:一是索引体积会暴增,因为每个字符串字段都会被索引;二是某些你不想被搜索的内部字段(比如_id之外的备注字段)也会被索引进去,可能导致误匹配。如果只是临时做原型验证,可以用;生产环境我建议还是显式列出需要的字段,或者结合wildcardProjection限定范围:
db.products.createIndex( { "$**": "text" }, { wildcardProjection: { internalNote: 0, stats: 0 } } )这样会排除internalNote和stats字段。
2.2 权重设置:让标题命中比正文命中更值钱
通常商品标题和描述的重要性不一样。搜“iPhone 15”,一个商品标题是“iPhone 15 手机壳”,另一个是“适用于 iPhone 15 的透明壳 防摔保护壳”,显然前者更相关。如果不设置权重,两者在评分上不会有太大差别,但你可以通过weights来人为拉大差距:
db.products.createIndex( { name: "text", description: "text" }, { weights: { name: 10, description: 3 } } )这样命中了 name 字段的文档,评分会乘以10,描述字段命中只乘以3。排序结果会明显偏向标题命中的商品。这个权重的设置绝不是凭感觉拍脑袋,建议根据业务转化数据迭代。比如你发现搜索“手机壳”时,品牌字段命中比描述命中更重要,那就可以调高 brand 的权重。
还有一个细节:同一文档的多个字段命中时,评分是累加的。比如同时命中 name 和 description,那得分就是两个字段的加权值相加。这符合直觉:信息越密集,文档越相关。
2.3 语言、索引名称与文本索引的特殊限制
创建文本索引时可以指定default_language:
db.products.createIndex( { name: "text" }, { default_language: "english" } )默认是 english,会启用停用词过滤和词干提取(比如 running -> run)。但如果你存储的是中文,这些英文语言处理器不但没有帮助,反而可能把中文句子按空格拆出奇怪的词项。中文分词是 MongoDB 文本索引的短板,后面专门说。尽量选一个贴近业务实际的语言,或者直接用"none"禁用停用词和词干提取。
文本索引还有一些限制需要提前知道,不然写代码的时候会报一些莫名其妙的错误:
- 一个集合只能有一个 text 索引。如果已经创建过 text 索引,想改字段或权重,必须先删掉旧的再创建。
- text 索引不能与
unique约束同时存在。 - text 索引不支持对
array字段内的每个元素单独加权,但可以索引数组字段,也就是数组里的每个字符串元素都会参与索引。 - 复合索引里,text 索引字段必须放在所有普通字段之后。比如
{ category: 1, name: "text" }是允许的,{ name: "text", category: 1 }则不允许。
索引名称如果不指定,MongoDB 会按照字段组合自动生成一个很长的名字。建议显式命名,尤其是将来做索引维护和监控的时候,名字能让人看懂:
db.products.createIndex( { name: "text", brand: "text" }, { name: "idx_products_name_brand_text" } )3. 查询阶段最容易拖慢性能的几种写法
3.1$text的基本用法,以及如何拿到合理的相关度排序
创建好文本索引,查询时要用$text操作符,而不是正则:
db.products.find( { $text: { $search: "iPhone 手机壳" } } )默认情况下 MongoDB 返回的结果是按照评分降序排列的。如果你想显式控制,可以用$meta:
db.products.find( { $text: { $search: "iPhone 手机壳" } }, { score: { $meta: "textScore" } } ).sort( { score: { $meta: "textScore" } } )注意,即使不显式排序,$text查询也会默认按照 textScore 降序返回,但这只发生在没有其他 sort 条件时。一旦你加了其他字段的 sort,优先级就变了,相关性排序会被覆盖。业务上如果想既考虑时间又考虑相关性,通常建议先做相关性排序,再做时间过滤,而不是把$sort放在查询上。比如:
db.articles.find( { $text: { $search: "性能优化" }, publishTime: { $gte: ISODate("2024-01-01") } }, { score: { $meta: "textScore" } } ).sort( { score: { $meta: "textScore" } } )这个场景下,publishTime的过滤条件会和文本索引的扫描过程结合,具体要看执行计划。如果过滤条件很强(比如半年内数据很少),MongoDB 可能会先扫过滤条件再用文本索引,这需要 explain 来判断。
3.2 短词、停用词和特殊符号:文本搜索的隐形杀手
$text分词之后,有三个现象会严重影响查询性能:
一是短词。MongoDB 的$text搜索默认要求每个词至少两个字符,而且如果词过长会走另外的逻辑。实践中如果一个词只有一个字符,比如“X 手机壳”,那个“X”会被忽略,但仍然会扫描手机壳的词项,问题不大。但如果你搜索“A”,整个查询会报错,因为所有词都被忽略了,错误信息会提示缺少有效词项。
二是停用词。英文中指 the、is、and 这类词,默认 english 分词器会直接忽略。查询$search: "the performance",实际上只搜 performance。如果你确实需要搜“the”这个词(比如代码里的变量名),就得把default_language设为"none",这样所有词都会被索引。
三是特殊符号。$text搜索不会处理标点符号,比如搜“C++”时,+号会被当成分隔符,实际分词结果是“C”。搜“Node.js”时,点号也会被忽略,最终搜的是“Node”和“js”。这是一个很大的坑,在技术文章类业务里尤其明显。解决办法通常有两种:一是把常见特殊符号替换成占位符再索引(比如 C++ 写成 CPLUSPLUS 存储到一个额外字段);二是接受这种粗粒度,通过业务层对结果做二次过滤。
3.3 不要迷信$text的评分,需要结合复合索引过滤
很多初学者把$text当作万能搜索,但实际查询中通常还会按分类、价格、库存等条件过滤。比如:
db.products.find( { $text: { $search: "iPhone" }, category: "phone", status: "on_sale" } )这时候执行计划怎么走的?如果只建了 name 的 text 索引,MongoDB 会用 text 索引拿到所有含“iPhone”的文档ID,然后逐条回表,再过滤 category 和 status。如果“iPhone”这个词命中了一百万条文档,即使最终结果只有几十条,回表代价也很大。
正确的做法是建立一个复合索引,把过滤字段放在普通字段位置,文本索引字段放在最后:
db.products.createIndex( { category: 1, status: 1, name: "text" } )注意顺序很重要:category和status在左边,name的 text 索引在右边。MongoDB 会先利用左边的字段做等值或范围过滤,再对过滤后的结果做文本匹配。这种复合索引在带过滤条件的场景下,性能提升非常明显。
我在实践中遇到过 filters 加不加复合索引差距达 10 倍以上的案例。所以创建 text 索引前,先统计一下高频查询里有哪些等值过滤字段,把它们放在 text 字段前面,往往比盲目调权重更有效。
3.4 用 explain 验证查询是否真正走了 text 索引
这个步骤容易被忽略,但真的很重要。写完查询,建议养成习惯跑一下explain("executionStats"):
db.products.find( { $text: { $search: "iPhone" }, category: "phone" } ).explain("executionStats")重点看以下几项:
winningPlan.inputStage是什么?如果是IXSCAN+FETCH,说明走了索引;如果出现COLLSCAN,说明索引没起作用。executionStats.totalDocsExamined应该远大于nReturned,但也不能太离谱。如果totalDocsExamined接近全表文档数,就要考虑复合索引的过滤效率。executionStats.executionTimeMillis的值能直观对比优化前后的变化。
有一次我排查一个慢查询,explain 后发现虽然用了 text 索引,但totalDocsExamined达到 30 万,因为 text 索引命中的词项本身包含很多文档,而等值过滤字段没进索引。后来加上复合索引,totalDocsExamined 降到 2000,查询时间从 900ms 降到 30ms。这个案例足以说明 explain 才是性能优化向导。
4. 大数据量下索引构建与运维的实战细节
4.1 索引构建方式:前台、后台还是滚动构建
如果集合数据量不大(几万条),直接createIndex就行,它会以默认的前台方式构建,期间会阻塞整个集合的读写。数据量一旦到亿级,前台建索引最好别碰,否则业务就得停服。
MongoDB 4.2 之后,createIndex默认行为发生了变化:4.2 以前的版本,background选项可以让建索引在后台进行;4.2 开始,所有的索引构建基本都在后台完成,那个background选项已经弃用。但即便如此,后台建索引依然会占用大量 I/O 和 CPU,在线业务高峰期照样会被拖慢。
建议在低峰期操作,或者使用滚动构建的方式。具体做法是:在副本集环境中,先关掉一个从节点的服务,在单机上建好索引,再重新加入副本集,让数据同步追赶。这样一个节点一个节点滚动替换,主节点全程不阻塞。这个方法需要脚本配合,运维成本高一些,但对大集合来说是最稳的。
如果用的是 MongoDB Atlas,可以直接在后台界面创建索引,它内部会自动选择合适的时间窗口。自建的话还是老老实实错峰。
4.2 索引大小和内存的账,要提前算明白
文本索引的体积通常比数据本身还大。为什么?因为一条文档只有几个字段,但分词后可能出现几十个词项,每个词项都要存一个词项字典。索引页会缓存到内存里,如果索引总大小超过 WiredTiger 缓存(一般是内存的一半),就会产生大量磁盘读,性能骤降。
衡量方法:建立索引后,用db.products.stats()查看totalIndexSize,和文档数据大小做个对比。我在一个博客系统里,200 万条文章记录,文本索引大小接近 2.5GB,而原始数据才 1.8GB。当时内存只有 8GB,WiredTiger 缓存 4GB,光索引就占了大半,导致其他查询也变慢。
应对策略:
- 只对真正需要搜索的字段建文本索引,不要用
$**偷懒。 - 尽可能过滤掉无意义的长文本(比如 description 如果非常长,可以考虑截断或者单独用外部搜索引擎)。
- 查询时用
projection只返回需要的字段,减少文档物化的内存开销。 - 提升服务器内存,这是最直接的办法。文本索引不适合跑在内存捉襟见肘的实例上。
4.3 分片集群与文本索引的边界
很多人以为用了分片集群就能解决一切性能问题,但文本索引在分片环境下有明确的限制:你不能在分片集合上创建 text 索引,除非这个 text 索引字段恰好是分片键的一部分。换句话说,如果你对content字段做文本搜索,但分片键是_id,那么这条 text 索引创建会直接失败。
这是因为文本索引的倒排表需要全局合并才能计算完整的相关度排序,而分片后每个分片只知道本地数据,全局排序无法高效完成。MongoDB 官方也不建议在分片集群中直接使用 text 索引做全文搜索,而是推荐引入单独的搜索引擎或者用 MongoDB Atlas Search。
如果业务已经按_id分片,又想用文本搜索怎么办?我的建议是:将搜索数据落到一个独立的未分片集合里,或者落一个专门的搜索库。说白了,全文搜索本身就是"重计算"场景,和分片的"水平扩展"是两种设计思路,强行混用容易出现性能瓶颈。
4.4 监控指标:哪些信号说明文本索引需要优化
日常运维,除了看慢查询日志之外,可以关注这几个指标:
- 查询语句执行计划中
totalKeysExamined过大:说明词项命中了太多文档,可以考虑提高过滤条件或者减少文档数。 - 系统磁盘 I/O 的 read IOPS 飙升:可能是索引在换页,缓存放不下,优先看索引大小。
currentOp里大量createIndex任务:说明建索引操作没有错峰。- 日志里出现
"text search non-metadata"的 warning:一般是索引配置和查询语言不匹配。
建议写个定时任务,每天抓一次collection.stats()的indexSizes变化和serverStatus里的metrics.query指标,持久化下来,观察趋势。
另外,MongoDB 的$text查询不支持 hint 到别的索引用来过滤,所以如果复合索引没设计好,调优空间很有限。这也是为什么前面强调复合索引的重要性。等后期数据量爆炸时,哪些设计是对的,哪些是错的,监控数据会给你答案。
5. 踩坑记录与经验总结
5.1 一次中文分词翻车事故
在一个资讯类项目里,我们用default_language: "none"建了文本索引,因为内容是中文。但搜索“人工智能”时,发现匹配不到“人工 智能”这个组合。原因在于 MongoDB 文本索引没有内置中文分词器,默认按空格、标点分隔词项。中文句子连在一起,会被切成一个超长词,或者按标点切。比如“人工智能改变未来”,会被当成一整串字符,除非用户输入完整的“人工智能改变未来”,否则命不中。
这个问题是 MongoDB 原生文本索引的硬伤。解决办法有几种:
- 在写入阶段预先对中文内容做分词(比如用 jieba 分词),把分词后的词语用空格拼接,存入一个额外的
searchText字段,对这个字段建 text 索引。查询时同样将输入做分词后搜索。 - 使用自建的 IK 分词插件配合 Mongo 的文本索引?其实不行,MongoDB 不像 Elasticsearch 那样可以自定义分析器。
- 如果业务对中文搜索要求高,建议放弃原生
$text,改用mongodb atlas search或 Elasticsearch。
我当时用的是第一种方案:业务里维护了一个中文分词服务,写入时将标题和正文的分词结果拼成一个searchText字段,查询时同样先分词再$text搜索。效果还行,但维护成本不低。所以如果是新项目且明确有中文全文搜索需求,我通常建议直接上 ES。
5.2 索引权重设置对结果排序的意外影响
之前有个客服工单系统,搜索工单标题和工单描述,给标题设置了权重 10,描述权重 2。结果用户搜索一个售后问题时,标题里包含“退款”的工单永远排最前面,但实际上有些工单标题只写了“退回”,而描述里详细写了退款流程,相关度其实很高。权重设置过于悬殊,导致搜索召回结果单一化。
后来我们把权重改为标题 5、描述 3,并增加了一个“最近更新工单优先”的规则,相关性排序加时间排序的组合,用户体验才正常。
权重这东西不是越大越好,它应该体现业务对“精确匹配”的期望。如果你不确定,可以先用默认权重跑一段时间,观察用户的点击分布再调整。
5.3 字段更新频繁导致索引膨胀
有个配置表,每天都会批量更新一批文档的status字段。结果发现文本索引体积不断增长,因为 MongoDB 的索引节点在更新时会标记删除旧词项,插入新词项,产生很多不可见的空位。虽然 WiredTiger 会重用空间,但如果反复更新同一个字段,索引的物理文件碎片化会比较严重。
解决办法是定期compact或者重建索引。注意,副本集环境compact只在主节点上运行,最终还是要滚动重建。文本索引尤其容易膨胀,建议每季度规划一次重建操作。
5.4 从 MongoDB 5.0 升级到 6.0 后带来的优化可能
MongoDB 5.0 开始改进了部分文本索引的存储格式,6.0 又优化了查询执行计划。我见过升级后同样的$text查询快了一半的情况,因为新版本对倒排表的遍历逻辑做了优化。如果你还在用 4.x 版本,建议评估升级。升级前务必在测试环境跑一遍全量查询集合,确认排序结果没有变化。
不过,即便升级到 6.0、7.0,原生文本索引的定位依然是"轻量级全文检索",它不会替代专用搜索引擎。做技术选型时要保持清醒:数据量小、搜索场景简单、不想引入额外组件,就用 MongoDB 文本索引;数据量大、有中文分词、需要高自定义相关度排序,直接上 ES 或 Atlas Search,别在 MongoDB 上死磕。
最后分享几个小技巧
回到最初那个电商案例,最后我们优化的组合拳是这样的:
- 给
name、brand建了文本索引,设置了权重为 5、3。 - 建了一个复合索引
{ category: 1, status: 1, name: "text" },覆盖大多数带过滤条件的查询。 - 查询时用
projection只返回商品ID和标题,减少文档物化开销。 - 对中文搜索需求,使用了一个预分词的
searchText字段。 - 每周用
explain抽查几个高频搜索词,观察执行计划是否稳定。
这套方案上线后,搜索平均耗时从 1.8 秒降到了 80 毫秒左右,在 800 万商品数据量下稳得很。其实很多 MongoDB 性能问题不是数据库本身的锅,而是索引设计没跟上业务增长。文本索引是一个好工具,但得顺着它的原理来使用:先分词,再倒排,配合合理的过滤条件和索引组合,才能发挥出真正的价值。希望这篇指南能帮你少走一些我走过的弯路。