前阵子接了个需求,领导甩给我一张全国省级行政区划表、一张地级市行政区划表,说得很简单:“算一下各省和各地级市之间的空间距离,做成接口。” 我当时心想这不就是两张表 JOIN 一下嘛,结果真动手之后才发现,里面全是坑:到底是算质心距离还是边界最近距离?算出来单位到底是度、米还是公里?坐标系怎么统一?还有一个容易忽略的问题——地级市本身就属于某个省,如果不排除“自己省”,算出来的距离全是 0,整个结果直接废掉。
这个项目最终的落地方案是:SpringBoot + PostGIS + MyBatis。PostGIS 负责所有空间计算,SpringBoot 负责把结果封装成接口对外提供,前端要查距离矩阵、最近省、TopN 排行都很方便。这套组合特别适合三类人参考:一是做 GIS 相关毕业设计的学生,二是刚接触空间数据后端的 Java 开发,三是想把“地理距离”做成标准查询服务的产品团队。下面我把整个项目的技术拆解、建表导入、核心 SQL、SpringBoot 整合代码、还有我实际踩过的一堆坑,一次性讲透。
先说结论:不要把坐标拉回 Java 内存里用循环去算,空间分析一定要下沉到数据库。PostGIS 里面内置了按地球球面计算的几何类型,严谨程度比自己在代码里写 Haversine 公式高一个量级。接下来我按做项目的顺序,从需求拆解开始,一条条给你盘清楚。
1. 先想清楚:项目里要算的“空间距离”到底是哪种
动手写代码之前,必须把“空间距离”这四个字定义清楚。我实际开发时发现,不同业务口子要的“距离”完全不是一回事,如果一开始不掰扯明白,后面返工能烦死人。
1.1 三种典型距离口径,对应三套SQL写法
我整理了一下,常见的“省与地级市距离分析”其实有至少三种理解方式:
| 距离类型 | 业务含义 | 典型例子 | 核心函数 |
|---|---|---|---|
| 质心到质心距离 | 用行政区划的几何中心点代表整个区域,计算两个中心的直线距离 | “从这个省的几何中心到那个市的几何中心有多远” | ST_Centroid+ST_Distance |
| 边界最近距离 | 两个区域边界上最近点之间的距离,也就是真正意义上的“相距最近” | “某市市区边界离外省边界最近几公里” | ST_Distance(geography, geography) |
| 点到面距离 | 某个城市坐标点到目标省边界的最短距离 | “某市政府驻点到隔壁省省界的距离” | ST_Distance+ST_DWithin |
我做的项目里,用户主要关心的是前两种。第三种通常出现在“计算某个点属于哪个省辐射范围”的场景,用法类似,只是把cities.geom换成点数据就行。
这里要提醒一个非常隐蔽的设计点:省市之间存在“包含关系”。每个地级市在行政上都归属于某一个省,如果直接让省表和市表做笛卡尔积,本市和本省的距离永远是 0,而且分析“本市到本省的距离”本身也没有意义。所以我在写 SQL 时,第一步就是通过province_id把“自己的省”过滤掉,保留的才是“外地省”与“本地市”的空间关系。
1.2 为什么我坚持选择 PostGIS,而不是在 Java 里手写距离公式
很多 Java 后端一听到 GIS 就头大,下意识的方案是:把经纬度查出来,然后在内存里用 Haversine 公式循环算一遍。如果数据量只有几百条,这个方案能跑,但一旦到了“全国 34 个省级单位 和 300 多个地级市”的全量矩阵,要算上万对距离,Java 循环就开始露怯了,而且代码里写满了魔法常数,很难维护。
PostGIS 的核心优势在于两点。第一,它提供了geography类型,直接以地球椭球体模型来计算距离,返回单位是米,不用自己乘以 111 公里这种估算系数;第二,它内置了空间索引 GIST,配合ST_DWithin、<->这类操作符,可以在索引层面先筛掉远距离目标,再精确计算。这套能力是 MySQL 早期版本完全不具备的,也是 PostgreSQL 在空间计算领域被广泛使用的原因。
所以技术栈定下来就是:PostgreSQL 做存储,PostGIS 做计算,SpringBoot 做接口。PostGIS 只负责回答“谁离谁有多远”,SpringBoot 只负责把 SQL 结果包装成 JSON 吐给前端。边界清晰,出了问题也好排查。
1.3 geometry 和 geography 的区别,不懂这个距离一定算错
这是整个项目里最容易踩的坑,我单独拿出来说。PostGIS 里空间数据有两种类型:
geometry:基于平面坐标系计算,它不管地球是个球,默认在“一张纸上”算距离。geography:基于椭球体模型计算,直接返回真实的地球表面距离,单位是米。
如果你把 WGS84 经纬度存在geometry类型的字段里,然后直接ST_Distance(geom1, geom2),PostGIS 会返回度,不是一个有意义的长度单位。只有当你把geometry转换成geography,或者建表时直接用geography类型,ST_Distance才会返回米。
我打一个比方:geometry就像拿一把直尺在一张世界地图上量距离,量出来是图上的厘米,还得靠比例尺换算;geography就像拿一根软尺沿着地球表面去绕,绕出来直接就是真实公里数。做行政区域分析,肯定要的是后者。所以在我的所有核心 SQL 里,都写了::geography这个转换,后面你会反复看到。
2. PostGIS 环境准备与矢量数据导入实战
确定了方案,接下来就是搭环境、导数据。这个阶段看起来不起眼,却是最容易劝退人的地方,光是 PostGIS 安装失败就能卡住不少人一整天。我把我的操作流程和排错记录全部放出来。
2.1 从“postgis安装失败”到顺利建扩展,我踩过的三个坎
网上关于“postgis安装失败”的求助特别多,我自己也遇到过。归纳起来主要就这三类:
| 失败现象 | 常见原因 | 解决办法 |
|---|---|---|
| Stack Builder 下载特别慢或一直失败 | 网络访问官方源不稳定 | 单独下载 PostGIS 安装包,不要依赖 Stack Builder |
| 安装时提示找不到 PostgreSQL 版本 | PostgreSQL 与 PostGIS 版本不匹配 | 先确认 PG 大版本,再找对应版本的 PostGIS 安装包 |
创建扩展时报错could not open extension control file | PostGIS 没装进 PostgreSQL 的扩展目录 | 重新执行安装程序,并确保安装时勾选了正确的 PG 实例版本 |
具体安装顺序是:先装 PostgreSQL,再装对应大版本的 PostGIS,最后在数据库中执行:
CREATE EXTENSION IF NOT EXISTS postgis;装完可以用这条命令验证:
SELECT postgis_version();我当时的教训是:安装时没注意 PostgreSQL 版本号,导致 PostGIS 和 PG 实例对不上,建扩展一直失败。后来卸载重装、严格对齐版本号才通过。这条经验说给所有新手的建议就一句话:版本对齐比什么都重要。
2.2 行政区划矢量数据从哪来,坐标系为什么必须统一
项目需要省级和地级市两级行政区划数据。我使用的是公开的 GeoJSON 格式行政区划数据,通常包含name、adcode和geometry字段。注意,公开下载的数据里字段名五花八门,建议导入后统一重命名,并明确坐标系。
我建表时把几何字段统一指定为geometry(MultiPolygon, 4326)。4326 就是 WGS84 经纬度坐标系,也是大多数在线地图和 GeoJSON 默认的坐标系。这里必须强调:如果数据本身的坐标系是 CGCS2000 或者其他投影坐标系,而你建表时硬说它是 4326,后面的距离计算结果会全部偏移。稳妥的做法是先确认数据来源说明,实在不确定就用 QGIS 打开看一眼右下角的坐标显示。
我导入时没有走笨办法,直接用ogr2ogr命令行把 GeoJSON 灌进 PostgreSQL,一条命令搞定:
ogr2ogr -f "PostgreSQL" PG:"host=localhost port=5432 user=postgres dbname=gis_db password=123456" \ -nln provinces -nlt MULTIPOLYGON -lco GEOMETRY_NAME=geom \ -t_srs EPSG:4326 \ china_province.geojson其中-t_srs EPSG:4326表示强制将数据转换为 WGS84 坐标系。省一级和市一级分别导入成provinces和cities两张表。如果是 Shapefile 格式,用 PostGIS 自带的shp2pgsql也可以:
shp2pgsql -s 4326 -I -W "UTF-8" china_city.shp cities | psql -U postgres -d gis_db-I参数会自动生成空间索引,强烈建议加上。
2.3 空间索引有多重要,GIST 索引原理一句话讲清
导完数据后,我立刻为两张表的几何字段分别创建了空间索引:
CREATE INDEX idx_provinces_geom ON provinces USING GIST (geom); CREATE INDEX idx_cities_geom ON cities USING GIST (geom);普通 B-tree 索引只能处理“大小、等于”这类查询,而空间查询要处理的是“距离最近”“范围相交”“边界距离”,B-tree 根本使不上劲。GIST 索引是一种通用索引框架,能把空间对象按位置组织起来,查询时先快速排除大量离得远的目标,再精确计算剩余少量候选,类似图书馆先按楼层找书、再在书架里找具体位置,不用一本一本翻。
后面做“最近省”查询时,我会用到 KNN 操作符<->,这个操作符之所以能毫秒级返回结果,完全依赖于 GIST 索引存在。忘了建索引的话,全表扫描能把 CPU 跑满,数据量再大一点接口就直接超时。
3. 核心 SQL 设计:把距离算得又快又准
数据就位后,最核心的部分就是写 SQL。我分三个场景来讲,每个场景都给出可以直接抄的完整 SQL,并解释为什么要这样写。
3.1 质心距离:用几何中心点算“省市相距多远”
业务场景是“不要边界,要一个代表点”。省级和市级面数据各有各的几何形状,把每个多边形转换为质心点,再算两个点之间的球面距离:
SELECT p.id AS province_id, p.name AS province_name, c.id AS city_id, c.name AS city_name, round( ST_Distance(ST_Centroid(p.geom)::geography, ST_Centroid(c.geom)::geography) / 1000 ) AS distance_km FROM provinces p CROSS JOIN cities c WHERE c.province_id <> p.province_id ORDER BY p.name, distance_km DESC;这里的ST_Centroid用于求多边形质心,::geography把点转到球面模型,ST_Distance返回米,除以 1000 转成公里。外层用round取整,后续接口展示更干净。
我特意写了WHERE c.province_id <> p.province_id这个条件,把本市和本省排除掉。如果你忘了加,查出来的结果表里会有一大片 0 值行,既没意义又会让前端以为系统 bug 了。
3.2 边界最近距离:真正意义的“最近相距多少公里”
质心距离适合宏观展示,但很多业务问的是“两个行政区边界之间最近有多远”。比如跨省物资调度,关心的是“隔壁省交界处离我们这里最近的地方在哪”。PostGIS 对两个面直接做ST_Distance,返回的就是两个多边形边界之间的最短距离。
SELECT p.name AS province_name, c.name AS city_name, round( ST_Distance(p.geom::geography, c.geom::geography) / 1000 ) AS nearest_km FROM provinces p CROSS JOIN cities c WHERE c.province_id <> p.province_id ORDER BY nearest_km ASC LIMIT 20;这条 SQL 是“全国范围内,与外地省边界最近的前 20 个城市”的查询。两个面之间有公共边时距离是 0,所以排在前面的往往是真正接壤的边界城市。
这里要解释一个容易误解的点:ST_Distance(p.geom::geography, c.geom::geography)不是取两个质心的距离,而是计算两个多边形边界上所有点对之间的最短距离。如果两个省相隔很远,这个值也很大;如果只是接壤但不重合,这个值就是 0。这个语义和业务上的“邻近分析”完全一致。
3.3 最近省查询与全量距离矩阵:KNN 粗排 + geography 精算
做“每个地级市最近的省是谁”这类查询,如果直接全表笛卡尔积再排序,全国 300 多个市 乘 34 个省,勉强能跑,但一旦数据量翻倍就完了。我的方案是先用 KNN 操作符<->在索引里找最近的候选省,再对候选做精确球面距离计算:
SELECT c.name AS city_name, p.name AS nearest_province, round( ST_Distance(c.geom::geography, p.geom::geography) / 1000 ) AS nearest_km FROM cities c CROSS JOIN LATERAL ( SELECT p.name, p.geom FROM provinces p WHERE p.province_id <> c.province_id ORDER BY c.geom <-> p.geom LIMIT 1 ) p;c.geom <-> p.geom返回的是两个几何对象在平面上的“盒子距离”,它不精确,但速度极快,适合用来粗排。因为只需要一个最近省,所以LIMIT 1配合索引能迅速锁定候选,然后外层再用::geography做精确距离。这是典型的粗筛 + 精算思路,生产环境里非常实用。
全量距离矩阵的 SQL 和 3.1 类似,把结果表导出后可以直接丢给前端做热力图或者散点矩阵。需要注意,全量矩阵按 34 个省 × 300 多个市算,大概一万行左右,接口返回全量还撑得住;如果以后扩展到了区县级,建议加LIMIT/OFFSET或者按省分批查询。
4. SpringBoot 工程搭建与接口实现
SQL 写好了,下一步就是把计算结果包进 SpringBoot 接口。这节我给出完整的工程视角,从依赖、配置到分层代码,照着抄就行。
4.1 工程结构、POM 依赖与数据源配置
我建的是标准的 SpringBoot Maven 工程,目录结构如下:
src/main/java ├── com/example/gisdemo │ ├── GisDemoApplication.java │ ├── controller/DistanceController.java │ ├── service/DistanceService.java │ ├── mapper/DistanceMapper.java │ ├── dto/ProvinceCityDistanceDTO.java │ └── config/MyBatisConfig.java src/main/resources ├── application.yml └── mapper/DistanceMapper.xmlPOM 里最关键的依赖是这几条:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency>关于 SpringBoot 版本,我需要多说一句。如果你用的是 SpringBoot 3.x,就要搭配 MyBatis 3.x 的 starter,因为 SpringBoot 3 把原来的javax包迁移到了jakarta。很多人的问题出在“SpringBoot 版本太高”,项目里还在用老版的mybatis-spring-boot-starter 2.x,启动直接报组件扫描失败。解决方案就一句话:版本对不上就去 Maven 仓库查对应版本号。
application.yml的数据源配置如下:
spring: datasource: url: jdbc:postgresql://localhost:5432/gis_db username: postgres password: 123456 driver-class-name: org.postgresql.Driver mybatis: mapper-locations: classpath:/mapper/*.xml configuration: map-underscore-to-camel-case: true有个容易误导人的点:不需要在 JDBC URL 里加任何 PostGIS 相关参数。PostGIS 是数据库内部的扩展,驱动层面和普通 PostgreSQL 完全一样。有些教程会让你改 url 后缀,那是给老版本空间扩展用的,别被带偏。
4.2 MyBatis 到底怎么映射 PostGIS 几何字段,用最省事的方式
这是 SpringBoot 整合 PostGIS 最让人头疼的地方。很多人的思路是把geometry字段直接映射成 Java 对象,结果发现 MyBatis 不认识org.postgis.PGgeometry,要么 ClassCastException,要么查出来一串看不懂的字节。
我踩过这个坑之后,推荐大家一个最省事的方案:SQL 层直接转换,不让 geometry 类型进 Java。需要原始坐标时用ST_AsGeoJSON,只需要距离和名称时直接把几何字段丢掉。比如我们项目里最终要返回距离排行,根本不需要几何对象,直接在查询里用province_name、city_name、distance_km三个字段,全部是普通 Java 类型,没有任何特殊处理。
如果前端确实需要边界坐标来画地图,就在 SQL 里加一列ST_AsGeoJSON(c.geom) AS geojson,返回的是 JSON 字符串,MyBatis 映射成 String 完全没问题。这个方案避免了自定义 TypeHandler,非常稳。
4.3 Controller、Service、Mapper 三层代码直接抄
我给出一个最简但完整的接口实现。首先是 DTO:
public class ProvinceCityDistanceDTO { private String provinceName; private String cityName; private Double distanceKm; // getter/setter 省略 }Mapper 我用了注解方式,简单直接:
@Mapper public interface DistanceMapper { @Select(""" SELECT p.name AS province_name, c.name AS city_name, round(ST_Distance(p.geom::geography, c.geom::geography) / 1000) AS distance_km FROM provinces p CROSS JOIN cities c WHERE c.province_id <> p.province_id ORDER BY distance_km DESC LIMIT #{limit} """) List<ProvinceCityDistanceDTO> selectTopNDistances(@Param("limit") int limit); }Service 层:
@Service public class DistanceService { private final DistanceMapper distanceMapper; public DistanceService(DistanceMapper distanceMapper) { this.distanceMapper = distanceMapper; } public List<ProvinceCityDistanceDTO> getTopNDistances(int limit) { return distanceMapper.selectTopNDistances(limit); } }Controller 层:
@RestController @RequestMapping("/api/distance") public class DistanceController { private final DistanceService distanceService; public DistanceController(DistanceService distanceService) { this.distanceService = distanceService; } @GetMapping("/top-n") public List<ProvinceCityDistanceDTO> topN(@RequestParam(defaultValue = "20") int limit) { return distanceService.getTopNDistances(limit); } }启动项目后访问:
GET /api/distance/top-n?limit=20就能拿到全国范围内相距最远的省与地级市排行。接口返回的 JSON 就是一个普通数组,前端可以直接渲染成表格或者地图散点,完全不需要感知 PostGIS 的存在。
4.4 前端可视化方向与地图渲染思路
后端把数据吐出来后,前端可视化一般两种做法。第一种是纯表格排行,适合管理后台;第二种是把 GeoJSON 边界数据配合同样来自后端、经纬度连线一起交给 Leaflet 或高德 JS API 渲染,用户点一个省,地图上就把对应市和距离线画出来。这里的关键是后端要额外提供一个“导出边界 GeoJSON”的接口,用我上面说的ST_AsGeoJSON转换就行,前端省去了大量坐标处理。
5. 常见问题速查:从安装失败到坐标乱套
这节是整篇里含金量最高的部分,全是我在实际调试中被折磨后整理出来的。你照着一条一条对,能省掉大半天排查时间。
5.1 PostGIS 安装失败与扩展创建失败的排查表
“postgis安装失败”是搜索热词,说明真有很多人卡在这一步。我把常见现象和解决方案整理成表,最实用:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| Stack Builder 卡在下载阶段 | 官方源访问慢 | 直接下载对应 PG 大版本的 PostGIS 独立安装包 |
| 安装程序找不到数据库实例 | PostGIS 安装时未匹配 PG 端口 | 确认 PG 服务端口(默认 5432),重装时选择正确实例 |
CREATE EXTENSION postgis报错permission denied | 当前用户不是超级用户 | 用postgres用户登录执行,或给当前用户赋予超级权限 |
| psql 命令找不到 | 没把 PG bin 目录加入 PATH | 到 PG 安装目录的bin下执行 psql,或手动配置环境变量 |
我的建议是,安装之前先去 PostgreSQL 官网确认 PG 大版本号,再到对应下载页找匹配的 PostGIS 版本。版本错位是 90% 安装失败的根源。
5.2 距离结果离谱:SRID、坐标系和单位问题
如果你算出来的“距离”是几百、几千,但你的业务场景明明只要几十公里,那十有八九是坐标系或类型出了问题。我总结出三条铁律:
- 确认几何字段的 SRID 是 4326,用
Find_SRID('public', 'provinces', 'geom')查询。 - 如果 SRID 是 0 或者不匹配,先执行
UPDATE provinces SET geom = ST_SetSRID(geom, 4326);修正。 - 算距离时务必加上
::geography,否则返回的是度,不是米。
还有一类是把 4326 数据直接ST_Transform到 3857(Web 墨卡托)再算距离。3857 在低纬度还行,纬度一高,距离会被拉长得很夸张。行政分析场景我一律推荐geography球面距离,省心且准确。
5.3 查询慢:空间索引失效的常见姿势
我见过最典型的慢查询长这样:
SELECT * FROM provinces p, cities c WHERE ST_Distance(p.geom::geography, c.geom::geography) / 1000 < 100;这种写法在 PostGIS 里基本用不上空间索引,因为它要全量计算每一对几何对象的精确球面距离,再筛选。正确做法是把精确判断换成空间索引友好的边界粗筛:
SELECT * FROM provinces p, cities c WHERE ST_DWithin(p.geom::geography, c.geom::geography, 100000); -- 100公里以内ST_DWithin会先用空间索引快速划出候选区域,再去精确判断。配合 GIST 索引,数据量翻个十倍也不会卡。凡是条件里出现函数包裹几何字段的,索引基本都废了,这是空间查询优化最核心的一条原则。
5.4 SpringBoot 3.x 版本太高导致的依赖迁移问题
很多人用的是新生成的 SpringBoot 3.x 项目,结果网上搜到的教程还是 SpringBoot 2.x 时代的写法,最典型的就是javax.servlet全部变成jakarta.servlet。如果代码里或者依赖里还在用旧包,启动就会抛NoClassDefFoundError。
我的处理办法是统一用 SpringBoot 3.x 配套的依赖版本,mybatis-spring-boot-starter必须用 3.0 以上。如果团队里还停留在旧写法,也不要硬升级,选定一个技术栈版本后就不要再混着引依赖,否则排查成本远大于升级收益。
5.5 线上 jar 与本地代码对不上,怎么排查
这里顺带提一个很实用的小经验:如果线上部署的 SpringBoot jar 和本地源码行为不一致,先别急着反编译 jar。正确流程是先把 jar 里的application.yml拉出来对比配置,再看数据库连接和 SQL 日志。90% 的“线上不对、本地正常”都是配置漂移导致。反编译是最后手段,而且只能反编译出字节码逻辑,注释和注释掉的草稿代码全都丢了,看多了容易误判。
6. 扩展玩法与我自己的一些实操心得
项目做到能跑只是第一步,我再补充两个我们后续实际做的扩展点,顺便把个人体会放在最后,算是给同样在做这个方向的朋友一个收尾提醒。
6.1 把距离分析做成通用空间查询服务
如果你不想每个距离分析都写一遍接口,可以做一个统一查询入口,让前端传“源表名、目标表名、距离类型、TopN”,后端用配置化 SQL 去执行。注意表名和字段名一定要做白名单校验,否则拼接 SQL 会有注入风险。这个通用化的好处是后面加了“区县级”“街道级”数据,完全不用新增接口代码。
6.2 行政区划数据更新的自动化思路
行政区划每年都有可能调整,边界数据不可能永远不变。我的做法是把 GeoJSON 文件放到对象存储里,通过一个定时任务检测文件变化后重新导入 PostGIS,导入前先备份旧表,导入后重建索引。这样地图边界和距离分析用的数据永远是同一份,不会出现“接口数据和地图数据对不上”的局面。
6.3 最后的体会:先把“距离口径”钉死,再写一行代码
我做这个项目最大的收获,不是学会了几个 PostGIS 函数,而是认识到:空间距离分析里,业务口径比技术实现重要得多。如果需求方说“我要各省与地级市距离”,你第一句话就该反问“你要的是质心距离、边界最近距离,还是点到面距离?单位用公里还是英里?要不要排除本市本省?”。这些问题没定死,后面所有代码都可能在返工。
我的个人习惯是,项目初始化时用几组已知真实距离做冒烟测试。比如从北京到上海的直线距离大约 1067 公里,如果算出来是 1000 公里上下,说明坐标系和计算方式基本正常;如果算出 0.1 或者 10000,那就是geometry和geography混用了。用这种“已知答案反推系统正确性”的方式,比我一遍遍看 SQL 日志效率高得多。
最后再分享一个小技巧:当你怀疑算出来的某个距离不对时,直接在 psql 里用 PostGIS 自己的函数验证一遍,不要急着去查 SpringBoot 日志。数据库算对了,再去查后端代码;数据库算错了,优先检查坐标系和字段类型。绝大多数空间距离问题,根源都不在 Java 代码里,而在数据本身的“空间身份”上。