33. MongoDB 地理空间与全文检索

地理空间与全文检索实战:2dsphere 索引与 GeoJSON、$geoNear 与 $geoWithin 查询、文本索引与 $text 检索、地理加文本加普通字段的复合索引设计与查询组合

“离我最近的便利店"与"同时满足关键词和位置条件的商家"是 LBS 应用的两大核心查询。MongoDB 的 2dsphere 索引支撑球面地理查询,文本索引支撑分词全文检索,而真正的复杂度在于两者与普通字段的叠加:一个位置、一个关键词、外加品类与价格过滤,应该建几个索引、怎么组合查询。本文从 GeoJSON 与 2dsphere 索引讲起,覆盖 $geoNear 与 $geoWithin 的使用差异、文本索引与 $text 的检索能力,最后给出地理、文本与普通字段三者叠加的索引设计与查询组合方法。

1. 2dsphere 索引与 GeoJSON

MongoDB 的地理查询以 GeoJSON 为基础。2dsphere 索引支持球面上任意 GeoJSON 几何类型的查询,是生产场景的首选地理索引。

1.1 GeoJSON 几何类型

// 点:最常见的坐标存储
db.places.insertOne({
  name: "望京咖啡",
  location: { type: "Point", coordinates: [116.481, 39.996] }
})

// 线:公交线路、河流
db.routes.insertOne({
  name: "13 号线",
  path: {
    type: "LineString",
    coordinates: [[116.42, 39.9], [116.44, 39.92], [116.47, 39.95]]
  }
})

// 多边形:商圈、行政区域
db.zones.insertOne({
  name: "国贸商圈",
  area: {
    type: "Polygon",
    coordinates: [[
      [116.45, 39.90], [116.47, 39.90],
      [116.47, 39.92], [116.45, 39.92], [116.45, 39.90]
    ]]
  }
})
GeoJSON 类型用途典型字段名
Point门店、用户位置location
LineString线路、轨迹path
Polygon商圈、围栏area
MultiPolygon多块区域areas

1.2 创建 2dsphere 索引

// 对点字段建立 2dsphere 索引
db.places.createIndex({ location: "2dsphere" })

// 复合形式:地理键 + 普通字段(后续章节详解)
db.places.createIndex({ location: "2dsphere", category: 1 })

// 检查索引
db.places.getIndexes()

坐标约定:GeoJSON 一律使用 [经度, 纬度] 顺序,这是最常见也最容易写反的坑。写反后查询结果会跑到地球另一边,且不会报错。同时注意 2dsphere 与旧式 2d 索引的差异:2d 只支持平面坐标([x, y]),2dsphere 支持球面与 GeoJSON,新项目一律用 2dsphere。

2. $geoNear 与 $geoWithin 查询

地理查询有两类入口:聚合阶段的 $geoNear 与查询操作符 $geoWithin、$near。它们的语义差异决定了使用场景。

2.1 $geoNear 聚合阶段

$geoNear 必须在聚合管道的第一阶段,且必须有 2dsphere 索引。它按距离排序输出,并为每条结果注入 distance 字段。

db.places.aggregate([
  { $geoNear: {
      near: { type: "Point", coordinates: [116.481, 39.996] },
      distanceField: "dist",
      maxDistance: 3000,          // 3 公里内
      spherical: true,
      query: { category: "coffee" }   // 附加过滤(使用索引)
    }
  },
  { $limit: 10 }
])

2.2 $geoWithin 查询操作符

$geoWithin 不要求地理索引(但建了会更快),它按"是否在范围内"返回,不排序、不计算距离。适合"圈选范围取全部"的场景,如电子围栏。

// $centerSphere:以 [经度, 纬度] 为中心、弧度半径
db.places.find({
  location: { $geoWithin: { $centerSphere: [[116.481, 39.996], 3000 / 6378137] } }
})

// $geometry:用 Polygon 圈选
db.places.find({
  location: {
    $geoWithin: {
      $geometry: {
        type: "Polygon",
        coordinates: [[[116.45, 39.90], [116.47, 39.90], [116.47, 39.92], [116.45, 39.92], [116.45, 39.90]]]
      }
    }
  }
})
查询方式返回排序距离计算索引要求
$geoNear聚合结果按距离升序有(distanceField)必需 2dsphere
$geoWithin范围内文档无无推荐 2dsphere
$near / $nearSpherefind 结果按距离升序无必需 2dsphere
$geoIntersects几何相交无无推荐 2dsphere

