☰
Apache Doris地理空间匹配实战:从ST_Contains到性能优化
2026/10/30 18:27:25 网站建设 项目流程

我接到过不少类似需求,这次是某即时配送项目里的实时区域匹配:每天几千万条带坐标的订单日志,要实时判断每一单落在哪个营业部覆盖区内,再回填到宽表给调度和时效预测用。一开始也想过引入PostGIS,但数据本身已经全部沉淀在Apache Doris里,再为“点位落区”这个动作单独铺一套GIS链路,成本和复杂度都划不来。最终我在Doris里自己把这套“地理空间匹配”扛下来了,还要在各种版本、各种数据格式、各种业务表之间做“适配”——Doris毕竟不是专业GIS引擎,但函数用对了、表结构设计好了,它完全撑得起这类场景。

如果你也正准备在Doris里做地理空间匹配,或者正被“GPS点位落入哪个区域”“附近门店排序”“电子围栏判断”这类需求折腾,这篇文章应该能给你省下不少试错时间。我会从方案选型、函数细节、建表导入、匹配SQL、性能优化一路讲到实战里的坑,基本是这次项目踩坑和填坑的完整记录。

1. 为什么在Doris里做地理空间匹配:方案与选型拆解

1.1 需求场景:从GPS点到业务区域

空间匹配落到业务上,核心就一句话:给定一个点,判断它落在哪个范围里。“范围”在不同行业里名字不一样,门店覆盖区、配送围栏、行政边界、商圈多边形,本质都是同一件事。我这个项目里的是外卖场景的站点匹配:用户下单后拿到经纬度,系统要立刻判断这个点属于哪个配送站点,站点知道后才好派单、算时效、预估运力。

数据链路复盘下来,这个需求有几个特点:点位量级大,一天几千万条这种量级;匹配时效要求高,不能等离线批处理;区域边界不是固定的,门店开出关掉、站点合并拆分都会导致多边形频繁变化。这些特点决定了不能依赖传统的业务库去跑,业务库撑不住千万级点位的空间join,也不适合频繁更新边界数据。

1.2 为什么不用专业GIS引擎,而选Doris

做空间匹配,行业里常见方案其实不少。PostGIS是专业户,空间函数全、精度高;Elasticsearch能做地理查询,但复杂多边形包含关系基本要靠脚本;ClickHouse也有GIS函数,但语法和生态跟业务链路耦合不深;MongoDB有GeoJSON索引,可惜做不了大规模分析。

这几个方案我都盘过一遍,卡点出奇一致:数据必须单独同步一份到对应引擎,匹配完再把结果搬回来。这意味着每天多一条跨系统数据管道,多一套运维监控,还多一个数据延迟的问题。而Doris本身就在链路最核心的位置,订单数据实时往里面灌,业务分析也都在Doris里做,空间匹配能直接下沉到SQL层,一个join就完事。

我当时的决策逻辑很简单:

  • Doris有ST_Contains、ST_Distance_Sphere这类函数,日常“点落区”“算距离”覆盖住了;
  • Doris是MPP分析引擎,几千万点位join几百个区域多边形,压力远小于业务库;
  • 整个匹配过程不出数仓体系,不引入新的存储组件,交付链路最短。

当然Doris不是万能的。复杂拓扑关系、线面叠加分析、栅格地理计算它确实做不了,真遇到这些,还是得老老实实引专业GIS引擎。但绝大多数互联网业务里的“空间匹配”,本质上就是点和多边形的包含判断加球面距离计算,Doris恰好够用。

1.3 版本适配是第一个门槛

这里要先给所有准备入手的同学打个预防针:Doris的空间函数能力是逐步补齐的,不同版本之间差异不小。你可能照着官方文档写的SQL,在测试环境跑得好好的,生产集群一跑就报function不存在,当时第一反应是SQL写错了,查了半天发现是版本差了一个小版本。

我这次用的是Apache Doris 2.1的集群,空间函数整体完整,ST_Point、ST_Contains、ST_Within、ST_Distance_Sphere、ST_GeometryFromText都在。如果还在用1.2甚至更老的版本,能用但函数行为可能有细微差别。最稳妥的做法是上集群先执行:

SHOW FUNCTIONS LIKE '%ST_%';

把当前环境支持的函数列出来逐一核对签名。函数名对上了,再谈接下来的实现。这一步能省下大量排查时间。

2. 核心函数与数据模型:Doris空间能力的正确打开方式

2.1 空间数据的存储方式:一切从WKT开始

