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_languagedb.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 返回的结果是按照评分降序排列的。如果你想显式控制可以用$metadb.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是什么如果是IXSCANFETCH说明走了索引如果出现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。当时内存只有 8GBWiredTiger 缓存 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 性能问题不是数据库本身的锅而是索引设计没跟上业务增长。文本索引是一个好工具但得顺着它的原理来使用先分词再倒排配合合理的过滤条件和索引组合才能发挥出真正的价值。希望这篇指南能帮你少走一些我走过的弯路。
