城市路径规划:Neo4j vs MySQL 之最短路径查询性能对比
Compare MySQL and Neo4j to query the shortest performance between two points
author:qiu
在城市路径规划中,最短路径查询是一个常见的问题。为了选择最佳技术方案,我对比了使用 MySQL 和 Neo4j 进行最短路径查询的性能和实现难度。本文将从数据建模、查询方式、性能和可扩展性等方面展开讨论。
MySQL 数据建模
假设我们有两个表来表示城市中的点(dot
)和路(road
):
CREATE TABLE dot (
id BIGINT PRIMARY KEY,
enable BOOLEAN NOT NULL,
longitude DOUBLE(18, 15) NOT NULL,
latitude DOUBLE(18, 15) NOT NULL
);
CREATE TABLE road (
id BIGINT PRIMARY KEY,
from_id BIGINT NOT NULL,
to_id BIGINT NOT NULL,
name VARCHAR(64) DEFAULT 'unknown',
distance DOUBLE(18, 9) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
为了进行性能测试,我们插入了 30000 条数据,如下所示:
MySQL 查询最短路径
我们选定了起点 from_id = -2162807189120628591
和终点 to_id = -2162807183076351438
,然后尝试使用递归查询最短路径:
WITH RECURSIVE t (from_id, to_id, distance, path) AS (
SELECT from_id, to_id, distance,
CAST(CONCAT(a.from_id, ' -> ', a.to_id) AS CHAR(10000)) AS path
FROM road a
WHERE from_id = -2162807189120628591
UNION ALL
SELECT t.from_id, b.to_id, t.distance + b.distance,
CAST(CONCAT(t.path, ' -> ', b.to_id) AS CHAR(10000)) AS path
FROM t
INNER JOIN road b ON b.from_id = t.to_id
WHERE INSTR(t.path, b.to_id) <= 0
)
SELECT * FROM t WHERE to_id = -2162807183076351438 ORDER BY distance LIMIT 1;
然而,该查询执行了 15 分钟 仍然没有结果。显然,MySQL 在递归查询复杂路径时表现不佳。为了简化测试,我构造了一个简单的图:
INSERT INTO dot VALUES (1, 1, 1, 1), (2, 1, 2, 2), (3, 1, 3, 3), (4, 1, 4, 4);
INSERT INTO road VALUES (1, 1, 2, 'unknown', 10, 1),
(2, 2, 3, 'unknown', 12, 1),
(3, 3, 4, 'unknown', 15, 1),
(4, 4, 1, 'unknown', 20, 1);
接着查询 1 -> 4 的最短路径:
WITH RECURSIVE t (from_id, to_id, distance, path) AS (
SELECT from_id, to_id, distance,
CAST(CONCAT(a.from_id, ' -> ', a.to_id) AS CHAR(10000)) AS path
FROM road a
WHERE from_id = 1
UNION ALL
SELECT t.from_id, b.to_id, t.distance + b.distance,
CAST(CONCAT(t.path, ' -> ', b.to_id) AS CHAR(10000)) AS path
FROM t
INNER JOIN road b ON b.from_id = t.to_id
WHERE INSTR(t.path, b.to_id) <= 0
)
SELECT * FROM t WHERE to_id = 4 ORDER BY distance;
查询结果如下:
该查询只返回了一个方向的路径。MySQL 的递归查询效率低下,且无法很好地处理双向路径。此外,递归深度高时,查询速度会大幅降低。
Java 实现 A* 算法
为了提高性能,我编写了一个 Java 程序来从 MySQL 中读取数据,并使用 A* 算法计算最短路径。Java 版本代码如下:
import java.util.*;
import java.util.stream.Collectors;
class AStar {
// A* 算法实现
public List<Dot> findShortestPath(Map<Long, Dot> dots, Map<Long, List<Road>> graph, long startId, long goalId) {
// 优先队列用于排序节点 (根据估算总成本 f = g + h)
PriorityQueue<Node> openSet = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f));
// 用于记录从哪个节点到达的当前节点
Map<Long, Long> cameFrom = new HashMap<>();
// 记录从起点到某节点的实际最小成本 g 值
Map<Long, Double> gScore = new HashMap<>();
// 初始化 gScore,所有节点设为无穷大
for (Long id : dots.keySet()) {
gScore.put(id, Double.MAX_VALUE);
}
gScore.put(startId, 0.0);
Dot start = dots.get(startId);
Dot goal = dots.get(goalId);
assert start!=null;
assert goal!=null;
// 初始化openSet
openSet.add(new Node(startId, 0.0, start.heuristic(goal)));
while (!openSet.isEmpty()) {
Node current = openSet.poll();
// 如果找到目标节点,重建路径
if (current.id == goalId) {
return reconstructPath(cameFrom, current.id,dots);
}
// 遍历邻居节点(通过边连接的点)
if (graph.containsKey(current.id)) {
for (Road road : graph.get(current.id)) {
if (!road.enabled) continue; // 忽略不启用的道路
long neighborId = road.toId;
double tentative_gScore = gScore.get(current.id) + road.distance;
// 如果找到更优的路径
if (tentative_gScore < gScore.get(neighborId)) {
cameFrom.put(neighborId, current.id);
gScore.put(neighborId, tentative_gScore);
double fScore = tentative_gScore + dots.get(neighborId).heuristic(goal);
openSet.add(new Node(neighborId, tentative_gScore, fScore));
}
}
}
}
return null; // 如果没有找到路径
}
// 重建路径
private List<Dot> reconstructPath(Map<Long, Long> cameFrom, long current, Map<Long, Dot> dots) {
List<Long> path = new ArrayList<>();
while (cameFrom.containsKey(current)) {
path.add(current);
current = cameFrom.get(current);
}
Collections.reverse(path); // 逆序获得从起点到终点的路径
return path.stream().map(dots::get).collect(Collectors.toList());
}
// 定义节点类
static class Node {
long id;
double g; // 实际成本
double f; // 总成本 (g + h)
public Node(long id, double g, double f) {
this.id = id;
this.g = g;
this.f = f;
}
}
}
运行 Java 版本程序后,查询时间约为 300 毫秒,相比 MySQL 的查询时间有了显著的提高。
Neo4j 查询最短路径
接着,我使用 Neo4j 进行同样的最短路径查询。在 Neo4j 中,可以通过 gds
库的 A* 算法快速查询最短路径:
MATCH (start:Dot {id: -2162807189120628591}), (end:Dot {id: -2162807183076351438})
CALL gds.alpha.shortestPath.astar.stream({
nodeProjection: '*',
relationshipProjection: {
Road: {
type: 'Road',
orientation: 'UNDIRECTED',
properties: 'distance'
}
},
startNode: start,
endNode: end,
relationshipWeightProperty: 'distance',
propertyKeyLat: 'latitude',
propertyKeyLon: 'longitude'
})
YIELD nodeId
RETURN gds.util.asNode(nodeId);
Neo4j 查询结果如下:
执行时间约为 140 毫秒,比 Java 实现的 A* 算法稍快。
Neo4j vs Java:性能对比
从性能上看,Neo4j 和 Java 的查询速度差别不大,Neo4j 略胜一筹。但从实际应用场景来看,Neo4j 更适合复杂关系查询,原因如下:
- 关系型数据库 vs 图数据库:MySQL 适合处理结构化数据,但对于复杂的图结构查询,Neo4j 的图遍历性能更优,尤其在节点和边数量较大的情况下。
- 查询复杂度:Neo4j 的查询时间复杂度取决于关系数量,而不是数据量。在路径较深的查询中,Neo4j 的优势更加明显。
- 内存管理:Java 虽然通过内存加载数据后可以快速查询,但需要将所有节点和边存入内存。而 Neo4j 则会根据查询需求选择性地加载数据,具有更好的扩展性。
结论
尽管 Java 通过内存加载和 A* 算法可以高效地查询最短路径,但对于大型图数据集和复杂的关系网络,Neo4j 提供了更优的性能和可扩展性。因此,在城市路径规划等场景中,Neo4j 是更适合的选择。
同时,Neo4j 的查询语法简洁,易于扩展,适合处理复杂的图数据。虽然 Java 可以实现按需加载,但复杂性较高。因此,除非对性能要求极致或数据规模相对较小,Neo4j 仍然是最推荐的解决方案。