Doris没有像PostGIS那样把几何对象当作一种原生类型存储,它的设计思路务实得多:空间对象以Well-Known Text(WKT)字符串的形式存在普通字段里,计算时再通过函数解析成内部几何结构。

一个多边形区域在表里长这样:

POLYGON ((116.30 39.92, 116.31 39.92, 116.31 39.93, 116.30 39.93, 116.30 39.92))

一个点不需要单独存成WKT,直接用lng、lat两个数值列就行,参与计算时用ST_Point临时构造。这种设计的好处是建表极度简单,Stream Load、Broker Load、insert任意导入方式都能兼容;代价是每次空间计算都要做一次字符串解析,所以对性能敏感的场景必须想办法减少函数调用次数。

这里必须提醒一点:Doris的地理函数处理的是经纬度平面坐标,常规距离计算一定用ST_Distance_Sphere而不是自己拿经纬度差套欧氏距离公式。直接算sqrt(Δx²+Δy²)在低纬度看着还行,纬度一高误差会大到离谱,这个坑我见过太多人踩。

2.2 匹配用到的函数族详解

Doris地理空间匹配真正高频的就这么几个函数,其他都是锦上添花:

函数参数说明
ST_Pointx, y构造点对象,x为经度,y为纬度
ST_GeometryFromTextwkt字符串将WKT文本解析为几何对象,支持点、线、多边形
ST_Containsg1, g2判断g2是否在g1内部,常用:多边形包含点
ST_Withing1, g2判断g1是否在g2内部,与ST_Contains参数方向相反
ST_Distance_Spherex1, y1, x2, y2计算两经纬度点球面距离,返回米
ST_Circle经度, 纬度, 半径米构造一个圆形地理对象,用于半径圈选

实际工作中最常见的组合是“区域表+点位表”的多对多join。区域表每行存一个多边形的WKT,点位表每行存一个经纬度,连接条件就是ST_Contains(区域多边形, 点)为真。一条SQL就能完成全量区域的包含判断,这个思路一定要建立起来,空间匹配在Doris里不是靠游标或存储过程,就是靠这种集合化的join。

2.3 函数参数顺序别踩坑

这个坑我单独拎出来说,因为实在太容易犯。Doris的ST_Contains(g1, g2)语义是“g2在g1内部”,第一个参数是大的、包含的那个对象,第二个参数是小的、被包含的那个对象。很多从PostGIS转过来的兄弟习惯性把点放第一个参数,结果查出来全是空,还以为是数据不对。

ST_Within恰好是它的镜像,ST_Within(点, 多边形)等价于ST_Contains(多边形, 点)。团队里如果写SQL风格不统一,有的用Contains有的用Within,很容易出现两条等价SQL在优化器里处理路径不同的情况。建议内部约定统一用“ST_Contains(大对象, 小对象)”这一种写法,至少排查问题的时候少一个变量。

还有一个容易被忽略的行为:当ST_Contains的第一个参数是NULL或者非法几何时,函数返回NULL而不是报错,在JOIN条件里NULL会被当FALSE处理,等于直接丢掉这条记录。如果你没提前判空,点位的匹配率会莫名下降,排查方向又会跑偏。

3. 实操过程:从建表到匹配SQL的完整链路

3.1 建表设计与数据导入

先看区域表。区域这种维度数据量不大,几百到几千行,重点是存好WKT,还要留一个MBR字段做粗筛:

CREATE TABLE region_geo ( region_id INT, region_name VARCHAR(128), region_wkt STRING, region_min_lng DOUBLE, region_max_lng DOUBLE, region_min_lat DOUBLE, region_max_lat DOUBLE, update_time DATETIME ) DUPLICATE KEY(region_id, region_name) DISTRIBUTED BY HASH(region_id) BUCKETS 8 PROPERTIES ("replication_num" = "1");

MBR是“最小边界矩形”,也就是把这个多边形外接的经纬度范围算出来,专门用来做第一层快速过滤。后面讲优化的时候你会看到它的威力。

点位表就简单了,就是一条条带坐标的业务记录:

CREATE TABLE user_location ( user_id BIGINT, lng DOUBLE, lat DOUBLE, event_time DATETIME ) DUPLICATE KEY(user_id, event_time) DISTRIBUTED BY HASH(user_id) BUCKETS 16 PROPERTIES ("replication_num" = "1");

这里注意两个设计细节:一是event_time参与分桶键,后续可以按时间分区裁剪,避免每次查询都扫全表;二是不要试图给lng、lat建索引来加速,Doris的索引模型对空间查询帮助有限,真正的性能靠分区裁剪和MBR粗筛。

