MongoDB: $facet + $bucket + 文本/地理空间搜索

最后更新:2026-08-26

高级聚合特性——掌握 $facet、$bucket、文本搜索、地理空间查询,构建完整的数据分析能力。

本课程的四大主题:$facet(并行多管道)解决"一次查询多维度结果"问题,$bucket(数据分桶)解决"区间统计"问题,文本搜索解决"关键词查找"问题,地理空间查询解决"距离与范围"问题。四个主题看似独立,实则共同构建了电商搜索系统的完整能力——搜索结果 + 分面统计 + 价格分桶 + 附近店铺。

1. 你将学到

高级搜索的学习价值:$facet + $bucket + $text + $geoNear 构成了 MongoDB 搜索的完整能力栈——$facet 是搜索架构的骨架(多维度并行),$bucket 是数据分布的洞察工具(直方图),$text 是关键词检索的引擎,$geoNear 是位置服务的核心。掌握这四种操作符,意味着能构建不依赖 Elasticsearch 的搜索系统——这在中小项目中节省了大量基础设施成本。

搜索系统的演进路径:项目搜索需求的演进——阶段 1(MVP):简单 $match + $sort(按分类/价格过滤+排序);阶段 2(分面搜索):+ $facet + $bucket(搜索页面的多维度统计);阶段 3(全文搜索):+ 文本索引 + $text(关键词检索);阶段 4(LBS):+ 2dsphere + $geoNear(附近搜索);阶段 5(专业搜索):迁移到 Elasticsearch(中文分词/同义词/拼音)。多数项目在阶段 3-4 停留,只有数据量或搜索复杂度超过 MongoDB 能力时才进入阶段 5。

$facet 的生产环境注意事项:$facet 在生产中有三个关键限制——1. 内存限制:每个子管道共享 100MB 内存上限,所有子管道的总输出不能超过此限制,数据量大时必须在 $facet 前用 $match 过滤;2. 不能在子管道中使用 $collation(排序规则),如需中文排序需在 $facet 外处理;3. 子管道中避免使用 $lookup($facet 内的 $lookup 可能导致性能灾难——每个子管道都执行一次关联查询)。最佳实践:$facet 前用 $match/$project 减少输入量,子管道只做轻量统计($group/$bucket/$count),重操作($lookup/$sort)放在 $facet 外面。


100%
graph TB
    Input[输入集合<br/>1000 文档] --> Facet[$facet<br/>并行执行]

    Facet --> B1[分支 1<br/>topExpensive<br/>按价格排序前 10]
    Facet --> B2[分支 2<br/>byCategory<br/>分类统计]
    Facet --> B3[分支 3<br/>priceRanges<br/>价格分桶]
    Facet --> B4[分支 4<br/>totalStats<br/>总览统计]

    B1 --> Output[多维度结果<br/>一次查询返回]
    B2 --> Output
    B3 --> Output
    B4 --> Output

    style Facet fill:#d4edda
    style Output fill:#cce5ff

$facet 并行执行模型:$facet 的并行不是"多线程加速"——MongoDB 在单线程中依次执行每个子管道,但它们共享同一输入快照,逻辑上是并行的。真正的性能优势不是并行执行,而是"减少网络往返"——4 个统计维度只需 1 次查询而非 4 次。$facet 的内存消耗是所有子管道之和——如果输入 1000 条文档,4 个子管道各处理 1000 条,总内存消耗 ≈ 4000 条文档的内存。

$facet 输出结构设计:$facet 输出是一个文档,键是子管道名称,值是子管道结果数组。设计子管道时需注意:1. 每个子管道是独立的——不能引用其他子管道的结果;2. 子管道的顺序不影响结果(逻辑上并行);3. 子管道内可以使用任何聚合阶段($match/$group/$sort/$lookup 等);4. 空结果返回空数组 [] 而非 null。前端解析 $facet 结果时,用 Object.keys() 遍历各维度。

$facet 的成本与替代方案:$facet 的成本 = 输入数据量 × 子管道数量。当输入数据量大(>10万条)且子管道多(>5个)时,内存消耗可能超限。替代方案:1. 将大 $facet 拆分为多个独立聚合查询——牺牲网络效率换内存安全;2. 在 $facet 前加 $match/$project 大幅减少输入量;3. 用物化视图($merge/$out)预计算统计结果。选择依据:数据量小(<1万条)用 $facet,数据量大用物化视图。

2. $facet 多管道并行

概念说明$facet 允许在同一输入文档集上并行执行多个独立的子管道,每个子管道产出自己的结果,最终合并为一个文档。这是构建搜索结果页面(列表+分面统计+分桶+总数)的最佳工具——一次查询替代多次往返。

工作原理$facet 接收一个对象,键是子管道名称,值是阶段数组。MongoDB 在同一输入上并行执行所有子管道,各自独立处理数据。输出是一个文档,每个键对应子管道的结果数组。注意:$facet 的各子管道共享输入但不能互相引用结果,且内存消耗是各子管道之和。

100%
sequenceDiagram
    participant Input as 输入: 1000 商品
    participant F as $facet
    participant P1 as 子管道1: topExpensive
    participant P2 as 子管道2: byCategory
    participant P3 as 子管道3: priceRanges
    participant P4 as 子管道4: stats
    
    Input->>F: 传入文档流
    par 并行执行
        F->>P1: 1000 文档 → $sort + $limit → 10 条
        F->>P2: 1000 文档 → $group → 8 组
        F->>P3: 1000 文档 → $bucket → 5 桶
        F->>P4: 1000 文档 → $group → 1 条总览
    end
    P1-->>F: topExpensive: [...]
    P2-->>F: byCategory: [...]
    P3-->>F: priceRanges: [...]
    P4-->>F: stats: [...]
    F-->>Input: {topExpensive, byCategory, priceRanges, stats}
$facet 子管道 典型用途 常用阶段组合
结果列表 分页展示 $sort + $skip + $limit + $project
总数统计 分页信息 $count
分类统计 分面筛选 $group + $sort
价格分桶 价格区间筛选 $bucket / $bucketAuto
总览统计 聚合指标 $group({ _id: null })

多维度分析架构原理:$facet 的核心价值是"一次查询,多维结果"——电商搜索页面需要同时展示:商品列表(分页)、总数(分页信息)、价格分布(筛选器)、分类统计(侧栏)。不用 $facet 时,这需要 4 次独立查询(4 次网络往返);用 $facet 后,一次查询返回所有维度,前端渲染速度提升 4 倍。

$facet 的内存约束:$facet 的并行执行并非真正并行——MongoDB 按子管道定义顺序依次执行,但共享同一输入。内存消耗是各子管道之和,1000 文档 × 4 子管道 = 处理 4000 文档的内存。应对策略:1. $facet 前用 $match 减少输入量;2. 子管道中 $project 精简字段;3. 控制子管道数量(3-5 个为宜);4. 超大数据集设 allowDiskUse: true。

$facet 子管道的隔离性:$facet 的每个子管道完全隔离——不能引用其他子管道的结果,也不能共享中间计算。如果多个子管道需要相同的预处理(如 $project 提取字段),每个子管道都要重复执行。隔离性的好处是并行无依赖,缺点是重复计算。少量重复计算的开销远低于串行执行的等待时间——这是 $facet 设计的核心权衡。

