一、痛点场景:调度员盯着地图点鼠标,系统只在“存数据”
物流车辆调度系统有一个常见的“假自动化”陷阱:系统里有车辆位置、有订单地址、有地图,调度员却仍然要手动把订单拖给某辆车。系统做的是“记录调度结果”,而不是“生成调度决策”。
真正的实时调度与路线优化,需要系统在秒级内完成一条完整的决策链:知道车在哪 → 找到附近可用的车 → 判断哪辆车接这单最划算 → 把这单插入该车现有路线的最优位置 → 持续监控执行偏差并在必要时重调度。这条链路上任何一环只靠 SQL 全表扫描或人工判断,都会在车辆和订单规模上来后失效。
行业实践也印证了分层设计的必要性:Redis GeoHash 以 O(log N) 复杂度检索附近运力,关系型数据库回归持久化本职;VRP 是 NP-hard 问题,实际求解依赖启发式算法而非精确解;路线优化引擎(OR-Tools)只负责在给定约束下逼近极值,业务侧的责任是把约束定义正确。
这篇文章围绕这条决策链展开:Redis GEO + Oracle Spatial 的分工、加权评分召回候选车辆、OR-Tools/Timefold 的 Java 集成、以及事件驱动的局部重调度,基于 Java 17 + Spring Boot 3.x + Oracle 19c。
二、整体架构:空间索引与求解器各司其职
2.1 为什么需要两种空间数据能力
车辆实时调度对空间数据有两类截然不同的需求:
第一类:高频写入、毫秒级检索。车辆 GPS 每秒上报,调度引擎需要在“当前位置 5 公里内”找到可用车辆。这类查询要求写吞吐高、读延迟亚毫秒,且数据是“热”的、可丢失重建的。Redis GEO 是这一层的正确工具——GeoHash 将二维坐标降维为一维编码,GEOSEARCH在纯内存中以 O(log N) 完成附近检索。
第二类:持久化、复杂空间分析。站点覆盖范围、电子围栏、历史轨迹归档、区域划分,这些数据需要事务、需要复杂空间关系判断、需要长期保留。Oracle Spatial 的SDO_GEOMETRY类型配合SDO_WITHIN_DISTANCE、SDO_NN等算子,负责这一层的持久化和空间关系计算。
关键分工:Redis GEO 服务调度热路径的“候选召回”,Oracle Spatial 服务持久化和“边界判定”。两者不是替代关系,是冷热分工。
2.2 服务划分
| 服务 | 职责 | 空间数据使用 |
|---|---|---|
| 位置接入服务 | 接收 GPS/北斗上报,清洗后写入 Redis GEO | RedisGEOADD |
| 调度决策服务 | 候选召回、加权评分、调用求解器 | RedisGEOSEARCH+ Oracle 距离矩阵 |
| 路径求解服务 | VRP/CVRPTW 求解,输出路线 | 消费距离矩阵,不直连空间库 |
| 执行监控服务 | 轨迹跟踪、偏差检测、触发重调度 | Oracle 轨迹归档 + Redis 最新位置 |
| WebSocket 推送 | 调度指令和车辆位置实时推送 | — |
位置接入服务把车辆实时位置写入 Redis,调度决策服务消费ORDER_CREATED事件后,从 Redis 召回候选车辆,再调用路径求解服务获取最优分配。求解器不直接访问 Redis 或 Oracle,只接收预先计算好的距离矩阵和约束参数,保持无状态和可水平扩展。
三、Oracle 空间数据层:距离矩阵的持久化与空间查询
3.1 站点与车辆的地理建模
Oracle Spatial 用SDO_GEOMETRY存储点、线、面。物流调度的基础地理数据包括站点坐标、车辆归属区域、电子围栏多边形:
sql
-- 站点表:点位 + 空间索引 CREATE TABLE logistics_station ( station_id NUMBER PRIMARY KEY, station_name VARCHAR2(100), location SDO_GEOMETRY, region_code VARCHAR2(16) ); INSERT INTO logistics_station VALUES ( 1, '深圳南山站', SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(113.9345, 22.5312, NULL), NULL, NULL), 'SZ-NS' ); -- 空间索引(必须建,否则 SDO_WITHIN_DISTANCE 退化为全表扫描) CREATE INDEX idx_station_spatial ON logistics_station(location) INDEXTYPE IS MDSYS.SPATIAL_INDEX; -- 电子围栏:多边形 CREATE TABLE geo_fence ( fence_id NUMBER PRIMARY KEY, fence_name VARCHAR2(100), boundary SDO_GEOMETRY );
3.2 距离矩阵:不是每次实时算,而是预计算 + 缓存
VRP 求解器需要的是任意两点之间的行驶距离/时间矩阵,不是单个距离查询。如果求解时逐点调用地图 API 或空间函数,请求数和延迟都不可接受。正确做法是预计算距离矩阵并缓存:
sql
-- 距离矩阵表:按“起点类型/起点ID/终点类型/终点ID”存储 CREATE TABLE distance_matrix ( origin_type VARCHAR2(16), -- STATION / VEHICLE / ADDRESS origin_id NUMBER, dest_type VARCHAR2(16), dest_id NUMBER, road_distance NUMBER, -- 道路距离(米) road_duration NUMBER, -- 行驶时间(秒) matrix_version NUMBER, -- 矩阵版本号,路网更新时递增 PRIMARY KEY (origin_type, origin_id, dest_type, dest_id, matrix_version) );
空间查询的典型用法:当需要“找出某个站点 5 公里内的所有候选站点”时,用SDO_WITHIN_DISTANCE;当需要“找出离某坐标最近的 N 个站点”时,用SDO_NN:
sql
-- 5 公里内的站点(带空间索引时才高效) SELECT s.station_id, s.station_name FROM logistics_station s WHERE SDO_WITHIN_DISTANCE( s.location, SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(113.9345, 22.5312, NULL), NULL, NULL), 'distance=5 unit=KM' ) = 'TRUE'; -- 最近的 3 个站点 SELECT station_id, station_name FROM ( SELECT station_id, station_name FROM logistics_station ORDER BY SDO_NN(location, SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(113.9345, 22.5312, NULL), NULL, NULL), 'sdo_num_res=3') ) WHERE ROWNUM <= 3;
工程约束:SDO_WITHIN_DISTANCE不加空间索引会全表扫描;SDO_NN的sdo_batch_size会影响性能,需根据数据分布调整。Oracle 的空间查询用于矩阵构建和边界判定,不进入秒级调度热路径——热路径的附近检索由 Redis GEO 承担。
四、Redis GEO:候选车辆召回的热路径
4.1 位置写入与附近检索
车辆位置由接入服务通过GEOADD写入 Redis,调度时用GEOSEARCH(或GEORADIUS)检索:
java
@Service public class GeoIndexService { private final StringRedisTemplate redis; // 车辆位置更新:由 GPS 上报触发,高频写入 public void updateVehicleLocation(Long vehicleId, double lng, double lat) { redis.opsForGeo().add("geo:vehicles:online", new Point(lng, lat), String.valueOf(vehicleId)); } // 附近可用车辆召回:调度热路径 public List<Long> findNearbyVehicles(double lng, double lat, double radiusKm, int limit) { Circle circle = new Circle(new Point(lng, lat), new Distance(radiusKm, Metrics.KILOMETERS)); GeoResults<RedisGeoCommands.GeoLocation<String>> results = redis.opsForGeo().search("geo:vehicles:online", GeoReference.fromCircle(circle), RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs() .includeDistance().sortAscending().limit(limit)); return results.getContent().stream() .map(r -> Long.valueOf(r.getContent().getName())) .collect(Collectors.toList()); } }GEOSEARCH的复杂度是 O(log N),在数万在线车辆的规模下,召回延迟在亚毫秒到毫秒级。“在线”状态与“位置”索引合并在同一个 GEO key 中,车辆下线时ZREM移除,避免召回后再过滤已下线车辆。
4.2 候选集不是“最终答案”
Redis GEO 召回的是空间上附近的车辆,不是“适合接这单的车辆”。附近但满载、车型不符、司机连续工作超时的车辆,都应该在后续评分中被排除。召回阶段的目标是快速缩小搜索空间,评分阶段才做多约束筛选。
五、加权评分与 VRP 求解:从候选集到最终分配
5.1 单订单派单:加权评分
对于“一单找一车”的即时场景(同城货运、即时配),加权评分是实用且可解释的方案。评分维度包括距离、空闲时长、历史履约率、车型匹配度:
java
public DriverScore score(Long vehicleId, Order order, double distanceKm, VehicleProfile vp) { double distanceScore = 1.0 / (1.0 + distanceKm); // 越近越高 double idleScore = normalizeIdleTime(vp.getIdleMinutes()); double fulfillScore = vp.getFulfillmentRate(); // 0~1 double typeScore = vp.getVehicleType() .supports(order.getCargoType()) ? 1.0 : 0.0; // 权重可按业务场景配置,不硬编码 double total = 0.35 * distanceScore + 0.20 * idleScore + 0.30 * fulfillScore + 0.15 * typeScore; return new DriverScore(vehicleId, total, distanceKm); }关键设计:权重不写死在代码里,而是按业务场景(即时配/预约配/大票零担)配置在数据库中,调整权重不需要发版。
5.2 多订单路线优化:OR-Tools 的 Java 集成
当需要为一批订单规划完整路线(CVRPTW)时,加权评分不再适用,需要约束求解器。OR-Tools 的 Java 绑定提供了RoutingModel,支持容量约束、时间窗约束和距离/时间维度:
java
@Component public class VrpSolver { public List<List<Integer>> solve(long[][] distanceMatrix, long[] demands, long[] vehicleCaps, long[][] timeWindows) { int numVehicles = vehicleCaps.length; int numNodes = demands.length; int depot = 0; RoutingIndexManager manager = new RoutingIndexManager( numNodes, numVehicles, depot); RoutingModel routing = new RoutingModel(manager); // 1. 弧成本 = 距离/时间 int transitIdx = routing.registerTransitCallback( (long from, long to) -> distanceMatrix[ manager.indexToNode(from)][manager.indexToNode(to)]); routing.setArcCostEvaluatorOfAllVehicles(transitIdx); // 2. 容量约束 int demandIdx = routing.registerUnaryTransitCallback( (long from) -> demands[manager.indexToNode(from)]); routing.addDimensionWithVehicleCapacity( demandIdx, 0, vehicleCaps, true, "Capacity"); // 3. 时间窗约束 int timeIdx = routing.registerTransitCallback( (long from, long to) -> distanceMatrix[ manager.indexToNode(from)][manager.indexToNode(to)]); routing.addDimension(timeIdx, 30, 1440, false, "Time"); RoutingDimension timeDim = routing.getDimensionOrDie("Time"); for (int i = 1; i < numNodes; i++) { long index = manager.nodeToIndex(i); timeDim.cumulVar(index).setRange( timeWindows[i][0], timeWindows[i][1]); } // 4. 求解(设置时限,避免长尾) RoutingSearchParameters params = main.defaultRoutingSearchParameters() .toBuilder() .setFirstSolutionStrategy( FirstSolutionStrategy.Value.PATH_CHEAPEST_ARC) .setLocalSearchMetaheuristic( LocalSearchMetaheuristic.Value.GUIDED_LOCAL_SEARCH) .setTimeLimit(Duration.ofSeconds(5)) .build(); Assignment solution = routing.solveWithParameters(params); return extractRoutes(routing, manager, solution, numVehicles); } }工程约束:
求解必须设时限。VRP 是 NP-hard,没有时限的求解在规模上来后会无限拖长。5 秒时限配合
GUIDED_LOCAL_SEARCH给出“足够好”的解,而不是“最优解”。距离矩阵的粒度决定求解质量。矩阵必须是道路距离/时间,不能是直线距离。直线距离会低估绕路,导致求解器给出物理上不可行的路线。
备用求解器:如果 OR-Tools 在特定约束组合下表现不佳,Timefold(OptaPlanner 的分支)或 Jsprit 是 Java 生态中可切换的备选。架构上求解器应封装在接口后,允许按场景路由到不同实现。
六、动态重调度:执行中的偏差不能等下一次规划
6.1 触发条件
出发前规划得再好,执行中必然有偏差。重调度不是“定时重算”,而是事件触发:
| 触发事件 | 条件 | 重调度范围 |
|---|---|---|
| 新订单插入 | 车辆剩余容量可容纳 | 仅重排该车剩余路线 |
| 车辆严重延误 | ETA 偏差 > 阈值(如 30 分钟) | 该车后续节点 + 受影响订单 |
| 车辆故障/司机异常 | 无法继续执行 | 该车全部未完成节点,重新分配 |
| 交通管制/封路 | 路段不可通行 | 经过该路段的路线局部重算 |
6.2 局部调整优先,全局重算兜底
DVRP 的求解策略是局部插入 + 局部交换,而不是每次全局重算。动态车辆路径问题的研究指出,实时决策需要适应需求的动态到达,而非在每次事件发生时重新求解整个问题。
一个可落地的重调度骨架:
java
public ReplanResult onDisruption(ReplanEvent event) { RoutePlan plan = planRepo.getActivePlan(event.getVehicleId()); switch (event.getType()) { case NEW_ORDER: // 尝试最优插入:遍历剩余节点间隙,选成本增量最小的 return insertBestPosition(plan, event.getOrder()); case DELAY: // 延误:只重排受影响节点之后的部分 return replanRemaining(plan, event.getFromNode()); case VEHICLE_FAILURE: // 车辆故障:未完成节点全部释放,由其他车辆消化 List<Task> orphaned = plan.releaseUnfinished(); return reassignToOtherVehicles(orphaned); case ROAD_CLOSURE: // 封路:局部绕行,不改变节点顺序 return rerouteAffectedLegs(plan, event.getClosedEdges()); } return ReplanResult.noop(); }关键约束:重调度结果必须经过状态机校验——已经完成的节点不可回退,已经签收的订单不可重新分配。状态机在重调度和正常执行之间充当“合法性守门人”。
6.3 WebSocket 实时推送
调度指令和车辆位置变化需要通过 WebSocket 推送到司机端和调度看板。Spring Boot 的@EnableWebSocket+ STOMP 是常用方案,心跳保活间隔建议 30 秒,否则 Nginx 默认 60 秒会断开连接。位置数据推送时过滤accuracy > 30的脏数据,避免 GPS 漂移导致看板上的车辆“乱跳”。
七、避坑指南
坑1:用 Oracle 实时算附近车辆,CPU 被打满。车辆位置秒级更新,如果每次都用SDO_WITHIN_DISTANCE实时查“附近车辆”,关系型数据库的 CPU 会迅速飙升。Redis GEO 承担热路径的附近检索,Oracle 只做持久化和冷路径的空间分析,是经过验证的分工。
坑2:OR-Tools 不设时限,求解拖垮调度线程。CVRPTW 在 100 个节点以上时,没有时限的求解可能运行数分钟。必须设 5 秒左右的时限,接受“足够好”的解。调度线程不能被求解器阻塞,求解应异步执行,超时返回次优解或降级为加权评分。
坑3:距离矩阵用直线距离。直线距离在城区路网中会低估绕路 30%–50%,求解器给出的路线在物理上不可行。距离矩阵必须来自道路规划 API 或预计算的路网矩阵,且需按路况版本更新。
坑4:重调度无视已执行状态。车辆故障后把“已完成的节点”也重新分配,导致重复派送或客户收到两次货。重调度必须基于未完成节点,状态机校验已完成/已签收的不可回退。
八、总结
物流车辆实时调度与路线最优规划的核心,是把“空间感知、候选召回、约束求解、执行重调度”做成一条分层的决策链,而不是把所有逻辑塞进一个 SQL 或一个方法:
空间数据冷热分离:Redis GEO 用 GeoHash +
GEOSEARCH扛住秒级位置写入和毫秒级附近召回;Oracle Spatial 用SDO_GEOMETRY+ 空间索引负责持久化、距离矩阵和边界判定。热路径不进关系库,冷路径不依赖内存。召回和求解是两阶段:Redis 召回“空间上附近”的候选车辆,加权评分或 VRP 求解器在候选集上做“业务上合适”的最终分配。距离矩阵必须预计算、必须是道路距离,求解必须设时限。
重调度是事件驱动的局部调整:新订单、延误、故障、封路各自触发不同范围的重算,优先局部插入/交换,全局重算只作为兜底。状态机守住“已完成不可回退”的底线。
> 本文作者:深圳+Eyf•半字节科技▪技术产品团队
> 物流、快递、快运作业管理系统,让每一票,都清晰可控。从任务分配到签收回传,一站式管理每个作业环节。
你在车辆调度中遇到的最棘手问题是“附近车辆召回不准”,还是“路线求解太慢/结果不可用”?欢迎在评论区分享具体场景。