简介: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> <groupId>org.neo4j.driver</groupId> <artifactId>neo4j-java-driver</artifactId> <version>${neo4j-driver.version}</version> </dependency> <!-- Web 层 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- PBF 读取,只用其中的读取组件,不引整套 --> <dependency> <groupId>org.openstreetmap.osmosis</groupId> <artifactId>osmosis-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/highway=motorway,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 List<EdgeRow> segment(long wayId, List<Long> nodeIds, Map<String, 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); // 缺失时按等级兜底 List<EdgeRow> 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 的标签。十有八九能看到highway=footway或者access=private。解决办法是在导入阶段就把这些标签过滤掉,不要留到查询阶段再处理,因为图已经建好了,改数据的成本远高于改过滤规则。单行方向的问题则要在拆分阶段多打日志,抽样几条高速 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%,一查才发现之前的高速返程一直在绕远路。要是没有这份基线,这种改动根本没人会注意到,等上线被投诉了才知道。
这就是那条血泪经验:图数据库的查询结果没有「报错」这个中间态,它对不对都能返回一条路径,长得还挺像那么回事。所以每次动权重、动过滤规则、动投影,我都强制先跑一遍那两百条基线,确认差异都在预期之内再往下走。希望帮到你。
本文还有配套的精品资源,点击获取