重要:需要"按距离排序 + 返回距离值"用 $geoNear;只需要"圈内是否有"用 $geoWithin。$geoNear 是聚合阶段,只能放管道首位;$geoWithin 是普通查询操作符,可与过滤、分页自由组合。

3. 文本索引与 $text 检索

全文检索通过文本索引实现。每个集合最多一个文本索引,但可以覆盖多个字段,并为不同字段分配不同权重。

3.1 创建文本索引与权重

// 单字段文本索引
db.posts.createIndex({ body: "text" })

// 多字段文本索引,title 权重高于 body
db.posts.createIndex({ title: "text", body: "text" }, {
  weights: { title: 10, body: 1 },
  default_language: "zh"      // 中文分词需注意语言支持
})

$text 查询支持分词搜索、短语搜索、排除词与语言过滤:

// 关键词搜索
db.posts.find({ $text: { $search: "咖啡 望京" } })

// 短语搜索(加引号)
db.posts.find({ $text: { $search: "\"精品咖啡\"" } })

// 排除词(减号前缀)
db.posts.find({ $text: { $search: "咖啡 -速溶" } })

3.2 文本相关性得分

$text 会给每条命中计算一个相关性得分,通过 $meta 提取并排序:

db.posts.find(
  { $text: { $search: "咖啡" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } }).limit(20)
能力语法说明
分词关键词$search: “a b c”OR 语义,任一命中即返回
短语精确匹配"abc"必须完整匹配
排除词-abc排除含该词的文档
相关性得分$meta: “textScore”按 TF/IDF 加权
语言处理default_language停用词与词干处理

注意:$text 在 find 中可与普通过滤并存,但在聚合管道中,$text 只能出现在管道首部的 $match 中,且与 $geoNear 无法同时作为管道首部使用(两者都要求排第一)。这是地理与文本组合时的核心约束,下节展开。

4. 地理与文本的复合索引设计

同时需要"按位置过滤 + 按关键词过滤 + 按普通字段过滤"的场景,是索引设计的难点。核心约束来自 MongoDB 的索引规则。

4.1 索引规则约束

  • 复合索引可以包含 2dsphere 键与普通字段,如 { location: "2dsphere", category: 1 }
  • 复合索引可以包含文本键与普通字段,如 { title: "text", price: 1 }
  • 同一复合索引不能同时包含文本键与 2dsphere 键,这是官方文档的明确限制
// 允许:2dsphere + 普通字段
db.places.createIndex({ location: "2dsphere", category: 1 })

// 允许:text + 普通字段
db.places.createIndex({ name: "text", price: 1 })

// 不允许:text + 2dsphere 混在同一复合索引
// db.places.createIndex({ location: "2dsphere", name: "text" })   // 报错

4.2 地理文本与普通字段的叠加组合

既然无法建单一索引,可行的方案是"拆分索引 + 查询计划器合并”:

// 方案:地理复合索引 + 文本复合索引 并存
db.places.createIndex({ location: "2dsphere", category: 1, price: 1 })
db.places.createIndex({ name: "text", category: 1, price: 1 })
// 组合查询:$geoWithin + $text + 普通过滤
db.places.find({
  $and: [
    { location: { $geoWithin: { $centerSphere: [[116.481, 39.996], 0.05] } } },
    { $text: { $search: "咖啡" } },
    { category: "coffee", price: { $lte: 40 } }
  ]
}).sort({ price: 1 }).limit(20)
组合方式索引利用适用场景
先地理后关键词(应用层二次过滤)$geoWithin + 应用过滤数据量小
索引交集(查询计划器)两个复合索引合并数据量大、两路过滤都有效
预聚合 + 标签字段单一索引关键词可离散化

决策铁律:地理、文本、普通字段三者叠加时,不要试图用一个索引解决全部。先评估三路过滤的选择性:位置圈选通常最收敛,关键词其次。让查询计划器用地理复合索引与文本复合索引做索引交集,是规模化场景的务实答案。

5. 综合查询场景与性能

以一个"附近且关键词匹配的商家"场景为例,演示从数据建模到查询落地的完整路径。

5.1 数据建模