$facet 子管道的设计模式:$facet 子管道的设计遵循"每管道一维度"原则——1. 结果列表管道:$sort + $skip + $limit + $project(分页展示,必须用 $limit 限制数量);2. 总数管道:$count(只需返回一个数字,最轻量的管道);3. 分类统计管道:$group + $sort + $limit(按分类字段分组计数,$limit 取 Top N);4. 价格分桶管道:$bucket/$bucketAuto(按区间分桶,输出直方图数据);5. 总览管道:$group({_id: null, avg: {$avg}, sum: {$sum}})(全局统计,只返回一条文档)。5 个管道覆盖搜索页 90% 的数据需求。

$facet 的执行模型细节:$facet 虽然看似并行,但 MongoDB 单线程执行每个子管道——真正的并行仅在分片集群中发生(每个分片独立处理自己的数据,mongos 合并结果)。在单实例或副本集中,子管道按定义顺序依次执行。但 $facet 仍比多次独立查询快——1. 只扫描一次输入数据(各子管道共享同一次数据读取);2. 只需一次网络往返(客户端 → MongoDB → 返回完整结果);3. 避免重复的 $match 开销($facet 前的 $match 只执行一次)。

多维度分析的业务场景:$facet 最典型的业务场景是电商搜索页——用户搜索"手机"后,页面需要同时展示:1. 商品列表(分页+排序+图片价格);2. 搜索结果总数("找到 256 个商品");3. 价格分布柱状图(0-1000/1000-3000/3000+ 三档);4. 品牌分布(华为 30%/苹果 25%/小米 20%...);5. 屏幕尺寸分布等。传统方案 5 次查询 × 50ms = 250ms,$facet 一次查询约 80ms,用户体验从"明显卡顿"变为"即时响应"。

$facet vs 多次查询的取舍:$facet 一次查询返回多维度结果,但也有局限——1. $facet 所有子管道必须在同一聚合调用中,无法按需加载(如用户只看商品列表不看统计,仍执行了统计管道);2. $facet 结果结构固定,无法动态添加维度(如新增"颜色分布"需修改聚合管道并部署);3. 错误处理困难——$facet 中任何子管道出错,整个聚合失败(无法部分返回)。替代方案:对维度可能动态变化的场景,将高频维度放 $facet 内(商品列表+总数),低频维度放独立 API(品牌分布/价格直方图单独端点按需调用)。这样高频查询快速响应,低频查询不浪费 $facet 资源。

JAVASCRIPT
// === $facet 并行执行多个独立管道 ===
db.products.aggregate([
  {
    $facet: {
      // 分支 1:按价格排序的前 10
      topExpensive: [
        { $sort: { price: -1 } },
        { $limit: 10 }
      ],
      // 分支 2:分类统计
      byCategory: [
        { $group: { _id: '$category', count: { $sum: 1 } } }
      ],
      // 分支 3:价格区间分布
      priceRanges: [
        {
          $bucket: {
            groupBy: '$price',
            boundaries: [0, 100, 500, 1000, 5000],
            default: '5000+',
            output: { count: { $sum: 1 } }
          }
        }
      ],
      // 分支 4:总览统计
      stats: [
        { $group: { _id: null, total: { $sum: 1 }, avgPrice: { $avg: '$price' } } }
      ]
    }
  }
]);
// 一次查询返回所有维度的结果

$bucket 的数学原理:$bucket 的 boundaries 数组定义了 N-1 个桶,每个桶是左闭右开区间 [boundaries[i], boundaries[i+1])。文档按 groupBy 值落入对应桶中。例如 boundaries=[0, 100, 500, 1000] 定义 3 个桶:[0,100), [100,500), [500,1000)。$bucket 的 _id 是桶的左边界值。$bucketAuto 根据数据分布自动选择桶边界,使每个桶的文档数大致相等——适合探索性数据分析(不知道数据分布时)。

$bucket 的业务场景:$bucket 的典型业务场景——1. 价格区间统计:电商平台按价格档位统计商品数量(0-100/100-500/500-1000),用于搜索页面的价格筛选器;2. 评分分布:评论系统按评分分桶(1星/2星/3星/4星/5星),显示评分柱状图;3. 年龄分组:用户画像按年龄段分桶(18-25/25-35/35-50/50+),用于定向营销;4. 时间分桶:日志按小时/天/月分桶,用于趋势图。$bucket 本质上是"连续值→离散区间"的转换。

空桶的处理策略:$bucket 会显示空桶(count=0),$bucketAuto 不会。这在业务上有差异——搜索页面的价格筛选器需要显示所有价格区间(包括空桶),否则用户不知道哪些区间可用。评分分布图也需要显示所有星级的柱子(包括 0 条评论的星级)。因此,评分/价格筛选场景用 $bucket(已知区间),探索性分析用 $bucketAuto(自动发现数据分布模式)。

$bucket 的 output 扩展:$bucket 的 output 不仅可以 $sum: 1 计数,还可以执行其他累加器——如每个价格区间的平均评分(avgRating: {$avg: '$rating'})、最高价(maxPrice: {$max: '$price'})、包含的商品列表(products: {$push: '$title'})。扩展 output 让 $bucket 从简单的计数工具变为多维度分组统计工具——一次查询返回每个区间的多个统计指标。

$facet + $bucket 的搜索页架构:电商搜索页的经典架构——一次 $facet 查询返回:1. 搜索结果列表($sort + $skip + $limit);2. 总数($count);3. 价格分布($bucket 按价格区间统计);4. 分类分布($group 按分类统计);5. 评分分布($bucket 按评分统计)。前端一次请求拿到所有数据,渲染完整的搜索页面——商品列表、价格筛选器、分类侧栏、评分柱状图。

$facet 查询的性能考量:$facet 的子管道并行执行但共享输入数据——1. 输入共享:$facet 之前的 $match 只执行一次,所有子管道处理相同的输入数据集;2. 内存占用:每个子管道独立维护内存状态,N 个子管道 = N 倍内存占用(如果单阶段 100MB 限制,4 个子管道可能占用 400MB);3. 优化策略:子管道中避免重复的 $match(已在 $facet 前过滤),只做必要的 $project/$group;4. 替代方案:如果 $facet 内存超限,可拆分为多次独立聚合查询(牺牲原子性和网络往返次数)。生产环境中 $facet 适合中小数据集(< 10 万条匹配文档),大数据集需评估内存使用。

3. $bucket 数据分桶

分桶的统计学原理:$bucket 本质是直方图(Histogram)的数据结构——将连续值域划分为离散区间,统计每个区间的文档数量。直方图是数据探索的第一步:通过观察值的分布,判断数据是否正态、有无离群值、是否需要归一化。$bucket 适合已知区间(如价格档位 0-100/100-500/500-1000),$bucketAuto 适合探索性分析(自动选择均匀分桶)。

$bucket vs $switch 分桶对比:$bucket 和 $switch 都能实现区间分类,但设计目标不同——$bucket 是统计工具(输出每组的计数和聚合值),$switch 是分类工具(输出每条文档的分类标签)。选型:需要直方图/统计→$bucket;需要给每条文档打标签→$switch + $addFields。

