基于Neo4j与OpenStreetMap的离线路网最短路服务实现

发布时间:2026/10/28 17:59:14
基于Neo4j与OpenStreetMap的离线路网最短路服务实现 简介Neo4jOSM是一套面向Java开发者与图数据库学习者的简单路由服务示例围绕Neo4j图数据库与OpenStreetMap开放地图数据展开重点解决将OSM路网数据转化为图模型并进行最短路径查询的问题。项目包共35个文件压缩后约314KB以22个Java源码为核心辅以3个OSM数据文件、2个properties配置、2个Markdown说明以及Gradle构建脚本等源码按主程序与测试目录组织便于直接编译运行和按需改造。已有170人学习适合想了解空间数据导入、节点关系建模、Cypher路径查询和路线API封装的读者参考。代码覆盖OSM/PBF数据解析、路网图构建、最短路径查询与服务接口等环节可帮助读者较快理解从开放地图数据到Neo4j图模型的落地流程并据此扩展物流路径优化或实时导航类应用。1. 从一张离线地图到一条可用路线内网环境里想算两点之间的路线调在线地图接口这条路基本走不通数据出不去规则也改不了。Neo4jOSM 就是为这种场景准备的把 OpenStreetMap 的路网数据灌进 Neo4j让图数据库原生地做多跳遍历和最短路计算外面套一层 Java 写的 HTTP 服务输入起终点的经纬度返回一串可以直接画在线上的坐标。它的定位很朴素——不是要做一个媲美商业地图的导航引擎而是把「路网即图」这件事跑通让你能在自己的机器上、用自己的权重规则算出路径。适合谁想把图数据库用在一个有真实数据量的场景里练手的后端需要在内网做路径规划的集成方以及想给自家调度系统加一个「按距离/时间算路」能力却又不想被外部服务卡脖子的团队。读下去你会发现真正花时间的不是写查询而是数据导入那一段。2. 技术栈与工程骨架Java 服务如何把路网挂到 Neo4j 上2.1 为什么路网这种数据适合放进图数据库路网的天然结构就是图路口是节点两个路口之间的一段路是边边上挂着距离、道路等级、是否单行这些属性。用关系型数据库表达它一次「从 A 到 B 经过哪些路段」的查询要写成递归 CTE跳数一多执行计划就崩更别提中途还要按权重剪枝。图数据库在这件事上的优势不是玄学而是存储层就按指针跳转来组织邻接关系一跳的代价是常数级Dijkstra 这类算法跑起来跟内存里的邻接表差不多。代价也很直接。一份中等规模城市的路网节点数在百万级边数在几百万级全量塞进 Neo4j 会吃掉相当可观的内存和磁盘导入过程也不是几分钟能完事的。所以这个项目的技术选型可以拆成三层来看OpenStreetMap 负责提供免费且可离线分发的原始数据Neo4j 负责把这份数据变成可遍历的图并提供最短路算法Java 负责把两者粘起来对外暴露一个简单的 HTTP 接口。中间不需要额外引入搜索引擎或缓存层因为路径计算的耗时主要压在算法本身而不是网络或序列化上。另一个容易被忽略的点是Neo4j 自带的空间类型和点索引让「给一个经纬度找最近的路上节点」这件事不需要额外部署一个空间数据库。这是整个链路里最省事的一环也是很多人初次接触时会低估的一环。2.2 工程目录与分层项目骨架是标准的 Spring Boot 分层加上一个独立的导入模块。导入模块不跟着 Web 服务一起启动而是做成命令行入口跑完就退出避免服务启动时误触发全量导入。目录大概长这样neo4j-osm-route/ ├── pom.xml ├── src/main/java/io/example/osmroute/ │ ├── OsmRouteApplication.java # 启动类 │ ├── web/ │ │ ├── RouteController.java # /route /nearest 两个接口 │ │ └── dto/ # 请求与响应对象 │ ├── service/ │ │ ├── RouteService.java # 吸附 最短路 路径还原 │ │ └── GraphProjectionService.java # GDS 图投影的创建与失效 │ ├── repo/ │ │ ├── NodeRepository.java # 节点批量写入与最近点查询 │ │ └── EdgeRepository.java # 边批量写入 │ ├── importer/ │ │ ├── OsmPbfReader.java # 读取 .osm.pbf产出 node/way 事件 │ │ ├── WaySegmenter.java # 把 way 拆成相邻节点对 │ │ └── ImportCommand.java # 命令行入口 │ └── util/GeoUtil.java # Haversine 距离等工具 └── src/main/resources/ ├── application.yml └── cypher/ # 所有 Cypher 语句集中放这里分层的边界要卡死repo层只负责发 Cypher 和收结果不做业务判断service层负责把「经纬度」翻译成「图上节点 ID」再调算法importer完全不依赖 Spring 容器用原生 driver 就行这样导入脚本可以单独打包成一个可执行 jar扔到数据机上跑。把导入和服务混在一起是很常见的翻车点一次误操作就可能把生产图数据重刷。2.3 依赖与运行配置依赖不算多核心就三个Neo4j 的 Java driver、Spring Web、以及一个能读 PBF 的 OSM 解析库。PBF 的解析库选择上有几种常见做法是用 Osmosis 的 PBF 读取组件或者更轻一点的 osm4j。前者生态老、文档多后者 API 更现代但示例少。我一般选前者因为导入是一次性任务稳定比优雅重要。dependencies !-- Neo4j 官方驱动导入和服务共用 -- dependency groupIdorg.neo4j.driver/groupId artifactIdneo4j-java-driver/artifactId version${neo4j-driver.version}/version /dependency !-- Web 层 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- PBF 读取只用其中的读取组件不引整套 -- dependency groupIdorg.openstreetmap.osmosis/groupId artifactIdosmosis-pbf/artifactId version${osmosis.version}/version /dependency /dependencies连接配置单独放一份因为导入和服务用的超时、批量大小完全不一样。导入时事务超时可以放到几分钟服务侧必须控制在秒级否则一个慢查询会把整个连接池拖死。neo4j: uri: bolt://127.0.0.1:7687 user: neo4j password: ${NEO4J_PASSWORD} database: osm pool: max-connection-pool-size: 20 # 服务侧够用导入侧另开一个 driver connection-timeout: 5s route: snap-max-meters: 800 # 吸附半径上限超过就报“附近无路网” default-weight: distance # 可切成 durationdatabase一定要显式指定别用默认库。导入和服务如果落在不同库上表现出来就是「导入明明成功了接口却查不到数据」排查起来非常浪费时间。3. 数据导入链路从 .osm.pbf 到可遍历图模型的落地步骤3.1 先过滤再解析别把整颗星球塞进来OpenStreetMap 的原始数据是全要素的包含建筑轮廓、兴趣点、行政边界、水系跟行车路径一点关系没有。直接全量导入的后果是节点表被撑大十几倍索引重建慢到无法接受。所以导入的第一步永远是过滤只保留带highway标签的 way以及这些 way 引用到的 node。过滤可以用官方的命令行工具在导入前做一次也可以在我们的 Java 代码里做一次判断。前者更快后者更可控。我倾向于两者都做命令行先粗筛掉大部分无关要素Java 侧再做一次精确的白名单判断。# 只保留可通行道路输出体积通常能降到原始的十分之一以内 osmium tags-filter city.osm.pbf \ w/highwaymotorway,motorway_link,trunk,trunk_link,primary,primary_link,\ secondary,secondary_link,tertiary,tertiary_link,residential,unclassified,living_street \ -o roads.osm.pbf --overwrite # 压缩成 o5m 或保留 pbf 都行解析侧只关心格式 osmium cat roads.osm.pbf -o roads.osm.pbf --overwrite过滤清单决定了最终路网的质量。把footway、path、steps一起放进来就会出现「最短路穿过公园台阶」这种结果看着像 bug其实是数据选错了。反过来只留motorway城区里根本连不通吸附阶段会大面积报「附近没有路网」。白名单要跟你的业务场景对齐做车辆导航就把上面那批留下做步行导航换成footway、pedestrian、path并关掉单行道约束。解析侧的核心是一个流式读取器逐条产出 node 和 way不在内存里攒全量对象。这点很关键city 级别的 PBF 解压后轻松上 GB一次性读进 List 就是给 GC 上强度。public class OsmPbfReader { // 回调式消费避免把整份数据构建成内存对象 public void read(Path pbf, NodeConsumer nodeConsumer, WayConsumer wayConsumer) throws Exception { var source new PbfReader(pbf.toFile(), 1); // 1 表示单线程读导入瓶颈在写库不在读 source.setSink(new Sink() { Override public void process(EntityContainer container) { Entity entity container.getEntity(); if (entity instanceof Node node) { nodeConsumer.accept(node.getId(), node.getLatitude(), node.getLongitude()); } else if (entity instanceof Way way) { // 只把 way 的节点引用序列和 tags 交给下游后续再做拆分 wayConsumer.accept(way.getId(), way.getWayNodes(), way.getTags()); } } }); source.run(); } }PbfReader的第二个参数是并发读线程数这里给 1。原因很实在真正的瓶颈在 Neo4j 的写入批次读得再快也会被写堵住多线程读反而让内存占用飙升。NodeConsumer和WayConsumer是两个函数式接口把「怎么处理」从「怎么读」里剥出来测试时可以直接传一个收集器验证解析结果。3.2 把 way 拆成相邻节点对边才有物理意义OSM 里一条 way 是一串有序的节点引用可能横跨十几个路口。图模型里不能直接把它建成一条边否则「中途某个路口可以拐弯」这件事就丢了。正确做法是把 way 按连续节点两两拆开每一对建成一条有向边边上带距离和道路属性。同一条 way 拆出来的边共享wayId方便后续按路段做聚合。public class WaySegmenter { private static final double EARTH_RADIUS_M 6371000; // 把一条 way 拆成相邻节点对产出待写入的边记录 public ListEdgeRow segment(long wayId, ListLong nodeIds, MapString, String tags) { String highway tags.getOrDefault(highway, unclassified); boolean oneway yes.equals(tags.get(oneway)) || motorway.equals(highway) // 高速默认单行不用等 tag || -1.equals(tags.get(oneway)); int maxSpeed parseSpeed(tags.get(maxspeed), highway); // 缺失时按等级兜底 ListEdgeRow rows new ArrayList(nodeIds.size() - 1); for (int i 0; i nodeIds.size() - 1; i) { long from nodeIds.get(i); long to nodeIds.get(i 1); double meters haversine(from, to); // 这里查的是内存里的坐标缓存 rows.add(new EdgeRow(wayId, from, to, meters, maxSpeed, highway)); // 非单行道路要补反向边否则从 B 回 A 会无解 if (!oneway) { rows.add(new EdgeRow(wayId, to, from, meters, maxSpeed, highway)); } } return rows; } }这几个参数都值得说一句。oneway除了看yes还要处理高速默认单行和-1表示方向与节点顺序相反这两种情况只判yes是最常见的错误会让大量高速出口单向不可达。maxSpeed缺失时按道路等级兜底比如primary给 60、residential给 30纯凭经验但比全部当 50 合理得多。haversine算的是球面距离单位米直接作为边的distance属性后续权重计算和展示都用它。边数会比节点数多这是必然的百万节点的城市路网拆出三百万到五百万条边很正常。所以写入必须走批量单条CREATE的写法在一万条边的时候就能让你等到怀疑人生。3.3 约束和空间索引先建后用顺序不能反批量写入之前先把约束和索引建好。唯一约束让MERGE能用上索引查找而不是全表扫点索引让吸附查询在百万节点上依然是毫秒级。这两条语句必须在导入之前执行导入之后再建索引等的时间够你喝完一杯咖啡。// 唯一约束节点 ID 来自 OSM天然唯一用它做主键 CREATE CONSTRAINT osm_node_id IF NOT EXISTS FOR (n:OsmNode) REQUIRE n.id IS UNIQUE; // 点索引吸附查询靠它没有这个索引就是全表扫描 CREATE POINT INDEX osm_node_location IF NOT EXISTS FOR (n:OsmNode) ON (n.location);节点写入用UNWIND加参数批次一条语句写一批比循环发一千条语句快两个数量级// $rows 是一批节点批量 MERGE靠唯一约束保证幂等 UNWIND $rows AS row MERGE (n:OsmNode {id: row.id}) SET n.location point({latitude: row.lat, longitude: row.lon})边写入同理但要先匹配两端节点。这里有个实操细节MATCH一批边涉及两倍的节点 ID如果这批边的端点分散匹配开销会很高。常见的优化是先把节点全量写完再统一写边让节点全部落在索引里边的MATCH就退化成索引命中。// 边批量写入两个 MATCH 都走唯一索引 UNWIND $rows AS row MATCH (a:OsmNode {id: row.from}) MATCH (b:OsmNode {id: row.to}) MERGE (a)-[r:ROAD {wayId: row.wayId}]-(b) SET r.distance row.distance, r.speedKph row.speedKph, r.highway row.highwaywayId放进关系属性里参与MERGE是为了让同一条 way 拆出的多段边互不覆盖同时保证重跑导入时不会产生重复边。整个导入流程设计成可重复执行很重要——第一次导入往往会因为过滤规则不对而重来三四次如果每次都要先清库那时间全浪费在建索引上了。4. 路由核心吸附、最短路与路径还原的三段式实现4.1 最近路网节点吸附把任意经纬度落到图上用户输入的起终点大概率不在路上可能在小区的某个角落甚至在楼顶。图算法只认节点 ID所以第一步是把坐标吸附到最近的路网节点上。这一步靠点索引配合point.distance完成写法简单但不能省ORDER BY和LIMIT// 找离输入坐标最近的路网节点d 是球面距离米 MATCH (n:OsmNode) WITH n, point.distance(n.location, point({latitude: $lat, longitude: $lon})) AS d ORDER BY d ASC LIMIT 1 RETURN n.id AS nodeId, d AS distancepoint.distance计算的是球面距离单位米直接用就行不用再自己算 Haversine。返回的distance要拿去做上限判断超过配置里的snap-max-meters就直接返回「附近没有路网」而不是硬算一条几十公里外的路线。这个判断看着多余实际能挡掉大量脏输入比如前端把经纬度顺序写反了变成纬度 120、经度 30吸附结果会跑到地图边缘去。两个端点都要做吸附而且是两次独立查询。有人会想把它们塞进一条 Cypher 里用UNION性能上没差别但可读性差很多出错时也不好定位是起点还是终点的问题。4.2 图投影与 Dijkstra 调用Neo4j 的最短路算法有两套用法一是用过程库里的算法直接跑二是用图数据科学库GDS先把子图投影到内存再算。前者省事但每次都要遍历存储后者需要管理投影的生命周期但重复查询快得多。这个项目选后者因为路由服务的调用频率远高于数据变更频率投影一次能服务很久。投影在服务启动时创建节点标签和关系类型都显式指定权重属性也提前声明// 把路网投影成内存图后续算法直接跑在内存里 CALL gds.graph.project( roadGraph, OsmNode, { ROAD: { properties: [distance, speedKph] } } )投影名roadGraph是后续所有调用的句柄。节点投影只带标签不带属性因为算法用不到标签之外的东西关系上保留distance和speedKph两个属性是为了支持按距离和按时间两种权重切换。这里要注意投影是快照导入新数据之后必须显式 drop 再重建否则算出来的还是旧路网。Java 侧的调用就是一段带参数的 Cypher把源节点和目标节点传进去public RouteResult shortestPath(long sourceId, long targetId, String weightProperty) { // 权重属性白名单校验防止把用户输入直接拼进 Cypher if (!Set.of(distance, speedKph).contains(weightProperty)) { throw new IllegalArgumentException(unsupported weight: weightProperty); } String cypher MATCH (source:OsmNode {id: $sourceId}) MATCH (target:OsmNode {id: $targetId}) CALL gds.shortestPath.dijkstra.stream(roadGraph, { sourceNode: source, targetNode: target, relationshipWeightProperty: $weight }) YIELD nodeIds, costs, path RETURN nodeIds, costs[-1] AS totalCost, [n IN nodes(path) | { lat: n.location.latitude, lon: n.location.longitude }] AS coords ; try (Session session driver.session(SessionConfig.forDatabase(osm))) { return session.executeRead(tx - tx.run(cypher, Map.of( sourceId, sourceId, targetId, targetId, weight, weightProperty)) .single(r - new RouteResult( r.get(nodeIds).asList(Value::asLong), r.get(totalCost).asDouble(), r.get(coords).asList(v - new Coord( v.get(lat).asDouble(), v.get(lon).asDouble()))))); } }relationshipWeightProperty只能传预先投影过的属性名所以这里用参数绑定加白名单双重限制不能把前端传来的字符串直接拼到语句里。costs[-1]取的是路径最后一段的累计代价也就是总距离或总耗时。nodeIds和coords一起返回前者用于排查和二次计算后者直接给前端画线。按时间做权重时代价不是distance除以速度那么简单——GDS 的权重属性必须是边上已有的数值。所以投影的时候要么提前写一个duration属性distance / speedKph * 3.6要么就用distance。这个取舍取决于你的数据里maxspeed的完整度完整度低的时候用时间权重反而不如用距离靠谱。4.3 对外接口与响应结构Web 层只做参数校验和结果包装不掺业务逻辑。接口设计成 GET参数是四个浮点数返回里带上吸附距离和总代价方便调用方判断结果可信度。RestController RequestMapping(/api) public class RouteController { private final RouteService routeService; GetMapping(/route) public RouteResponse route(RequestParam double fromLat, RequestParam double fromLon, RequestParam double toLat, RequestParam double toLon, RequestParam(defaultValue distance) String weight) { // 吸附半径由 service 内部按配置判断超限直接抛业务异常 return routeService.route(fromLat, fromLon, toLat, toLon, weight); } }响应对象里至少要有coords坐标数组、distanceMeters按距离算的总长、weightUsed本次用的权重、snapMeters两端吸附距离的最大值。snapMeters这个字段看起来是内部细节实际很值钱——前端拿到 800 米的吸附距离就该提示用户「起点不在路网附近」而不是闷头画一条线。返回结构做好了后面接调度、接可视化都省事。5. 避坑排查导入卡死、路径穿楼和服务超时的现场记录5.1 现象导入跑到一半堆内存直接打满进程被系统杀掉原因几乎总是写库批次太大。一条UNWIND语句带五万个节点driver 会把参数序列化成一大块消息服务端解析时又要构造五万个临时对象堆内存瞬间翻倍。再加上MERGE在唯一约束还没生效时会退化成扫描时间一长事务日志也跟着涨。解决办法是把批次压到 1000 到 5000 之间并且用apoc.periodic.iterate把整个导入过程切成可重试的小事务// 分批提交每批 2000 条失败自动重试 3 次 CALL apoc.periodic.iterate( UNWIND $rows AS row RETURN row, MERGE (n:OsmNode {id: row.id}) SET n.location point({latitude: row.lat, longitude: row.lon}), {batchSize: 2000, parallel: false, retry: 3} )batchSize不要盲目调大parallel单机导入时保持 false并行写入反而会因为锁竞争变慢。导入前顺手把唯一约束确认一遍这是最容易被跳过、代价却最大的一步。5.2 现象算出来的路径穿过小区、公园甚至水面原因是道路白名单放太宽或者没处理access、footway这类限制标签。更隐蔽的一种是 way 的节点顺序在某些数据里是反的导致单行方向判断错了算法从逆行方向穿过去。排查方法很笨但有效把路径经过的边的wayId打出来去原始数据里查这几条 way 的标签。十有八九能看到highwayfootway或者accessprivate。解决办法是在导入阶段就把这些标签过滤掉不要留到查询阶段再处理因为图已经建好了改数据的成本远高于改过滤规则。单行方向的问题则要在拆分阶段多打日志抽样几条高速 way 核对方向。5.3 现象改了数据、重跑导入接口返回的还是老路径这是 GDS 图投影的缓存问题。投影一旦创建就固定在内存里底层图数据变了它不会自动刷新。表现是明明删了几条边算法还是从那走。解决就是显式管理投影生命周期在导入命令结束时清掉旧投影服务启动时如果发现投影不存在再重建CALL gds.graph.exists(roadGraph) YIELD exists WITH exists WHERE exists CALL gds.graph.drop(roadGraph) YIELD graphName RETURN graphName更稳妥的做法是给投影名带上数据版本号比如roadGraph_v3查询侧从配置里读版本号拼接。这样新老数据可以共存切换就是改一行配置出问题能立刻切回去。5.4 现象单次查询几百毫秒稍微并发就大面积超时原因通常不在算法而在连接池和会话管理。每次请求都新建 driver、新建 session握手开销比算法本身还大。另一个高频原因是把executeRead写成了executeWrite白白让请求走了一遍写事务的排队流程。正确的做法是 driver 全局单例由容器管理生命周期session 用完必须关用 try-with-resources 包住只读查询一律走executeRead。连接池大小要跟查询耗时匹配池子开太大只会把数据库压垮一般给到 CPU 核数的两倍左右就够。5.5 现象终点就在起点旁边接口却返回「无路径」这种多半是吸附到了两个不连通的子图。OSM 数据里岛屿、封闭小区、施工路段都会产生孤岛吸附点落在孤岛上算法自然找不到路。处理办法分两层一是吸附时取最近的 K 个候选节点而不是一个逐个尝试直到找到连通路径二是在返回里区分「吸附失败」和「图不连通」两种错误码让调用方能做不同处理。前者会多一点查询开销但对用户体验的提升很明显尤其是起终点都在大型小区附近的时候。6. 二次开发换权重、加约束和一套能保命的回归方法拿到这份代码之后最常改的地方是权重函数。默认按距离算改成交通时间只需要在投影前给边补一个duration属性然后在算法调用时把relationshipWeightProperty换成它。补属性的语句不复杂但要注意单位统一// 用距离和限速估算通行秒数除零兜底按 30km/h MATCH ()-[r:ROAD]-() SET r.duration CASE WHEN r.speedKph IS NULL OR r.speedKph 0 THEN r.distance / (30.0 / 3.6) ELSE r.distance / (toFloat(r.speedKph) / 3.6) ENDduration的单位是秒speedKph是公里每小时换算成米每秒要除以 3.6这个换算写错会让所有路径的时间估计差出三倍多而且因为路径形状看起来正常很难第一时间发现。第二个常见改造是转向限制。路网图本身只有节点和边表达不了「这个路口不能左转」。可行的办法是把节点拆成「入边节点」和「出边节点」在两个节点之间按转向类型建一条虚拟边把惩罚加在虚拟边上。这个改造会让节点数翻倍导入时间明显变长属于典型的用空间换表达能力做之前先确认业务真的需要。第三个是批量算路。调度场景往往一次要算几百个 OD 对逐个发请求会把连接池打满。这时可以把源点和终点列表传给服务循环里复用同一个 session并且把吸附结果缓存起来——同一批任务里起终点重复率很高缓存命中率比我预想的好很多。改完之后怎么验证我的一般做法是准备一份固定的回归集两百条左右的 OD 对覆盖跨区、短途、跨江、单行道密集区这几种情况把每次的路径长度、节点数、耗时存成基线文件。改任何参数之后重跑一遍做 diff。有一次我把单行道的判断条件从yes.equals(...)改成加上高速默认单行基线里有十二条路径长度直接短了 40%一查才发现之前的高速返程一直在绕远路。要是没有这份基线这种改动根本没人会注意到等上线被投诉了才知道。这就是那条血泪经验图数据库的查询结果没有「报错」这个中间态它对不对都能返回一条路径长得还挺像那么回事。所以每次动权重、动过滤规则、动投影我都强制先跑一遍那两百条基线确认差异都在预期之内再往下走。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询