简介:Neo4jOSM 是一份面向 Java 开发者与图数据库学习者的地理路由实践项目,围绕 Neo4j 图数据库和 OpenStreetMap 开放地图数据,演示如何把路网信息转化为节点与关系,并通过 Cypher 查询完成最短路径等路线规划,适合做空间数据建模、路径查询和简单导航服务入门参考。压缩包共 35 个文件,约 314KB,以 22 个 Java 源码文件为主体,配合 3 个 OSM 与 1 个 PBF 地图数据、Gradle 构建脚本、properties 配置及 Markdown 说明文档,覆盖源码、测试、构建与项目说明等模块,结构紧凑,便于按数据导入、图构建和查询服务等环节阅读。目前已有 170 人学习。读者可从中了解 OSM 数据预处理、Neo4j 图模型设计、Cypher 路径查询和路线服务接口的完整实现链路,也能基于现成工程编译运行、调试测试并做二次改造,对需要快速理解图数据库处理地理网络的应用开发者具有参考价值。
1. 把 OpenStreetMap 塞进 Neo4j 之后,路由查询变成了图遍历
手头有个需求:给一批配送点算两两之间的行驶距离,要求必须贴着道路网走,不能拿直线距离糊弄。第一反应是引入一套路径规划引擎,但为了一个只查几十万条边的场景去运维一整套重型服务,光是把地图数据灌进去就够折腾半天。后来换了个思路——OpenStreetMap 本身就是一张现成的图,路口是节点,路段是边,那把它塞进 Neo4j 这样的图数据库里,所谓的路由服务就退化成了在图上做一次带权遍历。这份 Java 工程干的就是这件事:解析 OSM 数据、批量建图、对外暴露一个按起终点坐标返回路径的 HTTP 接口。它适合想在自己服务里嵌一层轻量路径规划能力、又不想被外部地图 API 的配额和计费卡住的开发者,尤其适合已经熟悉 Java 但没碰过图数据库的人——核心代码加起来不到一千行,改起来心里有底。
2. 技术选型与工程骨架:为什么是图数据库而不是关系库
2.1 三块拼图:OSM 数据、Neo4j 图库、Java 服务层
路径规划的本质是在一张带权有向图上找代价最小的路径。关系型数据库当然也能存这张图,一张 nodes 表、一张 edges 表,然后用递归 CTE 去查。问题在于递归 CTE 写起来啰嗦,深度一大性能就塌,中间结果集膨胀得厉害,调优全靠玄学。Neo4j 把"边"当成一等公民,遍历时顺着指针跳,跳几跳就是几跳的开销,不随全表规模线性增长,这一点对路由场景非常对口。
整个工程的职责切得很干净,三块拼图各自独立:
| 组件 | 承担的角色 | 落地形态 |
|---|---|---|
| OpenStreetMap | 路网数据源 | .osmXML 文件或.pbf压缩包 |
| Neo4j | 存图、遍历、算最短路 | 节点存路口,关系存路段,带距离和代价属性 |
| Java 服务 | 导入器 + HTTP 接口 | Maven 工程,JDK 8 及以上 |
| OSM 解析库 | 把 XML 读成对象 | osmosis、osm4j,或者手写 SAX |
| HTTP 层 | 对外暴露路由能力 | Spring Boot 或 JDK 自带 HttpServer |
选 Neo4j 还有一个现实理由:它自带point类型和point.distance()函数,经纬度可以直接存成原生空间类型,后面算球面距离不用自己拼 Haversine 公式(虽然为了性能关键路径上还是会手算)。如果换成纯内存的图结构,持久化和增量更新都得自己实现,反而更重。
代价也要说清楚。Neo4j 社区版对内存敏感,导入几百万节点时必须控制批大小,否则容易把堆撑爆;另外 Cypher 的内置最短路不认权重,只能算"跳数最少",真正带权的最短路要么用 APOC 库,要么自己在 Java 侧写。这两点后面都会展开。
2.2 一次路由请求的完整链路
从浏览器或调用方发出请求,到拿回一串坐标,中间经过六步:
第一步,HTTP 层接住GET /route?from=lat,lon&to=lat,lon,做参数校验,纬度必须在 -90 到 90 之间,经度在 -180 到 180 之间,顺手把格式错误的请求挡在门外。
第二步,把起终点的经纬度各自吸附到路网上最近的一个节点。这一步不能省,用户点的位置大概率在楼里、在河里、在绿化带中间,直接拿坐标去图上找是找不到的。吸附靠空间索引完成。
第三步,拿到两个 OSM 节点 ID 之后,交给路由算法。算法在图上扩展,一边记录已确定最短距离的节点集合,一边用优先队列维护待扩展的候选。
第四步,算法返回一条节点 ID 序列,服务层再回库把这批节点的经纬度批量捞出来,避免逐个查询。
第五步,把坐标序列组装成 GeoJSON 的LineString,同时累加每段距离得到总里程,按不同出行方式的速度估算耗时。
第六步,序列化返回。整个响应体里除了几何,还会带上节点数量、算法耗时、以及是否发生了"起终点吸附偏移过大"这类告警字段——这个字段在排查线上问题时特别有用。
提示:第三步和第四步之间最好加一个超时保护。图遍历最怕的是起终点不连通,算法会在一个巨大的连通分量里把能扩展的节点全扩一遍才肯罢休,表现就是请求挂死。
2.3 工程目录结构:一个典型 Maven 工程长什么样
目录按职责分层,导入、建图、路由、Web 四块互不干扰,想替换其中任何一块都不用动其他代码:
neo4j-osm-route/ ├── pom.xml ├── conf/ │ └── application.properties ├── data/ │ └── region.osm └── src/main/java/com/example/osmroute/ ├── Application.java 启动入口 ├── config/ │ └── Neo4jConfig.java 驱动与连接池 ├── importer/ │ ├── OsmImporter.java 导入主流程 │ └── OsmXmlHandler.java SAX 回调 ├── graph/ │ ├── GraphBuilder.java 批量写节点与关系 │ └── SpatialIndex.java 空间索引的建与查 ├── route/ │ ├── DijkstraRouter.java 带权最短路 │ ├── RouteService.java 吸附、组装、估算耗时 │ └── CostFunction.java 边权计算策略 ├── web/ │ └── RouteController.java HTTP 接口 └── model/ ├── OsmNode.java ├── OsmWay.java └── RouteResult.javaimporter目录只负责"读文件",graph目录只负责"写库",两者通过回调传递数据,中间不落盘也不缓存全量对象。这个分法看起来有点啰嗦,但当你需要支持.pbf格式时,只需要换掉OsmXmlHandler,GraphBuilder一行都不用改。
2.4 配置项:连接、批大小与代价参数
配置文件把容易变的参数全部外置,尤其是批大小和代价系数,这两个直接影响导入成功率和路由结果的合理性:
neo4j.uri=bolt://localhost:7687 neo4j.user=neo4j neo4j.password=change-me # 导入批大小,节点多时下调到 2000 以下更稳 import.batch.size=5000 # 只导入这些道路类型,其余忽略 import.highway.types=motorway,trunk,primary,secondary,tertiary,residential,service # 代价系数:数值越大代表越不愿意走 cost.factor.motorway=1.0 cost.factor.primary=1.2 cost.factor.residential=1.6 cost.factor.service=2.2 # 出行方式速度,单位 km/h speed.driving=45 speed.cycling=15 speed.walking=5代价系数这一块值得多说两句。单纯用物理距离当权重,算出来的路径会疯狂往小路里钻,因为小路往往更直更短,实际开车体验极差。给不同道路等级乘一个惩罚系数,等于在告诉算法"宁可绕远也走主干道"。系数怎么调没有标准答案,得拿几条已知的路线回归验证,我一般会拿十几组真实起终点对照人工判断的合理路径来调,调到结果符合直觉为止。
3. OSM 数据入库:从 XML 到图的完整落地
3.1 OSM 的三种元素:node、way、relation
OSM 的原始数据格式比想象中朴素,核心就三种元素。node是带经纬度的点,way是一串有序的 node 引用,relation用来表达更复杂的关系(比如公交线路、多边形边界)。截一段真实的 XML 结构看:
<osm version="0.6"> <node id="1001" lat="31.2304" lon="121.4737"/> <node id="1002" lat="31.2310" lon="121.4745"/> <way id="2001"> <nd ref="1001"/> <nd ref="1002"/> <tag k="highway" v="residential"/> <tag k="oneway" v="yes"/> </way> </osm>一个way里如果有 N 个nd引用,实际上代表的是一串首尾相连的路段,建图时应该拆成 N-1 条有向边,而不是把整条 way 当一条边。这一点新手很容易搞错,把整条路当成一条边,结果是算法只能在路口和路口之间跳,中间的路段全部丢失,算出来的路径会莫名其妙地"抄近道"。
relation里最常见的是type=restriction的转向限制,比如"禁止左转"。完整支持转向限制需要引入"边到边"的扩展图模型,复杂度上一个台阶。这份工程的定位是"简单路由服务",所以先不处理转向限制,只在文档里留了扩展点。
3.2 两遍扫描的解析策略:先用 SAX 读第一遍
OSM 文件动辄几百 MB,用 DOM 一次性加载进来必然 OOM,这是血泪经验。正确姿势是 SAX 流式解析,而且必须扫两遍:第一遍收集所有 node 的坐标,第二遍解析 way 时才能把引用翻译成坐标并算出边权。如果只扫一遍,解析到 way 的时候 node 还在后面没读到,只能先缓存引用关系,本质上还是要多占一份内存。
第一遍的回调处理器长这样:
public class NodeCollectHandler extends DefaultHandler { private final Map<Long, double[]> coords = new HashMap<>(1 << 20); @Override public void startElement(String uri, String localName, String qName, Attributes attrs) { if (!"node".equals(qName)) return; // 只保留普通路口节点,过滤掉 POI 等无坐标意义的点 long id = Long.parseLong(attrs.getValue("id")); double lat = Double.parseDouble(attrs.getValue("lat")); double lon = Double.parseDouble(attrs.getValue("lon")); coords.put(id, new double[]{lat, lon}); } public Map<Long, double[]> getCoords() { return coords; } }这段代码的关键点在于attrs.getValue("id")拿到的是字符串,必须显式parseLong,OSM 的节点 ID 在千万级数据里早就超过int范围了,用int存会静默溢出成负数,然后建图时出现一堆诡异的"节点找不到"报错。HashMap初始容量给1 << 20是为了减少扩容次数,一次百万级导入能省下不少时间。
第二遍扫描时,解析到way就立刻处理:读highway标签判断是不是我们要的道路类型,读oneway判断方向性,然后按相邻nd两两配对生成路段记录,每攒够一个批次就推给GraphBuilder写库。
3.3 批量写入 Neo4j:UNWIND 比循环 CREATE 快一个数量级
写库绝对不能一条一条CREATE。每次走一次网络往返,百万级数据能跑到天荒地老。正确做法是用UNWIND把一批数据当成参数数组传进去,一条 Cypher 搞定一批:
public void writeNodes(List<OsmNode> batch) { String cypher = "UNWIND $rows AS row " + "MERGE (n:Node {osmId: row.id}) " + "SET n.location = point({latitude: row.lat, longitude: row.lon})"; List<Map<String, Object>> rows = batch.stream().map(n -> { Map<String, Object> m = new HashMap<>(); m.put("id", n.getId()); m.put("lat", n.getLat()); m.put("lon", n.getLon()); return m; }).collect(Collectors.toList()); try (Session session = driver.session()) { session.writeTransaction(tx -> { tx.run(cypher, Collections.singletonMap("rows", rows)); return null; }); } }参数说明:$rows是一个 Map 列表,每个 Map 对应一个节点;MERGE而不是CREATE是为了支持断点续传——导入中途挂了,重跑一遍不会产生重复节点。point()函数构造原生空间类型,这样后面既可以用point.distance()算距离,也能被空间索引命中。
边写入的 Cypher 稍微复杂一点,因为它需要先MATCH到两端的节点:
UNWIND $rows AS row MATCH (a:Node {osmId: row.from}) MATCH (b:Node {osmId: row.to}) MERGE (a)-[r:ROAD {wayId: row.wayId}]->(b) SET r.distance = row.distance, r.cost = row.cost, r.highway = row.highway这里必须给Node.osmId建唯一约束,否则MATCH会退化成全表扫描,导入速度直接掉到没法看:
CREATE CONSTRAINT node_osm_id IF NOT EXISTS FOR (n:Node) REQUIRE n.osmId IS UNIQUE;注意:Neo4j 4.x 和 3.5 的约束语法不一样,3.5 是
CREATE CONSTRAINT ON (n:Node) ASSERT n.osmId IS UNIQUE。工程里锁死一个版本,别在两个版本之间反复横跳,驱动 API 也有差异,混用会编译不过。
3.4 空间索引:怎么快速找到"离我最近的路口"
吸附起终点靠的是空间索引。Neo4j 有几条路可以走:一是装 Neo4j Spatial 插件,功能全但版本兼容性是老大难,插件和数据库版本必须严格对齐,错一个次版本号就起不来;二是用 4.x 之后内置的点索引;三是自己在应用层用网格哈希建索引。
对于中等规模数据,内置点索引足够用:
CREATE POINT INDEX node_location IF NOT EXISTS FOR (n:Node) ON (n.location);查询最近节点用point.distance排序取第一条:
MATCH (n:Node) WITH n, point.distance(n.location, point({latitude: $lat, longitude: $lon})) AS d ORDER BY d ASC LIMIT 1 RETURN n.osmId AS id, d但这条查询在数据量大时依然会扫全表,因为ORDER BY ... LIMIT用不上索引的范围特性,只能靠 Neo4j 自己的空间索引做半径过滤。更稳的写法是先限定半径再排序:
MATCH (n:Node) WHERE point.distance(n.location, point({latitude: $lat, longitude: $lon})) < 500 RETURN n.osmId AS id ORDER BY point.distance(n.location, point({latitude: $lat, longitude: $lon})) ASC LIMIT 1半径给 500 米是个经验值。太小了,用户在空地上点一下可能周围五百米内一个路口都没有;太大了,山区或者郊外可能吸附到几公里外的国道上。我的做法是从 200 米开始逐级放大,最多放大到 2000 米,还没找到就返回"该位置附近无路网"。
3.5 边权计算:Haversine 公式与代价系数
两点间球面距离用 Haversine,比平面欧氏距离准得多,尤其是在高纬度地区:
private static final double EARTH_R = 6371000.0; public static double haversine(double lat1, double lon1, double lat2, double lon2) { double dLat = Math.toRadians(lat2 - lat1); double dLon = Math.toRadians(lon2 - lon1); double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); return 2 * EARTH_R * Math.asin(Math.sqrt(a)); }参数就四个,全是角度制。EARTH_R取 6371000 米是常用均值,误差在千分之三以内,对路径规划完全够用。算出来的distance存进关系属性,再乘以 3.4 节里的cost系数得到最终的cost,路由时用cost做权重,返回给用户时展示distance。
4. 路由核心:Cypher 的边界与 Java 侧 A* 实现
4.1 先看 Cypher 内置能力的天花板
Neo4j 自带shortestPath,写起来确实爽:
MATCH (a:Node {osmId: $from}), (b:Node {osmId: $to}) MATCH p = shortestPath((a)-[:ROAD*..5000]->(b)) RETURN [n IN nodes(p) | n.osmId] AS path, length(p) AS hops但它算的是"跳数最少",不是"代价最小"。一条由二十个短路段组成的小巷,跳数是 20;一条由三个长路段组成的主干道,跳数是 3。算法会毫不犹豫选主干道——这次是碰巧对了,但如果小巷只有两跳,它就会选小巷,哪怕那条小巷绕了五公里。带权最短路必须用apoc.algo.dijkstra:
MATCH (a:Node {osmId: $from}), (b:Node {osmId: $to}) CALL apoc.algo.dijkstra(a, b, 'ROAD>', 'cost') YIELD path, weight RETURN [n IN nodes(path) | n.osmId] AS path, weight'ROAD>'里的>表示只沿关系正方向走,这是单行道正确性的关键——少了这个符号,算法会逆着单行道开过去,结果看着像那么回事,实际违法。APOC 用起来省事,代价是多一个插件依赖,而且它对超大图的内存占用不好控制。所以这份工程里,APOC 只作为对照组,真正的主线是自己写 A*。
4.2 Java 侧 A* 实现:优先队列 + 启发函数
自己写的好处是完全可控:可以随时中断、可以限制扩展节点数、可以把整张子图预热到内存里避开反复查库。核心代码如下:
public List<Long> shortestPath(long start, long end) { Map<Long, Double> gScore = new HashMap<>(); Map<Long, Long> cameFrom = new HashMap<>(); PriorityQueue<long[]> open = new PriorityQueue<>( Comparator.comparingDouble(a -> Double.longBitsToDouble(a[1]))); gScore.put(start, 0.0); open.add(new long[]{start, Double.doubleToLongBits(heuristic(start, end))}); while (!open.isEmpty()) { long current = open.poll()[0]; if (current == end) break; // 提前收敛 for (Edge e : graph.neighbors(current)) { // 只取正方向边 double tentative = gScore.get(current) + e.cost; if (tentative < gScore.getOrDefault(e.to, Double.MAX_VALUE)) { cameFrom.put(e.to, current); gScore.put(e.to, tentative); double f = tentative + heuristic(e.to, end); open.add(new long[]{e.to, Double.doubleToLongBits(f)}); } } } return buildPath(cameFrom, start, end); }参数和逻辑逐点说明。gScore记录从起点到每个节点已知的最小代价,cameFrom记录路径前驱用于回溯,open是候选队列,用long[]打包节点 ID 和 f 值是为了省掉一个包装对象——A* 在图上会 push 几十万次,对象分配的压力不小。Comparator里用Double.longBitsToDouble还原是因为队列元素是long[],浮点值被按位转了。
heuristic是启发函数,用直线距离除以该出行方式的最大速度,得到"乐观估计的剩余代价"。这个函数必须满足"不高估",否则 A* 会退化成贪心,找到的路径可能不是最优。用最大速度而不是平均速度就是为了保证乐观。
if (current == end) break;这行是 A* 相对 Dijkstra 的核心优势:一旦终点出队,路径就确定了,不用把整个连通分量扩完。在长距离查询上,这个提前退出能省掉一大半的扩展。
还有一个工程细节:graph.neighbors()如果每次调用都去查一次 Neo4j,性能会惨不忍睹。实际做法是按需把子图加载到内存,第一次访问某个节点时批量拉取它的所有邻居缓存起来,后续都走本地。缓存要做容量上限,否则一次跨城查询就能把内存吃穿。
4.3 REST 接口与 GeoJSON 输出
接口层只做三件事:解析参数、调服务、拼响应。用 Spring Boot 的话大概长这样:
@GetMapping("/route") public ResponseEntity<?> route(@RequestParam double[] from, @RequestParam double[] to, @RequestParam(defaultValue = "driving") String mode) { if (from.length != 2 || to.length != 2) { return ResponseEntity.badRequest().body("坐标格式应为 lat,lon"); } RouteResult r = routeService.findRoute( from[0], from[1], to[0], to[1], CostFunction.of(mode)); if (r.getPath().isEmpty()) { return ResponseEntity.status(404).body("起终点不连通"); } return ResponseEntity.ok(r.toGeoJson()); }from和to用double[]接收,Spring 会自动按逗号切分,省掉手写split的麻烦。mode参数决定用哪套代价系数,驱动、骑行、步行三套系数存在CostFunction里。返回 404 而不是空结果,是因为空结果和不连通在语义上是一回事,调用方拿到 404 就知道该提示用户换个位置。
GeoJSON 输出形如{"type":"LineString","coordinates":[[lon,lat],...]},注意 GeoJSON 规范里坐标顺序是经度在前,和常见的"纬度在前"正好相反,写反了前端画出来的线会飞到地球另一边去。
4.4 单行道的方向性处理
单行道在建图阶段就处理掉,不要留到查询时判断。解析way时读oneway标签:值为yes、true、1时只建正方向的边;值为-1时只建反方向的边;没有这个标签或者值为no时建双向边。
双向边用两条独立的关系表达,而不是一条关系加个bidirectional标记。这样查询时只需要写[:ROAD],方向性天然正确,不用在 Cypher 里加一堆条件判断。代价是关系数量翻倍,存储和导入时间都会上涨,但换来的是查询逻辑的干净——这笔账我觉得划算。
junction=roundabout的环岛也要特殊对待,环岛默认就是单向的,哪怕没有显式标注oneway。漏了这一条,算出来的路径会出现"逆行穿过环岛"这种一看就不对的结果。
5. 跑起来:环境准备、数据导入与接口验证
5.1 环境准备与工程启动
需要三样东西:JDK 8 或更高、Maven 3.6 以上、一个能连上的 Neo4j 实例。Neo4j 用官方发行版或者容器起都行,起完记得改默认密码,然后用浏览器打开管理界面确认能连上。
工程侧的准备工作是拉依赖、编译:
mvn -q clean package -DskipTests java -jar target/neo4j-osm-route.jar --spring.config.location=conf/第一条命令编译打包,-DskipTests是因为首次构建时单测依赖一个已导入的图库,先跳过。第二条启动服务,--spring.config.location指定外部配置目录,这样改配置不用重新打包。
启动日志里会打印 Neo4j 的连接状态和已加载的节点数量,看到Neo4j connection OK和具体的节点计数就说明链路通了。第一次跑如果卡在连接阶段,九成是 bolt 端口没通或者密码不对,先去管理界面手动连一次排除掉。
5.2 导入数据与第一次请求
数据导入用一个独立的入口类,和 Web 服务分开,这样不用为了导数据把服务也起起来:
java -cp target/neo4j-osm-route.jar \ com.example.osmroute.importer.OsmImporter \ --file data/region.osm \ --batch 3000 \ --types motorway,trunk,primary,secondary,tertiary,residential--batch控制每批写入的节点数,机器内存 8G 以下给 2000 到 3000 比较稳。--types决定导入哪些道路,第一次跑建议先只导主干道,数据量小、导入快、链路好验证,跑通了再放开到全量。
导入完成后起服务,发第一个请求:
curl "http://localhost:8080/route?from=31.2304,121.4737&to=31.2410,121.4890&mode=driving"正常返回应该是一个 GeoJSON 对象,包含coordinates数组、distance总米数、duration估算秒数。如果拿到的是 404,先别怀疑算法,按下一节的顺序排查。
提示:第一次导入完成之后,建议手动跑一遍
MATCH (n:Node) RETURN count(n)和MATCH ()-[r:ROAD]->() RETURN count(r),把两个数字记下来。后面任何一次"结果不对"的排查,都会从这两个数字开始。
5.3 常见问题排查:现象、原因与解决
导入跑到一半进程被杀,日志里没有任何异常。现象是进程静默退出,dmesg里能看到内存不足的记录。原因是批大小设得太大,一批几十万个节点加上UNWIND参数序列化的副本,瞬间把堆撑爆,JVM 来不及抛 OOM 就被系统杀掉了。解决办法是把--batch降到 2000 以下,同时给 JVM 设一个明确的堆上限比如-Xmx4g,让它在撑爆之前先抛异常,至少能看到堆栈。
请求一直转圈,几十秒后超时。现象是短距离查询正常,跨区域查询必定超时。原因是起终点落在两个不连通的分量里,A* 会把整个分量扩完才返回空路径。解决办法有两层:一是给算法加扩展节点数上限,超过上限直接返回"范围过大";二是预处理阶段先算连通分量,把分量 ID 存到节点属性上,查询前先比对分量 ID,不一致直接返回不连通,一次属性查询就能挡掉大部分无效请求。
路径画出来是直的,穿过了建筑物和河。现象是返回的坐标序列只有起点和终点两个点。原因是way解析时把整条路当成了单条边,中间的nd节点全丢了。回到 3.1 节,确认建图逻辑是把 N 个引用拆成 N-1 段,每段单独建关系。改完要清库重导,光删关系不删节点会留下孤立节点。
同一段路来回查,去程和回程距离不一样但路线明明相同。现象是距离差了几十米。原因是双向边各自独立计算了 Haversine 距离,浮点误差导致两个方向不完全一致。解决办法是在建边时算一次距离,两个方向共用同一个值,别各算各的。
管理界面能看到数据,服务却报"节点不存在"。现象是 Cypher 手查能查到,Java 查不到。九成是连到了不同的库——Neo4j 4.x 支持多数据库,默认库和你在界面里选的库可能不是同一个。在连接串后面显式指定数据库名,或者在驱动初始化时传database参数,别依赖默认值。
6. 二次开发:把"能跑"改成"能用"的几个落点
拿到能跑的版本之后,真正花时间的改造集中在三处,按投入产出比排序。
第一处是代价模型。现在的CostFunction只按道路等级乘系数,实际业务里往往还要考虑实时路况、限高限重、收费路段规避。做法很简单,把CostFunction抽成接口,cost(Edge e)一个方法,然后用配置组装多个策略叠加。改完之后验证方式不是看代码,是构造十几组固定起终点,把改造前后的路径导出成 KML 叠在地图上肉眼比对,路径明显变绕或者钻进小路就是系数调过头了。
| 改造点 | 改动范围 | 验证方式 |
|---|---|---|
| 代价模型可插拔 | CostFunction一个类 | 固定样本路径比对 |
| 支持转向限制 | 建图阶段扩展边到边模型 | 已知禁左路口验证 |
| 子图预热与缓存 | Graph层 | 长距离查询 P95 耗时 |
| 多出行方式 | 配置 + 系数表 | 同起终点三种模式对比 |
第二处是转向限制。目前完全没处理,遇到"禁止左转"的路口会给出违规路径。要支持得在建图阶段把模型从"节点图"扩展成"边图"——状态变成"我正在从哪条边来",转向动作变成图的边。改造量不小,但如果你的场景对合规性有硬要求,这笔投入躲不掉。
第三处是缓存。同一批起终点会被反复查询,加一层按坐标网格量化的 LRU 缓存能挡掉大半重复计算。量化精度别太细,小数点后三位大概一百米,够用又能提高命中率。
验证方法我一般分两步走:先造一个小规模的、路径肉眼可判的样本区域,比如一个规整的网格状街区,跑单测断言距离在预期区间内;再拿真实数据跑一批随机起终点,只看有没有异常值和超时,不校验具体路径。两步都过了才敢往线上放。
最后说个教训。早期我做这类改造,习惯改完直接拿全量数据跑,结果一次系数调错,几十万条路径全部偏移,排查花了整整两天。从那以后我每次动代价模型之前,都强制先跑那个小样本单测,绿色的再谈别的。希望帮到你。
本文还有配套的精品资源,点击获取