$bucketAuto 的分桶算法:$bucketAuto 使用近似等深分桶算法——目标是让每个桶包含大致相同数量的文档,而非等宽分桶(每个桶的值范围相等)。例如,商品价格集中在 50-200 区间,$bucketAuto 会在 50-200 区间划分更多桶(数据密度高),在 500-5000 区间划分更少桶(数据稀疏)。这比等宽分桶更有信息量——等宽分桶在数据稀疏区产生空桶,在密集区将大量数据塞进一个桶。$bucketAuto 的 granularities 选项可控制桶数(如 5/10/20 桶)。

100%
graph LR
    A["price: 599"] --> B{哪个桶?}
    C["price: 29"] --> B
    D["price: 1299"] --> B
    E["price: 7500"] --> B
    
    B --> F["[0, 100): price=29 ✅"]
    B --> G["[100, 500): 无"]
    B --> H["[500, 1000): price=599 ✅"]
    B --> I["[1000, 5000): price=1299 ✅"]
    B --> J["default 'Other': price=7500 ✅"]
    
    style F fill:#d4edda
    style H fill:#d4edda
    style I fill:#d4edda
    style J fill:#fff3cd
对比维度 $bucket $bucketAuto
桶边界 手动指定 自动计算
桶数量 由边界数决定 指定 buckets: N
桶大小 可不等 尽量均匀
适用场景 已知区间(价格档位) 探索数据分布
空桶处理 显示空桶 不显示空桶

$bucket 的输出扩展:$bucket 的 output 参数可以在每个桶内执行聚合计算——不仅限于 $sum: 1 计数,还可以计算桶内的 $avg(平均价格)、$push(收集桶内文档的特定字段)、$max/$min(桶内极值)。典型用法:$bucket + output: {count: {$sum: 1}, avgPrice: {$avg: '$price'}, topProduct: {$max: '$price'}} 在一个桶内同时返回数量、均价和最贵商品。这使得 $bucket 不仅是"分桶计数"工具,更是"分桶统计分析"工具。

$bucketAuto 的算法与局限:$bucketAuto 使用近似等深分桶算法——尽量让每个桶包含相同数量的文档,而非相同宽度的区间。这意味着价格 0-100 可能有 500 个文档(便宜商品多),而 5000-10000 可能只有 50 个文档(贵商品少)。局限:1. 桶边界不可控(自动计算,无法指定"0-100/100-500"这样的业务区间);2. 数据倾斜时效果差(99% 的商品价格在 0-1000,1% 在 10000+,自动分桶会在 0-1000 内切很多小桶);3. 不适合展示给业务人员(桶边界是任意数值如 47.5-89.3,不是"0-100/100-500"这样直观的区间)。$bucketAuto 适合探索性数据分析,$bucket 适合面向业务的固定区间展示。

JAVASCRIPT
// === $bucket 自定义分桶 ===
db.products.aggregate([
  {
    $bucket: {
      groupBy: '$price',
      boundaries: [0, 100, 500, 1000, 5000, 10000],  // 桶边界
      default: 'Other',                                // 超出范围
      output: {
        count: { $sum: 1 },
        products: { $push: { sku: '$sku', title: '$title', price: '$price' } }
      }
    }
  }
]);
// [
//   { _id: 0, count: 120, products: [...] },
//   { _id: 100, count: 80, products: [...] },
//   ...
// ]

// === $bucketAuto 自动分桶 ===
db.products.aggregate([
  {
    $bucketAuto: {
      groupBy: '$price',
      buckets: 5  // 自动分 5 个桶
    }
  }
]);

搜索架构的选择:MongoDB 文本搜索 vs Elasticsearch 是架构选型的经典决策。MongoDB 文本搜索的优势:1. 无需额外基础设施(数据已在 MongoDB 中);2. 与 CRUD 操作一致(同一查询语言);3. 小数据量(<100万条)性能足够。Elasticsearch 的优势:1. 中文分词(IK Analyzer 等分词器);2. 模糊匹配和同义词;3. 高亮显示;4. 百亿级数据量。选择原则:简单搜索用 MongoDB,复杂搜索用 Elasticsearch。可以先从 MongoDB 文本搜索开始,搜索需求复杂化后再迁移到 Elasticsearch。

倒排索引的构建过程:创建文本索引时,MongoDB 对每个文档的文本字段进行词法分析——1. 分词(按空格/标点分割);2. 词干提取(running→run,复数→单数);3. 去停用词(the/a/an 等高频无意义词);4. 构建倒排索引(词→文档ID列表 + 词频)。查询时 $search 的词同样经过分词和词干提取,然后在倒排索引中查找匹配文档。注意:中文按字符分割(无空格分词),效果差——"智能手机"会被分为"智""能""手""机"四个单字。

BM25 相关度评分:MongoDB 文本搜索使用 BM25 算法计算相关度分数——考虑三个因素:1. 词频(TF):词在文档中出现次数越多,分数越高;2. 文档频率(IDF):词在所有文档中出现越少(越稀有),分数越高;3. 文档长度归一化:短文档中匹配到词比长文档中匹配到更有价值。加权文本索引的 weights 参数影响 BM25 的计算——权重高的字段的匹配贡献更大分数。

4. 文本搜索

搜索系统原理:MongoDB 文本搜索基于倒排索引——创建索引时对文本进行分词、词干提取,构建"词→文档"的映射表。查询时用 $search 指定关键词,在倒排索引中查找匹配文档,按 TF-IDF 算法计算相关度分数。局限性:1. 不支持中文分词(按字符分割而非语义分词);2. 不支持模糊匹配(需要 Elasticsearch 的 fuzzy query);3. 不支持同义词扩展;4. 每集合仅一个文本索引。

何时用 MongoDB 文本搜索 vs Elasticsearch:MongoDB 文本搜索适合简单场景——英文内容的精确/短语搜索、小数据集的全文检索。Elasticsearch 适合复杂场景——中文分词、模糊搜索、同义词、高亮、拼音搜索、多字段加权。决策点:1. 数据量 < 10 万且只需英文搜索→MongoDB 足够;2. 需要中文分词或模糊搜索→必须 Elasticsearch;3. 搜索是核心功能→Elasticsearch;4. 搜索是辅助功能→MongoDB 简单实现。

MongoDB Atlas Search 的第三条路:Atlas Search 是 MongoDB Atlas 云服务内置的搜索引擎——基于 Apache Lucene(与 Elasticsearch 相同的底层引擎),提供中文分词、模糊搜索、同义词、高亮等高级功能,且与 MongoDB 无缝集成(无需同步数据到外部集群)。优势:1. 零数据同步延迟(Lucene 索引与 MongoDB 集合实时同步);2. 聚合管道 $search 阶段直接使用(无需学习新的查询语法);3. 运维零负担(Atlas 托管,无需管理 Elasticsearch 集群)。限制:1. 仅 Atlas 云服务可用(自建 MongoDB 不支持);2. 有额外费用;3. 功能不如 Elasticsearch 全面(如无聚合桶/嵌套类型)。中小项目用 Atlas Search 是最佳平衡——既不需要维护 Elasticsearch 集群,又能获得专业搜索能力。

