换乘次数最少的路径 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到底是谁。
目录
- 引子
- 题目与数据
- 方案 1:WITH RECURSIVE 暴力搜索
- 方案 2:pgRouting + Dijkstra
- 规模再大怎么办
- Dijkstra 是谁
- 复盘:这道题教会我的六件事
- 附录:文档与链接
引子
上一篇做的是空间题:把骑手 GPS 点连成线,再用六边形网格切分、按长度比例分摊时间。这一篇做的是图论题:没有经纬度、没有几何对象,只有一张"从哪到哪、多远"的邻接表。
题目是:
平台有一张站点邻接表
station_edges(from_id, to_id, distance_m),记录两个站点之间是否有直达线路。用户从 1 号站出发到 39 号站,要求找出换乘次数最少的路径;如果换乘次数相同,返回总骑行距离最短的那条。
在 MySQL 时代,这种需求我大概率会这样处理:把边表全量拉到 Java 内存里,建 Map<Long, List<Edge>>,然后手写 Dijkstra 或 BFS。这次在 PG 里,我想试试纯 SQL 能不能做——于是就有了后面这一连串"能跑但跑不动"的故事:
WITH RECURSIVE初版:报integer/numeric类型不一致;- 类型修好后又发现:
hops < 4的深度限制让 1→39 永远无解; - 把限制放宽到 50 后理论上能通,但递归 CTE 的穷举本质在真实路网规模下会指数爆炸,而且拿不到并行计划;
- 最终上 pgRouting 的
pgr_dijkstra():用 Dijkstra 算法 + 优先队列,在这张 40 个站的小图上几乎瞬间返回; - 拿到结果后又闹了个"27,568 秒 ≈ 7.6 小时"的单位乌龙,最后发现单位其实是米。
这五步,恰好把「SQL 能表达什么」和「数据库能执行什么」之间的差距演示了一遍。
题目与数据
图结构:40 个站、两条线、两个换乘点
沿用上一篇造好的数据(local_life_platform.station_edges):
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) 排序,取第一条。
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 对不上
第一次执行直接报错:
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"。
修复方式很简单:把类型显式对齐,并检查两半的列顺序。
-- 修复方式 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)"迭代:
- 先执行非递归项,把结果放进结果集和一张临时工作表;
- 只要工作表非空,就重复:
- 把当前工作表代入递归项求值(也就是"下一层");
- 把新产生的行放进中间表;
- 用中间表替换工作表,进入下一轮。
所以 WITH RECURSIVE 在内部是迭代执行的,不是真的递归调用。在 EXPLAIN 里,你通常会看到 Recursive Union 节点和 WorkTable Scan 节点。
这也解释了两件后面会反复出现的事:
- 每一轮递归都依赖上一轮的工作表,所以整个递归过程必须串行,天然拿不到并行计划(
Recursive Union/WorkTable Scan不是并行节点); - 它算的是"所有可达路径",不是"一条最优路径"。终止条件只能靠防环 + 深度限制,而不是"找到终点就停"。你最后看到的"最优解",是窗口函数从所有枚举出来的路径里选出来的。
顺便把两个容易混淆的语法点说清楚:
JOIN是横向拼列:paths p JOIN station_edges e ON e.from_id = p.current_id,是把"当前路径"和"下一条边"拼成一行,找下一步去哪;UNION ALL是纵向追加行:把递归项产生的新路径继续追加到结果集下面,是驱动递归的"发动机"。- 递归 CTE 里一定要用
UNION ALL。UNION会做全量去重(排序/哈希),而每一行的path数组都不同,去重毫无意义,只会白白增加 CPU 和内存开销。
用窗口函数挑出最优路径
递归 CTE 只负责"把所有路径枚举出来",选优要靠窗口函数:
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:
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 把逻辑表达清楚",而不是"扛住导航级别的流量"。
PG 14+ 的 SEARCH / CYCLE 语法
如果只是想把"防环 + 深度优先/广度优先排序"写得更优雅,PostgreSQL 14 之后提供了 SEARCH 和 CYCLE 两个子句,不用再手写 path 数组和 NOT ... = ANY(...):
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,安装后按数据库启用:
-- 安装扩展(系统包名随 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 映射过去即可:
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 查询
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 列连起来看:
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 | 从起点到当前节点的累计代价 |
两个细节:
- 最后一行一定是
edge = -1, cost = 0,因为到了终点就没有"下一条边"了;真正的总代价看最后一行的agg_cost。 - 无路可达时,
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,就是"最少段数":
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 求的是"最少边数",不是"最少换乘次数"。 只要表里没有线路信息,"边数"和"换乘次数"就没法区分。
如果真要算"最少换乘 + 同换乘次数下最短距离",标准做法是把"线路"变成图的一部分:
- 给边补上
line_id; - 把节点从"站点"扩展成"(站点, 线路)"的状态节点:同一条线路内移动代价为 0(或距离),换到另一条线路代价为 1;
- 在这个"状态图"上跑 Dijkstra,
agg_cost就是换乘次数; - 需要距离做 tie-break 时,把代价设计成"换乘次数 × 大常数 + 距离",一次排序同时满足两个目标(或者分两阶段:先求最少换乘数,再在等换乘的路径里求最短距离)。
用递归 CTE 也能做同样的事——在递归行里额外维护 current_line,每次 line_id 变化时把 transfers + 1:
-- 前提: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 顺序拼起来:
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 已经能覆盖大多数"万级节点、百万级边"的路径规划场景,但如果业务继续长,还有几层方案:
- 换更专业的算法:启发式搜索
pgr_aStar()(需要坐标,用直线距离做启发函数)、双向搜索pgr_bdDijkstra()(从起点和终点同时扩展,搜索量约从b^d降到b^(d/2))、全源最短路pgr_floydWarshall()(适合离线把所有站点对的距离预计算好)。 - 换图数据库:Neo4j、NebulaGraph 这类图数据库底层用指针/邻接存储,遍历邻居是近似 O(1),并内置最短路算法,适合"关系本身是核心业务"的场景。
- 换内存图引擎:OSRM、GraphHopper 这类导航引擎会把路网预处理成 Contraction Hierarchies(收缩层级),查询是毫秒级;代价是预处理和内存占用。
- 预计算 + 缓存:站点数量有限时,把"核心站点两两之间的最短路径"算好落表(物化视图/定时任务),查询直接查表;热门 OD 对再套一层 Redis。
- 图分区:按城市/区域切子图,跨区只走"边界节点",避免每次都在全国路网上搜。
这跟前一篇的结论其实一样:SQL 写得再巧,也顶不住数据规模上一个数量级;该上扩展上扩展,该上专用引擎上专用引擎。
Dijkstra 是谁
第一次看到 pgr_dijkstra 时,我和看到 déjà vu 一样——完全不知道这词从哪来。
其实 Dijkstra 不是什么缩写,而是一个人的姓:Edsger Wybe Dijkstra(艾兹格·迪杰斯特拉),荷兰计算机科学家,1972 年图灵奖得主。图灵奖之外,他还留下了信号量、死锁处理、"goto 有害论"等一堆基础设施级贡献;而"最短路径"只是他 1956 年在咖啡馆里用 20 分钟想出来的一个算法。
这个算法的直觉是水波纹扩散:
- 从起点开始,先把代价最小的邻居"解锁";
- 永远优先扩展"当前累计代价最小"的节点;
- 当终点第一次被扩展到时,当前路径就是全局最短。
对比一下就很清楚:
| 递归 CTE(DFS/穷举) | Dijkstra(贪心 + 优先队列) | |
|---|---|---|
| 搜索方式 | 一条路走到黑,再回溯 | 每次扩展当前累计代价最小的节点 |
| 终止条件 | 枚举完所有路径 / 深度上限 | 终点被弹出优先队列即停 |
| 适合场景 | 小图、枚举所有路径、层级/树结构 | 单源最短路、边权非负的图 |
| 复杂度 | 最坏指数级 | O(V log V + E) |
Dijkstra 的限制是边权不能为负。需要处理负权时用 Bellman-Ford;要一次算出所有点对之间的最短路,用 Floyd-Warshall。另外,A* 就是在 Dijkstra 上加了一个"到终点的估算距离"作为启发项,导航软件里更常用。
复盘:这道题教会我的六件事
- 递归 CTE 是"表达力工具",不是"图算法引擎"。 它能把"沿边一步步走"写得非常清楚,适合层级/树/小图;但它枚举所有路径,不擅长"只找一条最优路径"。
- 递归 CTE 两半的列顺序和类型必须严格对齐。
integervsnumeric的报错只是表象,先对照两半的 SELECT 列表。显式cast永远比让 PG 猜靠谱。 - 深度限制是业务参数。
hops < 4不是"优化",是"业务约束";写限制之前,先推演图结构,确认目标可达。 - "换乘次数"必须在建模层定义清楚。 没有
line_id,"边数"就是"边数",永远不等于"换乘次数"。要算换乘,要么加字段,要么把线路做成状态节点。 - 代价(cost)是抽象的。 pgRouting 的
cost决定算法求什么:距离、时间、跳数、费用都行;agg_cost的单位完全取决于你传的cost。这也是我后来把"27,568 秒"纠正成"27,568 米"的原因。 - 规模决定选型。 小图用递归 CTE 足够优雅;生产级路网用 pgRouting;再大就上预计算、内存图引擎或图数据库。SQL 写得出,不等于跑得动。
回头看,这道题最大的收获不是"背下递归 CTE 的语法",而是理解了三条边界:
- 递归 CTE 的边界(能把逻辑写清楚,但扛不住指数爆炸);
- SQL 的边界(能表达图遍历,但不适合大规模图算法);
- 数据建模的边界("换乘"这种业务语义,不建模就永远算不对)。
下一篇是这套题的最后一块:pgvector 的向量检索与 PostGIS 空间过滤怎么揉进同一条 SQL——也就是现在 AI 应用里最常见的"语义搜索 + 地理围栏"混合查询。等我踩完坑继续写。
附录:文档与链接
PostgreSQL 文档
WITH RECURSIVE(递归查询):www.postgresql.org/docs/curren…SEARCH/CYCLE子句:www.postgresql.org/docs/curren…- 环检测(Cycle Detection):www.postgresql.org/docs/curren…
UNION/UNION ALL:www.postgresql.org/docs/curren…- 数组函数(
ARRAY_APPEND等):www.postgresql.org/docs/curren… ANY/ALL比较:www.postgresql.org/docs/curren…EXPLAIN使用说明:www.postgresql.org/docs/curren…- 并行查询限制:www.postgresql.org/docs/curren…
pgRouting
- pgRouting 官网:pgrouting.org/
pgr_dijkstra():docs.pgrouting.org/latest/en/p…- Dijkstra 函数族:docs.pgrouting.org/latest/en/d…
- pgRouting 核心概念(边表、cost、directed 等):docs.pgrouting.org/latest/en/p…
- 安装文档:docs.pgrouting.org/latest/en/p…
- GitHub 仓库:github.com/pgRouting/p…
pgr_aStar():docs.pgrouting.org/latest/en/p…pgr_bdDijkstra()(双向搜索):docs.pgrouting.org/latest/en/p…pgr_floydWarshall()(全源最短路):docs.pgrouting.org/latest/en/p…pgr_dijkstraCost()(只算代价):docs.pgrouting.org/latest/en/p…
算法与生态
- Dijkstra, E. W. A note on two problems in connexion with graphs. Numerische Mathematik, 1959:doi.org/10.1007/BF0…
- Boost Graph Library(pgRouting 底层):www.boost.org/doc/libs/re…
- Neo4j:neo4j.com/
- OSRM:project-osrm.org/