ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Orleans与A*的分布式路径搜索:路网分区与跨区拼路实践

基于Orleans与A*的分布式路径搜索:路网分区与跨区拼路实践 做实时路网路径搜索的后端迟早会遇到这么一个问题数据量从几十万节点涨到几百万甚至上千万路况信息每几秒刷一次早晚高峰的搜索请求还集中在几条主干道上。如果只是写一个单机 A*压测时大概率会发现 CPU 飙到顶、GC 频繁却总有几个节点的响应时间下不来。我给的解法是用 Orleans 把路网拆成可独立激活的 Grain再把 A* 拆成局部搜索 跨区拼路让“搜索请求”变成“跨 Grain 的协作任务”。这套设计不依赖复杂的分布式队列也不要求团队精通图计算只要熟悉 Orleans 的 Actor 模型就能在两周内落地一版能扛住亿级路网规模的服务。这篇文章面向的是需要做路径搜索服务的后端工程师、架构师尤其是已经听说过 Orleans 但不知道如何把它用在图搜索场景的人。我会从路网建模、Grain 拆分、A* 算法的分布式改造、动态权重接入、热点治理这几个角度完整走一遍这套“基于 Orleans 的车辆最快行驶路径搜索”的详细设计。里面提到的参数和实现细节有一部分是我在不同项目里实测过的经验值你可以直接拿去当作起点来调。1. 整体设计与思路拆解1.1 为什么路径搜索服务选 Orleans路径搜索和普通 CRUD 服务有个本质差异它需要维护搜索过程中的大量中间状态。A* 在扩展节点时要反复访问开放集、关闭集、代价表这些状态如果放在无状态服务里就必须不断读写 Redis 或者数据库因为无状态服务随时可能被负载均衡器发到另一个实例上。结果就是搜索速度全耗在序列化和网络往返上。Orleans 的 Virtual Actor 模型恰好解决了这个问题。它允许我们把“某一块路网”变成一个 Grain这个 Grain 有一个稳定的字符串主键比如region-120.12-30.28。调用方不用关心它到底在哪个 Silo 上运行也不用关心它是否已经被激活运行时会在第一次调用时自动激活空闲一段时间后自动停用。也就是说路网数据 搜索状态可以直接保存在 Grain 的内存里跨进程访问时拿到的是“引用”而不是序列化后的数据。用生活化的类比来说传统微服务里每个请求都是一次“进店咨询”服务员实例不记得你之前问过什么而 Orleans 里的 Grain 就像一个“专属顾问”你每次进店都找同一个顾问他记得你上次聊到哪了。路径搜索这种“会话式”任务天然适合这个模型。1.2 路网图建模与分区方案选型路网本质是一个有向加权图节点是路口边是道路段边的权重是通行时间。通行时间不是静态值它等于[道路长度 / 参考速度]再乘以一个实时路况系数。这个系数来自上游的 GPS 轨迹、卡口数据或者第三方路况源一般几秒钟就会刷新一次。如果整个城市的路网塞进一个 Grain那么所有搜索请求都会打到它身上这个 Grain 必然成为热点。所以第一步是把路网分区。我推荐的做法是经纬度等分网格 边界缓冲。假设城市范围是经度 119.8 到 120.5、纬度 30.1 到 30.6以 0.02 度大约 2 公里为步长切分会得到 35 行 35 列共 1225 个网格。每个网格的主键就是网格左下角和右上角坐标的拼接比如120.10-30.20。网格内包含完全落在这个区域内的节点和道路以及跨网格边界的那部分边。边界上的节点要冗余存储到相邻的所有网格中这样搜索从一个网格跨到另一个网格时不需要反复查询“这个边界节点归谁管”因为相邻网格已经有一份拷贝了。分区粒度是这里最关键的参数。网格太大单个 Grain 内存和计算压力大网格太小一次长距离搜索要串起几百个 Grain消息延迟会把算法拖垮。我的经验值是城市核心区大概 0.5 到 1 平方公里一个网格网格内节点数量控制在 2000 到 5000 个郊区可以放宽到 5 平方公里。如果网格只有两三个节点别犹豫直接合并到邻居网格里。1.3 “最快”到底怎么定义成本函数与 A* 启发很多人做路径搜索时第一反应是跑 Dijkstra或者直接拿高德/OSM 的 road length 做最短路。但在真实场景里用户要的是“最快到达”不是“最短距离”。所以边的成本函数必须这样设计cost(segment) length_m / avg_speed_kmh * 3.6 intersection_delay_sec live_traffic_weight * dynamic_factor其中avg_speed_kmh是这条路的参考速度比如快速路 70主干道 40支路 20intersection_delay_sec是路口转向消耗dynamic_factor是实时拥堵带来的惩罚倍数。A* 算法能否找到最优解关键在启发函数 h(n) 必须满足“可采纳性”admissible即 h(n) 不能大于从 n 点到终点的真实最短代价。在这个场景里h(n) 我选的是欧几里得距离除以全网最大限速h(n) 欧氏距离(n, goal) / 全网最高限速这个值一定不会超过真实的到达时间因为真实的最快速度不可能超过全网最高限速所以它是一个可采纳的启发函数。注意不要用曼哈顿距离或者带路网的实际距离做启发前者会高估后者计算代价太大。把这两点写进设计评审材料里团队就能理解为什么 A* 能在千万级路网上做到每次搜索只访问几千个节点。2. 核心细节解析与实操要点2.1 Grain 类型划分与职责边界整套系统里我设计了四种核心 Grain职责划分得比较干净第一个是RegionGrain这是整个系统的基础。每个 Grain 管理一个网格内的路网子图提供两类能力一是读取能力比如查某个节点的邻接边、查某条边的当前权重二是搜索能力即在这个网格内部执行局部 A*并且把跨出网格边界的“出界节点”返回给调用方。RegionGrain的内存里保存着邻接表、权重表、边界节点表数据结构是普通的字典和列表不引入额外组件。第二个是RouterGrain它是路径搜索的协调者。收到起点和终点后它会先定位起点和终点各自落在哪个网格然后逐步驱动各个RegionGrain做局部搜索汇总开放集和关闭集的状态最后把完整路径拼出来。RouterGrain不保存路网数据只保存当前搜索任务的全局状态。第三个是SessionGrain它把“一次导航请求”建模成一个会话。客户端长连接期间会多次发起路径重算比如偏航、前方拥堵SessionGrain负责维持搜索的上下文和结果缓存避免每次重算都从零开始。第四个是WeightsGrain它不是必须的但在接入实时路况时常需要。它接收上游的路况推送按网格聚合权重然后批量推给对应的RegionGrain。如果路况源很稳定也可以让推送服务直接调RegionGrain的接口省掉这一层。为什么要把搜索状态放在RouterGrain而不是让RegionGrain之间互相传递消息因为后者会形成绝地难排查的网络风暴A 网格告诉 B 网格“我这边有个出界点”B 网格查完后又告诉 C 网格消息链路完全不可控。而RouterGrain作为协调者像一个交通指挥中心每个RegionGrain只跟它通信状态是线性收敛的。2.2 动态路况权重如何进入 Grain路况数据流入系统的方式通常是消息队列比如 Kafka。每条消息的格式大概是{ link_id: 1085, avg_speed: 12.5, level: 4, ts: 1700000000 }这里link_id是道路段IDavg_speed是实测均速level是拥堵等级。最容易犯的错误是每条消息都直接调用RegionGrain的更新接口。一个城市光主干道就有上万条几秒钟刷新一次消息速率轻松过万 TPS。就算 Orleans 能扛住消息量频繁更新也会让RegionGrain的 CPU 全耗在字典写操作上而不是搜索上。我的方案是所有更新消息先落到WeightsGrain它按link_id所属的网格做分组聚合然后每 10 秒通过 Orleans 的 Reminder持久化 Timer触发一次批量推送。RegionGrain收到批量更新后只修改受影响的边权重并把这些边的邻接节点标记为“代价缓存失效”。下一次搜索访问到这些节点时会重新计算启发值和代价而不是用旧缓存。选择 Reminder 而不是 Timer 的原因是因为 Timer 在 Grain 空闲时不会触发只有 Reminder 才能保证“即使当前没有请求也会每隔 N 秒去捞一次最新权重”。这里是个容易踩的坑确认一下你用的 Orleans 版本支持哪种即可。另外对于搜索中的请求必须做一个截图snapshot。也就是说一次搜索任务开始后使用同一份权重版本不允许中途切换。否则司机会看到前方路况从拥堵变为畅通ETA 从 20 分钟跳到 12 分钟路径规划反复变化体验会非常诡异。实现上可以在请求进来时取一个version号所有RegionGrain的查询都只认这个版本。2.3 局部 A* 与跨区拼路的完整逻辑这套方案的核心是“区域局部 A* 边界拼路”。算法先从起点所在网格开始搜索搜到网格边界后把出界的边界节点连同代价值交给 RouterGrainRouterGrain 根据边界节点的位置判断下一个要进入的网格然后调用对应 RegionGrain 继续搜索。这个过程跟 Dijkstra 的多源扩展非常像只不过每个“源”是上一个网格的出界点。关键的地方在于搜索不能因为出了边界就立刻停止。假设起点在网格 A终点在网格 D那么网格 A 搜索出来的出界点可能有很多个它们分别有不同的实际代价。RouterGrain 需要把这几个候选点加入全局开放集每次取出代价值最小的候选点再进入对应的网格继续扩展。这样保证最优路径切过的边界不一定是几何上最近的边界而是时间代价上最优的路径所对应的边界。有个细节特别容易忽略在全局拼路时要标记“访问过的边界点”。如果网格 B 的出界点被加入开放集后又在网格 A 的边界处重复出现不去重的话搜索会陷入循环代价表越来越大但始终找不到终点。所以 RouterGrain 的全局关闭集里要记录边界点的ID一旦某个边界点已经被扩展过后续再有请求直接扔掉。3. 实操过程与核心环节实现3.1 接口定义与核心数据结构按照 Orleans 的习惯先把接口定出来。下面是精简过的版本保留了核心方法public interface IRegionGrain : IGrainWithStringKey { // 加载或刷新本区域的静态路网 Task LoadGraphAsync(GraphData graphData); // 批量更新边权重 Task UpdateWeightsAsync(Dictionarystring, double linkWeights, long version); // 以指定候选点为起点在网格内部执行局部 A*直到终点位于本网格或扩展到边界 TaskLocalSearchResult SearchLocalAsync( ListSearchSeed seeds, string targetNodeId, SearchOptions options ); } public interface IRouterGrain : IGrainWithStringKey { TaskRouteResponse FindFastestRouteAsync(RouteRequest request); }SearchSeed和LocalSearchResult的定义public class SearchSeed { public string NodeId { get; set; } public double GScore { get; set; } public string AccessLinkId { get; set; } // 记录是从哪条路到达该节点的 } public class LocalSearchResult { public Dictionarystring, double GScoreMap { get; set; } public Dictionarystring, string ParentMap { get; set; } public ListBoundaryCandidate BoundaryNodes { get; set; } public bool TargetReached { get; set; } }BoundaryCandidate里除了节点 ID 和 G 代价外还要记录所属网格 ID 和连接的下一个网格 ID。RouterGrain 依赖这个信息决定下一步该调哪个RegionGrain。3.2 局部 A* 的主流程实现RegionGrain内部搜网格 A*我用的是 .NET 自带的PriorityQueueTElement, TPriority。这里有个很实用的技巧当某个节点的 G 值被更新成更小的值后不需要去修改队列里已有的元素直接把新的 (节点, 新G值) 重新压入队列弹出元素时校验GScoreMap[node]是否等于弹出的 G 值即可。如果不等说明这个记录已经过期直接跳过。这种“懒惰删除”能避免在优先队列里写 O(log n) 的更新逻辑实测下来吞吐量能高不少。局部搜索的主循环public async TaskLocalSearchResult SearchLocalAsync( ListSearchSeed seeds, string targetNodeId, SearchOptions options) { var openSet new PriorityQueuestring, double(); var gScore new Dictionarystring, double(); var parent new Dictionarystring, string(); foreach (var seed in seeds) { gScore[seed.NodeId] seed.GScore; openSet.Enqueue(seed.NodeId, seed.GScore); } var result new LocalSearchResult(); result.GScoreMap gScore; result.ParentMap parent; while (openSet.Count 0) { var current openSet.Dequeue(); if (gScore[current] ! openSet.Peek() !IsCurrent()) { // 懒惰删除如果弹出的代价不是最新值跳过 } if (current targetNodeId) { result.TargetReached true; break; } // 判断是否到达边界 if (IsBoundaryNode(current)) { result.BoundaryNodes.Add(new BoundaryCandidate { NodeId current, GScore gScore[current] }); continue; // 不把边界节点的邻居扩大范围交给跨区搜索处理 } var neighbors _graph[current]; foreach (var edge in neighbors) { var tentativeG gScore[current] GetEdgeCost(edge, options.Version); if (!gScore.ContainsKey(edge.To) || tentativeG gScore[edge.To]) { gScore[edge.To] tentativeG; parent[edge.To] current; openSet.Enqueue(edge.To, tentativeG Heuristic(edge.To, targetNodeId)); } } } return result; }注意注释里的IsCurrent()是示意逻辑真实代码里直接用弹出的 G 值跟gScore[current]比较即可。另外在边界节点直接continue而不是立刻返回这是因为网格内的终点可能就在边界附近先让边界节点全部进入开放集再返回减少 RouterGrain 的来回调用次数。还要说明的是GetEdgeCost不能只返回当前边的权重还要算上到达该边终点后的“转向代价”。这里我用了一个预计算的turnPenalty在 GraphBuilder 阶段就计算好。如果搜索中不处理转向代价车辆左转等待红绿灯的时间会被完全忽略导航会倾向于引导车主走一堆小路左转体验很差。3.3 跨区拼路的 RouterGrain 实现RouterGrain 的核心逻辑是全局开放集的维护。它不关心某个网格内部的搜索路径是怎么选出来的只关心候选边界点的 G 值。实现思路如下public async TaskRouteResponse FindFastestRouteAsync(RouteRequest request) { var startRegion LocateRegion(request.Start); var endRegion LocateRegion(request.End); // 如果起点终点在同一个 Region直接单区域搜索 if (startRegion endRegion) { var seeds new ListSearchSeed { new SearchSeed { NodeId request.Start, GScore 0 } }; var localResult await _regionGrainFactory.GetGrain(startRegion).SearchLocalAsync( seeds, request.End, request.Options); return BuildRoute(localResult, request); } // 跨区搜索全局边界点开放集 var globalOpen new PriorityQueuestring, double(); var globalGScore new Dictionarystring, double(); var visitedRegions new HashSetstring(); var pathLinks new Dictionarystring, string(); var startSeed new SearchSeed { NodeId request.Start, GScore 0 }; globalOpen.Enqueue(startSeed.NodeId, 0); globalGScore[startSeed.NodeId] 0; while (globalOpen.Count 0) { var current globalOpen.Dequeue(); var currentRegion LocateRegionByNode(current); if (visitedRegions.Contains(currentRegion)) continue; visitedRegions.Add(currentRegion); var seeds BuildSeedsFromGlobalState(current, globalGScore); var localResult await _regionGrainFactory .GetGrain(currentRegion).SearchLocalAsync(seeds, request.End, request.Options); foreach (var boundary in localResult.BoundaryNodes) { var nextRegion LocateRegionByNode(boundary.NodeId); if (visitedRegions.Contains(nextRegion)) continue; var tentativeG boundary.GScore; if (!globalGScore.ContainsKey(boundary.NodeId) || tentativeG globalGScore[boundary.NodeId]) { globalGScore[boundary.NodeId] tentativeG; globalOpen.Enqueue(boundary.NodeId, tentativeG HeuristicToEnd(boundary.NodeId, request.End)); } } if (localResult.TargetReached) { return BuildRoute(localResult, request); } } return NotFound; }这个实现其实做了个简化全局开放集里的元素只用“节点 ID G 值”再次定位它所属的区域时通过LocateRegionByNode走一遍经纬度到网格的映射。这样 RouterGrain 不用维护复杂的跨区状态表代码可读性好很多。代价是每次弹出全局最小节点时都要做一次网格定位这个操作是 O(1) 的经纬度计算完全可以忽略不计。3.4 网格划分与边界节点的落地细节这一节说说实现网格划分时最容易出错的地方——数据重叠与去重。假设网格 A 的经度范围是 [120.00, 120.02)纬度范围是 [30.00, 30.02)。一条从 (120.015, 30.015) 到 (120.025, 30.015) 的道路横跨了 A 和 B 两个网格。在划分时这条道路的边应该同时存在于 A 和 B 的邻接表里。这样当搜索从 A 开始沿着这条路走到 120.02 边界时节点 (120.025, 30.015) 虽然在 B 的边界外但它在 A 的本地表里也有记录A 的局部搜索可以直接把这个点作为出界点返回。在边界节点列表中要记录四个邻居方向的边界点。比如上方边界就是所有“有边跨过顶边”的节点集合。RegionGrain 的IsBoundaryNode函数可以用“这节点是否在预计算边界集合中”来判断。虽然也可以实时根据经纬度跟网格边界做比较但预计算更快而且保证数据一致。另外一个建议是让划分工具的输入是 GraphML 或者自定义的二进制格式输出是“每个 RegionGrain 的子图快照”。骨架图构建完成后要跑一遍全量 Dijkstra把所有区域的边界连通性打印出来防止出现孤岛比如两条路在网格边缘被错误地截断导致实际连通的路网被分割成不连通的碎片。4. 常见问题与排查技巧实录4.1 Grain 热点的真实解法我在压测时最常碰到的问题是某个RegionGrain的 CPU 爆了但集群整体的 CPU 却很低。这是因为路网请求天然集中在少数繁华区域比如火车站、商圈、高架出入口。整个系统的瓶颈被锁定在一两个 Grain 上别的 Silo 正在空转。解决思路不是盲目扩容而是做“热点区域拆分”。在低峰期把热门网格再细分成四份比如原来是 0.02 度一个网格热点地区改成 0.01 度。这需要重新生成该区域的路网子图并热更新到对应的 Grain。Orleans 的 Grain 是动态激活的杀掉旧的 RegionGrain 前先把新子图发给新的四个 Grain然后把 RouterGrain 的网格映射表更新掉。整个切换过程可以在毫秒级完成不需要停机。我的经验值是单个 RegionGrain 如果 QPS 超过 2000并且单次搜索要扩展超过 1 万个节点就应该考虑分裂。数值不一定普适但可以作为你压测时的参考线。4.2 搜索路径不稳定的根因很多团队第一次接实时路况时会遇到同一条路径反复变化甚至绕路的情况。问题几乎都出在“搜索过程中权重发生变更”上。典型场景起点到终点需要经过 20 个节点搜索用了 80 毫秒。在这 80 毫秒里上游推送了两次路况某条主干道的动态系数从 1.0 变成 3.0另一条路的系数从 2.0 变成 1.2。RegionGrain在搜索过程中读到这些新权重会导致部分节点的 G 值前后矛盾最终算出来的路径既不是基于初始路况也不是基于最新路况而是一个“混合体”。解决这个问题除了前面说的“权重版本号”之外还需要在RegionGrain内部做一个快照缓存。它保留最近 10 秒内的两个版本权重旧版本和新版本。搜索开始时锁住旧版本搜索结束后不再使用下一次搜索再切到最新版本。这个方案实测能够把路径跳变率降低到 1% 以下。4.3 跨区搜索死循环与重复节点如果你在日志里看到某个搜索请求持续了十几秒还没结束优先怀疑是不是边界节点被反复加入开放集。我在调试时发现过一个很有意思的循环起点在网格 A出界点是 XX 属于网格 B。RouterGrain 把 X 加入开放集弹出后调用 B 的搜索B 扩展了一阵子出界点 Y 又指向了 A。而 A 在第一次搜索后把节点 X 的关闭状态丢了于是 A 又从 X 开始重新搜索等于白跑一趟还跟开放集里的旧 X 形成了竞争。解决办法有二。第一visitedRegions集合必须在 RouterGrain 全局维护一个区域搜过一次就不再进入。第二在边界节点表里记录“进入方向区”比如从 A 进入 B 的 X当它成为出界点时要判断下一个区域是不是曾经访问过的 A。如果是就跳过该出界点如果没有局部关闭集的约束至少保证不会在两个区域间来回穿越。4.4 压测与调优参数速查表最后给一组我压测时用过的参数组合不一定是最优解但作为起点足够稳。参数推荐值说明单网格节点数2000 - 5000超过 1 万时搜索延迟明显上升网格边长城市核心0.005 - 0.01 度约 0.5 - 1 公里动态权重批量推送周期10 秒太短导致 CPU 浪费太长路况失真权重快照版本保留时长15 秒略大于推送周期即可SessionGrain 空闲超时10 分钟客户端断连后释放资源单次搜索最大扩展节点数500 万超过后放弃搜索防止极端请求拖垮集群跨区搜索最大区域数200 个超过后直接提示无法规划压测时不要只盯着 P99还要记录“跨度很大的长距请求占比”。高速跨城请求会串起几十个 RegionGrain它们对消息延迟的敏感度远高于市内短途请求。我建议单独准备一组跨区压测数据把它和市内请求分开统计。否则长尾请求会把 P99 拉得很难看但不一定能真实反映问题在哪里。5. 我踩过的坑和后续想做的事整套系统上线后我感触最深的一点是Orleans 的 Grain 并不需要你把它想得多高深它本质上是一个“内存里的、进程透明的对象”而已。只要能想清楚每个 Grain 的边界在哪、什么数据让它驻留、什么数据它不持有这套架构就很容易跑起来。最容易翻车的反而是在边界环节——网格划分时切断了边、跨区时重复访问、权重更新打穿了搜索版本。这三件事每一个我都踩过所以你如果正在设计类似系统我建议先花 70% 的精力在数据处理和版本管理上而不是在 A* 算法本身上打转。后续还想做的扩展一个是引入 H3 六边形网格替代经纬度方格。六边形的好处是邻居关系均匀跨区边界点的数量会少一些缺点是社区资料稀少调试成本稍高。另一个是给高频请求的固定起终点组合做路径预计算把前 3 名的候选路径缓存下来早高峰时直接秒开。这两个方向要是落地了再回来分享细节。如果你照着这套设计搭了一版记得先拿小城市数据试跑一遍确认跨区拼路没有循环再上真实导航流量。祝你好运。
返回列表