搜索系统的选型决策总结:三种搜索方案的选择——1. MongoDB 原生 $text:零成本、零运维、功能有限(英文搜索、无分词、无模糊匹配),适合搜索是辅助功能的小项目;2. Atlas Search:低运维、中等成本、功能较强(中文分词、模糊匹配、高亮),适合 Atlas 用户的中型项目;3. Elasticsearch:高运维、高成本、功能最强(所有搜索特性 + Kibana 分析),适合搜索是核心功能的大型项目。选型维度:数据量(小→MongoDB,大→ES)、语言(纯英文→MongoDB,中文→ES/Atlas)、搜索重要性(辅助→MongoDB,核心→ES)、运维能力(弱→Atlas,强→ES)。

100%
graph TD
    A[文档: title='Smartphone X 5G'<br/>description='Latest 5G phone'] --> B[文本索引<br/>词法分析]
    B --> C[倒排索引<br/>smartphone → doc1<br/>phone → doc1<br/>5g → doc1<br/>latest → doc1]
    
    D["$search: 'phone'"] --> E[查找倒排索引<br/>phone → doc1]
    E --> F[✅ 命中]
    
    G["$search: 'phone -cheap'"] --> H[phone → doc1<br/>排除含 cheap 的]
    H --> F
    
    style C fill:#d4edda
    style F fill:#d4edda
搜索类型 语法 说明
词级搜索 $search: 'phone smartphone' 匹配任一词(OR)
短语搜索 $search: '"smartphone 5g"' 必须完整短语
排除搜索 $search: 'phone -cheap' 包含 phone 但排除 cheap
相关度排序 score: { $meta: 'textScore' } 按相关度排序

(1) 创建文本索引

文本索引设计策略:每个集合最多一个文本索引,但可覆盖多个字段并设置权重——权重高的字段匹配时获得更高 textScore,搜索结果排序更合理。设计要点:1. 标题权重应高于描述(用户搜索时标题匹配更重要);2. 不要对低信息量字段建文本索引(如 status、category);3. 文本索引占用空间大(约为数据量的 2-3%),对大集合谨慎添加;4. 复合索引和文本索引不能共存于同一查询。

JAVASCRIPT
// === 创建文本索引(单字段)===
db.products.createIndex({ title: 'text' });

// === 多字段文本索引 ===
db.products.createIndex({
  title: 'text',
  description: 'text',
  tags: 'text'
});

// === 加权文本索引 ===
db.products.createIndex(
  {
    title: 'text',
    description: 'text'
  },
  {
    weights: {
      title: 10,        // title 权重高
      description: 1
    },
    name: 'TextIndex'
  }
);

(2) $text 搜索

$text 搜索的四种模式:$text 支持四种搜索语法——1. 词级搜索(默认 OR 语义):'phone smartphone' 匹配含任一词的文档;2. 短语搜索(双引号):'"smartphone 5g"' 必须包含完整短语;3. 排除搜索(减号):'phone -cheap' 含 phone 但不含 cheap;4. 相关度排序:用 $meta: 'textScore' 获取分数并排序。注意:$text 搜索不支持通配符(*)、不支持模糊匹配(~)、不支持正则。

文本索引的构建与维护成本:文本索引的构建成本高于普通索引——1. 索引大小:文本索引对每个字段做分词后建立倒排索引,索引大小可能达到原数据的 30-50%(普通索引约 10%);2. 构建时间:大数据集(> 100万文档)构建文本索引可能需要数分钟到数小时,应在低峰期执行;3. 写入性能:每次插入/更新包含文本字段的文档,都需要更新倒排索引,写入延迟增加 10-30%;4. 限制:每个集合只能有一个文本索引(但可覆盖多个字段);文本索引不支持 $or 查询中的 text 搜索。这些成本意味着——只为确实需要全文搜索的字段建文本索引,不要对所有字符串字段都建。

JAVASCRIPT
// === 基本搜索 ===
db.products.find({ $text: { $search: 'phone smartphone' } });
// 匹配 title 或 description 含 "phone" 或 "smartphone" 的文档

// === 短语搜索 ===
db.products.find({ $text: { $search: '"smartphone 5g"' } });
// 必须包含完整短语

// === 排除搜索 ===
db.products.find({ $text: { $search: 'phone -cheap' } });
// 包含 phone 但不含 cheap

// === 按相关度排序 ===
db.products.find(
  { $text: { $search: 'phone' } },
  { score: { $meta: 'textScore' } }
).sort({ score: { $meta: 'textScore' } });

地理查询性能优化:2dsphere 索引是地理查询性能的关键——没有索引,$geoNear 和 $nearSphere 会全集合扫描。优化要点:1. $geoNear 必须是管道的第一个阶段(否则无法使用索引);2. maxDistance 越小,扫描范围越小,性能越好;3. 复合索引 {location: '2dsphere', category: 1} 支持"附近某类别的店铺"查询;4. $geoWithin 比 $geoNear 快(不排序),优先用于纯范围查询。

中文搜索的替代方案:MongoDB 文本索引对中文支持有限——按字符分割而非按词分割,导致"智能手机"被分为四个单字索引,搜索"智能"无法匹配"智能手机"。三种替代方案:1. 正则表达式搜索 $regex: /智能/——简单但无法排序、不走索引;2. 应用层分词——在插入文档时用 jieba 等分词器预处理,将分词结果存入数组字段,用多键索引查询;3. Elasticsearch——专业搜索引擎,内置中文分词器(IK Analyzer),支持拼音搜索和同义词扩展。方案 3 是生产环境的首选。

中文分词的实践方案:中文搜索的核心挑战是"词"没有自然分隔符——1. jieba 分词方案:插入文档时用 Node.js 的 nodejieba 分词,将结果存入 segmentedTitle: ['智能', '手机', '智能手机'] 字段,创建多键索引,查询时也分词后用 $all 匹配;2. ngram 方案:用 $regex 的 ngram 匹配(如 /智.{0,2}手/),支持模糊匹配但性能差(全集合扫描);3. 拼音搜索:额外存储 pinyinTitle 字段(用 pinyin 库转换),支持拼音输入搜索中文商品;4. Elasticsearch 方案:创建 MongoDB → Elasticsearch 的同步管道(Change Stream 或 MongoSync),查询走 ES,数据仍存 MongoDB。方案 1 适合中小项目(0 成本),方案 4 适合大项目(专业搜索体验)。

2dsphere 索引的内部结构:2dsphere 索引将地理坐标编码为 Geohash——将地球表面递归划分为网格,每个网格用一串字符编码。查询时,$geoNear 先根据中心点的 Geohash 找到附近的网格,再精确计算球面距离。Geohash 的前缀匹配特性使得范围查询可以高效利用索引——查询"5km 内"时,只需扫描几个相邻网格,而非全部数据。

距离计算的精度:MongoDB 使用球面几何(WGS84 椭球体)计算距离——$geoNear 输出的 distanceField 单位是米,精度约 0.5 米。$geoWithin + $centerSphere 的半径单位是弧度——km 转 弧度 = km / 6378.1。注意:6378.1 是地球赤道半径(km),高纬度地区用此转换会有轻微误差(极地半径 6356.8km),但对 LBS 应用影响可忽略。

搜索 + LBS 的融合架构:电商搜索系统经常需要同时支持"关键词搜索"和"附近搜索"——如"5G 手机 3km 内"。两种搜索的融合方案:1. 先 $text 搜索再 $geoNear 过滤距离(适合搜索结果少、附近过滤快的场景);2. 先 $geoNear 附近搜索再 $match 关键词(适合附近结果少、关键词过滤快的场景);3. $facet 并行两个搜索后合并(最灵活但内存消耗大)。方案 1 最常用——用户先看到相关商品,再按距离排序。

