1. 从"附近的店"说起:geo_distance 到底在算什么
做本地生活类应用的人迟早会撞上一个需求:用户打开页面,想看到"离我最近的几家店"。这个需求听起来简单,落到搜索引擎里就变成了一个典型的空间查询问题。Elasticsearch 从早期版本就内置了地理坐标类型,到了 7.x 这一代,geo_distance已经是一套相当成熟的过滤方案。它的核心逻辑其实一句话就能说清:给定一个中心点坐标和一个半径,把所有落在圆内的文档筛出来。
但真正上手写的时候,你会发现事情没那么直白。坐标怎么存、单位怎么写、半径大了性能会不会崩、排序怎么按距离来、分页的时候会不会漏数据——这些问题在官方文档里往往一笔带过,实际项目里却一个都躲不掉。我自己在几个涉及门店检索、同城社交、配送范围判定的项目里反复用过geo_distance,踩过的坑不算少,这篇就把这套东西从头到尾捋一遍。
先说清楚它适合谁看。如果你正在做门店列表、附近的人、配送范围过滤、打卡点推荐这类功能,并且后端用的是 Elasticsearch 7.x,那这篇基本能直接抄作业。如果你只是听说过地理查询但没动过手,也没关系,我会把坐标存储、查询语法、性能取舍这些基础环节都铺开讲。整篇的基调是"我实际怎么用的",不是"文档怎么写的",所以会有不少主观取舍和实测结论。
需要提前说明一点:geo_distance是**过滤器(filter)**语义,它不参与相关性打分,只做"是/否"的筛选。这个定位很重要,因为它决定了你应该把它放在bool查询的filter子句里,而不是must里。放在filter里有两个直接好处:一是可以利用查询缓存,二是避免无谓的打分计算。很多人第一次写的时候习惯性塞进must,功能上没错,但性能上白白亏了一截。
2. 坐标字段的建模:geo_point 的三种存法与选型逻辑
2.1 为什么必须用 geo_point 而不是两个 double 字段
最常见的错误建模方式,是拿两个double字段分别存纬度和经度,然后查询时用范围过滤去框一个矩形。这么做的问题在于:你算出来的是矩形范围,不是圆形距离,边角上的点会被错误地包含进来。更致命的是,你没法按真实距离排序,也没法用 Elasticsearch 内置的距离计算函数。
geo_point类型是专门为这种场景设计的,它内部对经纬度做了编码优化,支持距离计算、边界框查询、多边形查询、距离排序等一系列操作。所以第一步,映射里必须老老实实声明成geo_point:
PUT /shop_index { "mappings": { "properties": { "shop_name": { "type": "text" }, "location": { "type": "geo_point" } } } }这里有个细节值得强调:geo_point一旦建好,字段类型是不能直接改的。如果你一开始用了double,后面想改成geo_point,只能重建索引再导数据。所以建模阶段就要想清楚,别等到线上跑起来才发现类型不对。
2.2 三种坐标写入格式的取舍
geo_point接受三种写入格式,各有各的适用场景:
| 格式 | 示例 | 适用场景 | 注意事项 |
|---|---|---|---|
| 对象形式 | {"lat": 39.9, "lon": 116.4} | 可读性最好,调试方便 | 字段名必须是 lat/lon,顺序无所谓 |
| 字符串形式 | "39.9,116.4" | 日志、CSV 导入常见 | 顺序是"纬度,经度",写反了不报错但结果全错 |
| 数组形式 | [116.4, 39.9] | 体积最小,批量导入快 | 顺序是"经度,纬度",和字符串相反 |
我重点提醒字符串和数组这两种。字符串是"纬度在前",数组是"经度在前",这俩顺序是反的。这个设计确实反直觉,我见过不止一个团队在这里翻车——数据导进去没报任何错,查询也能跑,就是结果永远不对,排查半天才发现经纬度写反了。所以我的建议是:内部系统统一用对象形式,虽然多占一点存储,但可读性和容错性最好,出问题时一眼就能看出来。
提示:如果数据源是第三方接口返回的
[lng, lat]数组,直接透传给 Elasticsearch 的数组格式是刚好对上的;但如果你要转成字符串,记得手动调换顺序。
2.3 经纬度精度与存储成本
经纬度用double存,精度足够覆盖到厘米级,日常业务完全够用。有人担心精度太高浪费空间,其实geo_point内部会做编码压缩,实际占用比两个裸double要小。真正影响存储的是文档总量,而不是单个坐标的精度。所以别在精度上抠,该用多少用多少。
另外提一句,geo_point支持一个字段存多个点(数组形式的多组坐标),这在"一个门店有多个取货点"的场景下有用。但要注意,多值字段做距离查询时,只要任意一个点落在范围内就算命中,排序时取最近的那个点。这个行为大多数时候符合预期,但如果你需要精确知道是哪个点命中的,就得额外存一份结构化数据了。
3. geo_distance 查询的完整写法与参数拆解
3.1 最小可用查询长什么样
先给一个能直接跑的完整例子,假设要查中心点周围 5 公里内的门店:
GET /shop_index/_search { "query": { "bool": { "filter": [ { "geo_distance": { "distance": "5km", "location": { "lat": 39.9, "lon": 116.4 } } } ] } } }这段查询的含义是:以(39.9, 116.4)为圆心,半径 5 公里,筛出所有location落在圆内的文档。注意distance和location这两个参数名是固定的,location后面跟的字段名要和你映射里定义的一致。
3.2 distance 的单位写法与常见误区
distance支持的单位挺多,常用的有m(米)、km(千米)、mi(英里)、yd(码)、ft(英尺)、nmi(海里)。写法上有个坑:数字和单位之间不能有空格。"5 km"这种写法在部分版本里会直接报解析错误,老老实实写"5km"最稳。
单位换算上,1 千米约等于 0.621 英里,做跨境业务时如果前端展示英里、后端存千米,记得在展示层转换,别在查询层来回折腾。我一般建议后端统一用米或千米,前端按用户地区习惯格式化,这样查询逻辑最干净。
还有一个容易被忽略的点:distance的值可以是数字加单位,也可以纯数字(默认按米算)。但纯数字可读性差,容易让人误解,所以永远带上单位。
3.3 用 distance_type 控制圆的精度
这是geo_distance里最容易被忽视、但对结果影响最大的参数。Elasticsearch 计算"圆"的时候,并不是真的画一个完美的圆,而是用一系列近似算法。distance_type有三个取值:
arc:默认值,把地球当成完美球体来算,精度最高,但计算量稍大。plane:把局部区域当成平面来算,速度快,但在高纬度地区误差明显。sloppy_arc:更快的近似算法,精度介于两者之间。
实测下来,如果你的业务集中在单一城市或纬度跨度不大的区域,plane的性能优势是能感知到的,误差在几公里范围内通常可以忽略。但如果是全国范围、跨纬度很大的查询,老老实实用arc,否则北方的用户可能会发现"附近"的店算出来偏了。
注意:
distance_type是查询级别的参数,写在geo_distance对象内部,和distance、location平级。
3.4 把距离作为排序字段
光筛出来还不够,用户通常希望"最近的排前面"。这时候要用_geo_distance排序:
GET /shop_index/_search { "query": { "bool": { "filter": [ { "geo_distance": { "distance": "5km", "location": { "lat": 39.9, "lon": 116.4 } } } ] } }, "sort": [ { "_geo_distance": { "location": { "lat": 39.9, "lon": 116.4 }, "order": "asc", "unit": "km", "distance_type": "arc" } } ] }排序里的unit决定了返回结果中sort字段的距离单位。这个值会出现在每条命中的sort数组里,前端可以直接拿来展示"距您 1.2 公里"。这里有个实用技巧:排序用的中心点坐标和过滤用的中心点坐标必须一致,否则会出现"筛出来的点按另一个中心排序"的诡异结果。我一般会把坐标抽成一个变量,两处引用同一个值,避免手抖写错。
4. 性能这道坎:半径、缓存与分页的真实取舍
4.1 半径越大越慢,这是物理规律
geo_distance的性能和半径强相关。半径小的时候,Elasticsearch 可以用地理网格快速排除掉绝大部分文档;半径一大,候选集急剧膨胀,计算量就上来了。我做过一个粗略的压测:在千万级文档的索引上,1 公里半径的查询响应在十几毫秒,50 公里半径就涨到了几百毫秒,差距非常明显。
所以产品经理说"给我查 100 公里内的店"时,你得先问一句:真的需要这么大范围吗?大多数本地生活场景,5 到 10 公里已经覆盖了用户的实际活动半径。如果确实需要大范围,考虑加一层行政区域过滤先缩小候选集,再叠加geo_distance。
4.2 filter 缓存怎么帮你省下重复计算
前面提过要把geo_distance放进filter子句,这里展开说下为什么。Elasticsearch 对filter子句的结果有缓存机制,如果同一个过滤条件被反复执行(比如同一个商圈的用户都在查同一个中心点附近),第二次开始就能直接命中缓存,跳过实际的距离计算。
但缓存有个前提:查询条件要完全一致。中心点坐标哪怕差一个小数点,缓存就失效了。所以如果你的场景是"每个用户用自己的实时定位查附近",那缓存基本帮不上忙,因为每个人的坐标都不同。这种情况下,性能优化就得从别的地方想办法,比如限制半径、减少返回字段、用_source过滤只取必要字段。
4.3 深分页与距离排序的冲突
按距离排序 + 深分页,是个经典的性能陷阱。Elasticsearch 默认的from + size分页在深分页时要把前面所有页的数据都取出来再丢弃,配合距离排序,开销会成倍增长。search_after是更好的选择,但它要求排序字段有唯一性,而距离值可能重复。
解决办法是在排序里加一个唯一字段做兜底:
"sort": [ { "_geo_distance": { "location": {"lat": 39.9, "lon": 116.4}, "order": "asc" } }, { "shop_id": "asc" } ]这样即使距离相同,也能靠shop_id保证顺序稳定,search_after就能正常翻页了。这个技巧在"附近的人"这类场景里特别有用,因为人的位置可能高度重合,距离重复的概率不低。
4.4 索引层面的优化手段
除了查询写法,索引层面也有文章可做。如果数据量特别大,可以考虑按地理区域做索引拆分,比如按城市分索引,查询时先路由到对应城市的索引再执行geo_distance。这样每个索引的数据量小了,查询自然快。
另外,geo_point字段如果只用于过滤和排序,不用于聚合,可以在映射里关掉doc_values之外的一些特性来省空间。不过这个优化收益有限,除非你确实卡在存储瓶颈上,否则不建议过早折腾。
5. 几个真实踩过的坑与排查思路
5.1 坐标写反:最隐蔽也最致命
前面提过经纬度顺序问题,这里给一个具体的排查方法。如果你发现查询结果"看起来不对但又说不出哪里不对",先做一件事:拿一个已知坐标的点,查它自己周围 1 米。如果查不到自己,那基本就是坐标写反了。因为经纬度写反后,点会跑到地球另一边的某个位置,距离自己十万八千里。
这个自检方法我每次接入新数据源都会跑一遍,几秒钟的事,能省下后面几个小时的排查。
5.2 半径单位漏写导致的"查了个寂寞"
distance如果只写数字不写单位,默认按米算。有人想查 5 公里,写了"distance": 5,结果实际查的是 5 米,返回结果空空如也,还以为是数据没导进去。这种错误在代码里尤其常见,因为数字可能是从配置或变量里拼进去的。我的做法是在代码层面强制拼接单位,比如封装一个buildDistance(km)函数,杜绝裸数字。
5.3 跨日期变更线的诡异结果
如果你的业务涉及跨 180 度经线的区域(比如太平洋上的某些岛屿),geo_distance的行为可能会让你意外。因为经度在 -180 到 180 之间循环,跨越变更线时距离计算需要特殊处理。Elasticsearch 内部对这种情况有处理,但如果你自己在前端或中间层做了坐标归一化,反而可能引入 bug。遇到这类场景,建议直接用原始坐标交给 Elasticsearch 算,别自己预处理。
5.4 排序结果和过滤结果不一致
前面提过中心点坐标要一致,这里补充一个更隐蔽的情况:过滤用了plane,排序用了arc。两种算法的距离计算方式不同,可能导致某个点被过滤条件排除,但排序时又算出一个很小的距离,或者反过来。虽然这种情况不常见,但一旦出现会非常难排查。所以我的原则是:过滤和排序的distance_type保持一致,要么都用arc,要么都用plane。
6. 从 geo_distance 延伸出去的几个实用组合
6.1 距离过滤 + 关键词搜索
纯地理查询往往不够,用户还想搜"附近的火锅店"。这时候把geo_distance和match查询组合起来:
{ "query": { "bool": { "must": [ { "match": { "shop_name": "火锅" } } ], "filter": [ { "geo_distance": { "distance": "3km", "location": { "lat": 39.9, "lon": 116.4 } } } ] } } }关键词走must参与打分,地理范围走filter只做筛选,各司其职。这样既保证了搜索相关性,又保证了地理范围的精确性。
6.2 距离分档:把连续距离变成可聚合的标签
有时候业务需要统计"1 公里内有多少家、1 到 3 公里有多少家"。geo_distance本身不直接支持分档,但可以用多个geo_distance配合filter聚合来实现:
{ "aggs": { "distance_ranges": { "filters": { "filters": { "within_1km": { "geo_distance": { "distance": "1km", "location": {"lat": 39.9, "lon": 116.4} } }, "within_3km": { "geo_distance": { "distance": "3km", "location": {"lat": 39.9, "lon": 116.4} } } } } } } }注意这里的桶是叠加的,within_3km包含了within_1km的结果。如果需要互斥的区间,得用geo_distance_range或者手动做减法。
6.3 多中心点查询:用户有多个常用地址
有些场景下,用户可能同时关心"公司附近"和"家附近"的店。这时候可以用bool的should组合多个geo_distance,只要命中任意一个就算匹配。但要注意,should默认的匹配逻辑和minimum_should_match有关,记得显式设置,避免结果不符合预期。
7. 一些零散但有用的经验
关于坐标的存储,我再啰嗦一句:永远不要在应用层做距离计算然后传给 Elasticsearch 做过滤。我见过有团队为了"减轻 Elasticsearch 压力",在应用层先用勾股定理粗算一遍距离,再把候选 ID 传给 Elasticsearch。这种做法看似聪明,实则把简单问题复杂化了,而且应用层的距离计算精度和 Elasticsearch 不一致,容易出现边界数据对不上的情况。让 Elasticsearch 干它擅长的事,应用层只管展示。
关于测试,建议准备一组边界数据:正好在半径上的点、半径内外各差一点的点、坐标写反的点、跨变更线的点。每次改查询逻辑,拿这组数据跑一遍,能快速发现回归问题。这组数据我一般会固化成一个测试用例集,跟着代码走。
关于版本,7.x 系列内部小版本之间geo_distance的行为基本稳定,但从 6.x 升到 7.x 时,映射和查询语法有一些变化,升级前务必在测试环境验证地理查询的结果是否一致。特别是distance_type的默认值,不同版本可能有细微差别。
最后说个心态问题。地理查询看起来简单,但涉及坐标系、精度、性能、边界条件,坑比想象中多。我的建议是:先用最小可用查询跑通,再逐步加排序、加分页、加聚合,每加一层都验证结果。一次性把所有功能堆上去,出了问题很难定位是哪一层导致的。这套方法我在多个项目里用过,虽然前期慢一点,但总体省时间。