1. 从一张地图到一条最优路径:pgRouting 的价值在哪里
先抛一个实际场景。某个物流调度平台要做配送路径规划,数据库里有几万个网点坐标、几十万段道路数据,业务上要求输入起点终点后在几百毫秒内算出可行路线,还要能避开施工路段、按距离或耗时排序。很多人第一时间想到的是调在线地图 SDK,但放到自建 GIS 系统里,每调用一次就要付一次费、受限于别人的配额和策略,数据也出不了内网。这个痛点,正是 pgRouting 最擅长解决的。
pgRouting 是 PostGIS 生态里专门做路径分析的扩展,底层跑在图论算法上,直接在 PostgreSQL 里完成从路网数据建模、拓扑构建到最短路径求解的全过程。说直白点:你的数据存在哪,计算就在哪发生,不需要把坐标导给第三方服务,也不依赖外部网络。和市面上的在线路径规划 API 相比,它的核心优势有两个——一是完全自主可控,算法逻辑、路网数据、权重规则都捏在自己手里;二是和 PostGIS 的空间函数天然打通,算完路径还能直接做缓冲区分析、空间关联、地理编码回写,整套流程在一个数据库里闭环。
这篇文章适合谁看?如果你正在做物流配送调度、车辆路径规划、应急疏散分析,或者单纯想在自己的 GIS 项目里实现一个“输入两个点、返回一条线”的功能,同时又希望不依赖外部地图服务,那 pgRouting 是非常值得认真走一遍的技术路线。我会从路网数据准备讲起,覆盖拓扑构建、路径查询、真实路网下的坑,最后用一个带坐标系的完整例子串起来。
需要说明的是,本文假设你已经装好了 PostgreSQL 和 PostGIS,并且对 SQL 有基本了解。如果你连 PostGIS 都还没装好,可以先花半小时把环境搞定再来读,后面所有实操都基于一个可运行的数据库。
2. 路网数据建模:为什么不是“一堆线段”就能算路径
2.1 图论视角下的道路网
pgRouting 本质上是在解决图论里的经典问题:给定一个带权图,求两个节点之间的最短路径。这里的关键是,道路网必须被抽象成“节点 + 边”的结构。
- 节点:道路的端点、交叉口,或者一条道路被切分后的断点。
- 边:两个节点之间的路段,带有方向、长度、通行代价等信息。
如果你的路网数据只是一堆 LineString,每条道路是一条完整的线对象,那这些线之间是没有拓扑关系的。两条路在空间上明明相交,但数据库并不知道它们相连。比如十字路口,两条道路的线可能在中点交错,可如果没有在交点处打断并建立共享节点,路径算法会认为这两条路是孤立的,根本没法从一条路转到另一条路。
这是初学者最容易忽略的地方,也是后续所有计算的基础。
2.2 路网表的核心字段设计
pgRouting 对路网表有一个约定:必须包含两个关键列,分别表示每条边的起点节点编号和终点节点编号。一般我们还会加上长度、方向、道路类型、通行代价等字段。一个典型的路网表结构如下:
CREATE TABLE road_network ( id SERIAL PRIMARY KEY, geom GEOMETRY(LineString, 4326), source INTEGER, target INTEGER, length_m DOUBLE PRECISION, road_type TEXT, cost DOUBLE PRECISION, reverse_cost DOUBLE PRECISION );字段说明:
id:边的唯一标识。geom:线几何,建议指定坐标系。source/target:拓扑节点编号,对应节点表的id。length_m:实际道路长度,单位米。road_type:道路等级,可以用来区分高速、国道、乡道等。cost:正向通行代价,即从 source 走向 target 的开销。reverse_cost:反向通行代价,即从 target 走向 source 的开销。如果某条路是单行道,可以把反向代价设成一个很大的值,或者直接设为 -1 表示禁止通行。
这里多提一句cost的设计。它不一定等于长度,完全可以根据业务自定义。比如要避开拥堵路段,就把拥堵路段的 cost 调高,算法自然会倾向于绕开它。要算时间最短,就把 cost 设为预计通行时间而不是距离。这就是 pgRouting 灵活的地方——算法本身不关心 cost 是什么物理量,它只负责找总和最小的路径。
2.3 为什么说拓扑构建是决定成败的一步
假设你已经有一份 OSM 导出的路网数据,或者从某 GIS 数据平台拿到了一份 shp 格式的道路图层。导入 PostgreSQL 后,第一件要做的事是构建拓扑,也就是把空间上相交但对象独立的道路在交点处打断,并给每个交点分配唯一编号。
PostGIS 提供了两个专门函数:pgr_nodeNetwork和pgr_createTopology。
pgr_nodeNetwork负责把相交的线在交点处打断。打个比方,两条路原本是两根独立的“面条”,这个操作就是在它们交叠的位置切一刀,变成四根“面条段”,这样算法才能知道它们在这里是连通的。
pgr_createTopology则是在打断后的路网基础上,为每条边的端点生成节点编号,并写入source和target字段。
这是路径分析最耗时也最容易出问题的一步。很多人在这一步卡住,遇到“明明有路却算不出路径”的情况,十有八九是拓扑没构建好。
3. 拓扑构建的完整实操:切割道路并生成节点
3.1 准备一份可用的路网数据
为了演示,我会用一份模拟数据来走通整个流程。假设你已经把道路数据导入到一张名为roads_raw的表里,结构很简单:
CREATE TABLE roads_raw ( id SERIAL PRIMARY KEY, geom GEOMETRY(LineString, 4326) );如果你的原始数据带坐标系但不在 4326,记得先转换。这里我统一用 WGS84 经纬度坐标,确保后面算距离和做空间查询的结果一致。
3.2 打断相交道路:pgr_nodeNetwork 的用法和参数
执行下面的 SQL:
SELECT pgr_nodeNetwork('roads_raw', 0.00001, 'id', 'geom');参数说明:
- 第一个参数是表名。
- 第二个参数是容差,单位跟数据的坐标系一致。这里 0.00001 度大约相当于 1 米左右,具体换算取决于纬度。容差的意义是:如果两条线的端点或交点距离小于这个值,就视为同一个点。设置太小可能漏掉本该相连的断点,设置太大则可能把不该连的路连在一起。
执行完之后,pgRouting 会生成一张新表,名字一般是roads_raw_noded。这张表里,原本在空间上相交的路段已经被打断成多段,并且每段都有独立的id。
这一步值得注意的一个细节是:pgr_nodeNetwork默认只处理 LineString 类型,如果你的数据里有 MultiLineString,要先转成 LineString。可以用ST_LineMerge或者ST_GeometryN处理。
3.3 生成拓扑:pgr_createTopology 的完整参数
继续执行:
SELECT pgr_createTopology( 'roads_raw_noded', 0.00001, 'geom', 'id', 'source', 'target' );参数从左到右分别是:表名、容差、几何列名、主键列名、起点列名、终点列名。
执行成功后,roads_raw_noded表里会多出source和target两个整数列,并被填充上节点编号。同时,数据库中会生成一张名为roads_raw_noded_vertices_pgr的节点表,里面存放着每个节点的编号和坐标。
你可能会问,为什么容差要传两次?因为打断和建拓扑各自需要一次容差判断。打断时用它判断线是否相交,建拓扑时用它判断端点是否重合。两次的容差最好保持一致,否则可能出现“打断时算相交、建拓扑时又认为没连上”的尴尬局面。
3.4 验证拓扑质量
拓扑构建完成后,别急着算路径,先做一次体检。我一般会跑下面几个查询:
检查是否存在 source 或 target 为空的边:
SELECT count(*) FROM roads_raw_noded WHERE source IS NULL OR target IS NULL;正常结果应该是 0,如果有 NULL,说明部分道路的端点没有被正确识别为节点,通常是数据本身有悬挂线或者容差设置不当。
检查是否有孤立路段。孤立路段是 source 和 target 相同的边,比如一条只有一个节点的小短线:
SELECT count(*) FROM roads_raw_noded WHERE source = target;出现这种情况,多半是数据里有极短的碎线段,不影响大体计算,但会稍微拖慢性能。如果数量很大,可以考虑在预处理阶段剔除。
还有一个更直观的办法:把节点表和路网叠加到 QGIS 里看。节点应该恰好落在道路端点或交叉点上,而不是悬在半空。空间数据的问题,用眼睛看往往比查 SQL 更高效。
4. 核心路径算法实战:从点对点查询到多目标路径规划
拓扑构建好了,接下来就是愉快的算法阶段。pgRouting 自带一整套路径算法,从最基础的 Dijkstra 到带转向限制的算法都有。我挑几个实际项目中最常用的来讲。
4.1 最基础的点对点查询:pgr_dijkstra
pgr_dijkstra是使用频率最高的函数。它的作用是:在给定路网中,从一个起点节点到一个终点节点,找出一条总代价最小的路径。
基本语法:
SELECT * FROM pgr_dijkstra( 'SELECT id, source, target, cost, reverse_cost FROM roads_raw_noded', start_node_id, end_node_id, directed := true );这里的第一参数是一个 SQL 查询字符串,返回包含id、source、target、cost、reverse_cost的结果集。后两个参数是起止节点编号,最后一个参数指定是否为有向图。
返回结果里包含一组记录,每条记录代表路径上的一步,其中有几个字段很关键:
node:当前到达的节点编号。edge:走过的那条边的编号。cost:走过这条边的代价。agg_cost:从起点累计到当前节点的总代价。
把返回结果里所有的edge收集起来,再连回路网表取几何,就能拼出完整路径的线。
实际项目里,起止点通常不是节点编号,而是两个经纬度坐标。所以完整流程是:
- 用
ST_DWithin或者ST_ClosestPoint在节点表里找到距离输入点最近的节点。 - 拿到最近节点的 id 后,再传入
pgr_dijkstra。 - 对返回结果中的每条边和原路网做关联,用
ST_Collect或者ST_LineMerge把路径几何合并成一条线。
找最近节点的 SQL 可以这么写:
SELECT id FROM roads_raw_noded_vertices_pgr ORDER BY geom <-> ST_SetSRID(ST_MakePoint(116.4, 39.9), 4326) LIMIT 1;这里用到了<->运算符,它是 PostGIS 的距离排序运算符,配合索引可以很快找到最近的节点。
4.2 多个起点多个终点:pgr_dijkstra 的批量写法
业务中经常遇到“十个配送点之间的两两路径”这种需求。一个个调用函数当然可以,但效率太低。pgRouting 支持一次传入多组起终点,用pgr_dijkstra的另一种写法:
SELECT * FROM pgr_dijkstra( 'SELECT id, source, target, cost, reverse_cost FROM roads_raw_noded', 'SELECT id, start_vid, end_vid FROM trips', directed := true );第二个参数换成了一个查询,返回的列需要是id、start_vid、end_vid,其中start_vid是起点节点,end_vid是终点节点。返回结果里会多一个start_vid字段,用来区分是哪一组起终点算出来的路径。
这种批量模式在数据量大的时候节省的不仅是代码量,还有反复解析查询计划的耗时,值得养成习惯。
4.3 经过多个点的路径:pgr_dijkstraVia
如果你是做配送路线优化的,经常需要“从仓库出发,依次经过若干个客户点,最后回到仓库”。这类需求可以拆成多段点对点路径,但更好的办法是用pgr_dijkstraVia一次搞定。
SELECT * FROM pgr_dijkstraVia( 'SELECT id, source, target, cost, reverse_cost FROM roads_raw_noded', ARRAY[1, 5, 9, 1], directed := true );第二个参数是一个有序节点数组,表示依次要经过哪些点。函数会计算相邻节点之间的最短路径,并返回每一段的结果。注意它会保证路径经过数组里的每一个点,但不保证全局最优——换句话说,它给出的是一个“顺序固定、每段最优”的结果。如果你的业务流程里经过顺序是固定的,这个函数非常合适。
4.4 用驾驶距离还是驾驶时间:cost 的设计思路
上面提到的所有查询,都绕不开 cost 和 reverse_cost 这两个字段。到底填什么,取决于业务目标。
如果目标是距离最短,直接把 cost 设为边的长度:
UPDATE roads_raw_noded SET cost = length_m, reverse_cost = length_m;如果目标是时间最短,可以按道路类型设定平均速度,再换算时间:
UPDATE roads_raw_noded SET cost = length_m / (speed_kmh * 1000.0 / 3600.0), reverse_cost = length_m / (speed_kmh * 1000.0 / 3600.0);其中speed_kmh来自道路等级对应的平均速度,比如高速 100、国道 60、城市道路 30。这样算出来的 cost 单位是秒,路径规划结果就是“最快路线”而不是“最短路线”。
这里有一个非常容易踩的坑:速度和长度单位不一致。如果length_m是米,speed_kmh是公里每小时,一定要把速度换算成米每秒,或者把长度换算成公里。我一朋友在做类似项目时,忘了换算单位,结果算出来的“最快路线”绕了大半个城市,因为所有路段的耗时都被放大了 1000 倍。这种错误数据上很难一眼看出来,所以写完更新语句后,最好抽查几个路段的 cost 数值是否在合理范围内。
5. 真实路网下的性能优化与常见坑
5.1 空间索引:不建索引等于没入门
路径分析性能的瓶颈,往往不在算法本身,而在 SQL 查询里那些空间过滤步骤。比如前面提到的“找最近节点”这个操作,如果没有空间索引,<->排序会退化成全表扫描,几万个节点可能还能忍,几十万甚至上百万就明显卡顿。
所以建完拓扑后,务必给几何列建立 GIST 索引:
CREATE INDEX idx_roads_noded_geom ON roads_raw_noded USING GIST (geom); CREATE INDEX idx_vertices_geom ON roads_raw_noded_vertices_pgr USING GIST (geom);同时,source和target这两个字段也应该建普通 B-tree 索引,因为路径算法的内部查询会频繁按节点过滤边:
CREATE INDEX idx_roads_noded_source ON roads_raw_noded (source); CREATE INDEX idx_roads_noded_target ON roads_raw_noded (target);索引建完之后,路径查询的耗时往往能下降一个数量级。
5.2 单向路和禁行路段的处理
真实路网中有单行道,有施工路段,有转向限制。pgRouting 对前两者处理起来非常直接。
单行道:把禁行方向的代价设为 -1。
UPDATE roads_raw_noded SET reverse_cost = -1 WHERE oneway = 'yes';注意,-1 在 pgRouting 里表示“该方向不可通行”,算法会自动避开。但有个前提:directed := true必须开启。如果你用的是无向图模式,reverse_cost 会被忽略,单行道就形同虚设。
禁行路段:把对应方向 cost 设为一个很大的值,或者直接 -1。更灵活的做法是,在路径查询的 SQL 里动态拼接条件,比如:
SELECT * FROM pgr_dijkstra( 'SELECT id, source, target, cost, reverse_cost FROM roads_raw_noded WHERE status != ''closed''', start_id, end_id, directed := true );这样不需要改表数据,就能在查询时临时避开某些路段。
5.3 转向限制:比想象中更有用的高级功能
有时候两点之间明明有路,最短路径却要求“不能左转”。比如某些路口禁止左转,或者某条路只能直行不能掉头。这种问题单纯靠调整 cost 很难优雅解决,因为转向限制是发生在“边到边”的层面,而不是“点”的层面。
pgRouting 提供了pgr_trsp系列函数来处理带转向限制的路径规划。它的输入除了路网,还需要一张转向限制表,记录在某个节点上从某条边进入、不允许转到某条边出去。
转向限制表的结构一般是这样的:
CREATE TABLE turn_restrictions ( id SERIAL PRIMARY KEY, node_id INTEGER, incoming_edge INTEGER, outgoing_edge INTEGER, restriction_cost DOUBLE PRECISION );restriction_cost设为 -1 表示完全禁止该转向;设为一个正数则代表该转向需要额外代价,比如等待红绿灯的时间。
使用示例:
SELECT * FROM pgr_trsp( 'SELECT id, source, target, cost, reverse_cost FROM roads_raw_noded', start_node_id, end_node_id, 'SELECT * FROM turn_restrictions', directed := true );这个功能在市区配送场景里极其实用。没有它,算法给出的路线可能时时刻刻教你在禁止左转的路口左转,实际根本开不了。
5.4 一个典型的排查过程:算不出路线时该怎么办
我自己在项目里遇到最多的问题是“起点和终点附近都有路,但 pgr_dijkstra 返回空结果”。这种情况下,一定不要先怀疑算法,而是按照下面的顺序排查:
第一,确认起止节点是否真的存在。
SELECT id FROM roads_raw_noded_vertices_pgr WHERE id IN (start_id, end_id);如果查询结果少了一行,说明你传入的节点 ID 根本不在节点表里,可能是之前找最近节点时拿到的是旧数据。
第二,确认起止节点是否连通。用pgr_connectedComponents查看路网里有几个连通子图:
SELECT * FROM pgr_connectedComponents( 'SELECT id, source, target, cost, reverse_cost FROM roads_raw_noded' );如果起点和终点落在不同的组件里,说明路网本身不连通。最常见的原因是某些路段的拓扑没建完整,或者数据里本来就有断头路。这种情况再怎么调算法也没用,只能回头修数据。
第三,确认路网的代价字段是否合法。查询是否存在负值:
SELECT count(*) FROM roads_raw_noded WHERE cost < 0 OR reverse_cost < 0;cost或reverse_cost为负数是可以的,但对负数的语义有严格要求:-1 表示完全禁用。如果出现 -2、-3 这类值,算法行为就无法保证了。
第四,也是很多人忽略的:确认你有没有边连到了“错误”的节点上。比如一条双向路的 target 写错了,指向了一个本不该连通的节点,路径计算会绕远路,但不会直接报错。这种问题只能靠抽样式渲染来发现。
把这些步骤走完,90% 的“算不出路径”问题都能定位到原因。
6. 完整实战案例:配送路径规划 Demo
前面讲了不少原理和细节,可能有点抽象。我在这里用一个完整的模拟项目,把整个流程从零串一遍。这个案例模拟的是某同城配送系统的路径规划模块,数据集不大,但覆盖了本文提到的所有关键环节。
6.1 场景设定与数据准备
假设我们有这么一张配送订单表:
CREATE TABLE delivery_orders ( id SERIAL PRIMARY KEY, order_no TEXT, pickup_geom GEOMETRY(Point, 4326), dropoff_geom GEOMETRY(Point, 4326), planned_route GEOMETRY(LineString, 4326) );业务需求:为每张订单计算从取件点到送件点的驾驶路线,并把路线的几何写回planned_route字段。
路网数据已经是构建好拓扑的roads_noded表,包含id、source、target、cost、reverse_cost、geom字段,坐标系为 4326。
6.2 写一个可复用的路径计算函数
为了不让每次调用都写一大段 SQL,我会把“查找最近节点 → 计算路径 → 拼接几何”封装成一个数据库函数。
CREATE OR REPLACE FUNCTION calculate_driving_route( start_geom GEOMETRY, end_geom GEOMETRY ) RETURNS GEOMETRY AS $$ DECLARE start_node INTEGER; end_node INTEGER; route_geom GEOMETRY; BEGIN -- 查找距离起点最近的节点 SELECT id INTO start_node FROM roads_noded_vertices_pgr ORDER BY geom <-> start_geom LIMIT 1; -- 查找距离终点最近的节点 SELECT id INTO end_node FROM roads_noded_vertices_pgr ORDER BY geom <-> end_geom LIMIT 1; -- 计算最短路径,并把路径边几何合并成一条线 WITH route AS ( SELECT * FROM pgr_dijkstra( 'SELECT id, source, target, cost, reverse_cost FROM roads_noded', start_node, end_node, directed := true ) ) SELECT ST_LineMerge(ST_Collect(rn.geom)) INTO route_geom FROM route r JOIN roads_noded rn ON r.edge = rn.id; RETURN route_geom; END; $$ LANGUAGE plpgsql;这个函数接收两个点坐标,返回一条 LineString。有了它,批量更新订单表就非常简洁:
UPDATE delivery_orders SET planned_route = calculate_driving_route(pickup_geom, dropoff_geom) WHERE planned_route IS NULL;如果订单量大,可以加个WHERE条件分批跑,也可以先把最近节点的计算提前做好缓存,避免每条订单都重复执行两次最近节点搜索。
6.3 用 QGIS 把结果可视化出来
计算完成后,把delivery_orders表加载到 QGIS,添加底图,把planned_route按订单渲染成不同颜色。这一步的价值在于:它比任何 SQL 查询都更直观地暴露数据问题。
我每次做完一批路径规划,都会随机抽几条路径在 QGIS 里看:
- 路径是否平滑连续,有没有突然断开。
- 路径是否经过了明显不该走的区域。
- 起终点附近有没有“绕圈”现象,比如先从起点出发绕了一大圈才上正路。
如果发现异常,回到数据层排查。绝大多数问题都出在拓扑上,而不是路径算法上。
6.4 批量计算的性能实测
我在模拟数据上测试过一万条订单的批量更新,路网包含大约二十万个节点。在没做任何优化的情况下,跑完全部订单大约需要十几分钟。做了两个优化后,时间缩减到三分钟内:
第一,提前把节点表和路网表的相关字段加载到内存,用SET enable_seqscan = off强制走空间索引。
第二,把“查找最近节点”的结果提前算好,存到一张临时表里,避免每次函数调用都重新执行空间排序。
如果你的订单量更大,还可以考虑并行执行多个数据库会话,每个会话处理一部分订单。pgRouting 的路径计算本身是 CPU 密集型的,多核并行往往能明显提升吞吐量。
7. 进阶玩法:等时圈分析和多车路径规划
基础路径查询跑通之后,pgRouting 还能做几件很有意思的事情,我简单介绍一下,给想继续深入的人指个方向。
7.1 等时圈分析
所谓等时圈,就是从某个点出发,在规定时间内能到达的所有区域。比如“从医院出发,15 分钟救护车能覆盖哪些范围”。这在应急响应、商业选址里非常有用。
实现思路并不复杂:用pgr_drivingDistance算出从起点出发、累计代价不超过某个阈值的所有可达节点,再把这些节点对应的区域做空间聚合。
SELECT * FROM pgr_drivingDistance( 'SELECT id, source, target, cost, reverse_cost FROM roads_noded', start_node_id, 900, directed := true );第三个参数 900 表示最大累计代价,单位和你设置的 cost 一致。如果 cost 是秒,那 900 就是 15 分钟。返回的节点集合再做一次泰森多边形或缓冲区叠加,就能得到近似的服务范围。
7.2 多车路径规划
如果业务升级成“5 辆车要给 50 个客户点送货,怎么安排最合理”,单靠点对点路径规划就不够了。这是 VRP(车辆路径规划)问题,pgRouting 没有直接内置完整的 VRP 求解器,但它提供了两个基础工具:pgr_dijkstra负责算任意两点间的代价矩阵,pgr_ksp负责算 k 条备选路径。
实际项目里,可以用 pgRouting 快速生成客户点两两之间的行驶代价矩阵,然后把这个矩阵交给外部优化引擎做车辆调度,或者用模拟退火、遗传算法把路径串起来。数据库负责最耗时的路网计算,优化引擎负责组合优化,各司其职。
这种“数据库 + 外部算法”的架构,是我见过的大多数配送调度系统的标准做法。pgRouting 在这里的角色,就是可靠且高效地为上层算法提供路网层面的基础数据。
7.3 与 PostGIS 其他空间能力的联动
路径算完之后,PostGIS 的空间函数还可以继续派上用场。比如:
- 用
ST_Buffer把路径向两侧扩出一定范围,分析沿途覆盖的 POI 数量。 - 用
ST_Intersects判断路径是否经过某些风险区域。 - 用
ST_Distance计算两条路径之间的最近距离,评估路线相似度。
这些能力组合起来,pgRouting 就不是一个孤立的“路径计算器”了,而是整个空间分析链路中的一环。数据在数据库里流转,不需要导出导入,效率和准确性都有保证。
最后分享一点我的体会
在做路径分析的项目中,我最大的感受是:pgRouting 真正难的从来不是调用那几个函数,而是把路网数据处理成算法能理解的拓扑结构。数据质量决定了路径规划的天花板,算法只是把数据里已经蕴含的信息挖掘出来而已。
所以如果你刚开始接触这个方向,不要急着往函数参数里钻,先花时间把你的路网数据搞清楚:坐标系、道路方向、交叉口打断、连通性检查。这些基础工作越扎实,后面的路径查询就越顺手。
一个小技巧:每次处理完一批新路网数据,都写一个固定的检查 SQL 脚本,把节点为空、自环边、连通分量这几项指标跑一遍,形成习惯之后能省下大量排查问题的时间。我自己就是这样,数据进库之后第一步永远是体检,而不是急着算路径。这个习惯帮我避开了不少“看起来一切正常、跑起来处处报错”的尴尬局面。