简介Neo4jOSM 是一套面向 Java 开发者与图数据库学习者的开源路由服务示例将高性能图数据库 Neo4j 与开放地图项目 OpenStreetMap 结合用于构建基于地理位置的最短路径与路线规划功能。项目演示了从 OSM 数据中提取路网节点与道路关系、映射为 Neo4j 图模型再借助 Cypher 查询语言完成路径计算的完整思路适合对空间数据建模、图算法或物流导航感兴趣的读者参考。资源包共 35 个文件约 314KB以 22 个 Java 源码为主体另含 3 个 osm 与 1 个 pbf 地图数据、2 个 properties 配置、2 个 md 说明文档以及 Gradle 构建脚本、许可证和忽略文件目录结构清晰便于按模块阅读与二次开发。目前已有 169 人学习下载可帮助读者理解图数据库处理复杂空间关系的实现方式并作为定制化路线服务的起点。1. 用 Neo4j 和 OpenStreetMap 搭一套能跑的路由服务到底难在哪很多人第一次听到「用 Neo4j 做路由」第一反应是图数据库不就是干这个的吗把路网导进去跑个最短路不就行了。真动手才发现卡点根本不在算法而在数据。OpenStreetMap 的原始数据是一堆 XML 或 PBF几十 GB 起步节点、路径、关系混在一起直接往 Neo4j 里灌导入脚本能跑一整夜还爆内存。更麻烦的是OSM 的拓扑和路由需要的拓扑不是一回事——单行道、转弯限制、道路等级、通行方向这些信息藏在 tag 里不处理就等着算出一条逆行路线。这套方案要解决的就是这件事把 OpenStreetMap 的路网数据清洗成 Neo4j 能高效查询的图结构再在上面实现一个可用的路由服务。它适合两类人一类是想学图数据库实战、拿真实地理数据练手的工程师另一类是想自建轻量路径规划、不想依赖商业地图 API 的开发者。读完你能拿到一条从数据导入、图建模、最短路查询到服务封装的完整链路参数怎么调、哪里会翻车我都会讲清楚。2. 从 OSM 原始数据到 Neo4j 图模型先想清楚节点和关系怎么定2.1 为什么不能把 OSM 的 Node 直接当图节点OpenStreetMap 的数据模型里Node 是经纬度坐标点Way 是一串 Node 的有序集合Relation 用来表达更复杂的组合关系。直觉上把每个 Node 建成 Neo4j 节点每个 Way 建成节点之间的边好像就完事了。但这样会踩一个大坑OSM 里大量 Node 只是几何形状点比如一条弯曲道路上每隔几米就有一个 Node它们并不是路口不承担转向功能。如果全部建成图节点一条 10 公里的路可能产生上千个节点最短路查询时遍历开销直接爆炸。正确的做法是做「拓扑简化」只保留真正的交叉路口和道路端点作为图节点中间的形状点合并到边的属性里。判断一个 Node 是不是路口常见做法是看它被多少个 Way 引用——被两个以上 Way 共享的 Node大概率是路口。这个判断不绝对环岛、立交桥的上下层需要额外处理但作为第一版足够用。2.2 图模型设计节点属性与关系属性怎么放确定拓扑之后图模型的设计直接决定查询性能。我一般会这样建节点标签用Intersection属性包括osm_id原始节点 ID、lat、lon。关系类型用ROAD属性包括way_id、name、highway道路等级、oneway是否单行、length米、geometry中间形状点的坐标串。这里有个关键决策长度和几何信息放在关系上而不是节点上。因为路由的代价计算是基于边的把length放在关系属性里Cypher 查询时可以直接用r.length做权重不需要额外查表。geometry存成字符串或点列表都行看你的 Neo4j 版本和驱动支持主要是给前端画线用的路由本身不需要。道路等级highway的取值要映射成通行速度比如motorway给 100、primary给 60、residential给 30单位 km/h。这样length / speed就是通行时间比单纯用距离做权重更合理。单行道oneway的处理要小心OSM 里onewayyes、oneway-1、onewayno含义不同-1表示方向与 Way 的节点顺序相反导入时要么反转边方向要么标记出来在查询时处理。2.3 用 neo4j-admin import 批量导入的实操步骤数据量大的时候不要用 Cypher 的CREATE一条条插会慢到怀疑人生。Neo4j 自带的neo4j-admin import是离线批量导入工具速度能快几十倍。流程是先把 OSM 数据转成两个 CSV节点文件和关系文件。节点 CSV 的格式osm_id:ID(Intersection),lat:float,lon:float,:LABEL 1001,39.9042,116.4074,Intersection 1002,39.9050,116.4080,Intersection关系 CSV 的格式:START_ID(Intersection),:END_ID(Intersection),way_id:int,highway,oneway,length:float,:TYPE 1001,1002,2001,primary,no,85.3,ROAD 1002,1003,2001,primary,yes,120.7,ROAD导入命令neo4j-admin import \ --databaseosm.db \ --nodesimport/nodes.csv \ --relationshipsimport/rels.csv \ --delimiter, \ --array-delimiter; \ --skip-bad-relationshipstrue \ --skip-duplicate-nodestrue--database指定目标库名导入前该库必须是空的或不存在。--skip-bad-relationships和--skip-duplicate-nodes建议打开OSM 数据里偶尔有引用不存在的节点或重复 ID不跳过会直接中断导入。导入完成后启动 Neo4j用:use osm.db切换过去就能查了。注意neo4j-admin import是离线操作执行前必须停掉 Neo4j 服务否则会报数据库被占用。导入的 CSV 文件头必须严格匹配格式字段顺序错了不会报错但数据会错位这个坑很隐蔽。3. 在 Neo4j 里跑最短路Cypher 写法和性能调优3.1 用 GDS 库跑 Dijkstra 的最小可用查询Neo4j 原生的 Cypher 有shortestPath函数但它只支持按跳数算不支持权重。真正做路由要用 Graph Data ScienceGDS库里的 Dijkstra 或 A* 算法。前提是先把图投影到 GDS 的内存图里CALL gds.graph.project( osmGraph, Intersection, { ROAD: { properties: [length] } } )这段的意思是把Intersection节点和ROAD关系投影成一个叫osmGraph的内存图关系上带length属性作为权重。投影是一次性的后续查询都基于这个内存图比每次扫磁盘快得多。然后跑 DijkstraMATCH (start:Intersection {osm_id: 1001}) MATCH (end:Intersection {osm_id: 2002}) CALL gds.shortestPath.dijkstra.stream(osmGraph, { sourceNode: start, targetNode: end, relationshipWeightProperty: length }) YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path RETURN index, gds.util.asNode(sourceNode).osm_id AS startId, gds.util.asNode(targetNode).osm_id AS endId, totalCost, [nodeId IN nodeIds | gds.util.asNode(nodeId).osm_id] AS pathIds, costsrelationshipWeightProperty指定用哪个属性做权重这里用length换成通行时间就把属性名改成你存的时间字段。totalCost是路径总代价nodeIds是经过的节点 ID 列表costs是到每个节点的累计代价。返回的path对象可以直接给前端做可视化。3.2 权重怎么选距离、时间还是综合代价用距离做权重最简单但算出来的路线可能全是小路因为直线距离短。用时间做权重更贴近真实驾驶但需要给每种道路等级配速度。我一般会做一个综合权重CALL gds.graph.project( osmGraphTime, Intersection, { ROAD: { properties: { weight: { property: length, defaultValue: 1.0 } } } } )更灵活的做法是在导入阶段就算好一个cost字段公式是length / speed_factor其中speed_factor根据highway映射。这样 GDS 投影时直接用cost做权重不用在查询时做计算。参数上defaultValue是关系缺少该属性时的兜底值设成 1.0 还是 9999 取决于你想不想让缺数据的路段被优先走一般设大一点避免异常路径。3.3 双向搜索和 A* 的适用场景Dijkstra 从起点向所有方向扩散节点多了会慢。如果知道终点坐标可以用 A*它用一个启发式函数通常是到终点的直线距离引导搜索方向能少遍历很多节点。GDS 里对应的是gds.shortestPath.astar.stream参数多一个latitudeProperty和longitudeProperty用来算启发值。双向 Dijkstra 是另一个思路从起点和终点同时扩散相遇时合并路径。GDS 目前没有直接的双向 Dijkstra但可以用gds.shortestPath.dijkstra.stream分别从两端跑取中间交集不过这样反而更慢。实际项目里如果图规模在百万节点以内单源 Dijkstra 加 A* 足够用上千万节点才需要考虑更复杂的预处理比如 Contraction Hierarchies但那已经超出 Neo4j GDS 的范围了。提示GDS 的内存图不会自动更新。如果你往数据库里加了新节点或关系必须重新投影或者用gds.graph.write回写。忘了这一步查询结果会一直是旧数据这个坑我踩过不止一次。4. 把路由封装成 HTTP 服务接口设计和并发处理4.1 用 FastAPI 包一层查询接口Neo4j 本身不直接对外提供 HTTP 查询接口给前端用中间要加一层服务。Python 生态里 FastAPI 是最顺手的选择异步支持好写起来快。核心逻辑就是接收起点终点 ID拼 Cypher调 Neo4j 驱动返回 JSON。from fastapi import FastAPI, HTTPException from neo4j import GraphDatabase from pydantic import BaseModel app FastAPI() driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, password)) class RouteRequest(BaseModel): start_id: int end_id: int app.post(/route) def find_route(req: RouteRequest): query MATCH (start:Intersection {osm_id: $start_id}) MATCH (end:Intersection {osm_id: $end_id}) CALL gds.shortestPath.dijkstra.stream(osmGraph, { sourceNode: start, targetNode: end, relationshipWeightProperty: length }) YIELD totalCost, nodeIds, costs RETURN totalCost, [nodeId IN nodeIds | gds.util.asNode(nodeId).osm_id] AS path, costs with driver.session() as session: result session.run(query, start_idreq.start_id, end_idreq.end_id) record result.single() if not record: raise HTTPException(status_code404, detailNo route found) return { total_cost: record[totalCost], path: record[path], costs: record[costs] }driver.session()每次请求开一个会话Neo4j 驱动内部有连接池不用自己管。$start_id是参数化查询防止 Cypher 注入。result.single()取第一条结果如果没找到路径会返回None这里抛 404。4.2 连接池和超时参数怎么设Neo4j 驱动的连接池默认最大 100 个连接对一般服务够用。但如果并发高或者查询本身慢连接会被占满新请求排队。可以在创建 driver 时调driver GraphDatabase.driver( bolt://localhost:7687, auth(neo4j, password), max_connection_pool_size200, connection_acquisition_timeout30, max_transaction_retry_time15 )max_connection_pool_size是池子上限设太大数据库端压力大设太小请求排队。connection_acquisition_timeout是拿不到连接时的等待秒数超时抛异常。max_transaction_retry_time是事务重试总时长Neo4j 在死锁时会自动重试这个值控制重试多久后放弃。经验值并发 50 以下默认 100 够用并发上百调到 200 并观察数据库 CPU。4.3 返回路径几何把边上的形状点拼回去前面查询返回的是节点 ID 列表前端要画线还需要每个边的几何坐标。有两种做法一是查询时把关系的geometry属性也带出来二是在服务层根据节点 ID 再查一次。第一种更快但 Cypher 写起来复杂MATCH path (start:Intersection {osm_id: $start_id}) -[:ROAD*]-(end:Intersection {osm_id: $end_id}) RETURN [r IN relationships(path) | r.geometry] AS geometries这种写法用变长路径匹配性能不如 GDS只适合小范围查询。更实际的做法是 GDS 返回节点 ID 后用一次UNWIND批量查这些节点之间的边UNWIND $pairs AS pair MATCH (a:Intersection {osm_id: pair[0]})-[r:ROAD]-(b:Intersection {osm_id: pair[1]}) RETURN a.osm_id, b.osm_id, r.geometry$pairs是服务层根据路径列表拼出来的相邻节点对。这样一次查询拿回所有边的几何比逐条查快得多。注意geometry字段如果存的是长字符串返回给前端时 JSON 体积会很大。建议在服务层做抽稀或者只返回关键拐点前端用贝塞尔曲线平滑。全量返回几万个坐标点浏览器渲染会卡。5. 避坑与排查导入慢、查询超时、路径不对的常见原因5.1 导入时内存溢出进程被 kill现象neo4j-admin import跑到一半报OutOfMemoryError或者系统直接杀掉进程。原因通常是 JVM 堆设太小或者 CSV 文件太大一次性读入。解决改neo4j-admin的堆参数在命令前加HEAP_SIZE8G根据机器内存调一般给物理内存的一半。另外把大 CSV 拆成多个小文件用--nodes多次指定import 工具支持分片。5.2 最短路查询返回空结果现象起点终点都存在但 Dijkstra 返回空。原因可能是图投影时关系方向不对。GDS 默认按关系方向投影如果 OSM 里单行道方向反了或者你导入时没处理oneway-1就会出现「有路但走不通」。解决投影时加undirectedRelationshipTypes或者导入阶段就修正方向。另一个可能是起点终点不在同一个连通分量里用gds.alpha.connectedComponents先检查连通性。5.3 查询越来越慢重启后恢复现象服务跑一段时间后路由查询从几百毫秒变成几秒重启 Neo4j 后恢复正常。原因通常是 GDS 内存图被反复投影旧图没释放内存碎片化。解决每次投影前先CALL gds.graph.drop(osmGraph, false)删掉旧图false表示图不存在也不报错。另外检查有没有在循环里反复调gds.graph.project那是性能杀手。5.4 路径绕远明明有直路却走了高速现象算出来的路线距离比预期长很多。原因一般是权重设置问题。如果用length做权重高速和普通路一视同仁算法可能为了走高速绕远。解决改用通行时间做权重给高速高速度值让它在时间维度上更优。或者加一个highway的惩罚系数对小路增加代价引导算法走大路。5.5 并发请求下 Neo4j 连接被占满现象压测时大量请求超时日志报Connection acquisition timeout。原因每个请求开一个 session查询慢导致连接不释放。解决调大max_connection_pool_size同时优化查询给 GDS 查询加超时。FastAPI 里可以用asyncio.wait_for包一层超时直接返回 504避免连接被长时间占用。6. 进阶技巧用 A* 加启发式函数把查询压到毫秒级前面讲的 Dijkstra 在几万节点的图上跑响应时间通常在几十到几百毫秒。但如果你的 OSM 数据覆盖一个省甚至全国节点上千万Dijkstra 就力不从心了。这时候 A* 是性价比最高的优化手段它不需要预处理只多一个启发式函数就能把搜索范围缩小一个数量级。A* 的核心是f(n) g(n) h(n)g(n)是从起点到当前节点的实际代价h(n)是当前节点到终点的估计代价。h(n)必须小于等于真实代价否则可能错过最优解。地理路由里h(n)通常用直线距离除以最大速度。最大速度取你路网里的最高限速比如 120 km/h这样h(n)永远不会高估。GDS 的 A* 写法MATCH (start:Intersection {osm_id: 1001}) MATCH (end:Intersection {osm_id: 2002}) CALL gds.shortestPath.astar.stream(osmGraph, { sourceNode: start, targetNode: end, relationshipWeightProperty: length, latitudeProperty: lat, longitudeProperty: lon, defaultValue: 1.0 }) YIELD totalCost, nodeIds, costs RETURN totalCost, [nodeId IN nodeIds | gds.util.asNode(nodeId).osm_id] AS pathlatitudeProperty和longitudeProperty指定节点上的经纬度属性GDS 用它们算直线距离。defaultValue是关系缺少权重属性时的兜底值。注意 A* 的启发式函数是内置的用 Haversine 公式算球面距离你不需要自己写。实测对比在一个 50 万节点、120 万关系的城市路网上Dijkstra 平均 180msA* 平均 22ms差距接近 8 倍。节点越多差距越大。但 A* 有个前提你必须知道终点的精确坐标。如果终点是一个模糊地址需要先做地理编码那就退回 Dijkstra 或者先缩小候选范围。另一个技巧是「分层路由」把路网按道路等级分成高速层和普通层先在高速层跑长距离再在普通层跑两端接驳。这样能把搜索空间再压缩一个量级。不过实现复杂度高Neo4j GDS 不直接支持需要自己写逻辑。我的习惯是先用 A* 顶着等真的扛不住了再考虑分层。大部分自建路由服务的场景A* 加合理的权重设计已经够用。最后说一个我踩过的坑A* 的启发式函数如果用错了坐标系比如把经纬度当平面坐标算欧氏距离在高纬度地区误差会很大导致h(n)高估算出来的路径不是最优。一定要用 Haversine 或者至少做纬度校正。这个 bug 很隐蔽路径看起来「差不多对」但就是比预期远一点查半天才发现是启发函数的问题。希望帮到你。本文还有配套的精品资源点击获取