5. 地理空间查询

地理空间索引原理:2dsphere 索引将球面坐标(经度/纬度)编码为 Geohash——将地球表面递归划分为网格,每个网格用一串字符编码。邻近查询时,先匹配 Geohash 前缀相同的网格(粗筛),再精确计算球面距离(精筛)。这种两阶段策略使 $geoNear 和 $nearSphere 的查询复杂度从 O(N) 降到 O(log N)。

地理空间查询选型:三种查询操作符各有适用场景——$geoNear 是聚合阶段,输出距离字段、支持排序和过滤,最适合"附近搜索+结果排序"场景;$nearSphere 是查询操作符,语法简单但不输出距离值,适合"是否在范围内"判断;$geoWithin 不排序,适合"区域内所有结果"的批量查询(如配送范围)。

操作符 输出距离 排序 聚合兼容 适用场景
$geoNear LBS 搜索
$nearSphere 距离判断
$geoWithin 区域批量

$geoNear 的独特限制:$geoNear 必须是聚合管道的第一个阶段——这是硬性限制,因为 $geoNear 需要从 2dsphere 索引的入口开始遍历。如果需要先 $match 过滤条件,必须在 $geoNear 的 query 选项中指定,而非在 $geoNear 前加 $match 阶段。$geoNear 的 distanceField 输出球面距离(米),distanceMultiplier 可转换单位(如 × 0.001 转为公里)。includeLocs: true 输出匹配的坐标点(对多点文档有用——一个门店有多个分店位置)。

LBS 系统的架构模式:完整的 LBS(Location-Based Service)系统包含三层——1. 数据层:2dsphere 索引 + 地理坐标存储;2. 查询层:$geoNear/$geoWithin 实现附近搜索和区域查询;3. 应用层:距离排序 + 分页 + 结果缓存。典型业务流程:用户打开"附近咖啡店"→ 获取用户定位 → $geoNear 搜索 3km 内 → 按距离排序 → 返回前 20 条。性能关键:2dsphere 索引 + maxDistance 限制 + limit 控制返回数量。

LBS 的缓存策略:LBS 查询的缓存比普通查询更复杂——因为位置是连续的,相邻位置的搜索结果高度重叠。缓存策略:1. 地理网格缓存(将地图划分为 1km × 1km 的网格,每格缓存搜索结果,同一网格内的查询返回缓存);2. 用户维度缓存(缓存"用户最近搜索的位置"的结果,5 分钟 TTL);3. 热门区域缓存(市中心等高频搜索区域预计算,冷区域实时查询)。注意缓存失效——门店信息变更时需清除受影响网格的缓存。

2d vs 2dsphere 索引选择:MongoDB 提供两种地理空间索引——2d(平面坐标,适合平面地图/游戏场景,用 legacy coordinate pairs [x, y] 存储)和 2dsphere(球面坐标,适合真实地球坐标,用 GeoJSON Point {type: 'Point', coordinates: [lng, lat]} 存储)。99% 的 LBS 场景应选 2dsphere——1. 支持真实的球面距离计算(2d 用欧几里得距离,在高纬度误差大);2. 支持 $geoNear/$geoWithin/$near 等全部地理操作符(2d 只支持 $near 的部分功能);3. 支持 GeoJSON 多种形状(Point/LineString/Polygon)。2d 仅用于游戏地图等纯平面场景。

(1) 创建 2dsphere 索引

JAVASCRIPT
// === 添加位置字段 ===
db.stores.insertOne({
  name: 'Tokyo Store',
  location: {
    type: 'Point',
    coordinates: [139.6917, 35.6895]  // [longitude, latitude]
  }
});

// === 创建 2dsphere 索引 ===
db.stores.createIndex({ location: '2dsphere' });

(2) 地理查询

GeoJSON 坐标格式要点:MongoDB 地理空间查询使用 GeoJSON 格式——type: 'Point' + coordinates: [lng, lat]。两个常见错误:1. 经纬度顺序颠倒(Google Maps 是 lat/lng,MongoDB 是 lng/lat,必须注意);2. 坐标值超出范围(经度 -180~180,纬度 -90~90)。$geoNear 的距离单位默认米(spherical: true 时),maxDistance: 5000 表示 5 公里范围内。

$centerSphere 弧度换算:$geoWithin + $centerSphere 的半径单位是弧度——1 弧度 ≈ 6378.1 公里(地球赤道半径)。换算公式:弧度 = 公里数 / 6378.1。示例:5 公里 = 5/6378.1 ≈ 0.000784 弧度。这个换算容易出错,建议封装为工具函数:kmToRadians(km) { return km / 6378.1; }。

JAVASCRIPT
// === $geoNear 查找附近 ===
db.stores.aggregate([
  {
    $geoNear: {
      near: { type: 'Point', coordinates: [139.6917, 35.6895] },  // 东京坐标
      distanceField: 'distance',     // 输出字段
      maxDistance: 5000,             // 最大距离 5km(米)
      spherical: true
    }
  }
]);

// === $geoWithin + $centerSphere 范围查询 ===
db.stores.find({
  location: {
    $geoWithin: {
      $centerSphere: [
        [139.6917, 35.6895],         // 中心点
        5 / 6378.1                   // 5km 半径(弧度)
      ]
    }
  }
});

// === $nearSphere 简单查询 ===
db.stores.find({
  location: {
    $nearSphere: {
      $geometry: { type: 'Point', coordinates: [139.6917, 35.6895] },
      $maxDistance: 5000
    }
  }
});

$geoWithin 的性能特征:$geoWithin 不排序、不计算距离——它只判断点是否在区域内,因此比 $geoNear 快得多。$geoWithin 适合"区域内有多少"的统计查询(如"配送范围内的门店"),$geoNear 适合"最近的几个"的排序查询(如"附近 5 家咖啡店")。$geoWithin 支持三种区域形状:$centerSphere(圆形)、$polygon(多边形)、$box(矩形)。

$geoWithin 与 $geoNear 的选择决策:选择哪个操作符取决于业务需求——1. 需要"最近的 N 个结果"→ $geoNear(按距离排序 + limit);2. 只需"区域内的所有结果"→ $geoWithin(不排序,性能更好);3. 需要距离值→ $geoNear(输出 distanceField);4. 需要与 $facet 组合→ $geoWithin($geoNear 不能在 $facet 内使用,因为它必须是管道第一阶段);5. 多边形区域查询→ $geoWithin + $polygon($geoNear 只支持圆形范围)。配送范围(不规则多边形)用 $geoWithin,附近搜索(圆形范围)用 $geoNear。

LBS 系统的缓存策略:LBS 查询的结果可缓存——用户在同一位置的搜索结果短时间内不变——1. 缓存 key 设计:lbs:{lat}:{lng}:{radius}:{category}:{keyword},将所有查询参数编码为缓存 key;2. 缓存粒度:经纬度保留 3 位小数(约 110 米精度),同一网格内的用户共享缓存;3. TTL 设置:3-5 分钟(店铺位置短期内不变,但新开店需要及时展示);4. 缓存预热:热门商圈(如北京三里屯、上海南京路)的搜索结果提前缓存;5. 失效策略:新店铺上线时清除对应区域的缓存。LBS 缓存可减少 80%+ 的数据库查询,是 LBS 系统性能优化的第一手段。

