“离我最近的便利店"与"同时满足关键词和位置条件的商家"是 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 / $nearSphere | find 结果 | 按距离升序 | 无 | 必需 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 索引选择
| 索引类型 | 适用数据 | 查询能力 | 弃用条件 |
|---|---|---|---|
| 2dsphere | GeoJSON 与球面 | $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,而不是继续加索引。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。