换乘次数最少的路径 SQL 查询详解与实战经验——从 WITH RECURSIVE 到 pgRouting

换乘次数最少的路径 SQL 查询详解与实战经验------从 WITH RECURSIVE 到 pgRouting

上一篇《骑手轨迹热力图 SQL 查询详解与实战经验》之后,同一套本地生活平台数据集的第二题是图论:40 个站点、两条线路、两个换乘点,求 1 号站到 39 号站换乘次数最少(相同则总距离最短)的路径。

我先用 PostgreSQL 的 WITH RECURSIVE 手写深度优先搜索,踩了「类型不一致」「深度限制导致无解」「组合爆炸 + 无法并行」三个坑;最后切到 pgRouting 的 pgr_dijkstra()(Dijkstra 算法),拿到结果:1 → 2 → ... → 10 → 21 → ... → 39,28 段、1 次换乘、27,568 米。

这篇文章复盘整个迭代过程,并顺带搞清楚三件事:递归 CTE 到底适合什么场景、pgRouting 为什么快、以及 Dijkstra 到底是谁。

目录

  • 引子
  • 题目与数据
    • [图结构:40 个站、两条线、两个换乘点](#图结构:40 个站、两条线、两个换乘点 "#%E5%9B%BE%E7%BB%93%E6%9E%8440-%E4%B8%AA%E7%AB%99%E4%B8%A4%E6%9D%A1%E7%BA%BF%E4%B8%A4%E4%B8%AA%E6%8D%A2%E4%B9%98%E7%82%B9")
    • 先定义清楚"换乘次数"
  • [方案 1:WITH RECURSIVE 暴力搜索](#方案 1:WITH RECURSIVE 暴力搜索 "#%E6%96%B9%E6%A1%88-1with-recursive-%E6%9A%B4%E5%8A%9B%E6%90%9C%E7%B4%A2")
    • [初版 SQL](#初版 SQL "#%E5%88%9D%E7%89%88-sql")
    • [坑 1:integer 和 numeric 对不上](#坑 1:integer 和 numeric 对不上 "#%E5%9D%91-1integer-%E5%92%8C-numeric-%E5%AF%B9%E4%B8%8D%E4%B8%8A")
    • [坑 2:hops < 4 为什么永远到不了 39 号站](#坑 2:hops < 4 为什么永远到不了 39 号站 "#%E5%9D%91-2hops--4-%E4%B8%BA%E4%BB%80%E4%B9%88%E6%B0%B8%E8%BF%9C%E5%88%B0%E4%B8%8D%E4%BA%86-39-%E5%8F%B7%E7%AB%99")
    • [递归 CTE 到底是怎么执行的](#递归 CTE 到底是怎么执行的 "#%E9%80%92%E5%BD%92-cte-%E5%88%B0%E5%BA%95%E6%98%AF%E6%80%8E%E4%B9%88%E6%89%A7%E8%A1%8C%E7%9A%84")
    • 用窗口函数挑出最优路径
    • 性能天花板:为什么它只能算小图
    • [PG 14+ 的 SEARCH / CYCLE 语法](#PG 14+ 的 SEARCH / CYCLE 语法 "#pg-14-%E7%9A%84-search--cycle-%E8%AF%AD%E6%B3%95")
  • [方案 2:pgRouting + Dijkstra](#方案 2:pgRouting + Dijkstra "#%E6%96%B9%E6%A1%88-2pgrouting--dijkstra")
    • 安装与建边表
    • [pgr_dijkstra 查询](#pgr_dijkstra 查询 "#pgr_dijkstra-%E6%9F%A5%E8%AF%A2")
    • 结果怎么读
    • [为什么它比递归 CTE 快](#为什么它比递归 CTE 快 "#%E4%B8%BA%E4%BB%80%E4%B9%88%E5%AE%83%E6%AF%94%E9%80%92%E5%BD%92-cte-%E5%BF%AB")
    • [cost 是抽象权重:距离、时间、跳数、换乘](#cost 是抽象权重:距离、时间、跳数、换乘 "#cost-%E6%98%AF%E6%8A%BD%E8%B1%A1%E6%9D%83%E9%87%8D%E8%B7%9D%E7%A6%BB%E6%97%B6%E9%97%B4%E8%B7%B3%E6%95%B0%E6%8D%A2%E4%B9%98")
    • 把路径拼回几何
  • 规模再大怎么办
  • [Dijkstra 是谁](#Dijkstra 是谁 "#dijkstra-%E6%98%AF%E8%B0%81")
  • 复盘:这道题教会我的六件事
  • 附录:文档与链接

引子

上一篇做的是空间题:把骑手 GPS 点连成线,再用六边形网格切分、按长度比例分摊时间。这一篇做的是图论题:没有经纬度、没有几何对象,只有一张"从哪到哪、多远"的邻接表。

题目是:

平台有一张站点邻接表 station_edges(from_id, to_id, distance_m),记录两个站点之间是否有直达线路。用户从 1 号站出发到 39 号站,要求找出换乘次数最少 的路径;如果换乘次数相同,返回总骑行距离最短的那条。

在 MySQL 时代,这种需求我大概率会这样处理:把边表全量拉到 Java 内存里,建 Map<Long, List<Edge>>,然后手写 Dijkstra 或 BFS。这次在 PG 里,我想试试纯 SQL 能不能做------于是就有了后面这一连串"能跑但跑不动"的故事:

  1. WITH RECURSIVE 初版:报 integer / numeric 类型不一致;
  2. 类型修好后又发现:hops < 4 的深度限制让 1→39 永远无解;
  3. 把限制放宽到 50 后理论上能通,但递归 CTE 的穷举本质在真实路网规模下会指数爆炸,而且拿不到并行计划;
  4. 最终上 pgRouting 的 pgr_dijkstra():用 Dijkstra 算法 + 优先队列,在这张 40 个站的小图上几乎瞬间返回;
  5. 拿到结果后又闹了个"27,568 秒 ≈ 7.6 小时"的单位乌龙,最后发现单位其实是米。

这五步,恰好把「SQL 能表达什么」和「数据库能执行什么」之间的差距演示了一遍。

题目与数据

图结构:40 个站、两条线、两个换乘点

沿用上一篇造好的数据(local_life_platform.station_edges):

sql 复制代码
CREATE TABLE local_life_platform.station_edges (
    from_id    BIGINT  NOT NULL,
    to_id      BIGINT  NOT NULL,
    distance_m NUMERIC NOT NULL,
    PRIMARY KEY (from_id, to_id)
);

站点图是人为设计的"两根线 + 两个换乘点":

组成 站点 说明
主线 1 ~ 20 相邻站点双向连通:1↔2↔3↔...↔20
支线 21 ~ 40 相邻站点双向连通:21↔22↔...↔40
换乘点 A 10 ↔ 21 主线 10 号站换到支线 21 号站
换乘点 B 20 ↔ 30 主线 20 号站换到支线 30 号站

每条相邻边两个方向各存一行,共 19 × 2 + 19 × 2 + 4 = 80 行;距离在 500~1500 米之间随机生成。

从 1 号站到 39 号站,只有两条"像样"的走法:

候选路径 段数(经过的边) 换乘次数 说明
1 → ... → 10 → 21 → ... → 39 9 + 1 + 18 = 28 1 在 10 号站换乘,距离更短
1 → ... → 20 → 30 → ... → 39 19 + 1 + 9 = 29 1 在 20 号站换乘,多绕一段

两条路线的换乘次数都是 1,所以"换乘最少"这一条打平,最终是"总距离最短"决出胜负。

图 1:40 个站点排成主线(120)和支线(2140),两条换乘边分别连接 10↔21、20↔30。橙色是最终选中的路线(经 10→21,28 段),灰色虚线是另一条候选路线(经 20→30,29 段)。

先定义清楚"换乘次数"

这里有个非常关键、但很容易被忽略的点:当前的表结构里没有"线路 ID"字段。

station_edges 只有 from_id、to_id、distance_m。在这样的图上,"走一条边"和"换乘一次"是两回事:

  • 从 1 号站坐到 10 号站,中间经过了 9 条边,但一直待在主线上,换乘次数是 0;
  • 只有在 10 号站从主线切到支线(走 10→21 这条换乘边),才算 1 次换乘。

而递归 CTE 里如果只统计"走过的边数",得到的其实是乘坐次数/步数,不是换乘次数。这也是后面方案 1 的一个隐患:它算的是"最少步数",只不过在这道题的小图上,最少步数的方向和最少换乘恰好一致。

真正要算"最少换乘",必须在边上记录 line_id(线路 ID),或者把"站点 + 当前线路"组合成状态节点(后面"cost 是抽象权重"一节会展开)。

方案 1:WITH RECURSIVE 暴力搜索

初版 SQL

思路很直观:从 1 号站出发,沿着边一步步"走",每走一步就把新站点拼进 path 数组(顺便防环),把边数累加到 hops、距离累加到 total_distance;走到底之后,用窗口函数按 (hops, total_distance) 排序,取第一条。

sql 复制代码
WITH RECURSIVE paths AS (
    -- 锚点(Anchor):从 1 号站出发的第一条边
    SELECT
        from_id    AS start_id,
        to_id      AS current_id,
        ARRAY[from_id, to_id] AS path,   -- 已经走过的站点
        1          AS hops,              -- 已经走过的边数
        distance_m AS total_distance
    FROM local_life_platform.station_edges
    WHERE from_id = 1

    UNION ALL

    -- 递归部分:从当前站点继续找下一条边
    SELECT
        p.start_id,
        e.to_id,
        p.path || e.to_id,               -- 数组追加(也可以写 ARRAY_APPEND(...))
        p.hops + 1,
        p.total_distance + e.distance_m
    FROM paths p
    JOIN local_life_platform.station_edges e
      ON e.from_id = p.current_id
    WHERE NOT e.to_id = ANY(p.path)      -- 防环:走过的站不再走
      AND p.hops < 50                    -- 深度上限(最初写的是 4)
)
SELECT
    start_id,
    current_id AS end_id,
    path,
    hops,
    total_distance
FROM (
    SELECT *,
           ROW_NUMBER() OVER (
               PARTITION BY start_id, current_id
               ORDER BY hops ASC, total_distance ASC
           ) AS rn
    FROM paths
    WHERE current_id = 39
) t
WHERE rn = 1;

这条 SQL 把递归 CTE 的四个核心零件都用上了:

  • WITH RECURSIVE paths AS (...):CTE 自己引用自己;
  • 上半段(非递归项)+ UNION ALL + 下半段(递归项):递归的固定骨架;
  • path 数组:记录走过的站点,用 NOT ... = ANY(path) 防环;
  • hops / total_distance:分别累计步数和距离,最后交给窗口函数选优。

把深度上限放宽到 50 之后,这条查询在逻辑上就能走通:按 (hops, total_distance) 排序,选中的会是 1 → 2 → ... → 10 → 21 → ... → 39(28 段)。这个结论后来又被 pgRouting 独立验证了一遍------同样是 28 段、总距离 27,568.01 米。

不过这条 SQL 在跑通之前,先给了我两个下马威。

坑 1:integer 和 numeric 对不上

第一次执行直接报错:

txt 复制代码
ERROR:  recursive query "paths" column 5 has type integer in non-recursive term
        but type numeric overall
LINE 7:   1 AS hops
HINT:  Cast the output of the non-recursive term to the correct type.
SQL state: 42804

原因不复杂:递归 CTE 的"非递归项"和"递归项"必须产出完全一致的列类型 ,PG 会拿两半的结果做 UNION ALL,类型对不上就报错。

而这个报错特别容易误导人:字面量 1 默认是 integer,distance_m 是 numeric。如果两半的 SELECT 列表列顺序错位 (比如你把 total_distance 排在 hops 前面),PG 在合并时就会把 numeric 的列和 integer 的列对上,然后报"第 5 列 integer vs numeric"。

修复方式很简单:把类型显式对齐,并检查两半的列顺序。

sql 复制代码
-- 修复方式 A:非递归项显式 cast
1::numeric AS hops

-- 修复方式 B:递归项显式 cast
(p.hops + 1)::numeric

-- 或者干脆统一成 bigint(更符合"站点 ID / 步数"的直觉)
1::bigint AS hops
(p.hops + 1)::bigint

经验:写递归 CTE 时,两半 SELECT 的列顺序、列类型、列名(可选)最好逐列对照一遍 。这种错 PG 的报错位置("column 5")指的是 CTE 输出列的位置,不是表列名,所以别只盯着 hops 这一列看,先把整个列表对一遍。

坑 2:hops < 4 为什么永远到不了 39 号站

类型修好之后,我把 p.hops < 4 原样保留,结果查询返回空集。当时我还以为是防环写错了,后来一推演图结构就明白了:

  • 从 1 号站到 10 号站,要走 9 条边(hops 从 1 涨到 10);
  • 10→21 换乘,hops = 11;
  • 21 号站到 39 号站,还要 18 条边(hops 一路涨到 29,如果按"边数"算)。

也就是说,最短也需要 28 条边 。而 hops < 4 在递归走到第 4 条边时就掐断了,根本到不了 39。

这个坑的教训不是"把数字改大"这么简单,而是:

深度限制是一个业务参数,不是随手写的魔法数字。 它应该来自业务约束------最多允许换乘几次、最多允许坐多少站、最大可接受绕行距离。限制太松会爆,太紧会无解,中间那条线要业务来定。

在这道题里,我最后把限制放宽到了 p.hops < 50(40 个站,任何简单路径最多 39 条边,50 足够兜住)。

递归 CTE 到底是怎么执行的

要理解后面为什么慢,得先看 PG 文档里对递归查询执行过程的描述。官方给的模型是"工作表(working table)"迭代:

  1. 先执行非递归项,把结果放进结果集和一张临时工作表;
  2. 只要工作表非空,就重复:
    • 把当前工作表代入递归项求值(也就是"下一层");
    • 把新产生的行放进中间表;
    • 用中间表替换工作表,进入下一轮。

所以 WITH RECURSIVE 在内部是迭代执行 的,不是真的递归调用。在 EXPLAIN 里,你通常会看到 Recursive Union 节点和 WorkTable Scan 节点。

这也解释了两件后面会反复出现的事:

  1. 每一轮递归都依赖上一轮的工作表 ,所以整个递归过程必须串行,天然拿不到并行计划(Recursive Union / WorkTable Scan 不是并行节点);
  2. 它算的是"所有可达路径",不是"一条最优路径"。终止条件只能靠防环 + 深度限制,而不是"找到终点就停"。你最后看到的"最优解",是窗口函数从所有枚举出来的路径里选出来的。

顺便把两个容易混淆的语法点说清楚:

  • JOIN 是横向拼列 :paths p JOIN station_edges e ON e.from_id = p.current_id,是把"当前路径"和"下一条边"拼成一行,找下一步去哪;
  • UNION ALL 是纵向追加行:把递归项产生的新路径继续追加到结果集下面,是驱动递归的"发动机"。
  • 递归 CTE 里一定要用 UNION ALL。UNION 会做全量去重(排序/哈希),而每一行的 path 数组都不同,去重毫无意义,只会白白增加 CPU 和内存开销。

用窗口函数挑出最优路径

递归 CTE 只负责"把所有路径枚举出来",选优要靠窗口函数:

sql 复制代码
SELECT start_id, current_id AS end_id, path, hops, total_distance
FROM (
    SELECT *,
           ROW_NUMBER() OVER (
               PARTITION BY start_id, current_id
               ORDER BY hops ASC, total_distance ASC
           ) AS rn
    FROM paths
    WHERE current_id = 39
) t
WHERE rn = 1;

这里排序键是 hops ASC, total_distance ASC,对应"先比步数、再比距离"。

如果起点固定、只查一个终点,其实还有个更简洁的 PostgreSQL 写法------DISTINCT ON:

sql 复制代码
SELECT DISTINCT ON (current_id)
       start_id, current_id AS end_id, path, hops, total_distance
FROM paths
WHERE current_id = 39
ORDER BY current_id, hops ASC, total_distance ASC;

它等价于"每个 current_id 取排序后的第一行",但语法比套一层 ROW_NUMBER() 更短。多起点、多终点时,还是 ROW_NUMBER() 的 PARTITION BY 更灵活。

性能天花板:为什么它只能算小图

在 40 个站、80 条边的小图上,上面这套递归 CTE 其实能跑出结果。但把它放到真实路网里,问题会立刻放大:

1)分支指数级增长。 递归 CTE 枚举的是"所有简单路径"。假设平均每个站连着 4 个站、搜索深度 10 层,最坏情况就要展开 4^10 ≈ 104 万 条候选路径------而真实城市路网有几十万站点、几百万条边、大量环线和换乘组合,候选路径数量是指数级的。

2)只能单核串行。 递归是"上一轮工作表 → 下一轮工作表"的迭代,Recursive Union 不是并行节点;再加上 CTE 扫描在并行查询里的限制,这条查询基本只能占一个 CPU 核。并发一多,就是"一条 SQL 烧一个核"。

3)防环判断本身也有成本。 NOT e.to_id = ANY(p.path) 每扩展一行都要遍历一遍 path 数组,path 越长、候选行越多,成本越高;而且每行都在复制数组。

4)没有"找到终点就停"的能力。 递归 CTE 会老老实实把所有满足深度限制的路径都枚举完,外层窗口函数再排序。你可以在最外层加 LIMIT 1 提前要一行,但一旦外层需要 ORDER BY / 聚合,PG 通常还是会把递归结果都算出来(官方文档也提醒过,用 LIMIT 给递归查询"刹车"只适合调试,不适合生产)。

所以这类查询的定位应该是:小图、浅深度、离线/低并发场景。它的价值是"用一条 SQL 把逻辑表达清楚",而不是"扛住导航级别的流量"。

如果只是想把"防环 + 深度优先/广度优先排序"写得更优雅,PostgreSQL 14 之后提供了 SEARCH 和 CYCLE 两个子句,不用再手写 path 数组和 NOT ... = ANY(...):

sql 复制代码
WITH RECURSIVE paths AS (
    SELECT from_id AS start_id,
           to_id   AS current_id,
           distance_m AS total_distance,
           1 AS hops
    FROM local_life_platform.station_edges
    WHERE from_id = 1

    UNION ALL

    SELECT p.start_id,
           e.to_id,
           p.total_distance + e.distance_m,
           p.hops + 1
    FROM paths p
    JOIN local_life_platform.station_edges e
      ON e.from_id = p.current_id
) CYCLE current_id SET is_cycle USING path
SELECT *
FROM paths
WHERE current_id = 39
  AND NOT is_cycle
ORDER BY hops, total_distance
LIMIT 1;

CYCLE current_id SET is_cycle USING path 会自动做两件事:维护一个"已访问节点"数组 path,并给每一行打上 is_cycle 标记;遇到环的行不再继续递归。SEARCH BREADTH FIRST BY current_id SET ordercol 则可以直接得到 BFS 的排序键。

但要注意:SEARCH / CYCLE 只是语法糖,执行模型没变。它不能把"枚举所有路径"变成"只找一条最优路径",也解决不了指数爆炸的问题。真要算最短路,还是得换算法。

方案 2:pgRouting + Dijkstra

安装与建边表

pgRouting 是 PostgreSQL 上最成熟的图算法扩展,底层用的是 C++ 的 Boost Graph Library,内置 Dijkstra、A*、双向搜索、Floyd-Warshall 等一批算法。它依赖 PostGIS,安装后按数据库启用:

sql 复制代码
-- 安装扩展(系统包名随 PG 大版本变化,比如 postgresql-17-pgrouting;
-- 也可以按官方文档从源码编译,或者直接用带 pgRouting 的 Docker 镜像)
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgrouting;

-- 确认版本
SELECT pgr_version();

然后把它要求的三列(id、source、target、cost)准备好。我们的 station_edges 本身已经是"两个方向各一行",所以直接把 from_id → source、to_id → target、distance_m → cost 映射过去即可:

sql 复制代码
DROP TABLE IF EXISTS local_life_platform.pgr_station_edges;

CREATE TABLE local_life_platform.pgr_station_edges AS
SELECT
    row_number() OVER (ORDER BY from_id, to_id)::bigint AS id,
    from_id::bigint   AS source,
    to_id::bigint     AS target,
    distance_m::float8 AS cost
FROM local_life_platform.station_edges;

ALTER TABLE local_life_platform.pgr_station_edges
    ADD PRIMARY KEY (id);

CREATE INDEX idx_pgr_station_edges_source
    ON local_life_platform.pgr_station_edges (source);

几个容易踩的点:

  • cost 必须是非负数值;负值会被当成"这条边不存在"(这是 pgRouting 的约定,不是报错);
  • 我们的表里两个方向都建了边,所以用 directed := true 就行;如果只存单方向的边,就要配合 reverse_cost 或设成无向图;
  • pgr_dijkstra() 的边查询是一个字符串,pgRouting 会把它当作子查询去执行,因此边表名、过滤条件都可以写在里面。

pgr_dijkstra 查询

sql 复制代码
SELECT *
FROM pgr_dijkstra(
    'SELECT id, source, target, cost
       FROM local_life_platform.pgr_station_edges',
    1,        -- 起点:1 号站
    39,       -- 终点:39 号站
    directed := true
);

pgRouting 从 3.5 开始把各签名的输出列统一成了 8 列:seq, path_seq, start_vid, end_vid, node, edge, cost, agg_cost。我拿到的结果是一条 29 行的路径:

seq node edge cost agg_cost 说明
1 1 1 1072.49 0 从 1 号站出发
2 2 2 527.95 1072.49 主干线继续
... ... ... ... ... ...
10 10 77 1496.13 9662.67 到 10 号站,下一步走换乘边
11 21 39 1423.01 11158.79 进入支线
... ... ... ... ... ...
29 39 -1 0 27568.01 到达终点,edge = -1

把 node 列连起来看:

txt 复制代码
1 → 2 → 3 → ... → 10 → 21 → 22 → ... → 39

和方案 1 的结论一致:28 段边、1 次换乘(10→21)、总距离 27,568.01 米(约 27.57 公里)。两套完全不同的实现相互印证,说明数据建模和结果解读都没问题。

结果怎么读

pgr_dijkstra() 的输出列含义值得单独记一下(官方文档的定义):

列 类型 含义
seq integer 从 1 开始的顺序号
path_seq integer 当前路径内的相对位置,起点为 1
start_vid bigint 起点 ID
end_vid bigint 终点 ID
node bigint 当前节点
edge bigint 从 node 走到下一个节点所用的边;最后一个节点是 -1
cost float 走这条边的代价
agg_cost float 从起点到当前节点的累计代价

两个细节:

  1. 最后一行一定是 edge = -1, cost = 0 ,因为到了终点就没有"下一条边"了;真正的总代价看最后一行的 agg_cost。
  2. 无路可达时 ,pgr_dijkstra 不会返回这条路径的行(结果集为空);如果你用的是 pgr_dijkstraCost 这类只算代价的函数,不可达的代价会显示为 ∞。生产代码里要处理"空结果"的情况,不要默认一定有路径。

为什么它比递归 CTE 快

核心差别在算法,不在 SQL 写法:

  • 递归 CTE 是"穷举":把所有简单路径都生成出来,最后再排序选最优。复杂度随深度指数增长,而且拿不到并行。
  • Dijkstra 是"带优先队列的 BFS" :每次从"当前累计代价最小"的节点往外扩展,一旦终点被弹出优先队列,就可以停止。官方文档给出的运行时间是 O(|start vids| × (V log V + E)),其中 V 是节点数、E 是边数------只跟图的规模有关,跟路径深度不是指数关系。

在实现层面,pgRouting 把图加载进内存、用 C++ 的 Boost Graph 维护优先队列,Dijkstra 的"每次取最小"由堆完成,不需要像 SQL 递归那样一轮轮物化工作表。这就是"专业的事交给专业的扩展"。

图 2:左边是递归 CTE 的思路------把所有分叉都枚举一遍,最后再比大小;右边是 Dijkstra 的思路------按累计代价从小到大扩展(像水波扩散),终点第一次被扩展到就停。

cost 是抽象权重:距离、时间、跳数、换乘

pgRouting 最漂亮的设计是:cost 只是一个抽象权重,算法不关心它的物理单位。你传什么,它就优化什么:

业务目标 cost 传什么 agg_cost 的含义
最短距离 distance_m 米
最少步数 / 最少乘坐段数 1 段数
最短时间 distance_m / speed 秒(或小时,取决于 speed 单位)
最少费用 toll_fee 元

比如把 cost 全换成 1,就是"最少段数":

sql 复制代码
SELECT *
FROM pgr_dijkstra(
    'SELECT id, source, target, 1::float8 AS cost
       FROM local_life_platform.pgr_station_edges',
    1, 39,
    directed := true
);

这时最后一行的 agg_cost 就是 28(段数),路线依然是 1→...→10→21→...→39。

但要注意:cost = 1 求的是"最少边数",不是"最少换乘次数"。 只要表里没有线路信息,"边数"和"换乘次数"就没法区分。

如果真要算"最少换乘 + 同换乘次数下最短距离",标准做法是把"线路"变成图的一部分:

  1. 给边补上 line_id;
  2. 把节点从"站点"扩展成"(站点, 线路)"的状态节点:同一条线路内移动代价为 0(或距离),换到另一条线路代价为 1;
  3. 在这个"状态图"上跑 Dijkstra,agg_cost 就是换乘次数;
  4. 需要距离做 tie-break 时,把代价设计成"换乘次数 × 大常数 + 距离",一次排序同时满足两个目标(或者分两阶段:先求最少换乘数,再在等换乘的路径里求最短距离)。

用递归 CTE 也能做同样的事------在递归行里额外维护 current_line,每次 line_id 变化时把 transfers + 1:

sql 复制代码
-- 前提:station_edges 增加 line_id 字段;当前数据集没有该字段,此段仅示意
WITH RECURSIVE paths AS (
    SELECT
        from_id AS start_id,
        to_id   AS current_id,
        ARRAY[from_id, to_id] AS path,
        0 AS transfers,               -- 出发不算换乘
        line_id AS current_line,
        distance_m AS total_distance
    FROM local_life_platform.station_edges
    WHERE from_id = 1

    UNION ALL

    SELECT
        p.start_id,
        e.to_id,
        p.path || e.to_id,
        p.transfers
          + CASE WHEN e.line_id <> p.current_line THEN 1 ELSE 0 END,
        e.line_id,
        p.total_distance + e.distance_m
    FROM paths p
    JOIN local_life_platform.station_edges e
      ON e.from_id = p.current_id
    WHERE NOT e.to_id = ANY(p.path)
)
SELECT start_id, current_id, path, transfers, total_distance
FROM paths
WHERE current_id = 39
ORDER BY transfers, total_distance
LIMIT 1;

这也解释了为什么"换乘次数"必须放在数据建模层解决:算法只认权重,不认业务语义。你在图上少建一个维度,算法就永远算不出来。

把路径拼回几何

pgRouting 返回的是逻辑上的"节点跳转序列"。如果前端要在地图上画线,就把 route.edge 和边表的几何列做 JOIN,再用 ST_MakeLine 按 seq 顺序拼起来:

sql 复制代码
WITH route AS (
    SELECT *
    FROM pgr_dijkstra(
        'SELECT id, source, target, cost
           FROM local_life_platform.pgr_station_edges',
        1, 39,
        directed := true
    )
)
SELECT ST_MakeLine(geom ORDER BY route.seq) AS route_geometry
FROM route
JOIN local_life_platform.station_edges_geom g
  ON g.id = route.edge
WHERE route.edge <> -1;   -- 终点那行没有边

注意:这要求边表里存了 geom(LineString)。我们这次的数据只有"站点 ID 和距离",没有站点坐标,所以这一步是示意图------真实业务里通常会给每条边存一条 LINESTRING,拼出来的路线可以直接交给 Leaflet / OpenLayers 渲染。

规模再大怎么办

pgRouting 已经能覆盖大多数"万级节点、百万级边"的路径规划场景,但如果业务继续长,还有几层方案:

  1. 换更专业的算法 :启发式搜索 pgr_aStar()(需要坐标,用直线距离做启发函数)、双向搜索 pgr_bdDijkstra()(从起点和终点同时扩展,搜索量约从 b^d 降到 b^(d/2))、全源最短路 pgr_floydWarshall()(适合离线把所有站点对的距离预计算好)。
  2. 换图数据库:Neo4j、NebulaGraph 这类图数据库底层用指针/邻接存储,遍历邻居是近似 O(1),并内置最短路算法,适合"关系本身是核心业务"的场景。
  3. 换内存图引擎:OSRM、GraphHopper 这类导航引擎会把路网预处理成 Contraction Hierarchies(收缩层级),查询是毫秒级;代价是预处理和内存占用。
  4. 预计算 + 缓存:站点数量有限时,把"核心站点两两之间的最短路径"算好落表(物化视图/定时任务),查询直接查表;热门 OD 对再套一层 Redis。
  5. 图分区:按城市/区域切子图,跨区只走"边界节点",避免每次都在全国路网上搜。

这跟前一篇的结论其实一样:SQL 写得再巧,也顶不住数据规模上一个数量级;该上扩展上扩展,该上专用引擎上专用引擎。

Dijkstra 是谁

第一次看到 pgr_dijkstra 时,我和看到 déjà vu 一样------完全不知道这词从哪来。

其实 Dijkstra 不是什么缩写,而是一个人的姓:Edsger Wybe Dijkstra(艾兹格·迪杰斯特拉),荷兰计算机科学家,1972 年图灵奖得主。图灵奖之外,他还留下了信号量、死锁处理、"goto 有害论"等一堆基础设施级贡献;而"最短路径"只是他 1956 年在咖啡馆里用 20 分钟想出来的一个算法。

这个算法的直觉是水波纹扩散:

  1. 从起点开始,先把代价最小的邻居"解锁";
  2. 永远优先扩展"当前累计代价最小"的节点;
  3. 当终点第一次被扩展到时,当前路径就是全局最短。

对比一下就很清楚:

递归 CTE(DFS/穷举) Dijkstra(贪心 + 优先队列)
搜索方式 一条路走到黑,再回溯 每次扩展当前累计代价最小的节点
终止条件 枚举完所有路径 / 深度上限 终点被弹出优先队列即停
适合场景 小图、枚举所有路径、层级/树结构 单源最短路、边权非负的图
复杂度 最坏指数级 O(V log V + E)

Dijkstra 的限制是边权不能为负。需要处理负权时用 Bellman-Ford;要一次算出所有点对之间的最短路,用 Floyd-Warshall。另外,A* 就是在 Dijkstra 上加了一个"到终点的估算距离"作为启发项,导航软件里更常用。

复盘:这道题教会我的六件事

  1. 递归 CTE 是"表达力工具",不是"图算法引擎"。 它能把"沿边一步步走"写得非常清楚,适合层级/树/小图;但它枚举所有路径,不擅长"只找一条最优路径"。
  2. 递归 CTE 两半的列顺序和类型必须严格对齐。 integer vs numeric 的报错只是表象,先对照两半的 SELECT 列表。显式 cast 永远比让 PG 猜靠谱。
  3. 深度限制是业务参数。 hops < 4 不是"优化",是"业务约束";写限制之前,先推演图结构,确认目标可达。
  4. "换乘次数"必须在建模层定义清楚。 没有 line_id,"边数"就是"边数",永远不等于"换乘次数"。要算换乘,要么加字段,要么把线路做成状态节点。
  5. 代价(cost)是抽象的。 pgRouting 的 cost 决定算法求什么:距离、时间、跳数、费用都行;agg_cost 的单位完全取决于你传的 cost。这也是我后来把"27,568 秒"纠正成"27,568 米"的原因。
  6. 规模决定选型。 小图用递归 CTE 足够优雅;生产级路网用 pgRouting;再大就上预计算、内存图引擎或图数据库。SQL 写得出,不等于跑得动。

回头看,这道题最大的收获不是"背下递归 CTE 的语法",而是理解了三条边界:

  • 递归 CTE 的边界(能把逻辑写清楚,但扛不住指数爆炸);
  • SQL 的边界(能表达图遍历,但不适合大规模图算法);
  • 数据建模的边界("换乘"这种业务语义,不建模就永远算不对)。

下一篇是这套题的最后一块:pgvector 的向量检索与 PostGIS 空间过滤怎么揉进同一条 SQL------也就是现在 AI 应用里最常见的"语义搜索 + 地理围栏"混合查询。等我踩完坑继续写。

附录:文档与链接

PostgreSQL 文档

pgRouting

算法与生态

相关推荐
工控丫丫42 分钟前
帆软看板中经常使用的公式函数及经常搭的组合
前端·javascript·数据库
这个DBA有点耶2 小时前
云数据库半夜卡顿,CPU才30%?深挖云盘IOPS突发限制与内核挂起机制
数据库·mysql·云原生
无言听风雨丶3 小时前
Redis 运维实战与排错
运维·数据库·redis
小天源5 小时前
智叟云集系统 · 架构与职责分层
服务器·前端·数据库
herinspace5 小时前
管家婆实用贴-SQL 2008如何修改成混合模式
服务器·数据库·sql·管家婆软件·财务软件·合肥管家婆软件
打螺丝的小李5 小时前
制造企业质量管理系统的三类产品形态:分别解决什么问题
数据库·制造
xixingzhe26 小时前
MySQL 锁表故障排查与处理
数据库·mysql
真上帝的左手6 小时前
30. 技术专题-‌应用框架组件-关系型数据库-MySQL-Mybatis
数据库·分布式·mysql·中间件·mybatis
为思念酝酿的痛6 小时前
MySQL表的连接
数据库·mysql