WKT数据怎么来?一般是从地图服务商或者GIS工具导出。导出的时候先确认坐标系,我们业务统一用WGS84经纬度,如果你拿到的是高德或百度坐标,必须先在入库前做坐标转换,否则匹配结果的偏移可能大到完全没法用。别想着在SQL里临时转,那会产生大量重复计算,而且偏移逻辑复杂,SQL里写不清楚。

数据导入不需要特殊处理。区域表几百行,直接insert或者CSV扔进去;点位表量大,用Stream Load一条管道往里灌就行。WKT文本里别带多余换行,否则ST_GeometryFromText解析的时候很容易报格式错误,而且这种报错往往不是行数对不上,是匹配结果少几条,很难查。

3.2 实现“点落入区域”的匹配查询

核心匹配SQL长这样:

SELECT u.user_id, u.lng, u.lat, r.region_id, r.region_name FROM user_location u LEFT JOIN region_geo r ON ST_Contains(ST_GeometryFromText(r.region_wkt), ST_Point(u.lng, u.lat));

跑一次就能把所有点位和区域关联起来。点位不在任何区域内时,LEFT JOIN会保留这些记录,region_id为空,方便下游做兜底处理。如果你只想保留有结果的,把LEFT JOIN换成JOIN就行。

实际项目里通常要把结果落地到一张结果表,写成INSERT INTO ... SELECT的方式:

INSERT INTO user_region_result (user_id, lng, lat, region_id, region_name, match_date) SELECT u.user_id, u.lng, u.lat, r.region_id, r.region_name, '2024-01-01' AS match_date FROM user_location u LEFT JOIN region_geo r ON ST_Contains(ST_GeometryFromText(r.region_wkt), ST_Point(u.lng, u.lat));

最开始我图省事,直接在查询里反复调用ST_Point和ST_GeometryFromText,结果就是明明只join了几千个区域,执行时间却被拉得很长。后来改成先把点位转换结果放到子查询里,再和区域表做join,效果也不明显。直到看了执行计划才发现,问题不在重复构造,而在于ST_Contains无法走索引,每个点位都要和每个区域做一次全量几何比较。所以真正的性能拐点在下节讲的MBR粗筛。

3.3 距离计算与近邻匹配场景

区域包含之外,另一类高频需求就是“找附近N个门店”“找离我最近的站点”。最直接的SQL是这样:

SELECT shop_id, shop_name, ST_Distance_Sphere(116.30, 39.92, shop_lng, shop_lat) AS distance_m FROM dim_shop ORDER BY distance_m LIMIT 10;

这个写法在门店几千行、并发请求低的时候没毛病,可一旦请求量上来,全表排序加球面距离计算会给集群造成不小的压力。我给这种场景的优化方案是“矩形粗筛+精确计算”:先用一个经纬度矩形框把候选集砍小,再对剩下的少量门店做精确排序。

SELECT shop_id, shop_name, ST_Distance_Sphere(116.30, 39.92, shop_lng, shop_lat) AS distance_m FROM dim_shop WHERE shop_lng BETWEEN 116.28 AND 116.32 AND shop_lat BETWEEN 39.90 AND 39.94 ORDER BY distance_m LIMIT 10;

这个2公里见方的矩形在很多场景下已经能把候选门店从几千个压缩到几十个,ST_Distance_Sphere只计算少量行,整体请求RT压下来一大截。Doris是MPP架构,这种范围条件能很好地利用分区裁剪和谓词下推,实测性能提升非常明显。

半径圈选“2公里内的人”这类需求可以用ST_Circle:

SELECT u.user_id FROM user_location u WHERE ST_Contains( ST_Circle(116.30, 39.92, 2000), ST_Point(u.lng, u.lat) ) = 1;

不过ST_Circle的第三个参数单位是米,它内部会把米转成经纬度跨度,圆越大边界误差越明显。如果你的业务对边界精度敏感,还是推荐ST_Distance_Sphere加阈值的方式,至少边界值可控。

4. 常见问题与性能调优实录

4.1 常见报错与排查速查表

这个项目里我把踩过的坑整理成了一张速查表,基本覆盖了新手期最容易遇到的9成问题:

