實現(xiàn)路徑規(guī)劃:圖數(shù)據(jù)庫與Cypher實踐)
簡介這是一套將圖數(shù)據(jù)庫Neo4j與OpenStreetMap地理數(shù)據(jù)結合、以Java實現(xiàn)的簡單路由服務項目源碼面向需要在地圖類應用中集成路線規(guī)劃能力的Java開發(fā)者。壓縮包共35個文件核心為22個Java源文件附帶3個OSM示例地圖、1個PBF數(shù)據(jù)文件以及Gradle構建配置、屬性文件和說明文檔整體約314KB結構清晰便于直接導入與二次開發(fā)。項目展示了從OSM數(shù)據(jù)解析、路網(wǎng)建模到Cypher查詢最短路徑的完整鏈路可作為理解空間數(shù)據(jù)建模與圖數(shù)據(jù)庫路線的實踐參考。已有169人學習或下載適合對圖數(shù)據(jù)庫、地理信息系統(tǒng)或路徑優(yōu)化感興趣的中高級Java工程師。1. Neo4jOSM 到底是什么與其說得繞路不如換成圖遍歷做地圖和導航的都知道傳統(tǒng)路由服務要么用 PostGIS pgRouting要么自己寫 A*。但你有沒有想過一個問題路網(wǎng)本身就是一張圖路口是節(jié)點路段是邊而 Neo4j 天生就是存“節(jié)點和關系”的數(shù)據(jù)庫。Neo4jOSM 這個方向本質就是拿 OpenStreetMap 的原始路網(wǎng)數(shù)據(jù)灌進 Neo4j把“兩點之間怎么走”從最短路徑算法問題變成圖數(shù)據(jù)庫的遍歷查詢問題。我第一次接觸這個方案是被一個做同城配送的朋友拉去看的他們想在一套圖數(shù)據(jù)庫里同時管知識圖譜和路徑計算不想為了一個路由功能單獨再插一套 pgRouting。當時我的第一反應是“圖數(shù)據(jù)庫做路由肯定慢”但實際跑完才發(fā)現(xiàn)數(shù)據(jù)量控制在十萬級節(jié)點以內(nèi)時Cypher 的遍歷效率完全能接受而且開發(fā)和排錯的成本比傳統(tǒng)方案低得多。這個方案適合兩類人一類是已經(jīng)在用 Neo4j 做業(yè)務、需要順帶解決路線計算的人另一類是想快速做一個輕量導航原型、又不想碰地理信息系統(tǒng)那套重工具的開發(fā)者。2. 為什么路由要選圖數(shù)據(jù)庫從路網(wǎng)的數(shù)據(jù)模型說起2.1 路網(wǎng)不是坐標系而是圖結構很多人第一次接觸 OSM 數(shù)據(jù)時會下意識把它當成一堆經(jīng)緯度坐標。這其實是最大的誤解。OSM 的原始數(shù)據(jù)模型里只有三類核心元素Node點、Way線、Relation關系。一段從 A 路口到 B 路口的道路是由一串 Node 組成一條 Way多個 Way 再組合成完整的道路網(wǎng)。這和 Neo4j 的 Label、Relationship 模型有一種天然的對應關系。我在設計這套路由服務的數(shù)據(jù)模型時走了兩條路線來回對比。第一種方案是把每個 OSM 的 Way 當作一條 Neo4j 關系這條路的所有幾何信息存在關系的屬性里。第二種方案是節(jié)點對應路口關系對應路段。最終我選擇了第二種因為路由的核心計算單元是“從一個節(jié)點到另一個節(jié)點經(jīng)過哪些關系”如果 Way 直接作為關系路口的轉彎角度、多條路匯聚的邏輯反而要額外處理。OSM 數(shù)據(jù)里最容易被忽視的是方向性。高速公路是單向的普通街道是雙向的。在 Neo4j 建模時關系必須有 Direction 屬性區(qū)分 oneway 和雙向否則后續(xù)算出來的路線可能帶著你逆行。這不是算法的復雜度問題而是數(shù)據(jù)模型的精準度問題。2.2 圖數(shù)據(jù)庫的路由優(yōu)勢一條 Cypher 就夠傳統(tǒng)路由方案的痛點在于最短路徑算法是外部實現(xiàn)的要么是 GIS 擴展的存儲過程要么是獨立微服務。Neo4j 的好處是路徑遍歷是 Cypher 查詢語言的原生能力不需要額外寫算法模塊。拿一個最基本的需求舉例找從 A 點到 B 點的最短路徑。Cypher 里的 MATCH pshortestPath(...) 可以直接跑通。更重要的是你不用像傳統(tǒng)方案那樣預先建好拓撲關系表圖數(shù)據(jù)庫的節(jié)點和關系本身就是拓撲。新增一條道路就是一個新關系刪除一條封路就是刪除一個關系。對于“路線需要隨路況實時調(diào)整”的場景這種數(shù)據(jù)模型的靈活性是壓倒性的。當時我并用 pgRouting 和 Neo4j 各實現(xiàn)了一版在數(shù)據(jù)量一致的情況下Neo4j 的啟動慢一些但小范圍查詢的熱數(shù)據(jù)會比關系型方案更穩(wěn)因為遍歷過程是在內(nèi)存里完成的不需要反復 JOIN 多張表。2.3 用得當心不是所有路由需求都適合 Neo4j這是我在朋友圈說過最多的一句話拿 Neo4j 做路由控制好規(guī)模就是利器失控就是災難。Neo4j 的遍歷效率會隨跳數(shù)線性增長十跳以內(nèi)的路線幾乎秒出但超過二十跳響應時間開始惡化。如果你要做的全國范圍貨車調(diào)度幾十萬甚至上百萬個路網(wǎng)節(jié)點Neo4j 社區(qū)版未必扛得住。適合用 Neo4j 的場景是單城市或區(qū)域性的路網(wǎng)節(jié)點量在十萬級且路由計算不是核心業(yè)務的全部壓力。比如門店配送路徑、園區(qū)內(nèi)部導航、景點游覽路線。如果答案是“全國幾千萬個路口節(jié)點”我建議直接往 pgRouting 或 Valhalla 方向選型。3. 把 OSM 路網(wǎng)灌進 Neo4j完整導入鏈路與參數(shù)設置3.1 工作臺搭建設置與數(shù)據(jù)準備我是按這套流程跑的先準備好環(huán)境。Neo4j 社區(qū)版是必需的版本我推薦 4.x 以上因為 Cypher 的語義和 APOC 插件的兼容性更穩(wěn)定。另一個準備是 OSM 數(shù)據(jù)文件范圍別貪大先用一個小城市或一個區(qū)的 .osm.pbf 格式測試鏈路。我一般不建議直接用 pbf 去解析而是先轉成 OSM XML 格式方便肉眼檢查數(shù)據(jù)。用 osmium 工具做這個轉換最省心。wget https://download.geofabrik.de/asia/china-latest.osm.pbf osmium cat china-latest.osm.pbf -o beijing.osm這里的 china-latest.osm.pbf 是整個國家的數(shù)據(jù)但我只需要北京范圍所以用 osmium 的邊界過濾更合理。真正實操時我會加 bbox 參數(shù)只提取目標區(qū)域這樣導入 Neo4j 的數(shù)據(jù)量小一個數(shù)量級排錯也方便。上面的命令只是第一步重點在于后續(xù)的數(shù)據(jù)結構轉換解析要自己寫而不是靠現(xiàn)成的導入工具硬灌因為現(xiàn)成工具往往不做拓撲清洗。3.2 自己寫一個 Python 解析器核心代碼與邏輯拆解圖數(shù)據(jù)庫里做路由最重要的不是導入 OSM 數(shù)據(jù)本身而是構建出可導航的拓撲。OSM 原始數(shù)據(jù)里一條 Way 有多個 Node這些 Node 可能是純幾何形狀點也可能正好是路口。我的策略是只把路口節(jié)點導入 Neo4j路段作為 Relationship 屬性里的坐標串存下來。這里給一個能直接跑的解析器骨架import osmium from neo4j import GraphDatabase class RouterWriter(osmium.SimpleHandler): def __init__(self, driver): super().__init__() self.driver driver self.way_nodes {} self.node_coords {} self.turn_rules [] def node(self, n): # 只保留有路口潛力的節(jié)點坐標先全部緩存 self.node_coords[n.id] (n.location.lon, n.location.lat) def way(self, w): if highway not in w.tags: return highway_type w.tags[highway] if highway_type not in [motorway, trunk, primary, secondary, tertiary, residential]: return nodes [n.ref for n in w.nodes] oneway w.tags.get(oneway, no) # TODO確定哪些節(jié)點是交叉路口需要與其它 way 求交集 self.way_nodes[w.id] { nodes: nodes, highway: highway_type, oneway: oneway }關鍵邏輯先在 Python 里保留一份 way 節(jié)點序列緩存再在后續(xù)清洗中過濾出真實路口。直接邊讀 OSM 邊寫入 Neo4j 的做法我試過一次導入中途遇到數(shù)據(jù)異常就得回滾很難排查。所以我把清洗和寫入拆成了兩個階段第一階段先落盤成 CSV第二階段用 Neo4j 的批量導入命令。這是效率最高、也最容易控制錯誤的方式。3.3 Neo4j 導入命令和索引配置解析出路口和路段之后生成兩個 CSV 文件一個 nodes.csv 一個 ways.csv然后直接用 Neo4j 的LOAD CSV或 admin import 寫入。社區(qū)版里最穩(wěn)妥的是逐條執(zhí)行 Cypher 寫入數(shù)據(jù)量小于五萬條時沒問題。// 創(chuàng)建路網(wǎng)節(jié)點 LOAD CSV WITH HEADERS FROM file:///nodes.csv AS row CREATE (n:RoadNode { id: toInteger(row.osm_id), lat: toFloat(row.lat), lon: toFloat(row.lon) }); // 創(chuàng)建路段關系 LOAD CSV WITH HEADERS FROM file:///ways.csv AS row MATCH (a:RoadNode {id: toInteger(row.source)}) MATCH (b:RoadNode {id: toInteger(row.target)}) CREATE (a)-[:ROAD { highway_type: row.highway, distance_m: toFloat(row.distance_m), oneway: row.oneway }]-(b);注意這里的 oneway 屬性一定要在導入時處理成方向關系。我踩過的坑是雙向路段在 OSM 里是一條 Way但如果只在 Cypher 里建一條關系從 B 到 A 就沒辦法遍歷。正確做法是在解析器里判斷 oneway如果是雙向就把 source 和 target 互換再建一條反向關系。索引設置很關鍵。路由查詢的第一步是錨定起點和終點節(jié)點如果id屬性沒有索引neo4j 會全表掃描這會讓十萬級節(jié)點的查詢直接卡上十幾秒。我當時是這么下的CREATE INDEX road_node_id IF NOT EXISTS FOR (n:RoadNode) ON (n.id);這個索引幾乎決定了所有路由查詢的基礎性能務必最先創(chuàng)建。4. Cypher 里實現(xiàn)最短路徑三個查詢方案與參數(shù)調(diào)優(yōu)4.1 最直觀的寫法shortestPath 函數(shù)數(shù)據(jù)導入完成之后最重要的環(huán)節(jié)就是寫路由查詢。先頭最簡單的版本用 Neo4j 內(nèi)建 shortestPath 方法MATCH (a:RoadNode {id: 12345}), (b:RoadNode {id: 54321}) MATCH p shortestPath((a)-[:ROAD*]-(b)) RETURN p, reduce(s 0.0, r IN relationships(p) | s r.distance_m) AS total_distance這里的[:ROAD*]表示沿任意深度遍歷關系。shortestPath 內(nèi)部做的是雙向 BFS 的優(yōu)化版在小型路網(wǎng)上會非??炜蓡栴}是它默認按跳數(shù)最少優(yōu)先不是按路程最短優(yōu)先。如果一條路線經(jīng)過很多短路段另一條路線只經(jīng)過兩條長路段它會選擇后者。這跟我實際做配送路由時的需求正好相反。這是第一個要調(diào)的點如果只是求“經(jīng)過路口最少”的路線shortestPath 就夠如果求的是“物理距離最短”需要換方案。4.2 按距離加權的最短路徑APOC 擴展的 DijkstraNeo4j 官方的 APOC 插件庫提供了一套更靠譜的算法實現(xiàn)支持帶權重的 Dijkstra。這是我最常用的路由寫法也是真正能上生產(chǎn)環(huán)境的方式MATCH (a:RoadNode {id: 12345}), (b:RoadNode {id: 54321}) CALL apoc.algo.dijkstra(a, b, ROAD, distance_m) YIELD path, weight RETURN path, weightapoc.algo.dijkstra四個參數(shù)含義分別是起始節(jié)點、目標節(jié)點、關系類型、權重屬性。這里的 weight 就是上面導入時存的 distance_m單位是米。用它跑出來的結果是真正按距離算的最短路徑。這個查詢有兩點值得注意。第一APOC 版本要跟 Neo4j 版本對應我在 Neo4j 4.4 配 APOC 4.4 的 core 包否則會遇到閃退或函數(shù)找不到的問題。第二如果路網(wǎng)關系里存在 0 權重或負權重Dijkstra 會直接掛掉甚至死循環(huán)。導入數(shù)據(jù)時我做了過濾凡是 distance_m 小于 1 的路段直接丟棄避免這種邊緣情況。4.3 從一個節(jié)點出發(fā)查詢所有可達路徑熱詞里有人搜“neo4j查詢從一個節(jié)點出發(fā)如何查詢多條”這個恰恰是圖數(shù)據(jù)庫路由最有優(yōu)勢的場景。比如配送站要同時服務三十個客戶點傳統(tǒng)關系型數(shù)據(jù)庫要一條條調(diào)接口而 Cypher 只需要一次查詢就可以把所有按距離排序的可達路徑拉出來。MATCH (start:RoadNode {id: 12345}) MATCH (target:RoadNode) WHERE target.id IN [54321, 54322, 54323, 54324] CALL apoc.algo.dijkstra(start, target, ROAD, distance_m) YIELD path, weight RETURN target.id, weight, [n IN nodes(path) | n.id] AS node_list ORDER BY weight ASC執(zhí)行計劃上這種寫法每一次 CALL 都會觸發(fā)一次單源 Dijkstra所以如果目標節(jié)點有幾十上百個性能會直線下降。我的優(yōu)化思路是先加一個粗粒度的距離過濾比如用 Neo4j 的空間函數(shù)先算節(jié)點間的直線距離過濾掉三公里外的節(jié)點減少需要跑 Dijkstra 的目標數(shù)量。![Note] 可以把這段查詢發(fā)布成自定義存儲過程因為 Neo4j 官方寫法對結果去重和并行有一定限制存儲過程能更好地控制資源。5. 路由上線的避坑記錄坐標偏移、數(shù)據(jù)漂移與性能問題5.1 路線生成后在地圖上位置對不上這是最詭異的故障。數(shù)據(jù)導入了、查詢通了、路徑也返回了但在前端地圖組件上畫出來后路線浮在馬路邊的住宅區(qū)里。排查后發(fā)現(xiàn)是坐標參考系的鍋?,F(xiàn)象OSM 數(shù)據(jù)默認坐標系是 WGS84GPS 用的經(jīng)緯度坐標而高德或百度地圖在國內(nèi)使用的是 GCJ-02 加偏坐標系。Neo4j 里存的是原始 WGS84前端地圖工具自動做了坐標系糾偏于是雙重偏移導致路線錯位。原因把 OSM 的坐標直接當成了國內(nèi)地圖運營商坐標系里的坐標來用。解決要么在解析器里把 WGS84 轉成 GCJ-02 再入庫要么前端關閉自動偏轉。我選了前者寫了一個wgs84_to_gcj02的算法函數(shù)在導入 CSV 前先做一次坐標轉換這樣后續(xù)所有業(yè)務邏輯和可視化都保持一致。5.2 一度路口連不上拓撲斷裂導致路線查不到又一個高頻坑。導入完成后路由只返回了一個起點節(jié)點路徑完全不存在。我單步排查發(fā)現(xiàn)有 37% 的節(jié)點成了“孤島”完全沒有關系。原因有三個。第一OSM 原始數(shù)據(jù)里兩條路在物理上交叉但并沒有共享同一個節(jié)點 ID這也是數(shù)據(jù)拓撲斷裂的典型原因之一。第二解析時我把節(jié)點精度過濾做得太激進把一些交叉口誤判成了形狀點丟了。第三一條道路斷開兩段時終點節(jié)點 ID 和另一段的起點節(jié)點 ID 不一致。解決在導入后的 Neo4j 里寫一次清洗腳本把坐標距離小于五米的節(jié)點做合并。具體的條件是把兩個節(jié)點的經(jīng)緯度做差值小于閾值就用一個節(jié)點的 ID 統(tǒng)一替換掉關系里的引用。MATCH (a:RoadNode), (b:RoadNode) WHERE a.id b.id AND abs(a.lat - b.lat) 0.0001 AND abs(a.lon - b.lon) 0.0001 WITH a, b LIMIT 1000 MATCH (a)-[r:ROAD]-(n) MERGE (b)-[r2:ROAD {distance_m: r.distance_m}]-(n) DELETE r這種做法我先限制每批只處理 1000 條因為大批量 MATCH 在 WHERE 條件里的計算開銷非常大跑久了容易 OOM。跑一遍可以恢復大部分斷裂拓撲但會出現(xiàn)重復關系所以跑完之后還要加 DISTINCT 去重。5.3 Neo4j 沒按配置文件分配內(nèi)存查詢慢到像假死跑路由查詢時發(fā)現(xiàn)只要跳數(shù)超過十五次響應時間從 50ms 暴漲到 10s一度以為是數(shù)據(jù)量問題。后來發(fā)現(xiàn) Neo4j 默認的內(nèi)存配置把堆內(nèi)存限制在 512MB。原因Neo4j 的neo4j.conf文件沒有修改默認堆內(nèi)存只有幾百 MB圖遍歷是把節(jié)點加載到堆內(nèi)的內(nèi)存不夠自然開始頻繁 GC哪怕數(shù)據(jù)量只有幾萬條也卡。解決在neo4j.conf里修改以下配置。注意修改后必須重啟 Neo4j 服務。server.memory.heap.initial_size2g server.memory.heap.max_size2g server.memory.pagecache.size2g具體大小根據(jù)自己的機器來。我一般建議初始堆和最大堆設成一致避免運行中堆自動擴容帶來的停頓。這兩項設成不同值時Neo4j 會頻繁執(zhí)行擴縮容路由查詢的延遲曲線變得非常難看。5.4 關系方向設置錯誤路線總讓你掉頭我在測試一個環(huán)形路網(wǎng)的導航時路線算法給出的路徑一直在原地打轉或者強制連續(xù)兩次 U 形彎。翻遍代碼最后發(fā)現(xiàn)是把雙向路段的兩個方向關系都建了但 oneway 判斷錯誤把它當成僅單向錄入。OSM 數(shù)據(jù)里oneway標簽除了yes和no之外還有一種-1表示逆行車道即單向但方向與 Way 節(jié)點序列相反。我的解析器里一開始只判斷了yes把-1當成了雙向路段。解決方式很簡單把 oneway -1 的情況在生成關系時交換 source 和 target再調(diào)整 distance_m 并寫回。if oneway -1: source, target target, source這種錯誤不太容易靠日志發(fā)現(xiàn)因為圖結構上完全沒有異常但算出的路線就是不合理。所以我在每個路段關系上都加了一個direction屬性存儲車流方向后續(xù)查問題時可以直接用WHERE r.direction backward做過濾便于排查。6. 進階玩法給路由加上多約束條件與速度權重基礎的路由已經(jīng)能跑通但真實場景里還需要區(qū)分“最短距離”和“最快時間”。配送場景更關心時間自駕場景更關心是否走高速步行場景必須排除快速路。這些本質是把單一的distance_m權重屬性變成多維屬性再在查詢時按場景切換權重字段。我在關系上額外保存了speed_kph屬性從 OSM 的maxspeed標簽獲取如果沒有則按高速公路類型賦默認值。然后程序里先計算cost_seconds distance_m / speed_kph * 3.6存成單獨屬性再用 APOC 的 dijkstra 函數(shù)換成cost_seconds做權重MATCH (a:RoadNode {id: 12345}), (b:RoadNode {id: 54321}) CALL apoc.algo.dijkstra(a, b, ROAD, cost_seconds) YIELD path, weight RETURN [r IN relationships(path) | r.road_name] AS roads, weight AS total_seconds做法說起來很簡單真正要踩的坑是maxspeed的解析。OSM 標簽里maxspeed30表示限速 30 km/hmaxspeedwalk表示步行速度maxspeednone表示不限速。解析器里必須做一層歸一化否則字符串類型直接轉 float 時會直接報錯整條導入鏈路停掉。我還給自己的服務加過一個實用功能根據(jù)路由結果的起點和終點從路段的highway_type屬性過濾掉不適合當前交通模式的關系類型。騎行時剔除 motorway步行時剔除 trunk。把apoc.algo.dijkstra里的關系類型參數(shù)從ROAD改成動態(tài)拼接或直接在關系創(chuàng)建時打上access標簽組合CALL apoc.algo.dijkstra(a, b, ROAD, cost_seconds) YIELD path, weightROAD的表示只沿關系方向正向遍歷。這一招對單行道的處理特別管用可以繞開很多逆行的坑。整體把這套服務從原型到落地做下來我最深的感受是Neo4j 做路由的優(yōu)勢不在算法執(zhí)行速度而在工程速度。它讓路線計算和你原有的圖數(shù)據(jù)模型長在一起不用在業(yè)務和非業(yè)務之間頻繁做語義轉譯。這是一個性價比很高的方案關鍵是把數(shù)據(jù)清洗和方向處理做扎實希望幫到你。本文還有配套的精品資源點擊獲取