做 Elasticsearch 地理位置搜索这事,我前后折腾了快三年。第一次上线“附近门店”功能时,我也以为会写个geo_distance就算入门了,结果线上跑了一周,用户反馈“附近搜不到”“距离不对劲”“接口变慢”,然后就是漫长的排坑过程。这篇文章想把 elasticsearch 地理位置搜索从索引建模、坐标写入、查询组合到性能调优、故障排查的完整链路,按我自己的实操顺序过一遍。如果你是做 LBS 应用、附近推荐、地理围栏相关功能的工程师,或者正被百万级坐标查询折磨得头疼,那这篇应该能帮你少走不少弯路;刚接触 ES 的人也可以跟着完整跑通,先建立整体认知,再回来看官方文档细节就不慌了。
1. 地理位置搜索到底解决了什么问题
1.1 为什么传统数据库搞不定大规模 LBS
很多人上来就问,MySQL 里也能写经纬度,为什么非要用 Elasticsearch?这个问题的答案,直接决定了你后续的方案选型。
MySQL 计算两点距离最常用的方案是写ST_Distance_Sphere或者用Haversine公式在 SQL 里硬算。数据量只有几十万的时候,这样确实没毛病。但一旦到了百万、千万级,问题就来了:坐标点没法像普通一维字段那样建 B-tree 索引,因为“附近”是一个二维空间概念,B-tree 默认假设数据是线性有序的,你没法用它快速回答“这个点周围 3 公里内有哪些点”。所以大部分 MySQL 方案只能做全表扫描,然后在内存里逐行算距离,性能直接崩。
Elasticsearch 不一样,它的geo_point类型底层是 BKD 树,这是一种针对多维空间数据的索引结构,会把地球经纬度空间切分成一层层的格子,查询时只需要沿着树的分支找到目标区域对应的叶子节点,然后再在很小的候选集里做精确计算。你可以把它想象成地图 App 的网格加载:不是把全世界的道路都算一遍,而是只把屏幕范围内的几条路拉出来。这就是地理位置搜索能用在海量数据上的核心原因。
1.2 和关系型数据库相比,ES 的优势在哪
我用一个类比来解释 ES 在地理搜索上的优势:MySQL 的普通索引像一本按姓氏拼音排序的电话簿,你要查“姓张的人”很快,但要查“住在同一个小区的人”就抓瞎了;ES 的地理索引更像一张分层地图,你问“人民广场 3 公里内有哪几家咖啡店”,它会先锁定人民广场那一格,再往外扩一圈,而不是翻遍整本地图。
具体到工程指标上,我用一张表说明差异:
| 维度 | 传统数据库方案 | Elasticsearch |
|---|---|---|
| 千万级数据查询 | 全表扫描 + 逐行计算,延迟高 | BKD 树空间索引,检索范围大幅缩小 |
| 与业务条件组合过滤 | SQL 写起来冗长,难以灵活拼装 | bool查询天然支持 geo 过滤与 keyword/range 组合 |
| 按距离排序 | 需要额外算排序字段,复杂 | _geo_distance排序直接支持 |
| 区域聚合统计 | 基本没有现成能力 | geohash_grid、geo_tile聚合非常方便 |
| 横向扩容 | 分库分表后地理查询极其痛苦 | 天生分布式,按分片分散压力 |
注意,我不是说 ES 可以完全替代数据库。它有明显短板:数据一致性偏弱、运维成本更高、内存和磁盘开销比 MySQL 大。所以正确的姿势是:核心交易数据放数据库,地理位置检索与聚合分析放 ES,两者通过业务 ID 关联。你要先想清楚这一点,后面才不会做出一个四不像的架构。
1.3 什么场景适合用,什么场景别硬上
适合用 ES 做地理位置搜索的场景,我归纳为四类:
- 附近的人 / 附近门店:以某个点为中心,找一定半径内的实体,并按距离排序。
- 地理围栏:判断用户坐标是否进入了某个商圈、园区、配送区域,比如外卖骑手进店、共享单车禁停区提醒。
- 区域统计与热力图:对海量坐标做聚合,生成每个格子内的点数,用于人流分析、订单热度图。
- LBS 推荐前筛:结合品类、价格、营业状态等条件,先做地理粗筛,再做业务精排。
不适合硬上的场景也有,比如:数据量只有几万且请求量不高的内部系统,MySQL 完全够用;需要亚毫秒级响应且对距离精度要求极其苛刻的实时风控场景,可能该考虑 Redis GEO 甚至内存计算;强事务型系统更不能把 ES 当地理数据库用。方案选型不是越高级越好,而是越匹配越好,这是我做技术选型一贯的原则。
2. 核心前置:geo_point 的映射与数据写入
2.1 字段类型与映射设计要点
地理位置搜索的第一步不是写查询,而是先把索引字段类型定义对。ES 里表示经纬度有两种核心类型:geo_point表示单个坐标点,geo_shape表示点、线、多边形等复杂的几何形状。日常最常见的“附近搜索”,用geo_point就够了。
我建议的映射模板大概是这样的:
PUT /poi_index { "mappings": { "properties": { "poi_name": { "type": "keyword", "ignore_above": 256 }, "category": { "type": "keyword" }, "location": { "type": "geo_point" }, "open_time": { "type": "date" } } } }这里有几个细节我特别想强调。第一,location一定不要设成text或keyword,否则它只是“一堆数字文本”,既不能参与距离计算,也不能做地理过滤和排序,后面所有操作都白搭。第二,geo_point默认开启了doc_values,这对排序和聚合很重要,尽量别关。第三,如果你知道后续会有大量geo_bounding_box过滤,可以不用额外配置太多,ES 会按需使用 BKD 索引。映射是你整个地理位置业务的根基,一开始定错了,后面改索引代价极大,务必想清楚再建。
2.2 四种坐标写法,别再踩经纬度顺序的坑
我见过最多的线上问题,就是经纬度顺序写反导致搜不到数据。ES 对坐标的写法其实有好几种,每种顺序还不一样,这是新手最容易栽跟头的地方。
第一种是对象写法:
{ "location": { "lat": 31.2304, "lon": 121.4737 } }第二种是字符串写法,纬度在前经度在后:
{ "location": "31.2304,121.4737" }第三种是数组写法,注意这是 GeoJSON 格式,经度在前纬度在后:
{ "location": [121.4737, 31.2304] }第四种是 geohash 字符串,比如wtw3sj,一般是从地图 SDK 里直接拿到的。
这四种写法本身没问题,问题在于不同场景混用。你写入时用了数组[121.4737, 31.2304],查询时脑子里想的是“纬度 31、经度 121”,于是传了{lat: 31.2304, lon: 121.4737},看似对,其实因为内部存储始终是[lon, lat],一旦你写入用数组、查询用对象,很容易出现坐标错位。我现在的做法是:项目里统一约定用对象形式{"lat": 31.2304, "lon": 121.4737},后端拿到数据后先做一次范围校验,纬度必须在 -90 到 90 之间,经度必须在 -180 到 180 之间,不合格直接报错不写入。范围校验成本很低,但能救回大量脏数据问题。
2.3 写入之后如何验证坐标正常
写完数据后别急着查,先确认索引里的坐标到底存成了什么样。我最常用的是 Kibana 的 Dev Tools,一条命令看映射:
GET /poi_index/_mapping再查几条文档,确认_source里的location字段是对象还是数组:
GET /poi_index/_search { "size": 3, "_source": ["poi_name", "location"] }如果项目里接了 Kibana 的地图可视化功能,直接在 Discover 里选坐标字段,能地图上看到点分布,那基本说明写入没问题。这里还有个小技巧:写入前你可以用ingest pipeline统一加工坐标字段,比如把字符串格式统一转成数组,甚至在 ingest 阶段判断经纬度范围,超出就丢弃。这样数据层面就保证了统一性,查询时不用再猜前端到底传了什么格式。
3. 五种常见地理位置查询的实操拆解
3.1 geo_distance:附近 N 公里的人与门店
geo_distance应该算地理位置搜索里用得最多的查询了,它的语义就是“以某个点为中心,找指定距离内的所有点”。比如以人民广场为中心搜 5 公里内的门店,查询语句如下:
GET /poi_index/_search { "query": { "bool": { "filter": [ { "geo_distance": { "distance": "5km", "location": { "lat": 31.2304, "lon": 121.4737 } } }, { "term": { "category": "coffee" } } ] } } }这里我特意把查询放在了bool.filter里,而不是must里。原因是地理位置过滤本质是一个“有关系或没关系的条件判断”,不需要计算_score相关性评分。放filter里有两级好处:第一,不走评分,节约 CPU;第二,ES 可以缓存这个过滤结果,相同条件第二次查询直接走缓存。如果你把地理查询放到must,每次请求都得重新算分,性能差距在高 QPS 下非常明显。
关于距离单位,distance字段支持m、km、mi、yd等,建议查询层统一用km,展示层再换算,减少误解。distance_type参数也值得注意:默认是arc,按球面大圆距离计算,精度高但相对慢;plane是平面近似算法,计算快但在高纬度地区误差很大,只适合低精度场景。正常项目建议保持默认arc,除非你清楚自己在做什么。
3.2 geo_bounding_box:矩形围栏的第一道粗筛
如果说geo_distance像一个圆规画出来的圆,那么geo_bounding_box就是用一个矩形把范围框出来,它的特点是过滤速度快,尤其适合做第一层粗筛。
示例:查询上海某个矩形区域内所有 POI:
GET /poi_index/_search { "query": { "bool": { "filter": { "geo_bounding_box": { "location": { "top_left": { "lat": 31.5, "lon": 121.3 }, "bottom_right": { "lat": 31.0, "lon": 121.8 } } } } } } }写这个查询有两个容易踩坑的地方。第一,top_left的纬度要大于bottom_right的纬度,经度要小于bottom_right的经度,这是地理坐标的正常方向,反了就查不到。第二,geo_bounding_box默认type为memory,即不强制走索引;如果这个查询是你的高频入口,可以显式设置"type": "indexed",让它尽量使用 BKD 索引加速,代价是不能过滤掉没有坐标或坐标非法的文档。我实践中的做法是:矩形粗筛永远放在filter里,且放在geo_distance之前,让 ES 先用最省力的方式把范围缩到最小,再做精确距离计算。
3.3 geo_polygon 与 geo_shape:不规则区域圈定
很多业务场景并不是简单的圆形或矩形,比如“判断用户是否在朝阳区”“是否在某个不规则的小区范围内”,这时候就要用多边形了。老的geo_polygon查询可以直接在查询里传多边形顶点:
GET /poi_index/_search { "query": { "bool": { "filter": { "geo_polygon": { "location": { "points": [ {"lat": 31.5, "lon": 121.3}, {"lat": 31.4, "lon": 121.6}, {"lat": 31.2, "lon": 121.5}, {"lat": 31.3, "lon": 121.2} ] } } } } } }不过说实话,geo_polygon在新版 ES 里已经被标记为 deprecated,官方更推荐用geo_shape查询。geo_shape的思路是:你把区域边界(多边形)提前存成geo_shape类型,查询时传入一个点,让 ES 判断点和区域的关系。索引区域数据的映射大概是:
PUT /areas_index { "mappings": { "properties": { "area_name": { "type": "keyword" }, "boundary": { "type": "geo_shape" } } } }写入一个多边形区域后,查询某个点是否落在区域内:
GET /areas_index/_search { "query": { "bool": { "filter": { "geo_shape": { "boundary": { "shape": { "type": "point", "coordinates": [121.35, 31.22] }, "relation": "within" } } } } } }这里我栽过一个大跟头:relation参数的方向非常容易搞混。在geo_shape查询里,查询条件中的shape是“你要判断的那个对象”,比如一个点,而字段里存的是“大区域”。当你想表达“点落在区域内”时,需要用intersects而不是within。within的语义是“字段自身的范围完全包含查询形状”,听起来对,但实际查询结果空的时候极难排查。后来我的经验是:判断点面关系一律用intersects,多边形相交即命中,简单直接。
3.4 按距离排序与返回距离
附近搜索只过滤还不够,绝大多数产品还需要“从近到远”排序,并且显示“距离我多少米”。ES 的_geo_distance排序非常方便:
GET /poi_index/_search { "query": { "match_all": {} }, "sort": [ { "_geo_distance": { "location": { "lat": 31.2304, "lon": 121.4737 }, "order": "asc", "unit": "km", "distance_type": "arc" } } ] }排序结果的每条 hit 里,sort数组对应的值就是“按你给的unit算出来的距离”。如果unit是km,那值就是多少公里。这里有个很重要的建议:如果你既要做距离过滤,又要按距离排序,不要用两个一模一样的坐标点分别写在filter和sort里,那样虽然能跑,但没必要。直接把filter里的geo_distance当范围限制,排序用同一个中心点,逻辑清晰且性能最好。另一个细节是,如果你要返回的字段里没有坐标,光看距离排序值可能不够直观,可以在查询里加上"docvalue_fields": ["location"]或者"fields": ["location"],让结果里带着坐标信息,前端渲染更方便。
3.5 geohash_grid 聚合:热力图/区域统计的底座
前面几类都是“查询”,地理位置搜索真正体现 ELK 平台优势的,还有“聚合统计”。最常用的就是geohash_grid,它会把地图按 geohash 编码划分成一个个网格,然后统计每个网格内有多少文档:
GET /poi_index/_search { "size": 0, "aggs": { "grid": { "geohash_grid": { "field": "location", "precision": 6 } } } }返回结果大致是:
{ "aggregations": { "grid": { "buckets": [ {"key": "wtw3sj", "doc_count": 182}, {"key": "wtw3sk", "doc_count": 96} ] } } }precision是 geohash 字符串长度,它直接决定格子大小。经验值大概是:precision为 1 时格子约 5000 公里,3 时约 156 公里,5 时约 5 公里,7 时约 150 米。做全国热力图用 3 到 4,做城市级热力图用 5 到 6,做门店周边密集度分析用 7 左右。这个聚合特别适合做“订单分布热力图”“共享单车投放密度”之类的功能,配合地图 SDK 的色块渲染,就是一套非常经典的 LBS 数据可视化方案。除了geohash_grid,还可以关注geo_tile聚合和geo_centroid聚合,前者输出地图瓦片编码,后者能返回每个格子的中心点,对绘制聚合结果非常有帮助。
4. 让地理位置搜索变快的实操套路
4.1 filter 缓存与 bool 组合的查询性能差异
很多人在刚开始写地理查询时,习惯把所有条件堆在must里,结果发现查询一上量就变慢。这背后的逻辑不复杂:must里的每个子句都需要参与相关性评分,geo_distance本身不是为评分设计的,给它算分既没意义又拖慢速度。
我给出的标准姿势是:把geo_distance、geo_bounding_box、geo_shape这类地理位置条件全部放进bool.filter,同时把业务过滤条件如分类、营业状态、价格区间也放进去。比如“找 5 公里内、营业中、人均 50 元以下的咖啡店”,可以这样写:
GET /poi_index/_search { "query": { "bool": { "filter": [ { "geo_distance": { "distance": "5km", "location": {"lat": 31.2304, "lon": 121.4737} } }, { "term": {"category": "coffee"} }, { "term": {"is_open": true} }, { "range": {"avg_price": {"lte": 50}} } ] } } }把地理条件放在第一个filter里是我自己的习惯。ES 对多个 filter 条件的执行顺序不保证严格从左到右,但提前用地理过滤把候选集缩到最小,后面的term、range只需要在小集合里验证,整体开销就更小。另一个细节是,如果一个地理过滤条件被多个查询复用,可以考虑把它单独提出来作为filter子句,充分利用 ES 的 filter cache,而不是每次拼接新查询。
4.2 粗筛 + 精算的两步走策略
地理位置搜索最容易出现的性能瓶颈,是你直接拿geo_distance对一个超大的索引做全量范围过滤。比如公司有 5000 万条订单坐标,你每次“找附近 3 公里”都直接算球面距离,即便有 BKD 树,开销也很大。我的优化思路是“粗筛 + 精算”:先用geo_bounding_box把范围粗暴地框出来,这个矩形可能比真正的圆大了一圈,但胜在快,把候选集从 5000 万缩到几万;然后再用geo_distance在候选集里做精确圆过滤。
这个组合在实战里的效果非常明显。我做过一个测试,5000 万文档的索引,直接全量geo_distance过滤耗时 80 毫秒左右,加了前置geo_bounding_box后降到 20 毫秒以内,而且结果完全一致。注意,矩形范围如果过大,比如直接用几十公里的正方形,那前置过滤的意义就不大,要结合你的实际业务半径调整。一般来说,矩形边长大致是圆直径,或者稍大个 20%,粗筛效果最好。
如果你的场景既需要范围精确又需要距离排序,可以这样组合:filter里用geo_bounding_box做粗筛,sort里用_geo_distance做精确距离排序。这样查询路径更短,排序精度还不丢,是我现在最常用的 LBS 查询模板。
4.3 映射参数与索引优化细节
除了查询侧优化,索引侧也有不少能影响地理位置搜索性能的参数。第一个是ignore_malformed。默认情况下,一个字段里混入一条非法的坐标,整个文档写入就会失败。很多线上问题看起来是“写入偶尔失败”,实际上就是某条数据坐标lat超过了 90 度。我建议在geo_point字段上开启ignore_malformed,保证脏数据跳过而不是拖垮整个索引写入:
{ "mappings": { "properties": { "location": { "type": "geo_point", "ignore_malformed": true } } } }第二个是分片规划。地理位置搜索本质是范围查询,分片太少会导致单分片数据量过大、查询变慢;分片太多又会增加协调节点合并结果的成本。我的经验是:千万级数据,单分片 30 到 50GB 左右比较均衡,比如 5000 万文档大致配 3 到 5 个分片,副本 1 个就够。不要为了炫技把分片数设成几十个,得不偿失。
第三个是刷新频率。如果你在做地理位置数据的批量导入,频繁的refresh会让写入压力剧增。批量导入时可以把refresh_interval设为-1,导入完成后改回1s或30s:
PUT /poi_index/_settings { "index": { "refresh_interval": "-1" } }这样做之后,写入吞吐能明显提升,代价是数据不会实时可见,所以只适合离线导入场景。
5. 常见问题与排查技巧实录
5.1 为什么附近搜索查不到数据:先查经纬度顺序
这是地理位置搜索“第一杀手级”问题。我接手过一个项目,接口偶尔返回空数据,排查了两天才发现根因:前端传的是 GeoJSON 数组[31.12, 121.31],后端拿到后直接写进 ES 的数组字段。ES 数组格式要求是[lon, lat],但前端给的第一个值是纬度。结果坐标被解释成经度 31 度、纬度 121 度,彻底跑偏到别的半球去了。
排查方法其实很简单:直接查一条出问题的文档,看_source.location,再用 Kibana 自带地图看坐标是不是落在真实业务区域附近。再不行就写一个脚本,把查询条件和写入数据都打出来,人工对比。解决这类问题,我给三个固定动作:一,所有端统一传{"lat":..., "lon":...}对象;二,后端统一做一次经纬度范围校验;三,写个定时任务,抽样校验索引里的坐标分布是否合理,比如统计一下超出中国范围的坐标占比,超过阈值立刻报警。
5.2 为什么查询变慢:先看写入指标还是先看磁盘
有一个热搜问题问得特别好:写入 ES 变慢,怎么判断是磁盘问题还是其他原因?我的经验是先看 ES 自身的线程池和写入指标,再看系统层磁盘指标,顺序别颠倒。
第一步,看 write 线程池有没有堆积:
GET /_cat/thread_pool/write?v&h=node_name,name,active,queue,rejected如果queue长期不为 0,rejected持续增长,说明写入压力已经超过了集群处理能力。这时候要拆两个方向排查:如果是大批量写入触发了瓶颈,优先调整批量大小和并发;如果单条写入都慢,就要看索引层面的耗时。
第二步,看索引写入耗时指标:
GET /_nodes/stats?filter_path=**.indexing**重点看index_total和index_time_in_millis,两者相除就是平均每条文档索引耗时。如果这个值持续走高,说明 ES 内部处理慢,常见的元凶是有太多字段需要索引、refresh 太频繁或者做大量字段的 doc_values 写入。
第三步,再看系统层指标,用iostat -x 1看%util、await、iowait。如果%util接近 100% 且await很高,基本可以确认磁盘是瓶颈,可以换 SSD、扩大磁盘并行度、减少 refresh。如果磁盘指标很健康,那问题大概率在查询或写入逻辑上,别盲目加磁盘或扩机器。
我在实战中总结了一个速查表,分享给大家:
| 现象 | 优先排查指标 | 典型根因 |
|---|---|---|
| 写入变慢且 bulk 拒绝 | write 线程池rejected | 并发过高或 shard 数过多 |
| 单条写入耗时高 | index_time/index_total | refresh 频繁、字段过多、磁盘慢 |
| 磁盘 util 高 | iostat%util/await | 机械盘性能不足、segment 合并激烈 |
| 查询变慢 | search 线程池、segment 数 | 未用 filter、候选集过大、堆外内存不足 |
5.3 SpringBoot 集成时的几个隐藏坑
社区里 springboot 集成 elasticsearch 的帖子很多,我在实际项目里踩过几个文档里不怎么提的坑。
第一个是客户端版本必须对得上。ES 大版本 7 和 8 的客户端 API 差异很大,7.x 用RestHighLevelClient,8.x 推荐用新的ElasticsearchClient。版本不一致最典型的报错就是“this version of the jdbc driver is only compatible with elasticsearch version ...”,这个报错不仅出现在 DBeaver 连 ES 的场景,也出现在 Java 客户端里。我的建议是:在 pom 里直接指定与服务器 ES 完全一致的版本,不要用传递依赖的默认版本。用 JDBC 或 DBeaver 连接 ES 时同理,JDBC 驱动版本必须和 ES 集群版本对应,否则会在连接握手阶段就报版本不兼容。
第二个坑是批量写入。很多新手会把地理位置数据一条一条循环插入,结果写入性能惨不忍睹。正确的做法是用BulkProcessor或者批量 API,设置每个批次大小、并发线程数和刷新间隔。比如一秒钟攒够 1000 条或 5MB 就提交一次,这样写入吞吐可以提升数倍。
第三个坑是查询构建时坐标类型。在 Java 里构建geo_distance查询时,如果直接传字符串"31.23,121.47",很容易把纬度和经度搞错。更稳妥的方式是用GeoPoint对象:
GeoDistanceQueryBuilder qb = QueryBuilders.geoDistanceQuery("location") .point(new GeoPoint(31.2304, 121.4737)) .distance(5, DistanceUnit.KILOMETERS); BoolQueryBuilder bool = QueryBuilders.boolQuery(); bool.filter(qb); bool.filter(QueryBuilders.termQuery("category", "coffee"));这样至少从类型层面避免了顺序错误。
5.4 部署环境速记:Windows、Docker Compose 与 Kubernetes
地理位置搜索的索引和查询再优秀,部署环境不稳定也白搭。搜热词里关于部署的问题特别多,我简单分享几个直接能用的经验。
Windows 上启动 ES 最常见的问题是内存配置。ES 启动脚本默认会读取jvm.options,但 Windows 下用户经常忘记改,默认堆内存只有 1GB,一导入数据就 OOM。我的建议是:生产环境至少给 4GB 堆,并且-Xms和-Xmx要设置成一样,避免 JVM 动态扩容导致卡顿。另外,Windows 上建议把 ES 注册成服务,别用前台窗口跑,毕竟运维老哥可不想天天守着黑窗口。
Docker Compose 部署 ES 时,有几个配置几乎是必须的。第一要设置discovery.type=single-node,不然单实例会一直报 discovery 错误;第二要挂载数据卷,否则容器一删数据全没;第三要给 JVM 堆内存留够,不设置的话容器默认内存机制很容易导致 ES 被 OOM Killer 杀掉。贴一个我常用的最简配置:
version: '3' services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:7.17.10 container_name: es environment: - discovery.type=single-node - "ES_JAVA_OPTS=-Xms4g -Xmx4g" - bootstrap.memory_lock=true ulimits: memlock: soft: -1 hard: -1 ports: - "9200:9200" volumes: - esdata:/usr/share/elasticsearch/data volumes: esdata:注意bootstrap.memory_lock=true这个参数,它要求锁定内存,避免 ES 堆内存被 swap 到磁盘导致性能雪崩。在 Linux 上你可能还需要修改/etc/security/limits.conf里的memlock限制。这套配置在地理搜索的测试环境里足够稳定。
至于 KubeSphere 或 Kubernetes 上部署 ES,我没有特别多要讲的,核心原则是:用 StatefulSet 保证节点标识稳定,用 headless service 做节点发现,用 PVC 挂数据目录,给容器设置合理的 CPU 和内存 limits。只要保证这三件事不出错,集群就能跑得比较稳。千万不要图省事用 Deployment 部署多节点 ES,重启后节点身份变化,分片恢复会给你带来巨大的麻烦。
6. 从地理位置搜索到更多业务场景
6.1 配送范围判定与地理围栏
我用 geo_shape 做的最有成就感的项目,是外卖商家配送范围判断。商家可以手动绘制一个配送区域,本质是一个多边形;用户下单时,系统判断用户坐标是否落在商家配送多边形内。这个场景如果用geo_polygon每次实时传多边形顶点,性能会很难看,因为每个商家多边形可能有几十个点,每个订单都得算一遍。我的做法是:商家区域提前同步成 ES 的geo_shape索引,下单时直接查询用户坐标点和区域索引的intersects关系,命中即表示在配送范围内。一来过滤走 BKD 树很快,二来订单筛选还能叠加营业状态、起送价等业务条件,整套逻辑都在一次查询里解决。
6.2 热力图、区域人流统计与容量预估
另一个让我觉得 ES 地理位置搜索非常值钱的场景是区域人流统计。之前做一个景区调度项目,需要实时统计每个片区的人流量,数据来自几十万台设备的坐标上报。我们直接把设备坐标写入 ES,然后每隔一段时间跑一次geohash_grid聚合,拿到每个网格的doc_count,再根据网格精度换算成对应区域的设备密度。这套方案上线后,运维只需要监控 ES 集群的写入和查询延迟,不用自己写分布式统计任务,省了一大半精力。如果你有类似“按区域统计订单量”“按格子分析骑行轨迹”的需求,geohash_grid聚合完全可以照搬。
6.3 我的一些经验沉淀
做地理位置搜索这几年,我最大的体会是:性能问题大多出在数据规范上,而不是出在查询写法上。坐标混乱、经纬度写反、字段类型错误,这些基础问题一旦没有治理好,后面所有调优都是空中楼阁。第二个经验是:地理位置查询并不是越高阶越好,geo_distance加geo_bounding_box的组合已经能覆盖 80% 的业务场景,geo_shape才管剩下 20% 的区域围栏需求,不要一上来就把所有地理特性都堆上。第三个经验是:ES 的地理搜索定位应该是“检索利器”,不是“分析数据库”,数据量到了一定规模,聚合统计、热力分布、轨迹分析这些重活,可以考虑把聚合结果定期沉淀到其他存储,ES 继续专注服务高并发查询。把每一个坐标都当作核心资产来治理,按这个思路去设计,地理位置搜索会让你省心很多。