db.shops.insertOne({
  name: "巷口精品咖啡",
  category: "coffee",
  price: 38,
  location: { type: "Point", coordinates: [116.481, 39.996] },
  tags: ["手冲", "安静", "wifi"]
})

5.2 分步查询与度量

// 第一步:确认地理索引命中
db.shops.find({
  location: { $geoWithin: { $centerSphere: [[116.481, 39.996], 0.05] } }
}).explain("executionStats")
// winningPlan 应包含 IXSCAN(geo)

// 第二步:叠加关键词与普通过滤
db.shops.find({
  $and: [
    { location: { $geoWithin: { $centerSphere: [[116.481, 39.996], 0.05] } } },
    { $text: { $search: "咖啡" } },
    { category: "coffee", price: { $lte: 40 } }
  ]
}).explain("executionStats")
// 检查 totalDocsExamined 是否接近 nReturned,确认索引交集生效
查询复杂度索引依赖性能风险
仅地理2dsphere低
地理 + 普通字段2dsphere 复合低
地理 + 文本双索引交集中,检查执行计划
地理 + 文本 + 普通字段双索引交集 + 过滤中高,需 explain 验证

5.3 聚合中的组合限制

聚合管道中 $geoNear 必须排第一,$text 只能出现在管道首部 $match。两者无法共存于同一管道。可行的替代:先用 find 的 $geoWithin + $text 得到候选集,再进聚合做分组、排序等分析。

// 聚合中只用 $geoNear(文本过滤前置到 query 参数)
db.shops.aggregate([
  { $geoNear: {
      near: { type: "Point", coordinates: [116.481, 39.996] },
      distanceField: "dist",
      spherical: true,
      query: { category: "coffee", price: { $lte: 40 } }
    }
  },
  { $limit: 10 }
])

6. 数据建模与索引规划

地理与文本查询的性能根基在建模与索引规划,常见坑位集中在坐标顺序、单位换算与索引类型。

6.1 坐标顺序与单位

GeoJSON 用 [经度, 纬度],写反不报错但结果错误。$centerSphere 的半径单位是弧度,公里换算公式为 km / 6378137(地球半径米)。项目里应封装换算函数,避免到处手写。

// 弧度与公里的换算(mongosh 示意)
function kmToRadians(km) { return km / 6378.137 }
db.places.find({
  location: { $geoWithin: { $centerSphere: [[116.481, 39.996], kmToRadians(5)] } }
})

6.2 索引选择

索引类型适用数据查询能力弃用条件
2dsphereGeoJSON 与球面$geoNear/$geoWithin/$near无,新项目首选
2d平面 [x, y] 对平面距离遗留数据
text文本字段$text 分词检索需位置与关键词组合时配合地理索引
普通字段标量过滤等值/范围作为复合索引的一部分

6.3 规划清单

  • 地理字段统一 GeoJSON Point,索引 2dsphere 复合带高频过滤字段
  • 文本索引每集合唯一,权重分配给标题类字段
  • 无法建单一索引时,明确"索引交集"路线并定期 explain 验证
  • 聚合组合受限时,用 find 圈候选 + 聚合分析的两阶段方案

重要:地理与文本的索引规划应在数据建模阶段完成,而不是上线后补救。坐标顺序、单位换算、索引复合方式这三项一旦写错或建错,改造成本极高。设计文档里应把"地理字段格式、查询半径单位、复合索引清单"三件事写死。

7. 总结与最佳实践

地理空间与全文检索的组合能力,最终取决于对 MongoDB 索引规则和查询阶段约束的把握:

  • 地理查询:$geoNear 排第一且必配 2dsphere;$geoWithin 自由组合但不排序
  • 文本查询:每集合一个文本索引,$text 与 $meta: "textScore" 配套排序
  • 复合索引:2dsphere 与 text 不可同索引,拆分复合索引 + 索引交集
  • 聚合限制:$geoNear 与 $text 不能同管道共为首部,用 find + 聚合两阶段替代

决策铁律:先明确查询的"主要选择性维度"。若位置圈选后候选集已经很小,文本与普通字段交给应用层过滤即可;若三路过滤都很稀疏,才值得让数据库承担索引交集。性能不达预期时,回到 explain 看 totalDocsExamined 与 nReturned,而不是继续加索引。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 32. MongoDB 批量写入与吞吐优化
  2. 31. MongoDB 读写关注与一致性
  3. 30. MongoDB 静态加密与字段级加密