简介:这是一套将图数据库Neo4j与OpenStreetMap地理数据结合、以Java实现的简单路由服务项目源码,面向需要在地图类应用中集成路线规划能力的Java开发者。压缩包共35个文件,核心为22个Java源文件,附带3个OSM示例地图、1个PBF数据文件,以及Gradle构建配置、属性文件和说明文档,整体约314KB,结构清晰便于直接导入与二次开发。项目展示了从OSM数据解析、路网建模到Cypher查询最短路径的完整链路,可作为理解空间数据建模与图数据库路线的实践参考。已有169人学习或下载,适合对图数据库、地理信息系统或路径优化感兴趣的中高级Java工程师。
1. Neo4jOSM 到底是什么:与其说得绕路,不如换成图遍历
做地图和导航的都知道,传统路由服务要么用 PostGIS + pgRouting,要么自己写 A*。但你有没有想过一个问题:路网本身就是一张图,路口是节点,路段是边,而 Neo4j 天生就是存“节点和关系”的数据库。Neo4jOSM 这个方向,本质就是拿 OpenStreetMap 的原始路网数据灌进 Neo4j,把“两点之间怎么走”从最短路径算法问题,变成图数据库的遍历查询问题。
我第一次接触这个方案是被一个做同城配送的朋友拉去看的,他们想在一套图数据库里同时管知识图谱和路径计算,不想为了一个路由功能单独再插一套 pgRouting。当时我的第一反应是“图数据库做路由肯定慢”,但实际跑完才发现,数据量控制在十万级节点以内时,Cypher 的遍历效率完全能接受,而且开发和排错的成本比传统方案低得多。这个方案适合两类人:一类是已经在用 Neo4j 做业务、需要顺带解决路线计算的人;另一类是想快速做一个轻量导航原型、又不想碰地理信息系统那套重工具的开发者。
2. 为什么路由要选图数据库:从路网的数据模型说起
2.1 路网不是坐标系,而是图结构
很多人第一次接触 OSM 数据时,会下意识把它当成一堆经纬度坐标。这其实是最大的误解。OSM 的原始数据模型里只有三类核心元素:Node(点)、Way(线)、Relation(关系)。一段从 A 路口到 B 路口的道路,是由一串 Node 组成一条 Way,多个 Way 再组合成完整的道路网。这和 Neo4j 的 Label、Relationship 模型有一种天然的对应关系。
我在设计这套路由服务的数据模型时,走了两条路线来回对比。第一种方案是把每个 OSM 的 Way 当作一条 Neo4j 关系,这条路的所有几何信息存在关系的属性里。第二种方案是节点对应路口,关系对应路段。最终我选择了第二种,因为路由的核心计算单元是“从一个节点到另一个节点经过哪些关系”,如果 Way 直接作为关系,路口的转弯角度、多条路汇聚的逻辑反而要额外处理。
OSM 数据里最容易被忽视的是方向性。高速公路是单向的,普通街道是双向的。在 Neo4j 建模时,关系必须有 Direction 属性区分 oneway 和双向,否则后续算出来的路线可能带着你逆行。这不是算法的复杂度问题,而是数据模型的精准度问题。
2.2 图数据库的路由优势:一条 Cypher 就够
传统路由方案的痛点在于,最短路径算法是外部实现的,要么是 GIS 扩展的存储过程,要么是独立微服务。Neo4j 的好处是路径遍历是 Cypher 查询语言的原生能力,不需要额外写算法模块。
拿一个最基本的需求举例:找从 A 点到 B 点的最短路径。Cypher 里的 MATCH p=shortestPath(...) 可以直接跑通。更重要的是,你不用像传统方案那样预先建好拓扑关系表,图数据库的节点和关系本身就是拓扑。新增一条道路,就是一个新关系;删除一条封路,就是删除一个关系。对于“路线需要随路况实时调整”的场景,这种数据模型的灵活性是压倒性的。
当时我并用 pgRouting 和 Neo4j 各实现了一版,在数据量一致的情况下,Neo4j 的启动慢一些,但小范围查询的热数据会比关系型方案更稳,因为遍历过程是在内存里完成的,不需要反复 JOIN 多张表。
2.3 用得当心:不是所有路由需求都适合 Neo4j
这是我在朋友圈说过最多的一句话:拿 Neo4j 做路由,控制好规模就是利器,失控就是灾难。Neo4j 的遍历效率会随跳数线性增长,十跳以内的路线几乎秒出,但超过二十跳,响应时间开始恶化。如果你要做的全国范围货车调度,几十万甚至上百万个路网节点,Neo4j 社区版未必扛得住。
适合用 Neo4j 的场景是:单城市或区域性的路网,节点量在十万级,且路由计算不是核心业务的全部压力。比如门店配送路径、园区内部导航、景点游览路线。如果答案是“全国几千万个路口节点”,我建议直接往 pgRouting 或 Valhalla 方向选型。
3. 把 OSM 路网灌进 Neo4j:完整导入链路与参数设置
3.1 工作台搭建设置与数据准备
我是按这套流程跑的,先准备好环境。Neo4j 社区版是必需的,版本我推荐 4.x 以上,因为 Cypher 的语义和 APOC 插件的兼容性更稳定。另一个准备是 OSM 数据文件,范围别贪大,先用一个小城市或一个区的 .osm.pbf 格式测试链路。
我一般不建议直接用 pbf 去解析,而是先转成 OSM XML 格式,方便肉眼检查数据。用 osmium 工具做这个转换最省心。
wget https://download.geofabrik.de/asia/china-latest.osm.pbf osmium cat china-latest.osm.pbf -o beijing.osm这里的 china-latest.osm.pbf 是整个国家的数据,但我只需要北京范围,所以用 osmium 的边界过滤更合理。真正实操时我会加 bbox 参数只提取目标区域,这样导入 Neo4j 的数据量小一个数量级,排错也方便。上面的命令只是第一步,重点在于后续的数据结构转换,解析要自己写而不是靠现成的导入工具硬灌,因为现成工具往往不做拓扑清洗。
3.2 自己写一个 Python 解析器:核心代码与逻辑拆解
图数据库里做路由,最重要的不是导入 OSM 数据本身,而是构建出可导航的拓扑。OSM 原始数据里,一条 Way 有多个 Node,这些 Node 可能是纯几何形状点,也可能正好是路口。我的策略是只把路口节点导入 Neo4j,路段作为 Relationship 属性里的坐标串存下来。
这里给一个能直接跑的解析器骨架:
import osmium from neo4j import GraphDatabase class RouterWriter(osmium.SimpleHandler): def __init__(self, driver): super().__init__() self.driver = driver self.way_nodes = {} self.node_coords = {} self.turn_rules = [] def node(self, n): # 只保留有路口潜力的节点坐标,先全部缓存 self.node_coords[n.id] = (n.location.lon, n.location.lat) def way(self, w): if 'highway' not in w.tags: return highway_type = w.tags['highway'] if highway_type not in ['motorway', 'trunk', 'primary', 'secondary', 'tertiary', 'residential']: return nodes = [n.ref for n in w.nodes] oneway = w.tags.get('oneway', 'no') # TODO:确定哪些节点是交叉路口,需要与其它 way 求交集 self.way_nodes[w.id] = { 'nodes': nodes, 'highway': highway_type, 'oneway': oneway }关键逻辑先在 Python 里保留一份 way 节点序列缓存,再在后续清洗中过滤出真实路口。直接边读 OSM 边写入 Neo4j 的做法我试过一次,导入中途遇到数据异常就得回滚,很难排查。所以我把清洗和写入拆成了两个阶段,第一阶段先落盘成 CSV,第二阶段用 Neo4j 的批量导入命令。这是效率最高、也最容易控制错误的方式。
3.3 Neo4j 导入命令和索引配置
解析出路口和路段之后,生成两个 CSV 文件,一个 nodes.csv 一个 ways.csv,然后直接用 Neo4j 的LOAD CSV或 admin import 写入。社区版里最稳妥的是逐条执行 Cypher 写入,数据量小于五万条时没问题。
// 创建路网节点 LOAD CSV WITH HEADERS FROM 'file:///nodes.csv' AS row CREATE (n:RoadNode { id: toInteger(row.osm_id), lat: toFloat(row.lat), lon: toFloat(row.lon) }); // 创建路段关系 LOAD CSV WITH HEADERS FROM 'file:///ways.csv' AS row MATCH (a:RoadNode {id: toInteger(row.source)}) MATCH (b:RoadNode {id: toInteger(row.target)}) CREATE (a)-[:ROAD { highway_type: row.highway, distance_m: toFloat(row.distance_m), oneway: row.oneway }]->(b);注意这里的 oneway 属性一定要在导入时处理成方向关系。我踩过的坑是:双向路段在 OSM 里是一条 Way,但如果只在 Cypher 里建一条关系,从 B 到 A 就没办法遍历。正确做法是在解析器里判断 oneway,如果是双向就把 source 和 target 互换再建一条反向关系。
索引设置很关键。路由查询的第一步是锚定起点和终点节点,如果id属性没有索引,neo4j 会全表扫描,这会让十万级节点的查询直接卡上十几秒。我当时是这么下的:
CREATE INDEX road_node_id IF NOT EXISTS FOR (n:RoadNode) ON (n.id);这个索引几乎决定了所有路由查询的基础性能,务必最先创建。
4. Cypher 里实现最短路径:三个查询方案与参数调优
4.1 最直观的写法:shortestPath 函数
数据导入完成之后,最重要的环节就是写路由查询。先头最简单的版本,用 Neo4j 内建 shortestPath 方法:
MATCH (a:RoadNode {id: 12345}), (b:RoadNode {id: 54321}) MATCH p = shortestPath((a)-[:ROAD*]-(b)) RETURN p, reduce(s = 0.0, r IN relationships(p) | s + r.distance_m) AS total_distance这里的[:ROAD*]表示沿任意深度遍历关系。shortestPath 内部做的是双向 BFS 的优化版,在小型路网上会非常快,可问题是它默认按跳数最少优先,不是按路程最短优先。如果一条路线经过很多短路段,另一条路线只经过两条长路段,它会选择后者。这跟我实际做配送路由时的需求正好相反。
这是第一个要调的点:如果只是求“经过路口最少”的路线,shortestPath 就够;如果求的是“物理距离最短”,需要换方案。
4.2 按距离加权的最短路径:APOC 扩展的 Dijkstra
Neo4j 官方的 APOC 插件库提供了一套更靠谱的算法实现,支持带权重的 Dijkstra。这是我最常用的路由写法,也是真正能上生产环境的方式:
MATCH (a:RoadNode {id: 12345}), (b:RoadNode {id: 54321}) CALL apoc.algo.dijkstra(a, b, 'ROAD', 'distance_m') YIELD path, weight RETURN path, weightapoc.algo.dijkstra四个参数含义分别是:起始节点、目标节点、关系类型、权重属性。这里的 weight 就是上面导入时存的 distance_m,单位是米。用它跑出来的结果是真正按距离算的最短路径。
这个查询有两点值得注意。第一,APOC 版本要跟 Neo4j 版本对应,我在 Neo4j 4.4 配 APOC 4.4 的 core 包,否则会遇到闪退或函数找不到的问题。第二,如果路网关系里存在 0 权重或负权重,Dijkstra 会直接挂掉,甚至死循环。导入数据时我做了过滤,凡是 distance_m 小于 1 的路段直接丢弃,避免这种边缘情况。
4.3 从一个节点出发,查询所有可达路径
热词里有人搜“neo4j查询从一个节点出发如何查询多条”,这个恰恰是图数据库路由最有优势的场景。比如配送站要同时服务三十个客户点,传统关系型数据库要一条条调接口,而 Cypher 只需要一次查询就可以把所有按距离排序的可达路径拉出来。
MATCH (start:RoadNode {id: 12345}) MATCH (target:RoadNode) WHERE target.id IN [54321, 54322, 54323, 54324] CALL apoc.algo.dijkstra(start, target, 'ROAD', 'distance_m') YIELD path, weight RETURN target.id, weight, [n IN nodes(path) | n.id] AS node_list ORDER BY weight ASC执行计划上,这种写法每一次 CALL 都会触发一次单源 Dijkstra,所以如果目标节点有几十上百个,性能会直线下降。我的优化思路是,先加一个粗粒度的距离过滤,比如用 Neo4j 的空间函数先算节点间的直线距离,过滤掉三公里外的节点,减少需要跑 Dijkstra 的目标数量。
![Note] 可以把这段查询发布成自定义存储过程,因为 Neo4j 官方写法对结果去重和并行有一定限制,存储过程能更好地控制资源。
5. 路由上线的避坑记录:坐标偏移、数据漂移与性能问题
5.1 路线生成后,在地图上位置对不上
这是最诡异的故障。数据导入了、查询通了、路径也返回了,但在前端地图组件上画出来后,路线浮在马路边的住宅区里。排查后发现是坐标参考系的锅。
现象:OSM 数据默认坐标系是 WGS84(GPS 用的经纬度坐标),而高德或百度地图在国内使用的是 GCJ-02 加偏坐标系。Neo4j 里存的是原始 WGS84,前端地图工具自动做了坐标系纠偏,于是双重偏移导致路线错位。
原因:把 OSM 的坐标直接当成了国内地图运营商坐标系里的坐标来用。
解决:要么在解析器里把 WGS84 转成 GCJ-02 再入库,要么前端关闭自动偏转。我选了前者,写了一个wgs84_to_gcj02的算法函数,在导入 CSV 前先做一次坐标转换,这样后续所有业务逻辑和可视化都保持一致。
5.2 一度路口连不上:拓扑断裂导致路线查不到
又一个高频坑。导入完成后,路由只返回了一个起点节点,路径完全不存在。我单步排查发现,有 37% 的节点成了“孤岛”,完全没有关系。
原因有三个。第一,OSM 原始数据里,两条路在物理上交叉,但并没有共享同一个节点 ID,这也是数据拓扑断裂的典型原因之一。第二,解析时我把节点精度过滤做得太激进,把一些交叉口误判成了形状点丢了。第三,一条道路断开两段时,终点节点 ID 和另一段的起点节点 ID 不一致。
解决:在导入后的 Neo4j 里写一次清洗脚本,把坐标距离小于五米的节点做合并。具体的条件是把两个节点的经纬度做差值,小于阈值就用一个节点的 ID 统一替换掉关系里的引用。
MATCH (a:RoadNode), (b:RoadNode) WHERE a.id <> b.id AND abs(a.lat - b.lat) < 0.0001 AND abs(a.lon - b.lon) < 0.0001 WITH a, b LIMIT 1000 MATCH (a)-[r:ROAD]-(n) MERGE (b)-[r2:ROAD {distance_m: r.distance_m}]->(n) DELETE r这种做法我先限制每批只处理 1000 条,因为大批量 MATCH 在 WHERE 条件里的计算开销非常大,跑久了容易 OOM。跑一遍可以恢复大部分断裂拓扑,但会出现重复关系,所以跑完之后还要加 DISTINCT 去重。
5.3 Neo4j 没按配置文件分配内存:查询慢到像假死
跑路由查询时发现只要跳数超过十五次,响应时间从 50ms 暴涨到 10s,一度以为是数据量问题。后来发现 Neo4j 默认的内存配置把堆内存限制在 512MB。
原因:Neo4j 的neo4j.conf文件没有修改,默认堆内存只有几百 MB,图遍历是把节点加载到堆内的,内存不够自然开始频繁 GC,哪怕数据量只有几万条也卡。
解决:在neo4j.conf里修改以下配置。注意修改后必须重启 Neo4j 服务。
server.memory.heap.initial_size=2g server.memory.heap.max_size=2g server.memory.pagecache.size=2g具体大小根据自己的机器来。我一般建议初始堆和最大堆设成一致,避免运行中堆自动扩容带来的停顿。这两项设成不同值时,Neo4j 会频繁执行扩缩容,路由查询的延迟曲线变得非常难看。
5.4 关系方向设置错误,路线总让你掉头
我在测试一个环形路网的导航时,路线算法给出的路径一直在原地打转,或者强制连续两次 U 形弯。翻遍代码,最后发现是把双向路段的两个方向关系都建了,但 oneway 判断错误,把它当成仅单向录入。
OSM 数据里oneway标签除了yes和no之外,还有一种-1表示逆行车道,即单向但方向与 Way 节点序列相反。我的解析器里一开始只判断了yes,把-1当成了双向路段。解决方式很简单,把 oneway == '-1' 的情况在生成关系时交换 source 和 target,再调整 distance_m 并写回。
if oneway == '-1': source, target = target, source这种错误不太容易靠日志发现,因为图结构上完全没有异常,但算出的路线就是不合理。所以我在每个路段关系上都加了一个direction属性存储车流方向,后续查问题时可以直接用WHERE r.direction <> 'backward'做过滤,便于排查。
6. 进阶玩法:给路由加上多约束条件与速度权重
基础的路由已经能跑通,但真实场景里还需要区分“最短距离”和“最快时间”。配送场景更关心时间,自驾场景更关心是否走高速,步行场景必须排除快速路。这些本质是把单一的distance_m权重属性变成多维属性,再在查询时按场景切换权重字段。
我在关系上额外保存了speed_kph属性,从 OSM 的maxspeed标签获取,如果没有则按高速公路类型赋默认值。然后程序里先计算cost_seconds = distance_m / speed_kph * 3.6,存成单独属性,再用 APOC 的 dijkstra 函数换成cost_seconds做权重:
MATCH (a:RoadNode {id: 12345}), (b:RoadNode {id: 54321}) CALL apoc.algo.dijkstra(a, b, 'ROAD', 'cost_seconds') YIELD path, weight RETURN [r IN relationships(path) | r.road_name] AS roads, weight AS total_seconds做法说起来很简单,真正要踩的坑是maxspeed的解析。OSM 标签里maxspeed=30表示限速 30 km/h,maxspeed=walk表示步行速度,maxspeed=none表示不限速。解析器里必须做一层归一化,否则字符串类型直接转 float 时会直接报错,整条导入链路停掉。
我还给自己的服务加过一个实用功能:根据路由结果的起点和终点,从路段的highway_type属性过滤掉不适合当前交通模式的关系类型。骑行时剔除 motorway,步行时剔除 trunk。把apoc.algo.dijkstra里的关系类型参数从ROAD改成动态拼接,或直接在关系创建时打上access标签组合:
CALL apoc.algo.dijkstra(a, b, 'ROAD>', 'cost_seconds') YIELD path, weightROAD>的>表示只沿关系方向正向遍历。这一招对单行道的处理特别管用,可以绕开很多逆行的坑。
整体把这套服务从原型到落地做下来,我最深的感受是:Neo4j 做路由的优势不在算法执行速度,而在工程速度。它让路线计算和你原有的图数据模型长在一起,不用在业务和非业务之间频繁做语义转译。这是一个性价比很高的方案,关键是把数据清洗和方向处理做扎实,希望帮到你。
本文还有配套的精品资源,点击获取