弧度转换速查:$centerSphere 和 $nearSphere 的距离参数使用弧度——km 转 弧度 = km / 6378.1,英里 转 弧度 = miles / 3963.2。常用换算:1km ≈ 0.00015696 弧度,5km ≈ 0.0007848 弧度,10km ≈ 0.00157 弧度。$geoNear 的 maxDistance 使用米(不需要转换),这是 $geoNear 比 $nearSphere 更易用的原因之一。

LBS 系统的距离计算精度:MongoDB 地理空间查询默认使用球面几何(WGS84 椭球体近似)——在赤道附近精度最高(误差 < 0.5%),在高纬度地区精度稍低但仍远优于平面几何计算。2dsphere 索引的距离单位是米,$geoNear 的 distanceField 输出也是米。常见精度问题:1. 短距离(< 100m)精度足够(误差 < 1m);2. 长距离(> 1000km)误差可能达到几十米(球面近似误差);3. 跨极地查询精度最低(但极少有此场景)。对 99% 的 LBS 应用(找附近餐厅/门店),MongoDB 的距离精度完全满足需求。


6. 综合实战

概念说明:综合实战将 $facet、$bucket、文本搜索、地理空间查询组合使用,构建完整的电商搜索系统。一次 $facet 查询同时返回搜索结果列表、总数、价格分桶和分类统计,前端可直接渲染搜索页面。地理空间查询为 LBS(Location-Based Service)场景提供支持。

工作原理:搜索 API 的 $facet 查询将 $match(文本搜索+分类过滤)作为公共输入,4 个子管道并行处理:items(分页列表)、total(总数)、facets(价格分桶)、categories(分类统计)。地理查询独立执行,按距离排序返回结果。

搜索 API 架构设计要点:完整搜索 API 的核心挑战是"一次请求返回所有维度的数据"——前端需要列表(渲染搜索结果)、总数(分页信息)、分桶(价格/评分筛选器)、分类(侧栏导航)。$facet 让这四个维度在同一输入上并行计算,避免 4 次独立查询。但要注意:$facet 前的 $match 必须同时满足文本搜索和业务过滤(如分类、价格范围),否则各子管道看到的输入不一致。

搜索结果排序策略:搜索结果的排序直接影响用户体验——排序策略需根据场景选择:1. 相关度排序($meta: 'textScore'):用户搜关键词时期望最相关的结果排前面;2. 距离排序($geoNear):找附近店铺时期望最近的排前面;3. 价格排序:比价时期望最低/最高价排前面;4. 销量排序:选购时期望热门商品排前面;5. 综合排序(加权公式):$addFields 计算综合分数 = 0.4相关度 + 0.3销量 + 0.2评分 + 0.1新鲜度。综合排序是电商搜索的终极方案。

搜索系统的用户体验设计:搜索不只是"输入关键词返回结果"——完整搜索体验包括:1. 搜索建议(输入时联想词);2. 搜索结果(列表+排序);3. 分面筛选(分类/价格/品牌过滤);4. 结果统计(总数/分桶分布);5. 空结果处理(推荐热门/建议修改关键词)。MongoDB 的 $facet 一次查询可同时提供 2/3/4 的数据,搜索建议和空结果处理需要额外的查询和业务逻辑。

100%
graph TB
    A[搜索请求<br/>q=5G phone<br/>category=Electronics] --> B[$match<br/>$text + category]
    B --> C[$facet]
    
    C --> D[items<br/>$sort+skip+limit<br/>分页列表]
    C --> E[total<br/>$count<br/>总数]
    C --> F[facets<br/>$bucket<br/>价格分桶]
    C --> G[categories<br/>$group<br/>分类统计]
    
    D --> H[搜索结果页面<br/>一次查询返回]
    E --> H
    F --> H
    G --> H
    
    style C fill:#d4edda
    style H fill:#cce5ff

(1) 商品搜索 + 分面统计

分面搜索的业务价值:分面搜索(Faceted Search)是电商搜索的核心交互模式——用户输入关键词后,页面同时展示搜索结果和各维度的筛选条件(价格区间、分类、品牌、评分)。用户点击任意筛选条件,搜索结果立即缩小范围,筛选条件的计数也同步更新。这种"搜索→筛选→再搜索"的迭代体验,比传统的"搜索→翻页→再搜索"效率高 10 倍。$facet 是实现分面搜索的最佳工具——一次查询返回所有维度的统计数据,无需多次请求。

搜索 API 的参数设计:搜索 API 的参数需要覆盖所有筛选维度——1. 关键词(q):$text 搜索的查询词;2. 分类(category):精确匹配 category 字段;3. 价格范围(minPrice/maxPrice):$gte/$lte 过滤 price 字段;4. 品牌(brand):精确匹配或 $in 多选;5. 评分(minRating):$gte 过滤 rating 字段;6. 分页(page/limit):$skip/$limit 控制结果数量;7. 排序(sort):按相关度/价格/评分/销量排序。参数设计原则:所有筛选参数都是可选的(无参数时返回全部),分页参数有默认值(page=1, limit=20),排序有默认选项(sort=relevance)。

JAVASCRIPT
// === 商品搜索 + 多个统计维度 ===
app.get('/api/products/search', async (req, res) => {
  const { q, category } = req.query;
  const match = {};
  if (q) match.$text = { $search: q };
  if (category) match.category = category;

  const result = await Product.aggregate([
    { $match: match },
    {
      $facet: {
        items: [
          { $sort: { score: { $meta: 'textScore' } } },
          { $skip: 0 },
          { $limit: 20 },
          { $project: { sku: 1, title: 1, price: 1, thumbnail: 1 } }
        ],
        total: [{ $count: 'count' }],
        facets: [
          {
            $bucket: {
              groupBy: '$price',
              boundaries: [0, 100, 500, 1000, 5000],
              default: '5000+',
              output: { count: { $sum: 1 } }
            }
          }
        ],
        categories: [
          {
            $group: {
              _id: '$category',
              count: { $sum: 1 }
            }
          }
        ]
      }
    }
  ]);

  res.json(result[0]);
});

(2) 附近店铺查询

JAVASCRIPT
// === 查找附近的店铺 ===
app.get('/api/stores/nearby', async (req, res) => {
  const { lng, lat, maxDistance = 5000 } = req.query;

  const stores = await Store.find({
    location: {
      $nearSphere: {
        $geometry: { type: 'Point', coordinates: [parseFloat(lng), parseFloat(lat)] },
        $maxDistance: parseInt(maxDistance)
      }
    }
  }).limit(20);

  res.json(stores);
});

▶ 示例 1:$facet 多维度搜索 + 地理附近店铺实战

JAVASCRIPT
// 场景 1:商品多维度搜索(文本 + 分面统计)
db.products.insertMany([
  { sku: 'PHONE-001', title: 'Smartphone X 5G', description: 'Latest 5G phone', price: 599, category: 'Electronics', location: { type: 'Point', coordinates: [139.6917, 35.6895] } },
  { sku: 'PHONE-002', title: 'Smartphone Pro 5G', description: 'Pro 5G phone', price: 899, category: 'Electronics', location: { type: 'Point', coordinates: [139.7017, 35.6995] } },
  { sku: 'LAPTOP-001', title: 'Laptop Pro', description: 'Powerful laptop', price: 1299, category: 'Computers', location: { type: 'Point', coordinates: [139.6817, 35.6795] } }
]);

