OSM路网入Neo4j:Java路由服务的Dijkstra/A*算路与接口封装
简介:Neo4jOSM 是一个以 Java 实现的轻量级路线规划示例项目,面向希望将图数据库与开放地理数据结合的开发者与学习者,重点解决从 OpenStreetMap 路网数据到 Neo4j 图模型、再到路径查询的基础实践问题。压缩包共 35 个文件,约 314KB,包含 22 个 Java 源文件、3 个 OSM 数据文件,以及 properties 配置、Markdown 说明、Gradle 构建脚本和 wrapper 等,覆盖数据导入、图构建、Cypher 查询与接口示例等环节。已有 171 人学习或下载。项目结构便于读者理解 OSM 元素如何映射为节点和关系,并借助 Java 驱动完成图数据库操作;也可作为学习 Neo4j 空间数据处理、路线规划建模和 Gradle 工程组织的参考案例。
1. 从 OSM 路网到 Neo4j 路由服务:Java 项目能跑通哪条链路
Java 写的路由服务不稀奇,稀奇的是把 OpenStreetMap 路网直接落进 Neo4j,再用图查询算出一条从起点到终点的路线。Neo4jOSM 这个资源就是围绕这条链路做的:OSM 数据解析、节点和边建模、Java 侧最短路查询、HTTP 接口封装。它适合做课程设计、内部原型、算法验证,不适合直接扛生产级导航。读下去你会看到导入脚本、路由内核和服务层怎么接,以及几个让结果偏航的坑。
2. 数据落图:OSM 节点关系映射与批量导入的四个参数
2.1 为什么路网更适合属性图而不是纯邻接表
路网的本质是带权有向图:交叉口是节点,路段是边,边权可以是距离、耗时、通行费或者转向惩罚。用邻接表也能跑 Dijkstra,课程作业里一个Map<Long, List<Edge>>就够。但当需求从“算一条路”变成“按道路等级过滤、按单行限制、按区域裁剪、按时间窗更新”时,纯内存邻接表就要自己维护一堆索引和过滤逻辑,改一次权重模型就要重新加载整张图。
属性图的优势在于把拓扑和属性放在同一层。OsmNode存 OSM 节点 id、经纬度,ROAD关系存 way id、长度、道路等级、单行标记、速度。查询时先用唯一约束把起点和终点定位到节点,再沿着ROAD关系扩展。需要看某条路为什么被选中,直接把路径上的关系属性拉出来,排查成本比翻日志低。Neo4j 的 Cypher 对多跳查询友好,原型阶段用几十行就能验证路网是否连通。
边界也很清楚:Neo4j 不是为全图最短路设计的计算引擎。把几百万条边一次性读进 Java 堆内存做 A*,内存和 GC 会教做人;用 Cypher 的shortestPath跑大图,深度一大就容易超时。常见做法是分层:小范围验证用 Cypher,正式算路用 Java 内存图,Neo4j 负责存储、索引和增量更新。这个资源把两层都摆出来了,读的时候别把“能跑通”误当成“能扛生产”。
还有一个选型理由被很多人忽略:OSM 数据本身是分层的。node 是点,way 是折线,relation 是关系。路网导入时通常只处理 node 和 way,把 way 拆成相邻 node 对,relation 里的公交线路、行政区划先不碰。属性图刚好能表达“一条 way 拆成多条边”的结果,边上的wayId保留原始来源,后续按 way 回查、按道路等级过滤都方便。邻接表当然也能加字段,但当你需要给边加多个标签、多个索引、多个约束时,图数据库的 schema 管理更省事。
2.2 OSM 元素到图元素的字段映射
导入前先定一张映射表,不然后面 Cypher 里属性名大小写不一致,索引直接失效。下面这张表是这类项目里最常见的映射,字段名可以按自己习惯改,但一旦写进代码就不要来回换。
| OSM 元素 | 图元素 | 属性/关系 | 说明 |
|---|---|---|---|
| node id | OsmNode.osmId | 唯一约束字段 | 起点终点定位用,必须是 long |
| node lat/lon | OsmNode.lat、OsmNode.lon | double | 统一存 WGS84,别混投影坐标 |
| way id | ROAD.wayId | long | 保留原始 way,便于回查 |
| way tags.highway | ROAD.highway | string | 道路等级,过滤和默认速度用 |
| way tags.oneway | ROAD.oneway | string/int | yes正向、-1反向、no双向 |
| way tags.maxspeed | ROAD.speedKph | double | 解析失败时按等级给默认值 |
| 相邻 node 对 | ROAD.lengthM | double | 用 Haversine 算球面距离,单位米 |
解析时只保留带highway标签的 way,过滤掉footway、cycleway、steps这类当前场景不关心的类型,除非你要做步行路由。常见做法是先按 bbox 裁剪,再流式解析,避免把整省数据一次性拉进内存。oneway=-1很容易被当成no,实际它表示只能从 way 的末节点驶向首节点,导入时要反向建边。maxspeed可能是"50"、"50 mph"、"RU:urban"这类混杂格式,解析不了就按道路等级兜底,别让一条异常 tag 把整批导入打断。
坐标顺序是另一个高频翻车点。OSM 的 XML 里是lat和lon两个属性,很多解析库返回的顺序也不一样。代码里统一约定:lat是纬度,lon是经度;写 Cypher 时point({latitude: n.lat, longitude: n.lon})。不要用x/y这种模糊命名,过两周回来看代码会分不清哪个是经度。长度计算用 Haversine,别用平面欧氏距离,尤其跨纬度跨度大的模拟数据,误差会直接影响权重排序。
2.3 Java 批量导入骨架:先节点后关系
导入顺序必须是先节点后关系。关系两端的OsmNode不存在,MATCH会直接丢掉这一行,而且不报错,最后表现为“路网少了很多边”。下面这段代码是批量写入关系的骨架,节点写入同理,把CREATE (a)-[:ROAD...]换成CREATE (n:OsmNode {...})即可。
public void importEdges(Driver driver, List<EdgeRow> edges) { String cypher = """ UNWIND $rows AS row MATCH (a:OsmNode {osmId: row.from}) MATCH (b:OsmNode {osmId: row.to}) CREATE (a)-[:ROAD { wayId: row.wayId, lengthM: row.lengthM, highway: row.highway, oneway: row.oneway, speedKph: row.speedKph }]->(b) """; try (var session = driver.session(SessionConfig.builder() .withDefaultAccessMode(AccessMode.WRITE) .withFetchSize(1000) .build())) { // 每批 500 到 2000 行,太大容易把事务内存打满 session.executeWrite(tx -> tx.run(cypher, Map.of("rows", edges)).consume()); } }这段代码做三件事:用UNWIND把一批边展开成行,用MATCH把两端的 OSM 节点找出来,用CREATE建带属性的有向关系。Map.of("rows", edges)里的edges是List<EdgeRow>,每个元素至少包含from、to、wayId、lengthM、highway、oneway、speedKph七个字段。withFetchSize(1000)控制驱动从服务端拉取结果的批量大小,跟写入批次不是一回事,别混。
参数怎么设:edges每批 500 到 2000 行是常见起点,具体看单行属性大小和 JVM 堆。lengthM用双精度,别用 float,累计路径长度时误差会放大。speedKph默认值按道路等级给,比如高速 90、主干 60、次干 40、支路 30,这个表放在配置文件里,后面调权重不用改代码。oneway字段保留原始值,查询时再判断,不要在导入阶段把双向边拆成两条后丢掉原始标记,否则回查 OSM 来源时对不上。
提示:导入前先用小 bbox 跑 1000 条边,检查起点终点是否都能
MATCH到。空库直接灌大文件,最后发现边数只有节点数的十分之一,回头查很痛苦。
2.4 索引与约束:让起点查询不扫全图
节点表如果没有唯一约束,MATCH (n:OsmNode {osmId: $id})会退化成全表扫描。几万个节点还能忍,几百万个节点就是秒级和毫秒级的差别。约束和索引在空库阶段建好,导入完成后再检查一遍。
CREATE CONSTRAINT osm_node_id IF NOT EXISTS FOR (n:OsmNode) REQUIRE n.osmId IS UNIQUE; CREATE INDEX road_way_id IF NOT EXISTS FOR ()-[r:ROAD]-() ON (r.wayId);第一条给OsmNode.osmId加唯一约束,同时附带索引,用来定位起点和终点。第二条给ROAD.wayId建关系索引,按原始 way 回查、统计某条 way 拆了多少段时用。属性名必须和代码里完全一致,osmId和osmid在 Neo4j 里是两个属性,索引不会生效。Cypher 里也不要把索引属性包在函数里,WHERE toLower(n.osmId) = ...这种写法会让索引失效。
批量导入时有个取舍:先建约束再导入,唯一性有保障,但每插一个节点都要维护索引,导入速度会慢;先导入再建约束,速度快,但数据里如果有重复osmId,建约束会失败。常见做法是空库先建约束,或者导入前在 Java 侧用Set去重,导入完成后再建关系索引。无论哪种,导入后跑一次CALL db.indexes()看索引状态,别假设“我写了就一定生效”。
3. 路由内核:Java 实现 Dijkstra/A* 的权重与剪枝
3.1 权重模型:距离、耗时与转向代价
最短路不是“最短距离”的同义词。导航场景里,用户要的是最快路线,权重应该是耗时;物流场景可能还要加收费道路惩罚、转向惩罚、掉头惩罚。这个资源的基础权重可以写成:
weight = lengthM / (speedKph / 3.6) + turnPenaltylengthM是路段长度,speedKph是路段速度,除以 3.6 换成米每秒,得到通行秒数。turnPenalty是转向代价,直行 0 秒,左转 15 秒,右转 8 秒,掉头 60 秒,具体值按场景调。很多简单实现只累加距离,结果算出来的路线全是小路,因为小路距离短,但速度慢、红绿灯多,实际耗时更长。
| 权重项 | 建议起点 | 作用 | 调整信号 |
|---|---|---|---|
lengthM | Haversine 计算 | 基础几何长度 | 路径明显绕远时检查 |
speedKph | 按道路等级 | 把距离换成时间 | 高速路线偏多时下调支路速度 |
turnPenalty | 直行 0、左转 15、掉头 60 | 惩罚复杂转向 | 路线频繁掉头时加大 |
oneway | 导入时定方向 | 禁止逆行 | 返程路线异常时优先查 |
参数没有绝对标准。做原型时先用“距离 + 等级速度”跑通,再逐步加转向惩罚。每加一个权重项,拿固定 OD 对跑一遍,看路径是否合理,别一次加五个参数,最后不知道是哪个参数把路线带偏的。
3.2 用 Cypher 还是 Java 内存图
Cypher 的shortestPath适合快速验证连通性,不适合带复杂权重的正式算路。下面这段 Cypher 能查出起点到终点的一条路径,但它按跳数走,不按权重走,而且方向必须写清楚:
MATCH (s:OsmNode {osmId: $start}), (t:OsmNode {osmId: $end}) MATCH p = shortestPath((s)-[:ROAD*..2000]->(t)) RETURN p[:ROAD*..2000]里的2000是最大跳数,写太大容易把查询拖死,写太小长距离路线直接查不到。->表示有向扩展,能避开逆行,但shortestPath不认lengthM和speedKph,它只保证跳数最少。用来做“两点之间通不通”的冒烟测试可以,用来做导航算路会翻车。
Java 内存图的优势是可控:你可以用优先队列、可以提前终止、可以限制访问节点数、可以加转向状态。代价是启动时要加载图,图越大加载越慢。常见做法是服务启动时把OsmNode和ROAD流式读进邻接表,只加载当前服务区域,不要全量加载。如果图会频繁更新,就用“内存图 + 版本号”的方式,更新后重建,请求带版本号路由到对应实例,别在请求线程里改图。
3.3 Dijkstra 的可运行实现与剪枝参数
下面这段 Dijkstra 是单源最短路的核心,边权必须非负。maxVisited是剪枝参数,防止在不可达或大图上无限扩展。prev用来回溯路径,找不到终点时返回不可达,不要返回空列表让上层猜。
public Route shortestPath(long start, long end, int maxVisited) { Map<Long, Double> dist = new HashMap<>(); Map<Long, Long> prev = new HashMap<>(); PriorityQueue<NodeDist> pq = new PriorityQueue<>( Comparator.comparingDouble(n -> n.dist)); dist.put(start, 0.0); pq.add(new NodeDist(start, 0.0)); int visited = 0; while (!pq.isEmpty()) { NodeDist cur = pq.poll(); // 过期堆元素,跳过 if (cur.dist > dist.getOrDefault(cur.id, Double.MAX_VALUE)) continue; if (cur.id == end) break; if (++visited > maxVisited) { throw new IllegalStateException("visited limit exceeded"); } for (Edge e : adj.getOrDefault(cur.id, List.of())) { double nd = cur.dist + e.weight(); if (nd < dist.getOrDefault(e.to(), Double.MAX_VALUE)) { dist.put(e.to(), nd); prev.put(e.to(), cur.id); pq.add(new NodeDist(e.to(), nd)); } } } if (!dist.containsKey(end)) return Route.unreachable(); return Route.fromPrev(start, end, prev, dist.get(end)); }maxVisited设多少要看图规模。城市级路网单次算路访问几万到几十万节点都正常,先设 20 万,超了就返回“算路超时”,别让请求线程卡死。adj是Map<Long, List<Edge>>,启动时加载。Edge.weight()返回前面算好的秒数,不要在这里临时查数据库,否则每次松弛都走一次网络,性能直接崩。dist用HashMap在百万级节点上会有装箱开销,熟手可以换原生 long/double 的哈希表,但先跑通再优化。
注意:Dijkstra 只保证非负权正确。如果你把转向惩罚写成负数来“奖励”某类道路,算法结果不可信,换 Bellman-Ford 或把奖励改成惩罚。
3.4 A* 启发式与双向搜索的取舍
A* 比 Dijkstra 快,前提是启发函数h不能高估真实代价。路网场景常用“直线距离除以最大速度”作为时间下界:
private double heuristic(long node, long target) { // 直线距离是路网距离的下界,除以最大速度是时间的下界 double straightM = haversine(node, target); return straightM / MAX_SPEED_MPS; }MAX_SPEED_MPS取整个路网允许的最高速度,比如 120 km/h 换成 33.3 m/s。这样h不会超过实际最短时间,A* 仍然最优。如果你把h乘一个大于 1 的系数,搜索会更快,但可能返回次优路径,看业务能不能接受。双向搜索适合长距离算路,从起点和终点同时扩展,相遇即停,但转向惩罚和单行限制会让“相遇条件”变复杂,原型阶段先用单向 A*,别一上来就上双向。
常见误用是拿欧氏距离当h却不除以速度,或者拿“平均速度”当最大速度。前者量纲不对,后者可能高估,导致路径不是最优。每次改启发函数,拿固定 OD 对和 Dijkstra 结果对比总权重,差超过 1% 就要查。
4. 服务封装:把算路暴露成 HTTP 接口的契约与连接池
4.1 接口契约与返回结构
路由服务对外的接口不需要花哨,GET /route?from={osmId}&to={osmId}就够原型用。返回 JSON 至少包含found、distanceM、durationS、path四个字段。path是 OSM 节点 id 数组,调用方可以拿去画线;durationS是权重累计值,不是真实导航时间,要在文档里写清楚。
| 字段 | 类型 | 说明 |
|---|---|---|
found | boolean | 是否找到路径 |
distanceM | double | 路径几何长度合计,米 |
durationS | double | 按权重模型累计的秒数 |
path | long[] | 依次经过的 OSM 节点 id |
error | string | 失败时的原因,成功时不返回 |
错误码要分开:参数缺失或不是数字返回 400,起点终点不存在返回 404,图未加载完成返回 503,算路超过maxVisited返回 504 或自定义错误码。不要所有失败都返回 500,调用方没法区分是它传错了还是服务挂了。
4.2 用 JDK HttpServer 暴露路由
原型阶段不必上重框架,JDK 自带的HttpServer足够。下面这段代码把/route绑到路由内核,参数解析、算路、JSON 返回一条龙:
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); server.createContext("/route", exchange -> { Map<String, String> q = parseQuery(exchange.getRequestURI().getRawQuery()); long from = Long.parseLong(q.getOrDefault("from", "0")); long to = Long.parseLong(q.getOrDefault("to", "0")); Route r = router.shortestPath(from, to, 200_000); byte[] body = toJson(r).getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8"); exchange.sendResponseHeaders(r.found() ? 200 : 404, body.length); exchange.getResponseBody().write(body); exchange.close(); }); server.setExecutor(Executors.newFixedThreadPool(8)); server.start();from和to是 OSM 节点 id,用long解析,解析失败要捕获NumberFormatException返回 400,上面为了短省略了 try-catch,实际代码别省。Executors.newFixedThreadPool(8)的线程数按 CPU 核数和算路耗时调,算路是 CPU 密集型,线程开太多只会互相抢核。200_000是单次算路允许访问的最大节点数,跟第 3 章的maxVisited对应,放在配置里,不要写死在 handler 里。
toJson可以用轻量 JSON 库,也可以手写字符串拼接,但手写要处理转义。返回体不要带异常堆栈,调用方不需要,日志里留一份即可。
4.3 连接池、事务与超时
Neo4j Java Driver 是线程安全的,整个服务共用一个Driver实例,不要每个请求GraphDatabase.driver(...)新建。每次新建会创建连接池和后台线程,请求一多就把连接和文件句柄耗尽,表现是服务跑几分钟后开始超时。正确做法是启动时建Driver,关闭时driver.close()。
| 参数 | 建议起点 | 作用 | 调整信号 |
|---|---|---|---|
maxConnectionPoolSize | 32 | 最大连接数 | 大量 503 时先看连接等待 |
connectionAcquisitionTimeout | 10s | 拿连接的超时 | 超时说明池太小或查询太慢 |
maxTransactionRetryTime | 30s | 事务重试上限 | 写多读少时关注 |
connectionTimeout | 5s | 建连超时 | 网络抖动时避免线程卡死 |
读请求用executeRead,写请求用executeWrite,不要混。路由查询是只读,走读事务,Neo4j 可以路由到从库。导入是写事务,批量别超过事务内存。maxTransactionRetryTime不是越大越好,重试太久会把请求线程占住,前端看到的是“一直转圈”。
4.4 压测与结果校验
压测不要只看 QPS。先准备 20 到 50 对固定 OD,覆盖短途、中途、长途、单行密集区、不可达对。每次压测后校验:path首尾是否等于请求的 from/to;相邻节点之间是否存在ROAD关系;durationS是否等于路径边权之和;不可达对是否稳定返回 404。任何一条不满足,先修数据再调性能。
for i in $(seq 1 100); do curl -s "http://127.0.0.1:8080/route?from=1001&to=2002" \ -o /tmp/route_$i.json done这个脚本只验证接口不崩,不验证结果正确。正确性靠固定样例回归,性能靠 P50/P95 和错误率。压测时把maxVisited临时调小,观察超时比例,再逐步放开,找到当前图规模下的拐点。别拿一次压测数字当结论,图变了、权重变了,拐点也会变。
5. 避坑排查:五个把路由结果带偏的路网细节
5.1 单向道路被当成双向
现象:去程路线正常,返程路线出现逆行,或者返程直接 404。原因通常有两个:导入时把oneway=yes的 way 建成了双向关系,或者查询时用了无向匹配-(...) -而不是有向->。OSM 的oneway=-1更隐蔽,它表示只能从 way 的末节点驶向首节点,如果只判断yes,这条路的反向会被漏掉。
解决:导入阶段按oneway建边。yes只建正向,-1只建反向,no或缺失建双向。查询阶段坚持有向扩展,Cypher 里用->,Java 邻接表里只放允许通行的方向。回归样例里专门放一对“去程能走、返程不能走”的 OD,每次改导入逻辑先跑它。
5.2 坐标顺序与精度
现象:起点匹配到错误节点,路线从地图外开始,或者两条很近的路权重差出好几倍。原因常见于lat/lon顺序颠倒,或者用float存经纬度导致精度损失。OSM 的纬度范围是 -90 到 90,经度是 -180 到 180,写反了不一定报错,但 Haversine 算出来的距离会离谱。
解决:统一用double存经纬度,字段名写全lat、lon,不要用x、y。导入前抽样 100 个节点,检查lat是否在 -90 到 90、lon是否在 -180 到 180。长度计算用 Haversine,半径取 6371000 米。如果后续要接空间索引,再统一转point,不要在代码里混用两套坐标表示。
5.3 大图内存溢出
现象:导入中途OutOfMemoryError,或者服务启动加载图时 GC 飙升、响应变慢。原因通常是一次性把整个 PBF 解析成List,或者批量写入批次太大,事务内存扛不住。A* 的dist和prev两个 map 在大图上也会吃内存。
解决:解析用流式,边读边写,不要全量缓存。写入批次从 500 开始试,观察堆和事务日志,再往上加。服务端加载图时只加载当前 bbox 或当前连通分量,别全量灌。JVM 堆按数据量留余量,-Xmx不是越大越好,堆太大 Full GC 一次停更久。压测时加-Xlog:gc看停顿,别等线上报警。
5.4 Cypher 参数化与索引失效
现象:同样的查询,有时几毫秒,有时几秒;EXPLAIN里出现AllNodesScan。原因可能是属性名大小写不一致、索引没建、或者把索引属性包在函数里。Cypher 里{osmId: $start}和{osmid: $start}是两个属性,后者没有约束,自然扫全表。
解决:所有用户输入走参数,不要拼字符串。用EXPLAIN或PROFILE看执行计划,出现AllNodesScan就先查约束和索引。索引属性不要做类型转换,toString(n.osmId) = $id会让索引失效。批量导入后跑一次CALL db.indexes(),确认约束和索引都是ONLINE。
5.5 路网连通分量导致无路可走
现象:两个点直线距离很近,接口却返回 404;或者路径绕一大圈。原因可能是 bbox 裁剪把连接道路切断了,桥、隧道、轮渡被过滤掉,或者数据里本来就有多个连通分量。OSM 数据不是天然连通的,岛屿、封闭小区、施工路段都会造成断头。
解决:导入后跑一次连通分量标记,给每个节点打上componentId,算路前先判断起点终点是否在同一分量,不在就直接返回“无路可走”,别让 Dijkstra 白跑。对裁剪导致的断头路,可以在边界处加虚拟边,或者把服务范围外扩一圈再裁。回归样例里放一对“很近但不可达”的 OD,防止后续优化把 404 变成错误路径。
6. 进阶验证:等时圈、批量算路与回归样例
6.1 等时圈:从单条路径到可达范围
单条最短路只能验证一条线,等时圈能验证一片区域。做法是限制最大累计权重,跑一次 Dijkstra,把dist <= limit的节点全部返回,前端按经纬度画多边形。下面这段代码把 10 分钟可达范围拉出来,参数600.0是秒:
Set<Long> reachable = router.reachableWithin(start, 600.0);reachableWithin和shortestPath共用邻接表和权重,区别是不提前终止,直到堆空或超过限制。等时圈形状突然变成一条细线,通常说明单行或连通分量有问题;形状突然膨胀,通常是权重单位错了,把米当成秒。
6.2 批量算路与结果缓存
固定 OD 对回归是改权重后的后悔药。准备一张表,每次改模型跑一遍,对比总权重和路径节点序列。缓存 key 用from:to:weightVersion,权重版本不变才命中,变了自动失效。不要用from:to做 key,否则调了参数还在返回旧路径。
| 样例 | 类型 | 校验点 |
|---|---|---|
| S1 | 短途 | 路径不超过 10 个节点,方向合法 |
| S2 | 长途 | 总权重与 Dijkstra 基线误差小于 1% |
| S3 | 单行密集 | 返程不出现逆行边 |
| S4 | 不可达 | 稳定返回 404,不抛异常 |
6.3 回归样例与发布前检查
发布前跑三件事:导入小 bbox 数据,确认节点数和边数比例正常;跑固定 OD 对,确认路径首尾和总权重;跑一次等时圈,确认形状没有突变。任何一步失败,先回滚权重版本,再查数据。资源入口按需取用,拿到后先跑导入,再跑回归,别一上来就调 A*。
从那以后我每次改权重模型,都会先跑一遍固定 OD 对回归,再看等时圈是否变形,最后才看 QPS。希望帮到你。
本文还有配套的精品资源,点击获取