城市路径规划:Neo4j vs MySQL 之最短路径查询性能对比

209 阅读5分钟

城市路径规划: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 条数据,如下所示:

image.png

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;

查询结果如下:

image.png

该查询只返回了一个方向的路径。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 查询结果如下:

image.png

执行时间约为 140 毫秒,比 Java 实现的 A* 算法稍快。

Neo4j vs Java:性能对比

从性能上看,Neo4j 和 Java 的查询速度差别不大,Neo4j 略胜一筹。但从实际应用场景来看,Neo4j 更适合复杂关系查询,原因如下:

  1. 关系型数据库 vs 图数据库:MySQL 适合处理结构化数据,但对于复杂的图结构查询,Neo4j 的图遍历性能更优,尤其在节点和边数量较大的情况下。
  2. 查询复杂度:Neo4j 的查询时间复杂度取决于关系数量,而不是数据量。在路径较深的查询中,Neo4j 的优势更加明显。
  3. 内存管理:Java 虽然通过内存加载数据后可以快速查询,但需要将所有节点和边存入内存。而 Neo4j 则会根据查询需求选择性地加载数据,具有更好的扩展性。
结论

尽管 Java 通过内存加载和 A* 算法可以高效地查询最短路径,但对于大型图数据集和复杂的关系网络,Neo4j 提供了更优的性能和可扩展性。因此,在城市路径规划等场景中,Neo4j 是更适合的选择

同时,Neo4j 的查询语法简洁,易于扩展,适合处理复杂的图数据。虽然 Java 可以实现按需加载,但复杂性较高。因此,除非对性能要求极致或数据规模相对较小,Neo4j 仍然是最推荐的解决方案。