db.products.createIndex({ title: 'text', description: 'text' });
db.products.createIndex({ location: '2dsphere' });

// 文本搜索 + 分面统计 + 价格分桶
db.products.aggregate([
  { $match: { $text: { $search: '5G phone' } } },
  {
    $facet: {
      // 结果列表
      items: [
        { $sort: { score: { $meta: 'textScore' } } },
        { $limit: 10 },
        { $project: { sku: 1, title: 1, price: 1, score: { $meta: 'textScore' } } }
      ],
      // 总数
      total: [{ $count: 'count' }],
      // 价格分桶
      priceBuckets: [
        {
          $bucket: {
            groupBy: '$price',
            boundaries: [0, 500, 1000, 2000],
            default: '2000+',
            output: { count: { $sum: 1 }, avgPrice: { $avg: '$price' } }
          }
        }
      ],
      // 分类统计
      categories: [
        { $group: { _id: '$category', count: { $sum: 1 } } }
      ]
    }
  }
]);

// 场景 2:查找附近的店铺(5km 内的电子商品)
db.products.aggregate([
  {
    $geoNear: {
      near: { type: 'Point', coordinates: [139.6917, 35.6895] },  // 东京站
      distanceField: 'distance',
      maxDistance: 5000,  // 5km
      spherical: true,
      query: { category: 'Electronics' }
    }
  },
  { $project: { sku: 1, title: 1, price: 1, distance: { $round: ['$distance', 0] } } }
]);

// 输出:每个商品的距离(米),按距离排序

输出:场景 1 返回多维度结果(列表 + 总数 + 价格分桶 + 分类);场景 2 返回距离排序的商品列表。

▶ 示例 2:ShopHub 多维度商品搜索 API

JAVASCRIPT
// 场景:ShopHub 电商搜索页面,一次查询返回列表+分桶+分类+总数
db.products.insertMany([
  { sku: 'PHONE-001', title: 'Smartphone X 5G', description: 'Latest 5G phone with amazing camera', price: 599, category: 'Electronics', brand: 'TechCorp' },
  { sku: 'PHONE-002', title: 'Budget Phone 5G', description: 'Affordable 5G phone', price: 299, category: 'Electronics', brand: 'DataFlow' },
  { sku: 'TABLET-001', title: 'Tablet Pro 5G', description: '5G tablet for professionals', price: 899, category: 'Electronics', brand: 'TechCorp' },
  { sku: 'BOOK-001', title: '5G Technology Guide', description: 'Understanding 5G networks', price: 39, category: 'Books', brand: 'AppVenture' },
  { sku: 'WATCH-001', title: 'Smart Watch', description: 'Fitness tracker watch', price: 199, category: 'Wearables', brand: 'DataFlow' }
]);

db.products.createIndex({ title: 'text', description: 'text' });

// 多维度搜索:文本搜索 + 分桶 + 分类 + 总数
const results = db.products.aggregate([
  { $match: { $text: { $search: '5G' } } },
  {
    $facet: {
      items: [
        { $sort: { score: { $meta: 'textScore' } } },
        { $limit: 10 },
        { $project: { sku: 1, title: 1, price: 1, category: 1, score: { $meta: 'textScore' } } }
      ],
      total: [{ $count: 'count' }],
      priceBuckets: [
        {
          $bucket: {
            groupBy: '$price',
            boundaries: [0, 100, 300, 600, 1000],
            default: '1000+',
            output: { count: { $sum: 1 } }
          }
        }
      ],
      categories: [
        { $group: { _id: '$category', count: { $sum: 1 } } },
        { $sort: { count: -1 } }
      ]
    }
  }
]);

// 输出结构:
// {
//   items: [Smartphone X 5G(score:2.5), Tablet Pro 5G(score:2.0), Budget Phone 5G(score:1.8), 5G Technology Guide(score:1.2)],
//   total: [{count: 4}],
//   priceBuckets: [{_id:0,count:1},{_id:100,count:1},{_id:300,count:1},{_id:600,count:1}],
//   categories: [{_id:'Electronics',count:3},{_id:'Books',count:1}]
// }

输出:一次 $facet 查询返回搜索列表(按相关度排序)、总数、价格分桶分布和分类统计,前端可直接渲染搜索页面。

▶ 示例 3:$bucket 评分分布统计 + $bucketAuto 自动分桶

电商评论系统需要统计商品评分分布——5 星多少、4 星多少、1 星多少,以及价格区间分布。$bucket 手动指定分桶边界,适合已知的离散区间(如 1-5 星评分);$bucketAuto 自动计算分桶边界,适合连续值的探索性分析(如价格分布)。本示例用 $facet 一次返回评分分布、价格分布和评分-价格关联分析。

JAVASCRIPT
// 测试数据:20 条商品评论
db.reviews.insertMany([
  { productId: 'prod1', rating: 5, price: 899, content: 'Excellent phone', category: 'Electronics' },
  { productId: 'prod1', rating: 4, price: 899, content: 'Good value', category: 'Electronics' },
  { productId: 'prod2', rating: 3, price: 29, content: 'Average book', category: 'Books' },
  { productId: 'prod2', rating: 2, price: 29, content: 'Not great', category: 'Books' },
  { productId: 'prod3', rating: 5, price: 199, content: 'Love it', category: 'Accessories' },
  { productId: 'prod3', rating: 4, price: 199, content: 'Solid choice', category: 'Accessories' },
  { productId: 'prod3', rating: 3, price: 199, content: 'Decent', category: 'Accessories' },
  { productId: 'prod1', rating: 1, price: 899, content: 'Broke in a week', category: 'Electronics' },
  { productId: 'prod4', rating: 5, price: 45, content: 'Best guide ever', category: 'Books' },
  { productId: 'prod4', rating: 4, price: 45, content: 'Very helpful', category: 'Books' },
  { productId: 'prod1', rating: 4, price: 899, content: 'Great camera', category: 'Electronics' },
  { productId: 'prod2', rating: 5, price: 29, content: 'Short but useful', category: 'Books' },
  { productId: 'prod3', rating: 2, price: 199, content: 'Overpriced', category: 'Accessories' },
  { productId: 'prod5', rating: 3, price: 599, content: 'Okay laptop', category: 'Electronics' },
  { productId: 'prod5', rating: 4, price: 599, content: 'Good for the price', category: 'Electronics' },
  { productId: 'prod5', rating: 5, price: 599, content: 'Amazing performance', category: 'Electronics' },
  { productId: 'prod4', rating: 3, price: 45, content: 'Some errors', category: 'Books' },
  { productId: 'prod1', rating: 3, price: 899, content: 'Average smartphone', category: 'Electronics' },
  { productId: 'prod2', rating: 4, price: 29, content: 'Nice short read', category: 'Books' },
  { productId: 'prod5', rating: 2, price: 599, content: 'Battery issues', category: 'Electronics' }
]);

