上个月我在做一个地图业务相关的模拟项目X,核心需求一句话:用户手机上报经纬度,系统判断这个点落在哪个运营区域,再触发对应区域的派单规则。运营区域不是数据库里存几个圆心和半径,而是各地对业务比较熟的同学用地图工具画好边界后导出的GeoJSON文件,里面既有独立的多边形,也有带洞的复杂边界,甚至还有跨越多块区域的情况。这个需求听起来不复杂,真正落地时才发现GeoJSON解析、点面包含判断、边界稳定性、大量区域下的匹配性能,每一环都有值得记录的坑。这篇文章就把我从零实现“Java + GeoJSON + 经纬度匹配”的完整过程写出来,给正在做电子围栏、网格派单、门店服务范围判断、区域客流统计的朋友一个参考。
1. 场景与整体设计思路
1.1 这类需求本质是点与面的包含关系
“用GeoJSON地理信息对经纬度点做匹配”,听起来像是个数据查找问题,其实本质是一个几何运算问题:给定一个经纬度坐标点,判断它是否位于某个多边形内部,并进一步找到它命中的区域ID。外卖平台要根据位置分配配送范围,物流系统要判断订单地址是否属于可派送区域,安防项目要做电子围栏进出提醒,本质上都是同一个逻辑,只是数据规模和实时性要求不同。
这里面最容易忽略的一点是:匹配不是“等于”,也不是“最近距离”,而是“点在多边形内”。区域边界可能是任意形状,而不是矩形。所以不能用简单的经纬度区间去判断,必须走几何算法。
衡量一个实现好不好,不只是“能判断”,还要看几件事:边界上的点是否稳定、带洞区域是否正确、MultiPolygon是否被兼容、区域数量达到上万时是否还能扛住压力。这些点我会在后面的章节逐一拆开讲。
1.2 先理顺GeoJSON的组织结构
GeoJSON是一种基于Json的地理数据交换格式,地图工具、GIS软件和很多开放平台都支持导出。最常见的结构是FeatureCollection:
json复制{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"id": "A001",
"name": "东区"
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[120.153, 30.274],
[120.162, 30.281],
[120.171, 30.268],
[120.153, 30.274]
]
]
}
}
]
}
新手最容易懵的是coordinates的层级。拿Polygon来说,coordinates是一个二维数组:第一层是环,其中下标0是外环,从下标1开始都是洞,也就是多边形内部需要挖掉的部分。环内部的每一个点,是一个[经度, 纬度]的数组,注意顺序是经度在前、纬度在后。
如果类型是MultiPolygon,coordinates会在Polygon的基础上再多一层:也就是说,它是一个由多个Polygon坐标数组组成的数组,每个Polygon都有自己的外环和洞。解析时不能想当然地认为所有边界都在同一层,必须按类型区分。
1.3 技术选型:手写算法还是引入几何库
我在做这个项目X的时候,区域数据大概有几千个,QPS不算特别高,但也不想引入一套很重的地理服务中间件。当时有三个方案摆在面前:
第一个是纯手写方案:自己解析GeoJSON,自己实现射线法,自己加网格索引。优点是轻量、可控、没依赖,缺点是遇到非简单多边形或者特殊边界时,需要自己兜底。
第二个是引入JTS(Java Topology Suite)。这是Java生态里非常成熟的几何计算库,点面关系、缓冲区、空间索引都有,比如org.locationtech.jts:jts-core。优点是真的省心,几何异常也能处理;缺点是多一个依赖,而且很多人不读文档直接调用,容易在坐标顺序上踩坑。
第三个是手写快速算法加现有库混合,比如自己解析GeoJSON做数据模型,点面判断用JTS,索引用JTS的STRtree。这个组合比较均衡。
我当时的选择是:先用第一个方案把主流程跑通,理解核心原理,然后压测,如果性能不够再引入JTS。事实证明这个思路是对的,因为纯手写射线法的核心代码并不多,真正决定性能的反而是数据预处理和索引设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节与几何原理
2.1 射线法算法的原理解读
点面匹配用得最多的算法是“射线法”,也叫奇偶规则。它的原理非常简单:从待判断的点出发,向右水平画一条射线,统计这条射线与多边形边的交点数。如果交点数量是奇数,点在多边形内部;如果是偶数,点在外部。
你可以想象成一个不规则的鱼塘,你站在鱼塘里向右看,墙面的次数一定是奇数,因为在里面每穿出去一次就会再穿回来一次,最后肯定停在内部。如果站在鱼塘外面,向右看穿过的次数总是偶数。
对应到代码,核心就是一个循环,遍历多边形的每一条边:
java复制public static boolean isPointInRing(double x, double y, List<LngLat> ring) {
boolean inside = false;
int n = ring.size();
for (int i = 0, j = n - 1; i < n; j = i++) {
double xi = ring.get(i).getLng();
double yi = ring.get(i).getLat();
double xj = ring.get(j).getLng();
double yj = ring.get(j).getLat();
// 跨越条件:点的纬度在线段两端点纬度之间
if ((yi > y) != (yj > y)) {
// 计算当前纬度处,线段所在位置的x坐标
double intersectX = (xj - xi) * (y - yi) / (yj - yi) + xi;
if (x < intersectX) {
inside = !inside;
}
}
}
return inside;
}
这段代码很经典,但里面藏着一个重要的边界条件:(yi > y) != (yj > y),它保证了一条边的顶点不会在两次循环中被重复计数。如果没有这个条件,点在多边形的顶点正上方或正下方时,结果会不稳定。
2.2 边界条件、顶点相交与容差
射线法在数学上很漂亮,但浮点计算中存在精度问题,尤其是当点非常接近边界时,交点计算可能产生微小误差,导致判断结果来回跳。
我实际遇到过这类问题:同一个经纬度点,连续调用两次匹配,一次命中,一次没命中。排查到最后,不是随机bug,而是因为浮点误差把射线和多边形边的交点算到了边界左右两侧。
解决办法有两层:
第一层,加容差。判断点与边的关系时,不要用严格等于0,而是给一个很小的阈值,比如1e-9:
java复制if (Math.abs(crossProduct) < epsilon) {
// 认为点在直线上
}
第二层,明确业务规则。如果边界上的点应该视为命中,就直接返回true,而不是继续走射线法。否则边界点在原算法里会被当作外部或内部,行为不可控。
我个人建议,在代码里把“点在边界上”和“点在内部”分开判断,这样业务上需要调整时,只动一个方法就够了。
2.3 带洞多边形和MultiPolygon的处理
GeoJSON里的洞是很常见的,比如一个运营区域边界内有一块不归你管的区域,这时候地图上画出来就是一个外环加一个内环。匹配逻辑不能只判断外环,否则会误把洞里的点也归入区域。
正确的做法是:
- 先判断点是否在外环内部;
- 如果在外环内部,再依次判断是否在任意一个洞的内部;
- 如果在某个洞内部,则不算命中;
- 只有在外环内部、且不在任何洞内部的点,才算真命中。
对应代码:
java复制public boolean contains(double x, double y) {
for (Polygon polygon : polygons) {
if (!GeometryUtils.isPointInRing(x, y, polygon.getOuter())) {
continue;
}
boolean inHole = false;
for (List<LngLat> hole : polygon.getHoles()) {
if (GeometryUtils.isPointInRing(x, y, hole)) {
inHole = true;
break;
}
}
if (!inHole) {
return true;
}
}
return false;
}
MultiPolygon的处理更简单:一个区域可能由多块互不相邻的多边形组成,比如一个市有几个区,数据被合并成了MultiPolygon。匹配时对每个Polygon都做一次包含判断,只要有一个Polygon命中,就认为这个点属于该区域。
2.4 坐标顺序与坐标系问题
GeoJSON规范明确规定,坐标采用WGS84坐标系,也就是GPS使用的经纬度坐标系,并且必须按照经度、纬度的顺序排列。如果拿到的数据源是某个地图平台导出的,很可能不是WGS84,而是经过偏移的坐标,比如国内常见坐标。直接拿未转换的经纬度去匹配WGS84的GeoJSON边界,结果会偏出去几十米甚至几百米。
这个问题的典型症状是:用手机定位的GPS坐标去匹配运营同学上传的GeoJSON,明明人就在门店大厅里,却总是匹配不到对应区域。因为手机原生定位是WGS84,地图平台的边界却是另一套坐标。
我做项目时定的规矩是:所有进入匹配引擎的坐标,统一先做坐标系归一化,要么全部是WGS84,要么全部是业务坐标系,绝对不能混着用。如果运营导出的GeoJSON与线上定位数据存在系统偏差,先做一个坐标转换预处理,再灌入匹配引擎。
射线法本身不涉及距离计算,所以不需要把经纬度投影成米制坐标,直接用度数做包含判断没有问题。但如果后面要做距离计算、面积统计,建议先转换到合适的投影坐标系。
3. 完整实操:从解析到匹配的实现
3.1 定义轻量数据模型
为了不让代码被JSON解析逻辑绑死,我先定义了几个简单的数据类。
java复制public class Region {
private String id;
private String name;
private List<Polygon> polygons;
private Bounds bounds;
public boolean contains(double lng, double lat) {
if (!bounds.contains(lng, lat)) {
return false;
}
for (Polygon polygon : polygons) {
if (polygon.contains(lng, lat)) {
return true;
}
}
return false;
}
}
public class Polygon {
private List<LngLat> outer;
private List<List<LngLat>> holes = new ArrayList<>();
public boolean contains(double lng, double lat) {
if (!GeometryUtils.isPointInRing(lng, lat, outer)) {
return false;
}
for (List<LngLat> hole : holes) {
if (GeometryUtils.isPointInRing(lng, lat, hole)) {
return false;
}
}
return true;
}
}
public class LngLat {
private final double lng;
private final double lat;
public LngLat(double lng, double lat) {
this.lng = lng;
this.lat = lat;
}
// getter...
}
public class Bounds {
double minLng = Double.MAX_VALUE;
double minLat = Double.MAX_VALUE;
double maxLng = -Double.MAX_VALUE;
double maxLat = -Double.MAX_VALUE;
public void expand(double lng, double lat) {
this.minLng = Math.min(minLng, lng);
this.minLat = Math.min(minLat, lat);
this.maxLng = Math.max(maxLng, lng);
this.maxLat = Math.max(maxLat, lat);
}
public boolean contains(double lng, double lat) {
return lng >= minLng && lng <= maxLng
&& lat >= minLat && lat <= maxLat;
}
}
这里把contains直接做在模型上,后续匹配引擎只负责遍历区域,职责比较清晰。每个Region内部会预计算一个外接矩形Bounds,用于粗筛。
3.2 解析GeoJSON并灌入匹配模型
解析GeoJSON我用的Jackson,先整体读成JsonNode,再按type字段分发。
java复制public class GeoJsonParser {
private final ObjectMapper mapper = new ObjectMapper();
public List<Region> parse(String geojson) throws Exception {
JsonNode root = mapper.readTree(geojson);
JsonNode features = root.get("features");
List<Region> regions = new ArrayList<>();
for (JsonNode feature : features) {
JsonNode geometry = feature.get("geometry");
String geometryType = geometry.get("type").asText();
JsonNode properties = feature.get("properties");
Region region = new Region();
region.setId(properties.has("id") ? properties.get("id").asText() : "unknown");
region.setName(properties.has("name") ? properties.get("name").asText() : "");
if ("Polygon".equals(geometryType)) {
region.getPolygons().add(parsePolygon(geometry.get("coordinates")));
} else if ("MultiPolygon".equals(geometryType)) {
for (JsonNode polygonCoords : geometry.get("coordinates")) {
region.getPolygons().add(parsePolygon(polygonCoords));
}
} else {
// 其他类型如Point、LineString暂时跳过
continue;
}
// 计算外接矩形
for (Polygon polygon : region.getPolygons()) {
for (LngLat p : polygon.getOuter()) {
region.getBounds().expand(p.getLng(), p.getLat());
}
}
regions.add(region);
}
return regions;
}
private Polygon parsePolygon(JsonNode coords) {
Polygon polygon = new Polygon();
polygon.setOuter(parseRing(coords.get(0)));
for (int i = 1; i < coords.size(); i++) {
polygon.getHoles().add(parseRing(coords.get(i)));
}
return polygon;
}
private List<LngLat> parseRing(JsonNode ringNode) {
List<LngLat> ring = new ArrayList<>();
for (JsonNode point : ringNode) {
double lng = point.get(0).asDouble();
double lat = point.get(1).asDouble();
ring.add(new LngLat(lng, lat));
}
return ring;
}
}
这段代码有几个地方值得注意:
第一,很多GeoJSON文件顶层是FeatureCollection,但也可能是单个Feature,解析前最好判断一下root.get("type"),做兼容。
第二,properties里不一定有id,如果业务上需要稳定的区域编号,尽量在导数据时约定好字段名。
第三,计算Bounds时我只遍历了外环,因为洞一定在外环内部,不会影响外接矩形范围。
3.3 匹配主流程实现
有了数据模型和解析器,匹配引擎本身非常简单:
java复制public class RegionMatcher {
private final List<Region> regions;
public RegionMatcher(List<Region> regions) {
this.regions = regions;
}
public Region match(double lng, double lat) {
for (Region region : regions) {
if (region.contains(lng, lat)) {
return region;
}
}
return null;
}
}
这个版本是线性扫描,区域数少时完全够用。每个区域先做外接矩形判断,大部分点在被射线法判断前就被挡掉了。
我本地模拟过一组数据:区域数量800,随机生成100万次匹配请求,不做任何优化的情况下,单线程吞吐量大概在每秒几万次左右;如果区域数量涨到2万,线性扫描的性能会明显下降,因为每个点都要遍历2万个Bounds,即使Bounds判断很轻,乘以很高的QPS也扛不住。这时候就需要做索引。
3.4 大数据量场景下的加速方案
针对区域数量大的情况,我尝试过两种优化方式。
第一种是网格分桶。思路是把整个经纬度空间按照固定的步长切分成多个格子,比如每0.1度一个格子。区域预处理时,把这个区域Bounds覆盖到的所有格子都记录下来,在格子ID到区域列表之间建立映射。匹配时先算出点落在哪个格子,只遍历这个格子里的区域。
java复制public class GridIndex {
private final double step = 0.1;
private final Map<String, List<Region>> map = new HashMap<>();
public void add(Region region) {
Bounds b = region.getBounds();
int minI = floor(b.getMinLng() / step);
int maxI = floor(b.getMaxLng() / step);
int minJ = floor(b.getMinLat() / step);
int maxJ = floor(b.getMaxLat() / step);
for (int i = minI; i <= maxI; i++) {
for (int j = minJ; j <= maxJ; j++) {
String key = i + "_" + j;
map.computeIfAbsent(key, k -> new ArrayList<>()).add(region);
}
}
}
public List<Region> query(double lng, double lat) {
int i = floor(lng / step);
int j = floor(lat / step);
return map.getOrDefault(i + "_" + j, Collections.emptyList());
}
}
网格索引的优点是实现简单、纯内存、查询O(1),缺点是比较吃内存,因为大区域会被登记到很多格子里。如果格子步长设得太小,内存会涨得很快;设得太大,预筛效果就不明显。我的经验是:根据区域平均大小来调步长,让每个格子平均只挂少数区域。
第二种更省心的方法是引入JTS的STRtree。JTS提供了R树实现,代码量很少:
java复制STRtree tree = new STRtree();
Envelope env = new Envelope(region.getBounds().getMinLng(),
region.getBounds().getMaxLng(),
region.getBounds().getMinLat(),
region.getBounds().getMaxLat());
tree.insert(env, region);
tree.build();
List<Region> candidates = tree.query(new Envelope(lng, lat, lng, lat));
查询结果是有可能命中的候选区域,数量很小,再逐个做精确的射线法判断。我在某个模拟项目中用2万区域压测,STRtree方案比线性扫描快了十倍以上,而且这个库还附带了几何校验工具,适合处理异常边界数据。
如果对引入JTS有顾虑,也可以只引入它的索引部分,点面判断仍然用自己的射线法,这样依赖可控,性能也有保障。
3.5 接口层与线程安全
匹配服务通常会被接口层调用,比如一个Spring Boot接口,接收lng和lat两个参数,返回区域信息。
这里需要特别注意线程安全。RegionMatcher在启动时加载一次后,内部的List<Region>和索引都不再变化,只读状态天然线程安全。但如果后续要支持GeoJSON动态更新,就不能直接改List,而是先构建一个新的RegionMatcher,再用volatile引用替换:
java复制private volatile RegionMatcher currentMatcher;
public void reload(String geojson) {
RegionMatcher newMatcher = new RegionMatcher(new GeoJsonParser().parse(geojson));
this.currentMatcher = newMatcher;
}
这种做法能够避免更新过程中出现半个区域命中、半个区域没命中的中间态。
4. 常见问题与排查技巧
4.1 点在边界上结果不稳定
这是射线法最常见的问题。用户站在区域边界上,系统一会儿认为他进来了,一会儿认为他没进来。原因有两个:一是浮点精度,二是算法本身对边界点的归属没有明确定义。
我的处理方式是加一个“点在线上”的判断。具体做法是用叉积判断点是否在某条线段上,并设置一个很小的容差值。点在边界上一律视为命中,并且要在接口文档里对外说明这个约定。如果不约定,测试同学拿着边界坐标来回打点,永远会认为是bug。
4.2 经纬度顺序写反,白排查半天
GeoJSON的坐标顺序是经度、纬度,但在Java代码里很多人习惯写成lat, lng,尤其是从数据库习惯带过来的时候。
有一次我调试某个区域的匹配结果,手机点在区域内却始终返回null。检查解析代码时发现,解析第0个坐标时取了lat当成x,第1个坐标取lng当成y。由于国内大部分业务数据经度和纬度的数值相差不大,这种错误在视觉上很难一眼发现,只有用已知点做单元测试才能暴露。
所以我后来专门写了一个冒烟测试:构造一个已知区域的中心点,校验匹配结果;再构造一个明显在区域外的点,校验返回null。每次解析GeoJSON跑一遍这个测试,能挡住大部分低级错误。
4.3 自相交与不闭合环
GeoJSON规范要求多边形环必须是闭合的,即第一个点和最后一个点相同;同时不允许自相交。但运营同学用地图工具一笔一画描绘边界时,经常导出不标准的几何体。
处理自相交多边形时,普通射线法的计数会乱。比如一个蝴蝶结形状的自相交多边形,某个点明明在视觉上的“叶片”里,射线法的奇偶规则给出的结果可能完全相反。
解决思路分两步:第一步,在导入时用几何库的isValid()方法检查数据合法性;第二步,对非法数据要么弃用、要么预处理。JTS可以这么处理:
java复制Geometry validGeom = invalidGeom.buffer(0);
buffer(0)会把自相交的多边形拆成多个简单多边形,同时保留外部边界。对于闭合问题,解析时检查首尾点是否相等,如果不相等就手动补一个点,保证算法正确。
4.4 跨180度经线的区域
这个坑比较隐蔽。如果某个区域跨越东经180度和西经180度,比如太平洋上的一部分区域,它的minLng可能是179,maxLng可能是-179。在这种数据下,外接矩形包围盒会变得非常大,看起来覆盖了几乎整个地球,实际上区域只有一小条。
粗筛逻辑碰到这种情况会直接失效,因为Bounds.contains会认为从179到-179的区间包含了大部分经度值,于是大量无关点都进入精确判断,性能和结果都受影响。
处理方式是在预处理时识别这种跨越情况。如果maxLng - minLng > 180,说明这个区域跨东西经度分界,需要拆成两个包围盒:一个从minLng到180,另一个从-180到maxLng。匹配时任意一个包围盒命中,再走精确判断。
4.5 性能优化时的缓存策略
我在压测阶段发现一个特别影响性能的点:每次请求都重新解析GeoJSON。这种写法起初只是在验证逻辑,代码放在match方法里临时读取文件,结果QPS一上来,立刻变成磁盘IO瓶颈,CPU全浪费在JSON解析上。
正确做法是启动时一次性加载,内存里只保留Region模型和索引。如果区域数据会周期性更新,做一个后台任务,解析完成后替换内存镜像,而不是在每次请求时做任何文件或字符串解析。
另外,几何对象如果用了JTS,不要在高频查询路径里频繁创建GeometryFactory和Geometry,尽量复用工厂,Point对象也需要轻量化处理。
5. 几点经验沉淀
5.1 先花时间核对数据质量
几次问题排查下来,我发现大多数匹配bug都出在数据上,而不是算法上。拿到一份GeoJSON后,先写一个简单的数据体检工具,统计Feature数量、MultiPolygon数量、环是否闭合、有没有自相交、坐标顺序是否符合预期,能省下大量后期调试时间。
数据质量这根弦,值得在项目一开始就绷住。运营同学导出数据后,可以先导入到地图工具里人工看一眼,再进匹配引擎做自动化测试。宁可在这个环节多花几小时,也不要等到线上匹配异常再回头啃数据。
5.2 别为了炫技过度设计
如果业务区域只有几十个、几百个,线性扫描加外接矩形粗筛已经完全够了。强行引入空间索引、分桶、分布式,只会增加维护成本。我在这个项目X里是先用最简单的方式跑通,然后基于压测数据决定是否优化。
最终项目的实际方案是:区域数量在几千这个量级,用网格索引做粗筛,射线法做精确判断,单机支撑当时业务场景的QPS毫无压力;如果后续区域数和请求量继续往上涨,可以直接把查询段换成JTS STRtree,数据模型和业务代码都不用大改。这种渐进式的实现节奏,是我做完这个项目最想分享的经验。
