最近有不少朋友私信我问MongoDB里“周围的人和店铺怎么查这么快的”,其实答案就在索引层面。当业务里开始出现“离我最近的门店”“某个多边形围栏内的订单”“判断用户坐标是否落在配送范围”这类诉求时,普通的字段索引就帮不上忙了,这时候绕不开的就是MongoDB的地理空间索引。
我最早接触这个功能是在做一个基于位置的服务模块,当时天真地以为用经纬度的两个字段做普通索引就够了,结果一上线就被查询延迟打脸。后来把方案切到MongoDB自带的Geo索引,查询时间直接从几百毫秒降到十几毫秒,这才意识到“选对索引类型”比“加多少索引”更重要。这篇文章我就把地理空间索引的底层原理、建索引姿势、常用查询写法以及我踩过的坑一次性讲清楚,给刚入坑或者准备入坑的同学一份可以直接实操的参考。
1. 为什么普通索引解决不了位置查询
1.1 经纬度不是普通的两个数字
很多人第一次接触地理坐标时会觉得,经度、纬度不就是一个double字段嘛,我建个复合索引不就是了?理论上确实可以这样建,但实际查询的时候问题就来了。
假设我们要查“当前坐标点周围5公里内的所有餐厅”,如果用普通复合索引,数据库首先要算出当前点和每条记录之间的球面距离,然后过滤掉距离大于5公里的数据。这个计算过程对于单条记录来说并不复杂,但问题是数据库无法利用索引去跳过那些“明显不满足条件”的记录,只能一条一条地全表扫描并计算距离。
比如一张表里有10万家餐厅,走普通索引的流程就是:加载所有10万家餐厅的经纬度,逐条计算与当前点的距离,判断是否在5公里内。这个过程的开销主要卡在“无法提前剪枝”,数据量一旦上来,查询时间就会肉眼可见地膨胀。
地理空间索引的核心作用,是把二维或球面上的坐标用一种特殊的空间数据结构组织起来,让数据库能通过索引直接定位到“候选区域”,只对一小部分数据进行精确的距离计算,从而大幅减少无效运算。
1.2 空间索引的本质:用网格和树把地球切块
为了理解MongoDB的地理空间索引是怎么工作的,我们没必要把底层算法全背下来,但至少要清楚它的大方向逻辑。
MongoDB的地理空间索引内部会把地球表面看成一个大平面,然后按照一定的规则切成很多小格子。当我们给某个坐标建立索引时,这个坐标并不会像普通索引那样按照数值大小排成一条线,而是根据它落在哪个格子里,被记录到对应格子的位置。查询的时候,数据库会根据查询范围找到相关的几个格子,只在这些格子里找候选点,然后对候选点做精确计算。
这种把空间切块、逐层细分的思路,本质上就是在做“先粗筛,再精算”。粗筛阶段利用空间索引跳过了大量不相干的记录,精算阶段则保证最终结果的距离或相交判断是精确的。正因为有了这个设计,才能支撑起“附近的人”“附近的门店”“配送范围判断”这类高频线上业务的实时响应。
用生活场景来类比,就像你在一个大型图书馆里找某本书。没有索引的方式是走遍每一个书架去翻每一本书,而空间索引相当于图书馆先按分类分了楼层,再按编号分了架位,你可以直接锁定某一个区域,然后在这个小区域里快速定位。地理位置查询面临的“在一圈范围里找目标”问题,和这个本质上是同一类。
2. 两种地理空间索引:2d与2dsphere
2.1 它们分别解决什么问题
MongoDB的地理空间索引主要分两种类型:2d索引和2dsphere索引。很多初学者会混淆,这里我直接拆开讲。
2d索引是MongoDB早期的地理索引方案,它把坐标看作平面上的点,计算距离时也按照平面几何的方式去处理。这种索引适合小范围、对球面精度要求不高的场景,比如一个园区内部的设备定位,或者一个城市内部的粗略位置查询。它的数据形式很简单,字段值可以是一个包含两个数字的数组,比如[经度, 纬度],或者一个形如{x: 经度, y: 纬度}的文档。
2dsphere索引则是基于球面几何模型设计的,它把地球当成一个真实的球体来处理,支持GeoJSON对象,比如点、线、多边形等,计算距离时使用球面距离算法,精度更高,应用范围也更广。现在绝大部分生产环境场景,比如周边门店搜索、地理围栏、路径区域判断,都更推荐使用2dsphere索引。
说得直白一点:如果你做的是全球级或者全国级的业务,坐标涉及大范围经纬度,距离计算要求准确,那就用2dsphere;如果你只是一个局部小场景,数据范围不大、精度要求不苛刻,2d也能凑合。
2.2 GeoJSON格式与坐标顺序是最容易踩的坑
使用2dsphere索引时,数据通常要存成GeoJSON格式。这里有一个非常容易踩的坑:GeoJSON的坐标顺序是[经度, 纬度],也就是先写经度,再写纬度。
这个顺序和我们平时口语里常说的“纬度、经度”(比如“北纬39度,东经116度”)不一样,也和我们习惯的“先纬度后经度”思维相反。我自己刚上手的时候就在这里出过错,把经纬度写反了,结果查询结果全都飘到了另一个半球。排查了半天才发现是坐标顺序的问题。
MongoDB里存储GeoJSON点的标准格式是这样的:
{ "name": "某商圈门店", "location": { "type": "Point", "coordinates": [116.397, 39.908] } }上面这段里,116.397是经度,39.908是纬度,坐标数组里先经度后纬度。如果业务代码里已有的坐标格式是纬经度,写入前一定记得调换顺序。
除了点之外,GeoJSON还支持LineString、Polygon等类型。比如要表示一个配送范围,可以存一个多边形:
{ "name": "三环配送范围", "area": { "type": "Polygon", "coordinates": [ [ [116.300, 39.800], [116.500, 39.800], [116.500, 40.000], [116.300, 40.000], [116.300, 39.800] ] ] } }需要注意的是,GeoJSON多边形要求首尾坐标点闭合,也就是第一个点和最后一个点必须相同。我在实际建数据时发现,不少人因为这个闭合问题导致查询结果异常,后面排查问题时非常浪费时间。
2.3 两种索引的适用场景对比
为了帮大家快速决策,我把两种索引的差异整理成表格:
| 对比维度 | 2d索引 | 2dsphere索引 |
|---|---|---|
| 数据模型 | 普通经纬度数组或子文档 | GeoJSON对象或普通经纬度 |
| 计算模型 | 平面几何 | 球面几何 |
| 距离精度 | 小范围尚可,大范围偏差大 | 高精度,适合大范围 |
| 支持的查询 | 基本的地理位置查询 | 支持更全面的GeoJSON查询 |
| 建议使用场景 | 园区、场馆等局部定位 | 全局业务、门店搜索、围栏判断 |
| 是否支持地理位置排序 | 支持 | 支持 |
从实践角度看,新项目我基本都直接用2dsphere,哪怕数据量小一点,也省得以后业务范围扩大时要迁移索引。毕竟2d索引在大量复杂地理操作上存在很多限制,后期迁移的代价比一开始选型时多花的那点成本大得多。
3. 动手实操:建索引、写数据、跑通第一个查询
3.1 造一批带坐标的测试数据
在写代码之前,先准备好测试数据。我用一个简化的门店集合来演示,集合名叫shops,里面存门店名称、经纬度坐标和评分字段。
先往集合里插入几条门店数据:
db.shops.insertMany([ { name: "东城店", location: { type: "Point", coordinates: [116.40, 39.90] }, rating: 4.5 }, { name: "西城店", location: { type: "Point", coordinates: [116.35, 39.92] }, rating: 4.2 }, { name: "南城店", location: { type: "Point", coordinates: [116.38, 39.85] }, rating: 4.8 }, { name: "北城店", location: { type: "Point", coordinates: [116.42, 39.95] }, rating: 4.1 } ])这里所有坐标点我都用GeoJSON的Point类型存储,字段名用location。字段名不是固定的,你可以根据自己的业务去命名,比如geo、position都可以,但建索引时要注意保持一致。
3.2 创建2dsphere索引
插入数据后,第二步是创建地理空间索引。在MongoDB shell里执行:
db.shops.createIndex({ location: "2dsphere" })这条命令的含义是:在location字段上建立一个2dsphere类型的地理空间索引。建完索引以后,可以查看一下索引列表确认是否成功:
db.shops.getIndexes()返回结果里应该能看到一个key为location_2dsphere的索引。这一步就算完成了。
如果业务中既有GeoJSON点,也有旧格式的普通经纬度数组,你也可以同时对两种格式建索引吗?实际上2dsphere索引支持GeoJSON和旧式坐标对混用,但强烈建议统一为GeoJSON格式。混用格式虽然不会报错,但查询时的解析路径会变得复杂,也容易埋坑。
3.3 查询当前坐标周边3公里内的门店
索引建好之后,我们来跑一个最经典的“周边查询”场景。假设用户当前位于经度116.39、纬度39.88的位置,我们要找出3公里范围内的所有门店,并按距离从近到远排序。
这里用到的是$nearSphere操作符:
db.shops.find( { location: { $nearSphere: { $geometry: { type: "Point", coordinates: [116.39, 39.88] }, $maxDistance: 3000 } } } )注意$maxDistance的单位是米,这个在2dsphere索引下是直接按球面距离计算的。上面的代码执行后,MongoDB会先利用2dsphere索引定位到以当前点为圆心、半径3公里的圆形区域,只对落入这个区域内的门店做精确距离判断,然后按距离排序返回。
这里有一个容易被忽略的细节:使用$nearSphere时,结果默认会按照距离由近到远输出,不需要你再单独做排序操作。如果你需要额外筛选条件,比如只看评分大于4.0的门店,可以在查询条件里直接加上:
db.shops.find( { location: { $nearSphere: { $geometry: { type: "Point", coordinates: [116.39, 39.88] }, $maxDistance: 3000 } }, rating: { $gte: 4.0 } } )这种写法既能利用地理索引缩小候选集,又能在候选集里做非地理条件的过滤,性能和写法上都很干净。
3.4 判断一个坐标点是否落在某个多边形区域内
除了“周边门店”这种点对点的查询,业务里更常见的还有“多边形包含判断”。比如外卖平台要判断一个用户地址是否在某个门店的配送范围内,就可以把配送范围存成一个多边形,然后用$geoWithin来做判断。
假设我们有一个集合delivery_zones,里面存了各个门店的配送范围:
db.delivery_zones.insertMany([ { shopId: "shop_a", zone: { type: "Polygon", coordinates: [ [ [116.30, 39.80], [116.45, 39.80], [116.45, 39.95], [116.30, 39.95], [116.30, 39.80] ] ] } } ])然后我们要判断用户坐标(116.35, 39.85)是否落在某个配送范围内:
db.delivery_zones.find( { zone: { $geoIntersects: { $geometry: { type: "Point", coordinates: [116.35, 39.85] } } } } )这个查询会返回所有与当前点相交的多边形记录。如果返回结果不为空,说明用户坐标在配送范围内。
有人会问$geoWithin和$geoIntersects有什么区别?简单说,$geoWithin是从数据里的点或形状出发,判断是否完全落在查询形状内部;而$geoIntersects是判断两个几何对象是否有交集,只要有一点相交就算命中。实际使用中,“点是否落在多边形内”用$geoWithin也完全可以,写成:
db.delivery_zones.find( { zone: { $geoWithin: { $geometry: { type: "Point", coordinates: [116.35, 39.85] } } } } )两种写法在你的诉求是“点是否在多边形内”时结果等价,但如果你将来要判断“两个多边形是否有重叠区域”,就必须用$geoIntersects,$geoWithin在这种场景下是无法直接完成任务的。
4. 几个高频业务场景的完整实现思路
4.1 附近门店搜索接入分页
回到开头说的“附近门店”这个场景。很多应用的首屏是“推荐附近门店”,用户不断上拉翻页。这里除了用$nearSphere查询,还要考虑分页怎么做。
最常见的做法是用每页固定的条数加skip和limit:
db.shops.find( { location: { $nearSphere: { $geometry: { type: "Point", coordinates: [116.39, 39.88] }, $maxDistance: 5000 } } } ).skip(20).limit(10)这样的写法在数据量不大的时候问题不大,但数据量大了以后,skip越深性能越差。因为skip是先把前面所有结果都取出来丢掉,再返回后面的记录。地理查询的结果集通常不是无限大的,所以多数场景还能接受,但如果真的要做深分页,建议改为基于上一页最后一条的距离值做游标分页。不过这个方案对地理位置分页来说实现复杂度会上升,一般小团队没必要一上来就做这么复杂,先用skip加limit顶着,业务量上来之后再优化是更务实的路线。
4.2 地理围栏触发与告警
另一个很常见的场景是地理围栏。比如一辆配送车或者一个快递员,每隔一段时间上报一次位置,系统需要判断他是否离开了配送区域。
实现思路很简单:把区域边界存成多边形,把实时位置存成点,然后每一次位置上报都做一次$geoWithin判断。如果用MongoDB来做,通常还会配合一个定时任务或者消息队列去批量消费位置上报数据。MongoDB本身不负责实时触发告警,它更多承担的是“位置判断”这一步,后续的告警推送逻辑由业务系统自己完成。
这里有一个实战中的注意事项:如果位置上报的频率很高,每次上报都做一次地理查询会对数据库造成压力。合理的做法是先把一条上报数据写入位置流水表,再由一个消费者批量读取一定时间窗口内的数据,分批做围栏判断。这样既能保证判断逻辑统一,又能把高频写入和查询逻辑解耦,降低数据库负载。
4.3 大范围查询的距离单位换算
使用2dsphere索引后,距离单位统一为米。这一点在写代码时务必和产品对齐,否则很容易出现“范围写大了十倍”或者“写小了十倍”的问题。
我之前接过一个需求,产品经理说“附近3公里的门店”,结果他口头说的是英里,开发时如果没确认单位,直接写成了3000英里,后果就是查询把整个省的门店都捞出来了,接口响应时间和数据量全部失控。所以在涉及地理距离的单位上,强烈建议在代码里用常量定义,并加注释说明单位是米。这一步看起来简单,但能省很多沟通和排查上的麻烦。
代码层面可以这样做:
const MAX_DISTANCE_METERS = 3000 db.shops.find({ location: { $nearSphere: { $geometry: { type: "Point", coordinates: [lng, lat] }, $maxDistance: MAX_DISTANCE_METERS } } })把单位说明和业务含义写清楚,后续接手的人就不会因为理解偏差而改错参数。
5. 地理空间索引的排序与性能细节
5.1 地理索引为什么能加速排序
普通索引能加速排序我们都能理解,因为索引本身就是有序结构,直接按索引顺序扫描就能返回排序结果。地理空间索引也承担了一部分排序能力,这就是$nearSphere和$near能够默认按距离排序的原因。
$nearSphere在2dsphere索引下,可以利用索引本身的特性,按照距离由近到远依次返回结果。这意味着数据库不需要把候选数据全部查出来再统一做排序,而是边扫描索引边返回结果,大大节省了内存和CPU开销。
这里要提醒一下:如果查询里使用了$nearSphere,同时又用sort去显式指定其他排序字段,比如先按评分排序再按距离排序,那地理索引的排序优势就会大打折扣,甚至可能导致内部需要重新计算和排序。如果业务上确实需要“距离优先、评分其次”这种混合排序,建议先评估数据量,数据量不大时可以直接在内存里做,数据量大时就要考虑索引设计和查询结构是否合理了。
5.2 索引字段的选择与查询计划
地理空间索引的查询性能不仅取决于有没有索引,还取决于查询方式是否命中索引。
MongoDB的地理索引只能用在地理操作符上,比如$near、$nearSphere、$geoWithin、$geoIntersects。如果你在查询里用地理操作符,但坐标字段上没有对应的地理索引,MongoDB会直接报错,而不是像普通查询那样走全表扫描。这一点和普通索引有很大区别,相当于强制要求你建索引。
你可以用explain来查看地理查询是否走了索引:
db.shops.find({ location: { $nearSphere: { $geometry: { type: "Point", coordinates: [116.39, 39.88] }, $maxDistance: 3000 } } }).explain("executionStats")在返回结果里关注queryPlanner.winningPlan和executionStats字段。如果查询命中了索引,winningPlan里会出现SORT相关的信息,并且executionStats.totalDocsExamined会远小于集合总文档数。如果发现totalDocsExamined和totalDocsExamined数量接近,就要检查是不是索引没有正确使用,或者查询条件里有没有其他字段导致索引失效。
5.3 复合地理索引的注意事项
有时候一个查询既要过滤地理位置,又要过滤其他业务字段,比如“距离3公里内且评分大于4.5的门店”,这时候是否可以建一个复合索引?
答案是可以的,但顺序很重要。MongoDB的复合地理索引一般推荐把地理字段放在最前面,后面跟过滤字段。比如:
db.shops.createIndex({ location: "2dsphere", rating: 1 })这样查询时,先用地理索引锁定空间范围,再在这个范围内按rating字段做过滤,效率相对较高。如果反过来,把普通字段放在地理字段前面,地理索引的优势就很难发挥出来,空间范围无法第一时间缩小。
不过这里也有个坑:并不是所有查询都适合建复合地理索引。如果rating这类字段的选择性不高,比如大部分门店评分都在4.0到4.5之间,索引带来的收益就有限。而且复合地理索引会占用更多的存储空间,写入时的索引维护成本也会变高。所以建复合地理索引前一定要先分析业务查询模式,先确认哪些查询是真正的热点,再决定要不要加字段。
6. 常见问题与排查技巧实录
6.1 查询报错:无法使用地理索引
这是新手最容易遇到的问题。报错信息通常长这样:
Query failed: error processing query: ... unable to find index for geoNear query这个报错的原因一般是:查询里用了$nearSphere或$geoNear,但对应字段没有建2dsphere索引。
排查方式很直接:
- 先确认字段名是否一致。比如查询写的是
location,索引也建在location上。 - 再确认索引类型。geoNear查询必须使用2dsphere或者2d索引,普通索引不行。
- 最后确认索引是否真的建成功。通过
getIndexes()查看索引列表。
有一个容易忽略的细节:如果集合是空集合时建索引,MongoDB会立刻成功返回;但如果有存量数据,建索引进程可能需要一段时间,此时查询不会立即生效。在索引构建完成之前发起的geoNear查询,同样会报错。
6.2 结果里出现了距离非常远的记录
明明设置了$maxDistance: 3000,为什么还能查到更远的点?
这个问题大概率是单位弄错了,或者使用的是2d索引而不是2dsphere索引。2d索引下的距离单位默认是弧度,不是米。如果你用的是2d索引,拿3000当米去传,实际查询范围几乎可以覆盖大半个地球。
如果确认使用的是2dsphere索引,还有另一个可能原因是坐标顺序错误。坐标顺序反了以后,查询点和目标点都变得不可预测,结果自然就乱了。遇到这种诡异结果时,先随机挑一条返回记录,人工对比一下它和查询点的实际距离,基本就能快速定位问题方向。
6.3 建索引耗时过长
当集合数据量很大的时候,在location字段上建地理索引可能要跑几十分钟甚至几小时。这个阶段如果直接在生产环境执行,会影响线上读写性能。
处理这个问题的经验是:尽量在业务低峰期建索引,或者使用后台建索引的方式。
db.shops.createIndex({ location: "2dsphere" }, { background: true })新版MongoDB中background选项已经默认启用,而且官方不再推荐显式指定,但如果你用的还是老版本,这个参数仍然有效。建索引期间会占用一定的CPU和IO资源,建议先在测试环境评估一下索引构建时长,再在预发或低峰期对生产库操作。
6.4 经纬度字段想顺便做范围查询
有些人会想,既然经纬度已经建了地理索引,那我能不能用它来做普通的大于小于范围查询,比如“经度大于100且小于120”?
不可以。地理索引是专门给地理操作符用的,普通的大于小于查询无法直接利用地理索引。如果业务里确实需要这种普通范围查询,那就只能另建普通索引。但一般情况下,坐标字段的使用方式要么是地理查询,要么是普通范围查询,很少会同时高频使用,所以建议不要为了节省索引数量而强行混用,否则两个场景的性能都得不到保障。
7. 从实践中总结的几条选型建议
地理空间索引不是银弹,但它确实是把地理位置查询从“不可用”变成“可用”的关键设施。以我这几年做位置相关服务的经验来看,有几点建议值得大家参考。
第一,新项目无脑优先考虑2dsphere,除非你能确定自己的业务永远只在一个很小的局部区域内运行。否则一旦业务从城市扩大到全国,2d索引带来的误差会让人非常痛苦。而且从2d迁移到2dsphere不是改一行配置就完事,需要重新建索引、重新验证数据格式,成本不小。
第二,GeoJSON格式一定要统一,坐标顺序一定要反复检查。这类问题出现频率极高,而且排查起来很恶心,因为报错信息不一定明显,往往是查询结果“看起来不对劲”。建议在数据写入入口做一层校验,统一把坐标转换成[经度, 纬度]的数组格式再落库。
第三,测试环境一定要有模拟数据。很多人以为填充少量假数据就能验证功能,但地理查询这种东西,数据量和分布方式对结果影响很大。建议生成一批覆盖目标城市范围的数据,至少几百条到几千条,再结合explain看一看查询性能和返回结果是否符合预期。如果只在测试集合里放三条数据,很多问题根本暴露不出来。
第四,监控执行计划。MongoDB的慢查询日志里经常能看到地理查询的影子,如果某条geoNear查询的响应时间突然变长,优先检查是否因为索引没建好、索引被误删,或者是查询范围参数被改动。这个排查顺序能帮你节省大量时间,因为大部分地理查询变慢的问题,根源都在索引命中率而不是数据库机器性能上。
最后再说一个小技巧。地理索引虽然好用,但不要一个集合里重复建多个地理索引,不但浪费存储,而且查询优化器也不一定总能选对索引。每个集合一个地理索引字段是最常见也是最优的实践,除非你确实有多个完全不同的地理位置语义(比如一个存门店位置,一个存配送区域),才考虑分开建立。多数情况下,一个集合一个地理索引足够覆盖所有地理查询场景。