地理距离过滤这个需求,最早我是在做一个本地生活服务类项目时碰到的。当时产品经理甩过来一句话:“用户打开App,要能看到离自己最近的几家店,按距离排。”听起来简单,但真做起来,从坐标存储、距离计算到性能优化,每一步都有坑。Elasticsearch 7.x 的geo_distance过滤器就是解决这类问题的核心工具,它支撑着“附近的商店”“附近的人”“附近的充电桩”等一大票LBS场景。这篇文章我会把 geo_distance 的完整用法拆开讲透,包括字段映射怎么建、查询怎么写、距离怎么算、性能怎么调,以及我在实际项目里踩过的那些坑。不管你是刚接触ES的新手,还是已经用过geo查询但总觉得哪里不对的老手,应该都能从里面找到有用的东西。
1. 地理距离过滤的整体设计思路
1.1 为什么选 geo_distance 而不是其他方案
做“附近”这个功能,最直觉的做法可能是:把用户坐标和所有店铺坐标都取出来,在应用层算距离,然后排序取前N个。数据量小的时候没问题,几百条数据随便算。但一旦店铺数量上万、用户并发上来,这种全量拉取+内存计算的方式直接崩掉。数据库层面用 MySQL 的经纬度计算函数(比如 ST_Distance)也能做,但没法走索引,每次都是全表扫描,几万条数据就开始卡。
Elasticsearch 的 geo_distance 解决的就是这个问题。它把地理坐标作为一种专门的字段类型(geo_point)来存储,底层用BKD树(Block K-Dimensional tree)做索引,能在海量数据里快速圈出指定半径范围内的点。你给它一个中心点和一个半径,它直接返回落在圆内的文档,不需要你手动算距离。这是它和普通数值范围查询最本质的区别——它理解“地理空间”这个概念。
选 geo_distance 而不是 geo_bounding_box 或 geo_polygon,是因为“附近”天然是一个圆形范围。用矩形框(bounding box)会多出四个角的多余数据,还得二次过滤;用多边形又太复杂,没必要。geo_distance 直接以中心点为圆心、指定距离为半径画圆,语义最贴合“附近”这个需求。
1.2 geo_point 字段的存储选型
geo_distance 能生效的前提是字段类型必须是geo_point。ES 支持多种 geo_point 的写入格式,常见的有这几种:
- 对象格式:
{"lat": 39.9, "lon": 116.4} - 字符串格式:
"39.9,116.4"(注意是纬度在前) - 数组格式:
[116.4, 39.9](注意是经度在前) - Geohash 格式:
"wx4g0b"
我一般推荐用对象格式,因为可读性最好,不容易搞混经纬度顺序。字符串和数组格式最大的坑就是顺序问题——数组是[lon, lat],字符串是"lat,lon",这俩刚好反过来,我见过太多人在这里写错导致查出来的结果完全不对。
映射定义大概长这样:
PUT /shop_index { "mappings": { "properties": { "shop_name": { "type": "text" }, "location": { "type": "geo_point" } } } }这里有个细节:geo_point字段默认是建立索引且可搜索的,但如果你不需要按坐标排序,可以把"index": true保留、"doc_values"也保留(默认就是true),因为距离排序需要用到 doc_values。如果你只做过滤不做排序,理论上可以关掉 doc_values 省空间,但实际项目里“附近”功能几乎都要按距离排序,所以别关。
1.3 距离计算的地理模型选择
geo_distance 在计算距离时,底层用的是Haversine 公式(半正矢公式),把地球近似成一个球体来算两点间的大圆距离。这个模型在大多数场景下够用,误差在0.5%以内。ES 还支持另一种更精确的模型叫arc(弧长),但默认就是 Haversine,一般不用改。
距离单位支持很多种:m(米)、km(千米)、mi(英里)、ft(英尺)、yd(码)、nmi(海里)。国内项目基本就用km或m。我建议统一用m,因为前端展示时再除以1000转成公里,避免精度丢失。
注意:Haversine 公式假设地球是完美球体,实际地球是椭球体,所以在极地附近或者超长距离(几千公里以上)计算时会有偏差。但“附近的商店”这种几公里范围内的场景,误差可以忽略不计。
2. 核心查询语法与参数详解
2.1 geo_distance 查询的基本结构
一个最基础的 geo_distance 查询长这样:
GET /shop_index/_search { "query": { "bool": { "filter": { "geo_distance": { "distance": "5km", "location": { "lat": 39.9042, "lon": 116.4074 } } } } } }这里有几个关键点要说明。第一,我把 geo_distance 放在bool的filter里而不是must里,因为地理过滤不参与相关性打分,放在 filter 上下文可以利用缓存,性能更好。第二,distance参数支持多种写法:"5km"、"5000m"、"3mi"都行,ES 会自动解析单位。第三,中心点的坐标可以用对象、字符串或数组表示,和字段存储格式无关,查询时你写哪种都行。
2.2 distance_type 参数:sloppy_arc 与 arc 的区别
geo_distance 有一个容易被忽略的参数叫distance_type,它控制距离计算的精度:
| 参数值 | 计算方式 | 精度 | 性能 |
|---|---|---|---|
| sloppy_arc(默认) | Haversine 公式 | 较高 | 快 |
| arc | 考虑地球椭球 | 最高 | 稍慢 |
| plane | 平面几何 | 低 | 最快 |
默认的sloppy_arc在几公里到几百公里范围内精度足够,性能也好。arc更精确但计算量大,除非你做的是跨国物流这种超长距离场景,否则没必要用。plane把地球当平面算,只在赤道附近小范围内准确,基本不用。
我实测过,在10公里范围内,sloppy_arc和arc的结果差异在几米级别,对“附近的商店”这种场景完全无感。所以保持默认就行,别瞎折腾。
2.3 按距离排序:_geo_distance 排序器
光过滤出范围内的数据还不够,用户通常希望按距离从近到远排。ES 提供了_geo_distance排序:
GET /shop_index/_search { "query": { "bool": { "filter": { "geo_distance": { "distance": "5km", "location": { "lat": 39.9042, "lon": 116.4074 } } } } }, "sort": [ { "_geo_distance": { "location": { "lat": 39.9042, "lon": 116.4074 }, "order": "asc", "unit": "m", "distance_type": "sloppy_arc" } } ] }排序结果里每个文档会多出一个sort字段,值就是距离(单位由unit指定)。这个值可以直接返回给前端展示“距您 1.2km”。
实操心得:排序用的中心点坐标必须和过滤用的坐标完全一致,否则会出现“过滤出来的数据排序距离对不上”的诡异现象。我建议把坐标提取成一个变量,两处引用同一个值,避免手抖写错。
2.4 runtime_mappings 动态计算距离字段
ES 7.11 之后引入了 runtime field,可以在查询时动态计算距离字段,不用提前在映射里定义。这在需要同时返回距离值又不想用排序的场景下很有用:
GET /shop_index/_search { "runtime_mappings": { "distance_km": { "type": "double", "script": { "source": "emit(doc['location'].arcDistance(params.lat, params.lon) / 1000.0)", "params": { "lat": 39.9042, "lon": 116.4074 } } } }, "query": { "bool": { "filter": { "geo_distance": { "distance": "5km", "location": { "lat": 39.9042, "lon": 116.4074 } } } } }, "fields": ["distance_km", "shop_name"], "_source": false }这样返回结果里会直接带上distance_km字段。不过要注意,runtime field 是查询时计算的,不走索引,数据量大时性能不如直接用_geo_distance排序。它的优势是灵活,适合临时性的分析需求。
3. 完整实操流程与性能调优
3.1 从零搭建一个“附近商店”索引
假设我们要做一个商店搜索服务,完整流程分四步:建索引、导数据、写查询、调性能。
第一步,建索引并定义映射:
PUT /shop_index { "settings": { "number_of_shards": 3, "number_of_replicas": 1 }, "mappings": { "properties": { "shop_id": { "type": "keyword" }, "shop_name": { "type": "text", "analyzer": "ik_max_word" }, "category": { "type": "keyword" }, "location": { "type": "geo_point" }, "address": { "type": "text" }, "rating": { "type": "float" } } } }分片数设3是因为数据量预计在百万级,3个分片能并行查询。如果数据量只有几万条,1个分片就够了,分片太多反而增加协调开销。
第二步,批量导入数据:
POST /shop_index/_bulk {"index":{}} {"shop_id":"S001","shop_name":"老王面馆","category":"餐饮","location":{"lat":39.908,"lon":116.412},"rating":4.5} {"index":{}} {"shop_id":"S002","shop_name":"便利超市","category":"零售","location":{"lat":39.902,"lon":116.405},"rating":4.2} {"index":{}} {"shop_id":"S003","shop_name":"咖啡时光","category":"饮品","location":{"lat":39.910,"lon":116.400},"rating":4.8}第三步,写查询:
GET /shop_index/_search { "query": { "bool": { "filter": [ { "term": { "category": "餐饮" } }, { "geo_distance": { "distance": "3km", "location": { "lat": 39.9042, "lon": 116.4074 } } } ] } }, "sort": [ { "_geo_distance": { "location": { "lat": 39.9042, "lon": 116.4074 }, "order": "asc", "unit": "m" } } ], "size": 20 }这个查询做了两件事:先按分类过滤,再按地理范围过滤,最后按距离排序取前20条。size设20是因为用户一般不会翻太多页,设太大浪费资源。
3.2 距离参数的计算与单位换算
很多人搞不清distance参数到底该写多大。这里给个经验值参考:
| 场景 | 推荐半径 | 说明 |
|---|---|---|
| 步行找店 | 1-2km | 人步行15-25分钟的范围 |
| 骑车找店 | 3-5km | 骑行10-20分钟 |
| 开车找店 | 10-20km | 车程15-30分钟 |
| 附近的人 | 5-50km | 社交场景,范围弹性大 |
| 同城服务 | 30-50km | 覆盖整个城区 |
单位换算上,1km = 1000m,1mi ≈ 1609m。我建议统一用米做单位,前端展示时再格式化。比如后端返回distance: 1234,前端显示“1.2km”。
注意:半径设得越大,命中的文档越多,排序开销越大。如果业务允许,尽量把半径控制在合理范围,别一上来就设100km。
3.3 性能调优的几个关键手段
geo_distance 的性能瓶颈主要在两方面:一是候选文档太多,二是排序计算量大。针对这两点,我总结了几个实用手段。
手段一:用 filter 上下文而非 query 上下文。前面提过,filter 不计算相关性得分,还能被缓存。同样的 geo_distance 放在 filter 里比放在 must 里快不少。
手段二:先用其他条件缩小范围。如果业务上有分类、状态等过滤条件,把它们和 geo_distance 一起放在 filter 数组里,ES 会先执行选择性高的条件,减少地理计算的数据量。
手段三:合理设置分片数。地理查询是计算密集型的,分片太多会导致协调节点合并结果的开销增大。一般建议单分片数据量在 20-50GB 之间,根据这个反推分片数。
手段四:避免深分页。from + size超过 10000 会报错,而且深分页性能极差。附近搜索场景建议用search_after做游标分页,或者直接限制返回条数。
手段五:考虑 geohash_grid 聚合做粗筛。如果数据量特别大(千万级以上),可以先用 geohash 聚合把地图切成格子,只查询目标格子及相邻格子,再在格子内做精确距离过滤。这是进阶玩法,普通场景用不上。
3.4 多坐标点与多边形的距离过滤
有时候一个商店有多个门店坐标,或者你想查“离我5公里内且位于某个区域内”的商店。前者可以用数组存多个 geo_point:
{ "location": [ { "lat": 39.908, "lon": 116.412 }, { "lat": 39.915, "lon": 116.420 } ] }geo_distance 会匹配任意一个点落在范围内即命中。后者可以结合 geo_shape 查询做多边形过滤,但那就不是 geo_distance 的范畴了,这里不展开。
4. 常见问题与排查技巧实录
4.1 查不到数据的五大原因
这是我在实际项目里遇到最多的问题,按出现频率排序:
原因一:经纬度写反了。这是头号杀手。纬度范围是 -90 到 90,经度范围是 -180 到 180。如果你把经度当纬度写,比如lat: 116.4,ES 会直接报错(超出范围),但如果两个值都在合法范围内,它不会报错,只是查出来的结果完全不对。排查方法:确认纬度绝对值一般小于90,经度可能大于90。
原因二:字段类型不是 geo_point。如果映射里写成了geo_shape或者普通double,geo_distance 查询会直接报错。用GET /shop_index/_mapping确认字段类型。
原因三:distance 单位写错。比如写成"5"没有单位,ES 会报解析错误。必须带单位,如"5km"。
原因四:坐标格式和字段定义不匹配。如果字段是 geo_point,但写入时用了"116.4,39.9"这种字符串格式,注意字符串是"lat,lon"顺序,写反了数据就错了。
原因五:分片路由问题。极少数情况下,如果索引的分片分配有问题,某些分片不可用,会导致部分数据查不到。用GET /_cat/shards/shop_index检查分片状态。
4.2 距离排序结果不准的排查
有次线上反馈说“明明离得很近的店排在后面”。排查下来发现是排序用的坐标和过滤用的坐标不一致——过滤用的是用户实时定位,排序用的是缓存里的旧坐标。这种问题很隐蔽,因为两个坐标看起来差不多,但差了几百米就足以影响排序。
排查思路:把过滤和排序的坐标打印出来对比,确认完全一致。另外检查distance_type是否一致,过滤用默认、排序用 arc,理论上结果应该一致,但极端情况下可能有微小差异。
4.3 大数据量下的超时问题
数据量上到千万级后,geo_distance 查询可能超时。我遇到过几次,解决方案分三层:
第一层,加缓存。附近搜索的结果对同一坐标短时间内是稳定的,可以用 Redis 缓存查询结果,设置较短的过期时间(比如5分钟)。
第二层,预计算。如果店铺位置固定,可以提前算好每个店铺到几个热门地标的距离,查询时直接按预计算值过滤。这适合地标数量有限的场景。
第三层,降级。超时时返回一个默认列表(比如按评分排序),而不是直接报错。用户体验上,有结果比没结果好。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 查询报错 "failed to parse" | distance 没带单位 | 检查查询语句 | 补上 km/m 等单位 |
| 查询报错 "field not found" | 字段名写错或类型不对 | 查看 _mapping | 修正字段名或重建索引 |
| 结果为空但数据存在 | 经纬度写反 | 检查坐标值范围 | 修正坐标顺序 |
| 距离排序不对 | 过滤和排序坐标不一致 | 打印两处坐标 | 统一使用同一变量 |
| 查询超时 | 数据量大或半径过大 | 查看慢查询日志 | 缩小半径、加缓存、预计算 |
| 部分数据查不到 | 分片异常 | 查看 _cat/shards | 修复分片分配 |
4.5 几个容易被忽略的细节
细节一:geo_point 的精度问题。ES 存储 geo_point 时默认精度是 1e-7 度,大约 1 厘米。这个精度对绝大多数场景足够,但如果你做的是室内导航这种厘米级需求,可能需要更高精度。不过 ES 不支持调整这个精度,只能接受。
细节二:跨日期变更线的查询。如果你的服务覆盖全球,经度跨越 180 度线时,geo_distance 的处理可能出乎意料。比如中心点在 179 度,半径覆盖到 -179 度,ES 能正确处理,但如果你自己写逻辑判断就要小心。
细节三:空值处理。如果某些文档的 location 字段为空,geo_distance 查询会直接跳过它们,不会报错。但如果你期望空值也返回,那就要用missing参数或者用 bool should 组合。
细节四:坐标数组的匹配语义。当一个文档有多个坐标点时,geo_distance 是“任意一个点命中即返回”,而不是“所有点都在范围内”。这个语义要搞清楚,否则业务逻辑会错。
我在实际项目里最大的体会是:geo_distance 本身不难,难的是数据质量和坐标一致性。经纬度写反、坐标格式不统一、过滤排序坐标不一致,这三个问题占了线上bug的八成以上。建议在数据入库前做一层校验,把纬度绝对值大于90、经度绝对值大于180的数据直接拦掉,能省掉后面很多排查时间。另外,如果业务允许,尽量把半径设小一点,既符合用户预期,又能减轻ES的压力。