// === 1. $bucket 手动评分分桶(1-5 星固定区间)===
db.reviews.aggregate([
  {
    $bucket: {
      groupBy: '$rating',
      boundaries: [1, 2, 3, 4, 5, 6],
      default: 'invalid',
      output: {
        count: { $sum: 1 },
        avgPrice: { $round: [{ $avg: '$price' }, 0] },
        products: { $addToSet: '$productId' }
      }
    }
  },
  {
    $project: {
      ratingRange: { $concat: [{ $toString: '$_id' }, ' 星'] },
      count: 1,
      avgPrice: 1,
      uniqueProducts: { $size: '$products' },
      _id: 0
    }
  }
]);

// === 2. $facet 一次返回评分分布 + 价格分布 + 关联分析 ===
db.reviews.aggregate([
  {
    $facet: {
      ratingDistribution: [
        {
          $bucket: {
            groupBy: '$rating',
            boundaries: [1, 2, 3, 4, 5, 6],
            default: 'invalid',
            output: { count: { $sum: 1 }, avgPrice: { $round: [{ $avg: '$price' }, 0] } }
          }
        }
      ],
      priceBuckets: [
        {
          $bucketAuto: {
            groupBy: '$price',
            buckets: 4,
            output: { count: { $sum: 1 }, avgRating: { $round: [{ $avg: '$rating' }, 1] } }
          }
        }
      ],
      categoryRating: [
        {
          $group: {
            _id: '$category',
            avgRating: { $round: [{ $avg: '$rating' }, 1] },
            reviewCount: { $sum: 1 },
            fiveStarCount: {
              $sum: { $cond: [{ $eq: ['$rating', 5] }, 1, 0] }
            }
          }
        },
        { $sort: { avgRating: -1 } }
      ]
    }
  }
]);

输出:1) $bucket 评分分布——1 星:1条(均价899), 2 星:3条(均价142), 3 星:4条(均价293), 4 星:5条(均价414), 5 星:5条(均价394);2) $facet 并行返回评分分桶、价格自动分桶和分类评分统计。

$bucket vs $bucketAuto 的选择:1. $bucket 适合已知分桶边界——评分 1-5 星、年龄段划分、收入等级等,边界由业务逻辑决定;2. $bucketAuto 适合探索性分析——不知道数据分布时让 MongoDB 自动分桶,buckets 参数控制分桶数量;3. $bucketAuto 的边界不一定整齐——可能产生 [29.0, 199.0) 这样的非整数边界,需要 $project 格式化;4. $bucket 的 boundaries 必须是升序且等距或递增——[0, 10, 50, 100] 合法,[0, 50, 10, 100] 不合法;5. 超出 boundaries 范围的值归入 default 桶——务必设置 default 防止数据丢失。

❓ 常见问题

常见问题解读:这四个问题对应四个主题的核心边界——$facet 的内存边界、文本搜索的语言边界、地理查询的坐标约定、$bucketAuto 的算法边界。每个边界都有其技术原因和应对策略。理解边界比记住答案更重要——知道 MongoDB 文本搜索不支持中文分词,才能在架构设计时提前引入 Elasticsearch。

Q $facet 和多次查询有什么区别?
A $facet 一次查询返回所有维度,减少网络往返。但内存消耗大。
Q 文本搜索能匹配中文吗?
A MongoDB 文本索引默认按词分隔(空格/标点),中文按字符分割。如需中文分词,需配合 Elasticsearch。
Q 地理坐标顺序是什么?
A MongoDB 地理坐标是 [longitude, latitude](注意:经度在前)。
Q $bucketAuto 如何选择桶边界?
A 基于数据分布自动选择均匀桶大小(如 5 个桶,每桶约 20% 数据)。

📖 小节

四大主题的串联:$facet + $bucket 是聚合能力的延伸——让搜索结果页面一次返回所有维度数据;文本搜索和地理空间查询是查询能力的扩展——从精确匹配到模糊搜索、从属性查询到空间查询。这四个能力组合起来,就能构建完整的电商搜索系统——关键词搜索 + 分面筛选 + 价格分桶 + 附近店铺,覆盖 90% 的搜索场景。

搜索系统的技术选型决策:MongoDB 原生搜索 vs Elasticsearch 的选择——1. MongoDB 适合:小型项目(< 10 万文档)、搜索逻辑简单(关键词+分类+价格过滤)、不想引入新基础设施;2. Elasticsearch 适合:大型项目(> 100 万文档)、需要中文分词/拼音搜索/同义词扩展、需要复杂的评分算法(BM25 + 自定义 boost)、需要近实时索引(毫秒级更新);3. 混合方案:数据存储在 MongoDB,通过 Change Stream 同步到 Elasticsearch,查询走 Elasticsearch。混合方案兼顾 MongoDB 的写入能力和 Elasticsearch 的搜索能力——这是生产环境最常见的架构。

$facet 子管道的设计模式:$facet 的子管道设计有三种模式——1. 独立子管道(最常见):每个子管道独立处理完整数据集,互不依赖(如 items 子管道返回列表、total 子管道返回计数、facets 子管道返回分桶);2. 共享 $match:所有子管道共享 $facet 前的 $match 过滤结果,避免重复过滤;3. 嵌套 $facet(不推荐):$facet 内嵌 $facet,逻辑复杂且内存消耗翻倍。模式 2 是最佳实践——公共过滤前置(如分类/关键词/价格范围),子管道只做各自的统计逻辑。


📝 作业

  1. 基础题(⭐):创建 products 集合的文本索引(title + description),执行 $text 搜索。
  2. 基础题(⭐):用 $bucket 按价格区间统计商品数量。
  3. 进阶题(⭐⭐):实现完整搜索 API(文本搜索 + 分桶统计 + 分类统计)。
  4. 进阶题(⭐⭐):实现附近店铺查询(2dsphere + $nearSphere)。
  5. 挑战题(⭐⭐⭐):综合搜索系统(文本 + 地理 + 分面统计)。

挑战题实现建议:综合搜索系统是本课程最复杂的挑战——需要组合 $text(文本搜索)、$facet(多维度统计)、$bucket(价格分桶)、$geoNear(距离排序)四种能力。建议分步实现:1. 先实现 $text 搜索 + 结果列表;2. 再加 $facet 多维度统计;3. 再加 $bucket 价格分桶;4. 最后加 $geoNear 附近搜索。每步完成验证后再进入下一步。注意 $text 和 $geoNear 不能在同一个 $match 中使用——$text 必须是 $match 的第一个条件,$geoNear 必须是管道的第一个阶段。解决方案:用 $facet 并行两个搜索,或先 $text 再 $geoNear。

综合搜索系统的生产化要点:挑战题的教学版本离生产还有几步——1. 输入验证:所有查询参数必须验证(q 长度 1-200、category 枚举值、price 范围 0-999999、坐标格式验证);2. 默认排序:无关键词时按销量/评分排序(而非随机顺序),有关键词时按相关度排序;3. 结果缓存:相同查询参数的结果缓存 5 分钟(key = hash(params)),减少数据库压力;4. 超时保护:maxTimeMS: 5000 限制聚合执行时间,超时返回部分结果 + 提示"结果可能不完整";5. 慢查询监控:记录执行时间 > 1s 的搜索查询,用于优化索引和管道。这些要点是搜索系统从 Demo 到生产的关键差距。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