现象可能原因处理办法
ST_Contains返回NULL几何对象构造失败或参数顺序反了确认WKT文本格式,核对函数签名,确认参数前大后小
WKT导入不合法多边形未闭合、坐标越界、带换行符入库前统一程序生成WKT并做合法性校验
匹配结果偏移很大多边形和点位坐标系不一致入库前统一转成WGS84,别在查询里临时转
查询慢,扫描全表缺少粗筛条件增加MBR字段做矩形过滤,结合时间分区裁剪
区域更新后结果没变DUPLICATE模型覆盖语义和预期不符用临时表重建,或改用UNIQUE模型重新导入同key覆盖
函数低版本不可用版本太旧,空间函数支持不全升级Doris版本,或先SHOW FUNCTIONS核对
LEFT JOIN匹配率骤降区域WKT有NULL或空字符串被静默过滤导入前清洗空值,脚本里加非NULL过滤条件
多边形自相交导致匹配错乱第三方工具导出的边界数据有质量问题用GIS工具做拓扑修复后重新导出

最容易栽跟头的是倒数第二条。ST_Contains遇到NULL输入时不报错,返回NULL,在JOIN条件里等于把这条记录过滤掉。你的数据明明在区域内,但匹配率就是上不去,最后一条条查才发现是区域表里混进了几条空WKT。这个行为防不胜防,建议在导入流程里就加一层校验,不要让脏数据进表。

4.2 性能优化:分区、预计算与UDF补充

空间匹配的性能优化,核心原则只有一个:尽量减少ST_Contains这种重函数被调用的次数。

第一步是MBR粗筛。给每个区域表额外维护region_min_lng、region_max_lng、region_min_lat、region_max_lat这四个字段,查询时先判断点是否落在矩形框内,矩形框不满足的直接跳过,命中了再去比较精确多边形。

SELECT u.user_id, r.region_id FROM user_location u LEFT JOIN region_geo r ON u.lng BETWEEN r.region_min_lng AND r.region_max_lng AND u.lat BETWEEN r.region_min_lat AND r.region_max_lat AND ST_Contains(ST_GeometryFromText(r.region_wkt), ST_Point(u.lng, u.lat));

这样ST_Contains只在少数命中MBR的候选对上执行,函数调用量能降两个数量级。我在生产环境用两亿条点位测试,不加MBR要跑15分钟,加了MBR后压到3分钟以内,效果立竿见影。

第二步是把重复计算预计算掉。如果位置点是相对固定的设备或门店,可以在离线阶段把“点-区域”关系算好写入映射表,线上直接查映射表而不是每次现场算空间关系。Doris最擅长这种“宽表+预关联”的玩法,这也是它比很多专业GIS引擎更适合业务的原因——空间计算是手段,业务关联才是目的。

第三步万不得已才上UDF。Doris支持Java UDF,社区里也有人用它实现更复杂的空间逻辑,但UDF的维护成本和排查难度不是内置函数能比的。能用SQL层解决的,别轻易上UDF,这是过来人的忠告。

4.3 周边适配:Doris Manager、CCR、与外部GIS引擎协同

“适配”这个词其实不止于写SQL,空间匹配落到生产环境,周边工具的适配同样重要。

集群管理上,Doris Manager是排查匹配查询性能问题的好帮手。我遇到过匹配任务把某个BE节点CPU打满的情况,就是靠Manager里按查询耗时排序找到那条全表ST_Contains的SQL,再根据执行计划补上MBR粗筛解决的。如果整个集群偶发大查询把资源吃满,Manager的节点监控和查询审计能很快定位到具体是哪条空间SQL在作妖。

数据同步上,如果你有多套集群或者要做容灾,Doris CCR功能可以把空间数据表的变更实时同步到目标集群。站点多边形经常调整,用了CCR之后区域表的WKT更新能自动带到从集群,不用手工维护双份数据,省心很多。

再往大了说,Doris做不了的那部分空间计算,也没必要硬扛。常见做法是Doris负责大规模点区匹配和距离排序,PostGIS或专业的GIS工具负责生成复杂多边形、做拓扑校验,两边通过离线文件或接口交换数据。这种“各干所长”的适配方式,比逼着Doris去实现所有GIS功能要务实得多。

5. 最后的一点实战心得

项目收尾时我回头复盘,最大的体会是:在Doris里做地理空间匹配,真正的难点不在函数本身,而在想清楚哪些计算放到查询里、哪些放到入库前、哪些干脆下沉到专业GIS引擎。空间数据天然有坐标漂移、边界模糊、多源不一致这些问题,Doris能帮你把匹配结果稳定高效地喂给业务,但数据质量的把关、适配策略的设计,还是得靠工程上一点点磨。

如果你也是刚接到类似需求,我的建议是先花半天把区域数据质量摸一遍,再多花半天确认集群版本的函数支持情况,剩下的时间专心把MBR粗筛做好。这几个动作做完,这个项目大概率不会让你失眠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询