在处理地理位置数据时,MongoDB 提供了一套完整的地理空间查询机制。球面距离查询的核心是基于地球的曲率计算两点之间的最短弧长,这比简单的平面坐标距离要复杂一些。MongoDB 的 2dsphere 索引支持 GeoJSON 格式的地理位置数据,并且能够利用球面几何公式完成距离计算。很多业务系统在实现查找附近的人或店铺时,首先接触到的可能是查询选择器中的 $near 操作符。它确实可以在普通 find 查询中快速获取附近的文档,但聚合管道中不能直接这样写。

接下来的内容会先说明 $near 在基础查询中的用法,再重点剖析聚合管道里 $geoNear 阶段如何实现相同的球面距离查询目标。通过对比两者的参数和返回结果,可以帮助你更准确地设计地理位置检索逻辑。
$near 操作符与球面距离计算
$near 是 MongoDB 查询语言中的一个地理位置操作符,它要求目标集合已经创建了 2dsphere 或 2d 索引。使用 2dsphere 索引时,$near 默认按照球面距离对结果进行排序,距离单位是米。这意味着你传入一个 GeoJSON 点作为参考坐标,MongoDB 会返回距离该点从近到远的文档。
球面距离与平面距离的差异在跨纬度或经度较大的区域会很明显。例如,在北纬 60 度附近,经度每差 1 度的实际地面距离只有赤道附近的一半左右。如果错误地使用平面索引 2d 而没有开启 spherical: true,计算出来的结果就会与实际位置产生很大偏差。$near 操作符配合 2dsphere 索引时并不需要显式指定 spherical,因为该索引本身就是球面索引。
下面是一个在 find 查询中使用 $near 的简单示例。它从 places 集合中查找距离指定坐标点最近的 5 个地点:
db.places.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [ 116.404, 39.915 ] },
$maxDistance: 5000
}
}
}).limit(5)
这里传入的坐标为 GeoJSON 格式,经度在前、纬度在后。 $maxDistance 的单位是米,表示只返回 5 公里以内的地点。虽然这个写法很直接,但当业务需要把距离值作为字段返回、或者需要在管道后续阶段继续处理时,find 查询就显得力不从心。
聚合管道中的 $geoNear 阶段
聚合管道无法直接使用 $near 操作符,这一点经常让开发者产生困惑。实际上,MongoDB 为聚合框架提供了专用的 $geoNear 阶段。它的作用与 $near 类似,但更适合在管道中使用,因为它不仅返回匹配的文档,还会把计算出的距离写入指定字段,供后续的 $match、$project 或 $group 阶段使用。
$geoNear 阶段的参数比 $near 更加丰富。其中 near 用于传入参考坐标点,distanceField 是输出距离值的字段名,spherical 用来显式声明球面计算,maxDistance 和 minDistance 可以限制搜索半径的范围。一个典型的 $geoNear 阶段写法如下:
db.places.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [ 116.404, 39.915 ] },
distanceField: "distance",
spherical: true,
maxDistance: 5000
}
}
])
需要特别注意的是,$geoNear 必须是聚合管道中的第一个阶段。如果管道中已经有 $match、$sort 或其他阶段,MongoDB 会直接抛出错误。这是因为 $geoNear 需要先利用地理空间索引完成距离排序,然后才能执行后续的管道操作。此外,集合必须存在 2dsphere 索引,否则也会报错。
与 $near 查找相比,$geoNear 的优势在于距离值可以直接参与后续计算。例如,你可以根据距离字段进行二次过滤,或者将距离转换为公里后输出给前端。下面的管道在 $geoNear 之后使用 $match 过滤出 2 公里范围内的结果,并用 $project 调整输出内容:
db.places.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [ 116.404, 39.915 ] },
distanceField: "distance",
spherical: true,
maxDistance: 10000
}
},
{ $match: { distance: { $lte: 2000 } } },
{ $project: { name: 1, category: 1, distanceKm: { $divide: [ "$distance", 1000 ] } } }
])
这个例子中,$geoNear 先粗筛 10 公里内的地点,然后 $match 再进一步收紧到 2 公里。虽然多了一步,但可以更灵活地调整业务规则,而无需重新计算距离。
实战:创建索引并构建完整查询
要真正让球面距离查询生效,第一步是创建 2dsphere 索引。假设有一个 stores 集合,每个文档包含一个 location 字段,存储 GeoJSON Point 数据。创建索引的命令如下:
db.stores.createIndex({ location: "2dsphere" })
如果集合中已经存在旧的 2d 索引,建议先删除或转换为 2dsphere,否则球面距离计算可能不会按预期进行。创建索引后,插入几条测试数据,注意 GeoJSON 坐标顺序必须是 [longitude, latitude],也就是经度在前、纬度在后。
db.stores.insertMany([
{ name: "王府井店", location: { type: "Point", coordinates: [ 116.410, 39.915 ] } },
{ name: "西单店", location: { type: "Point", coordinates: [ 116.374, 39.912 ] } },
{ name: "国贸店", location: { type: "Point", coordinates: [ 116.461, 39.909 ] } }
])
完成数据准备后,可以编写一个完整的聚合查询。需求是查找距离天安门广场 5 公里范围内的门店,并按照距离排序,同时输出店名、类别和格式化后的距离。参考实现如下:
db.stores.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [ 116.397, 39.908 ] },
distanceField: "distance",
spherical: true,
maxDistance: 5000
}
},
{ $sort: { distance: 1 } },
{ $project: { name: 1, category: 1, distanceMeters: "$distance" } }
])
运行这段代码后,每个返回的文档都会带有一个 distance 字段,表示与参考点的球面距离,单位是米。$sort 可以省略,因为 $geoNear 默认已经按距离升序输出,但显式排序能提高可读性。如果后续还需要根据距离做分页,可以配合 $skip 和 $limit,但要注意性能影响。
常见问题与性能优化
使用 $geoNear 或 $near 时,最常见的报错是没有创建 2dsphere 索引。MongoDB 会提示 unable to find index for $geoNear query 或类似错误。解决方法是确认集合上的索引类型,必要时重建索引。另一个容易忽略的细节是 GeoJSON 的坐标顺序,如果经纬度写反,查询结果会完全偏离预期,而且不会报错。
性能方面,$geoNear 会利用地理空间索引进行排序,但如果 maxDistance 设置得过大,或者匹配的文档数量非常多,查询延迟会明显上升。在实际业务中,建议结合 limit 限制返回条数,因为 $geoNear 阶段支持直接使用 limit 参数,而不是依赖管道末尾的 $limit。示例:
db.stores.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [ 116.397, 39.908 ] },
distanceField: "distance",
spherical: true,
maxDistance: 5000,
limit: 20
}
}
])
这样可以减少需要处理的数据量,提升响应速度。如果业务还需要按类别、状态等字段进一步过滤,可以在 $geoNear 之后使用 $match,但不要把过滤条件放在 $geoNear 之前。对于更复杂的查询,可以考虑建立复合索引,将地理空间字段与常用过滤字段组合在一起,不过这需要根据查询模式谨慎设计。
此外,距离单位始终是米,如果需要公里显示,在 $project 阶段除以 1000 即可。球面距离计算虽然准确,但也会消耗更多 CPU,对于海量数据或高并发查询场景,可以考虑引入 Redis GEO 或专门的搜索引擎来分担压力。理解这些限制和优化思路,有助于在 MongoDB 中稳定地实现球面距离查询功能。

