简介:面向Java开发者的Neo4jOSM路由服务源码包,将OpenStreetMap路网数据导入图数据库Neo4j,并以Cypher查询实现路线规划。项目覆盖从OSM文件解析、路网图建模、最短路径/最少转弯等路由查询到提供API接口的完整链路,适合希望为应用快速加入地理路由功能,或学习用图数据库处理空间数据的开发者。压缩包共35个文件,其中22个Java源文件承载核心逻辑,3个OSM数据文件与1个PBF文件作为导入测试样例,另有2个properties配置、2个md文档及Gradle构建脚本、许可证等,整体约314KB,结构精简,可快速对照代码理清数据转换与查询流程。当前已有169人学习下载;通过本项目可直观掌握OSM路网在Neo4j中的节点/关系组织方式、Cypher路径查询写法与Java服务封装思路,为实际路线规划或物流路径优化提供可复用参考。
1. 从“路网数据”到“可查询路由服务”:Neo4jOSM 的定位
朋友扔给我一个小项目,要我用 Java 顺手跑通一个基于 Neo4j 和 OpenStreetMap 数据的简单路由服务。第一反应是无所谓,反正图数据库面试题做得多,但真正拆源码时才发现,OSM 的路网数据乱七八糟,要提取一条能走的路径,远远不是“连点成线”那么容易。Neo4jOSM 做的是这么一件事:把 OpenStreetMap 的 XML 路网数据解析成图节点,按道路关系连成结构,再暴露一个最短路径查询入口,让你最终得到一条有坐标、有距离的路径结果。适合想接触图数据库落地的开发者,也适合拿来做路由原型验证,不用先背一整套 GIS 引擎。
2. 整体架构与数据模型:把路网装进图数据库
2.1 技术栈与模块边界
从工程结构看,这是一个标准的 Java 多模块思路,没有引入重型框架。核心依赖是 Neo4j Java Driver、一个 OSM 数据解析器以及 Spring Boot 的 Web 层,如果你不想启动 REST 服务,也可以直接用 main 方法跑批处理。项目内部大致分成三条线:数据导入线、图操作线、路由查询线。
我用 Maven 组织依赖时习惯给每条线一个独立 package,比如osmreader、neo4jstore、router,这样调试时能清楚知道数据在哪一步出问题。导入线只负责把.osm或.pbf转成内存里的节点和路径;图操作线负责写入 Neo4j,创建索引和约束;路由线负责接收起点终点 ID,扔一串 Cypher 出去,再把返回的 path 解析成可读的地理坐标。
Neo4j 在这里不是存完之后当摆设,它是真正的计算引擎。这也是这个项目最值得借鉴的设计选择:路由不需要在应用层自己写 BFS 或 Dijkstra,图数据库能把路径查找拆成关系遍历,由 Cypher 查询承担。这样整个服务的计算压力落在 Neo4j 侧,Java 这边只做很薄的协议转换。
2.2 图模型:节点、道路关系与标签
OpenStreetMap 数据的原始结构是三类元素:node表示一个经纬度坐标点,way表示一串节点组成的线段,tag给 way 或 node 附加属性。要把它变成可路由的图,最直接的做法是让 OSM 节点映射为OSMNode,连续节点之间建立ROAD关系,道路名称、类型、长度都塞在关系上。
我拆完代码后整理的模型是这样:每个OSMNode至少包含osmid、lat、lon三个属性,前一个是 OSM 原始编号,后两个是 WGS84 坐标。ROAD关系承载四个属性:wayId对应原始 way 的编号,name表示道路名,highway是道路等级(例如primary、residential),length是两个相邻节点之间的距离,单位是米。
UNWIND $segments AS seg MATCH (a:OSMNode {osmid: seg.fromId}), (b:OSMNode {osmid: seg.toId}) MERGE (a)-[r:ROAD {wayId: seg.wayId}]->(b) ON CREATE SET r.name = seg.name, r.highway = seg.highway, r.length = seg.length ON MATCH SET r.name = seg.name, r.highway = seg.highway, r.length = seg.length这段代码是导入路径关系的核心,我反复看了很久。UNWIND把传入的 segment 数组展开成一行行记录,每个 segment 里包含fromId、toId、wayId、name、highway、length六个字段。MATCH强制要求两端节点必须已经存在于图中,如果节点缺失,那这条路就导不进去,这其实是好事,能提前暴露数据不完整的问题。
使用MERGE而不是CREATE是为了避免重复导入时产生两套同样的关系。同一个 way 可能在解析过程中被分多次写入,MERGE利用关系定义的wayId加上两个端点来保证幂等。ON CREATE和ON MATCH的写法很关键,第一次插入时赋值,以后重复遇到同一个关系时只更新属性,不会建第二条边。
这套模型的优点在于查询非常自然:想找从 A 到 B 怎么走,只需要沿ROAD关系遍历,Neo4j 会把整条路当成图里的路径返回。缺点也很明显:如果一条 OSM way 有几十个节点,每对相邻节点都建关系,数据会膨胀得很厉害。我建议在实现时保留 OSM 节点的原始 ID 不压缩,但长度计算和关系数量要提前规划,避免一个超大 way 拖垮写入性能。
2.3 权重与长度计算的取舍
OSM 数据里没有现成的道路长度,必须自己算。两个经纬度点之间的距离最常用的是 Haversine 公式,它能近似得出球面上两点的大圆弧距离。如果只依赖投影坐标的平面欧氏距离,在高纬度地区误差会变得很离谱,所以在图导入阶段就要确定长度计算方式。
public double distanceMeters(double lat1, double lon1, double lat2, double lon2) { double radLat1 = Math.toRadians(lat1); double radLat2 = Math.toRadians(lat2); double deltaLat = Math.toRadians(lat2 - lat1); double deltaLon = Math.toRadians(lon2 - lon1); double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return 6371000 * c; }这段代码是通用的大圆距离计算。6371000是地球平均半径,单位为米。Math.toRadians把经纬度转成弧度,haversine的核心是中间那个正弦平方的表达式。值得注意的一点是,length计算出来是直线距离,不是真实道路的弯曲里程。对于大多数城市路网,相邻 OSM 节点之间的距离本来就很短,直线距离和实际道路距离差不了太多,足以支撑做原型验证。
如果你后续想改进,最划算的做法是把这个函数的输出乘以一个道路弯曲系数,而不是换一套更复杂的距离算法。弯曲系数一般取 1.2 到 1.5,实测效果比直接硬编码直线距离要准。
关系导入时的顺序也影响长度属性:必须等两端的OSMNode都写入完成后,再执行ROAD关系批量语句,否则MATCH会查不到节点。很多第一次跑这个项目的人会栽在“为什么边导不进去”这个问题上,而实际原因就是导入顺序错了。
3. 核心模块拆解:从 OSM 原始数据到一条最短路径
3.1 用 StAX 流式解析 OSM XML 文件
OSM 的.osm文件本质是 XML,常见格式有原始 XML 和压缩后的.pbf。Neo4jOSM 的导入起点是 XML,解析方式用 StAX 流式读取,而不是 DOM 一次性加载。原因很明显:一个城市的 OSM 文件动辄几百 MB,DOM 会把整个文档塞进 JVM 堆内存,极易 OOM。流式读取一次只处理一个元素,内存占用基本恒定。
XMLInputFactory factory = XMLInputFactory.newInstance(); XMLStreamReader reader = factory.createXMLStreamReader(inputStream); while (reader.hasNext()) { int event = reader.next(); if (event == XMLStreamConstants.START_ELEMENT) { String tag = reader.getLocalName(); if ("node".equals(tag)) { long id = Long.parseLong(reader.getAttributeValue(null, "id")); double lat = Double.parseDouble(reader.getAttributeValue(null, "lat")); double lon = Double.parseDouble(reader.getAttributeValue(null, "lon")); nodes.add(new OSMNode(id, lat, lon)); } else if ("way".equals(tag)) { handleWay(reader); } } }XMLInputFactory是标准 JAXP 接口的一部分,不需要额外引第三方库。reader.next()驱动事件循环,START_ELEMENT表示进入一个 XML 标签。这里要注意getAttributeValue传入的第二个参数是属性名,OSM 的node标签上挂的就是id、lat、lon三个属性,直接取用。
way的处理逻辑比节点复杂,因为 way 内部嵌套很多<nd>子标签表示节点引用,还有<tag>子标签表示道路属性。解析时需要维护一个列表和一个 Map 分别保存节点引用和标签键值,并且要等整个 way 读完才能确定它的highway类型,所以handleWay内部还要有一个小循环把子元素全部消费掉。
流式解析的坑在于事件顺序,任何时候都不要假设某个标签是单个事件就能处理完的。way的结束标签是END_ELEMENT,不能在遇到<way>开始标签时就立刻拿去仓库写入,必须等所有<nd>都读完。否则,只拿到一个空壳 way,关系永远建不出来。
3.2 UNWIND 批量导入与索引策略
解析出来的节点还只是内存里的列表,真正进 Neo4j 要靠批量写入。逐条写入最慢的环节不是网络延迟,而是每个独立小事务的提交开销。Neo4j Java Driver 的事务要比你想象的贵得多,一秒钟塞几条最终还是能跑,但导一个城区几十万节点就变成灾难。
所以批量导入的第一步是攒 batch,每攒够 500 到 2000 条就执行一次写入。我用 1000 条做默认值,实测在多个版本下性能均衡。
public void importNodeBatch(List<OSMNode> nodeBatch) { try (Session session = driver.session()) { session.executeWriteWithoutResult(tx -> { tx.run( "UNWIND $batch AS row " + "CREATE (n:OSMNode {osmid: row.id, lat: row.lat, lon: row.lon})", Map.of("batch", nodeBatch) ); }); } }executeWriteWithoutResult是 Neo4j Java Driver 4.x 以后推荐的写事务 API,它表示这段操作不需要返回值。UNWIND配$batch参数把节点列表展开成虚拟行,CREATE为每一行建一个节点,参数名row可以在后面的属性表达式里随意引用。
这个写法比循环内多次调用run高效太多,一次 Cypher 处理一批数据,事务提交次数大幅下降。如果导入过程中可能出现重复的osmid,建议把CREATE换成MERGE,但要注意MERGE的代价是每次都要做存在性探测,性能比纯CREATE差一些。第一次全量导入直接用CREATE,再次导入时才用MERGE。
导入节点后必须建索引。路由查询是根据osmid定位起点和终点的,没有索引就是全图扫描。
CREATE INDEX OSMNode_osmid IF NOT EXISTS FOR (n:OSMNode) ON (n.osmid)这个语句在 Neo4j 4.x 到 5.x 都适用。IF NOT EXISTS保证重复执行不报错。你还可以再加一个lat和lon的复合索引,但如果项目里准备配合空间查询做最近点匹配,建议直接引入 Neo4j 的空间索引插件,这比手写坐标查找靠谱。
3.3 路由查询:APOC Dijkstra 与 Cypher shortestPath
图导好了,路由查询就变成纯粹的 Cypher 问题。最直观的做法是用 Neo4j 内置的shortestPath函数,但它有一个很要命的局限:shortestPath只按遍历步数算最短,默认情况下给每条边相同的代价。也就是说,它返回的是“经过线段最少”的路径,而不是“距离最短”的路径。对城市路网来说,这会导致绕路。
所以更合理的方案是用 APOC 插件里的apoc.algo.dijkstra,它支持按关系上的权重属性求最短加权路径。
MATCH (start:OSMNode {osmid: $startId}), (end:OSMNode {osmid: $endId}) CALL apoc.algo.dijkstra(start, end, 'ROAD>', 'length') YIELD path, weight RETURN path, weight'ROAD>'这个字符串很微妙,它表示查询只沿箭头方向的ROAD关系遍历。如果把字符串写成'ROAD',就表示无视方向双向遍历,适合没有单向道路约束的场景。'length'是权重属性名,weight在返回结果里就是路径的总长度。
path变量返回的是完整的路径对象,可以通过标签获取节点列表、关系列表以及坐标序列。为了把这些信息转成 REST 友好的 JSON 结构,Java 侧要做一层转换:遍历path.nodes()把每个节点的lat、lon提取出来,拼成一个坐标数组,再把weight作为距离字段返回。
for (Node node : path.nodes()) { coordinates.add(new double[]{ node.getProperty("lon").asDouble(), node.getProperty("lat").asDouble() }); }这里需要注意嵌套顺序,返回 GeoJSON 通用格式是[lon, lat],不是[lat, lon]。我见过好几个团队在这个顺序上翻车,前端画出来的轨迹整个歪掉。如果路径查询结果需要前端直接渲染,强烈建议在服务端统一输出 GeoJSON,让调用方不用再关心坐标顺序。
4. 运行与验证:把路由服务在本地跑起来
4.1 环境准备与依赖搭建
先确认基础环境。Neo4jOSM 是 Java 项目,JDK 版本建议 11 以上,Neo4j 数据库 4.4 或 5.x 都可以,Maven 负责打包。如果你不想手动下载 Neo4j,用 Docker 跑一个容器是最省事的方式。
docker run -d --name neo4j-routing \ -p 7687:7687 -p 7474:7474 \ -e NEO4J_AUTH=neo4j/yourpassword \ -v $PWD/data:/data \ -e NEO4J_PLUGINS='["apoc"]' \ neo4j:5.19-p 7687是 Bolt 协议端口,Java Driver 走这个端口连接数据库。-p 7474是 Neo4j Browser 的 HTTP 端口,方便你在浏览器里查看图结构。NEO4J_PLUGINS指定安装 APOC 插件,这里必须设置,因为路由查询依赖apoc.algo.dijkstra。
如果网络环境不便拉镜像,用 Neo4j Desktop 或者直接解压社区版服务器包也能跑起来,只是手动装插件要拷贝 jar 包到 plugins 目录。APOC 版本必须和 Neo4j 大版本完全匹配,不然启动时插件加载直接报错。
4.2 构建、启动与接口请求
代码拉下来以后,项目根目录下执行 Maven 打包,得到可执行 jar,然后启动应用。假设项目里配了 Spring Boot 的 Web 模块,路由接口会暴露为 HTTP:POST/route,请求体里带上起点和终点的osmid。
mvn clean package java -jar target/neo4j-osm-router.jar启动后,用一个简单的 curl 请求测试:
curl -X POST http://localhost:8080/route \ -H "Content-Type: application/json" \ -d '{"startId": 123456789, "endId": 987654321}'startId和endId来自你导入的 OSM 节点 ID。如果你只导了一个城市的小范围数据,可以在命令行里先搜一下有哪些节点再填参数。响应会返回一条 JSON,包含两个字段:coordinates是经纬度坐标数组,distance是整条路径的长度。
如果接口抛异常,第一步看日志里的最后一句话。常见的是NodeNotFound或者EmptyResult,前者说明osmid没有匹配到节点,后者说明两个节点之间没有连通路径,大概率是路网断裂或者方向关系没建对。
4.3 结果验证:坐标、路径与耗时
拿到返回结果后,不要只盯着distance看,还要验证路径形状是否符合地理常识。我的习惯是把coordinates导出到一个在线地图工具里渲染一次,确认轨迹没有穿越大楼、没有无意义绕圈。如果只是盯着数字看,很容易忽略单向道路建模错误的问题。
路径耗时也有参考价值。对一个几万节点的小城区网络,APOC Dijkstra 的查询时间通常在几十毫秒到一两秒之间,具体取决于路径长度和图的密度。如果查询时间稳定超过三秒,先查索引是否生效。
EXPLAIN MATCH (start:OSMNode {osmid: $startId}), (end:OSMNode {osmid: $endId}) CALL apoc.algo.dijkstra(start, end, 'ROAD>', 'length') YIELD path, weight RETURN path, weightEXPLAIN关键字能显示执行计划,重点看NodeIndexSeek是不是真的用上了索引。如果执行计划里出现NodeByLabelScan,说明索引没建成功或者没被识别,查询会退化成全表扫描,几百万节点的图直接卡死。
验证完这条路径后,我通常还会随手挑几个相距很远的节点做跨区查询,检查路网边界区域是否正常。边界处是最容易出问题的位置,经常出现路到了某个坐标就突然断掉的情况,因为裁剪数据时把道路切成了两段,只保留了其中一段的连接关系。
5. 避坑记录:导入慢、绕路、断头路的排查清单
5.1 导入性能的坑
现象:节点导入速度一开始快,到几万条后急剧下降,每批写入耗时越来越大。
原因:这个坑我踩了两次。最常见的原因是没有批量写入,每遍历一个 OSM 节点就发一条 Cypher。另一个原因是 Neo4j 默认的dbms.memory.heap_max_size和pagecache配置对导入负载不适合,大量写入操作导致磁盘 I/O 成为瓶颈。
解决:先把解析和导入分离,解析线程只管往队列里塞数据,导入线程按固定 batch 批量刷。batch 大小建议用 1000 到 2000,太大事务失败回滚成本高,太小又浪费事务开销。另外把 Neo4j 配置文件里的dbms.transaction.timeout调大,防止大批量事务执行时间长被强制中断。
5.2 零长度和超短路段的坑
现象:导入完成后,路网里出现长度约等于 0 米的ROAD关系,路由查询有时返回一个原地打转的路径。
原因:OSM 数据里存在大量坐标重复的冗余节点。有些是地图编辑工具产生的误差,有些是同一个坐标节点被分成多个 ID。连续两个节点如果坐标一致,算出来的长度就接近零。
解决:在导入阶段加一个过滤条件,两点距离小于 1 米就直接跳过这段关系,不建ROAD。过滤逻辑放在解析完成之后、写图之前,用一个循环判断相邻节点的距离。这样能减少图里的边数量,也让下游查询不出现原地小碎步。
5.3 单向道路建模错误的坑
现象:某个 OD 对之间明明有路,但路由返回结果绕了一大圈,甚至提示无路径。
原因:问题几乎都出在单向道路的方向关系上。OSM 的oneway标签取值是yes、no或-1。-1表示方向与 way 的节点序列相反。如果导入时没有对-1做反转处理,也没有对同一段路建立反方向的关系,那么从端点反向出发的查询永远找不到这条路。
解决:建立ROAD关系时先解析oneway字段。双向道路生成a -[:ROAD]-> b和b -[:ROAD]-> a两条关系;oneway=yes只生成一条;oneway=-1只生成反向的一条。如果你图省事,可以在 Cypher 查询阶段把关系类型写成'ROAD'不限制方向,但这会让无向图模型下的单行道丢失语义,我一般只在双向路网验证阶段才这么干。
5.4 断头路和跨区域不连通的坑
现象:查询返回No path found,但在 Neo4j Browser 里肉眼能看到两段路非常接近,就差一个节点连接。
原因:城市 OSM 数据的导出区域如果被人工裁剪,某些道路会被截断在边界上,截断处的节点没有与相邻区域的对应节点合并,导致物理上能走过去、图上却断成两截。另一个常见原因是 way 与 way 之间的十字路口本应共享同一个节点,但实际数据里两个 way 在这个点各自分配了不同的osmid。
解决:导入完成后做一轮拓扑清理,用空间匹配把距离极近且没有关系连接的节点对补建一个连接关系。更简单的方式是在构建图的时候,对每个节点找出 10 米范围内的所有其他节点,如果它们有引用关系就自动合并 ID。这个步骤不复杂,但能省掉后面一大半断头路问题。
5.5 经纬度坐标顺序的坑
现象:前端渲染出来的轨迹横穿整个城市,路径点的经纬度被反了。
原因:OSM XML 里节点属性的顺序严格是lat然后lon,但不少 GeoJSON 工具约定先lon后lat。解析代码里如果把lat存进lon字段,又没在 JSON 输出时转换,轨迹必然天翻地覆。
解决:在统一出口做坐标序转换。一旦确定输出格式是 GeoJSON,就在序列化时全部转成[lon, lat],内部图数据库存储保持lat、lon分离属性,不要混淆。我习惯在代码里加一个toGeoJsonCoord()的辅助方法,所有坐标输出都走这个方法,避免散落各处的手动拼接。
6. 二次开发:把“简单路由”变成自己的路由引擎
改造成本最低、收益最明显的一个点,是把距离权重升级成行驶时间权重。距离最短的路径在真实驾驶场景里往往不是最优解,高速公路和城市支路的时速差别太大。具体做法是在图里的ROAD关系上追加一个cost属性,用道路长度除以对应等级的平均车速,得到估算通行时间。
MATCH (n:OSMNode)-[r:ROAD]->(m:OSMNode) WITH r, CASE r.highway WHEN 'motorway' THEN 100 WHEN 'trunk' THEN 80 WHEN 'primary' THEN 60 WHEN 'secondary' THEN 50 WHEN 'tertiary' THEN 40 WHEN 'residential' THEN 20 ELSE 30 END AS speedKmh SET r.cost = r.length / (speedKmh * 1000.0 / 3600.0)duration属性被赋值为以秒为单位的浮点数。speedKmh先转成米每秒,再让长度除以它。写完以后,把查询语句里的权重属性从'length'改成'cost',你得到的就是一条时间最短路线。
不过这里有个细节容易被忽略:不同道路等级的默认速度只是个粗糙估计,真实场景里要考虑红绿灯、转弯惩罚和实时拥堵。你可以把转弯惩罚建模成关系上的额外属性,也可以在cost里加上交叉口等待时间,但那样图模型会变得更复杂。
第二个值得做的改造是输出标准化。把/route接口的返回改成标准 GeoJSON FeatureCollection,里面包含geometry和properties字段。这样直接用 Leaflet 或 Mapbox 就能画路径,不需要前端再解析坐标数组。
Map<String, Object> geometry = Map.of( "type", "LineString", "coordinates", coordinates ); Map<String, Object> feature = Map.of( "type", "Feature", "properties", Map.of("distance", weight), "geometry", geometry );我一般会把坐标类型、距离、权重都塞进properties,调试时很方便,前端也能直接显示路径总长。再往下,你可以把最近节点匹配也做进接口:接收任意经纬度坐标,在图里算出最近的OSMNode作为起点终点,这样调用方就不用自己管osmid了。
最后说一个我自己的习惯。现在每次导完路网数据,我都会先抽查几条路径在真实地图上比对,确认不穿楼、不逆行、不横穿广场,再交给前端联调。这轮检查虽然花了时间,但能拦下八成所谓“图数据库路由有玄学”的问题,其实都不是玄学,是导入时留下的脏数据。希望这套排查思路能给你省下几个晚上,也让 Neo4jOSM 在你手里真正跑起来。
本文还有配套的精品资源,点击获取