Java智能物流管理系统:从订单库存表设计到调度算法实战

发布时间:2026/10/5 4:18:43
Java智能物流管理系统:从订单库存表设计到调度算法实战 简介基于Java的智能物流管理系统设计与实现毕业设计文档完整呈现了系统分析与可行性论证、功能与数据库设计、SSM框架编码实现的全过程适合高校计算机相关专业学生及物流信息化开发者阅读参考。文档详细讲解B/S架构下Spring、SpringMVC、MyBatis与MySQL数据库的集成方案系统涵盖管理员、顾客、员工、店主四类角色围绕个人中心、顾客管理、员工管理、门店信息管理、部门分类管理、订单信息管理、工作日志管理等核心模块展开完整设计。资源包内为一个docx文档大小1.29MB包含框架搭建、数据库设计与功能模块实现的完整论述可直接作为毕业设计文档框架参考。目前已有88人学习。通过该文档可系统掌握智能物流系统的可行性分析思路、数据库设计方法与模块化开发模式对从事JavaWeb开发及物流管理系统建设的在校学生和初级开发者具有实用价值。1. 基于 Java 的智能物流管理系统到底在解决什么问题先别管“智能”把业务链条理清一个常见画面仓库里积压了几百个待配送订单调度员在 Excel 里手动给司机排线路一边打电话确认车辆位置一边被催单。结果经常出现 A 车满载绕路、B 车空驶返回客户追问包裹到哪了客服只能去翻微信记录。基于 Java 的智能物流管理系统本质就是把“订单、库存、车辆、路径、签收”这条链路放到同一个数据库里再用算法代替人工经验做决策。它不是炫技的 AI而是先把每个状态变动记录下来让调度有数据可依。适合有 Java Web 基础的开发者也适合要给仓库或配送团队搭内部系统的技术负责人。下面按“业务流程 - 表结构 - 调度算法 - 上线避坑”的顺序给你一条可以直接复现的路。2. 把系统拆成几块订单、库存、运单怎么流转才不打架系统设计的经验是先画业务流再定表结构。很多人一上来就建表结果订单和库存对不上、运单重复分配后面改起来非常痛苦。我先梳理一遍最小可用流程再讲模块边界最后落到 Spring Boot MyBatis 的骨架工程上。2.1 从订单到签收核心业务流程决定数据表怎么建客户在小程序或 ERP 里下单后订单模块写入订单主表和明细表随后系统预占库存注意这里不是立刻扣减而是先把可用库存锁住防止多个订单同时超卖仓库按订单拣货并出库此时预占库存才真正扣减调度模块读取等待配送的订单结合可用车辆和位置信息生成运单把多个顺路的订单合到一辆车上司机按规划路径配送每到一个点回传轨迹客户签收后订单状态关闭。这个流程里订单状态是唯一的事实来源库存流水、运单轨迹都是围绕它留下的证据。如果需求还包含拒收、退回那么状态机必须允许“配送中”回退到“已出库”或“已取消”否则业务根本跑不通。我在第一个版本就犯过这个错把订单状态设计成只能往前推结果线上退货单没法闭环最后只能补一个状态流转更新接口去救火。从数据表设计角度看这段流程决定了四个核心实体订单、库存、运单、轨迹。订单表保存配送地址和签收状态库存表保存可用数和预占数运单表把一批订单绑定到一辆车上轨迹表记录车辆实际行驶路径。它们之间的关系是订单通过关联表和库存发生预占/扣减通过运单关联表进入配送再通过运单 ID 关联到轨迹点。业务流走一遍表之间的外键和索引基本就清楚了。2.2 模块划分订单、仓储、运输、调度、报表各自管什么模块核心职责关键实体订单模块订单增删改查、状态机流转order_master, order_item仓储模块库存预占/扣减/释放、库存流水inventory, inventory_flow运输模块运单生成、轨迹点接收、签收回写waybill, waybill_order, track_point调度模块读取待配送订单、调用路径算法生成运单跨模块调用报表模块订单量、配送时长、车辆装载率统计聚合查询这样划分的原因是让每个模块拥有明确的写权限。订单模块不能直接改库存只能调用仓储模块的“预占库存”接口调度模块不能直接改订单状态只能写运单。如果一开始就允许业务代码里彼此乱改表后面做并发控制和算法替换时你会在全部代码里找数据到底是谁改的非常痛苦。我一般会在项目根包下按模块分包controller只接收参数和返回结果service做业务校验与状态流转mapper只负责 SQL。调度算法放一个algorithm子包不掺任何 Web 层代码。这样算法迭代时不会把 Controller 层搞坏也方便用命令行方式单独跑历史数据做回归。2.3 用 Spring Boot MyBatis 搭骨架最小配置与注解事务选择 Spring Boot MyBatis 不是因为它们最新而是因为它们能让团队最快把核心业务跑起来。Spring Boot 自动完成数据源、Web 容器、事务管理器装配MyBatis 允许你手写 SQL这对库存扣减和状态更新这类需要精准控制语句的场景更友好不像 JPA 自动生成的 SQL 在并发下容易出问题。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency这里版本匹配是第一个坑。如果你用 Spring Boot 3.xMyBatis starter 必须是 3.x 分支如果用 2.7.x2.3.2 是稳定选择。JDK 版本也要对齐Spring Boot 2.7 支持 JDK 8 和 11Spring Boot 3.x 要求 JDK 17。项目里曾经因为本机java -version显示 17却引入了 Spring Boot 2.3 的旧依赖启动时直接报UnsupportedClassVersionError最后统一降级 JDK 8 才稳定下来。spring: datasource: url: jdbc:mysql://localhost:3306/smart_logistics?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root hikari: maximum-pool-size: 20 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.logistics.entitymaximum-pool-size不是越大越好MySQL 默认连接数有限20 足够支撑几十个并发请求connection-timeout设置 30 秒是防止极端情况下请求无限等待。serverTimezone必须指定否则 JDBC 连接时区和数据库不一致会导致时间字段偏差。事务控制建议放在 Service 层而且只包住必要的写操作。下面是一个订单创建并预占库存的典型写法Transactional(rollbackFor Exception.class) public void createOrderAndReserveStock(OrderDTO dto) { orderMapper.insert(dto); int rows inventoryMapper.reserveStock(dto.getSkuId(), dto.getQuantity()); if (rows 0) { throw new BusinessException(库存不足); } }rollbackFor Exception.class必须写因为 Spring 默认只对运行时异常回滚如果业务方法抛出受检异常不回滚的话会导致订单已插入但库存没扣成功。reserveStock返回受影响行数0 表示条件不满足需要立即抛异常让事务回滚。这个方法后续会在并发避坑章节里再展开因为它其实还不够安全。3. 表结构设计四张主表把物流状态钉死在数据库里表结构是物流系统的骨架。最怕的是用一张大订单表存所有信息配送信息、库存信息全塞在一起最后只能拆表。我的做法是拆成订单、库存、运单、轨迹四组核心表每组表各管一段状态。3.1 订单主表与订单明细表状态字段还是状态机订单主表保存客户信息、收货地址和订单状态订单明细表保存 SKU、数量、单价。一个订单可能包含多个商品所以必须拆主表和明细表否则库存和金额统计都会很难做。CREATE TABLE order_master ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, customer_id BIGINT NOT NULL, receiver_name VARCHAR(50) NOT NULL, receiver_address VARCHAR(255) NOT NULL, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已审核 3已配货 4已出库 5配送中 6已签收 7已取消, total_amount DECIMAL(10,2) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_customer (customer_id), INDEX idx_status (order_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_status用 TINYINT 而不是字符串一是省空间二是索引更快。状态流转不能靠纸上约定要在 Service 层用状态机校验。下面这段代码是兼容 JDK 8 的写法private static final MapInteger, SetInteger ALLOWED_TRANSITIONS new HashMap(); static { ALLOWED_TRANSITIONS.put(0, new HashSet(Arrays.asList(1, 7))); ALLOWED_TRANSITIONS.put(1, new HashSet(Arrays.asList(2, 7))); ALLOWED_TRANSITIONS.put(2, new HashSet(Arrays.asList(3, 7))); ALLOWED_TRANSITIONS.put(3, new HashSet(Arrays.asList(4, 7))); ALLOWED_TRANSITIONS.put(4, new HashSet(Arrays.asList(5, 7))); ALLOWED_TRANSITIONS.put(5, new HashSet(Arrays.asList(6, 7))); } public void changeOrderStatus(Long orderId, int targetStatus) { OrderMaster order orderMapper.selectById(orderId); SetInteger allowed ALLOWED_TRANSITIONS.get(order.getOrderStatus()); if (allowed null || !allowed.contains(targetStatus)) { throw new BusinessException(非法状态流转); } orderMapper.updateStatus(orderId, targetStatus); }这里有两点很容易翻车。第一HashMap的静态初始化块放在类加载时执行不能用Map.of的写法因为Map.of是 Java 9 才有的很多老项目还在 JDK 8。第二状态校验必须在数据库更新前完成而且要重新读取最新状态不能拿着前端传来的状态直接更新否则前端可以随意把已签收订单改成已支付。3.2 库存表与库存流水避免超卖要用锁和流水两条腿库存是物流系统里最容易出并发问题的地方。一张简单的inventory表至少要有可用库存stock和预占库存reserved_stock两个数字再加一个操作流水表把每次变动记清楚。CREATE TABLE inventory ( sku_id BIGINT PRIMARY KEY, stock INT NOT NULL DEFAULT 0 COMMENT 可用库存, reserved_stock INT NOT NULL DEFAULT 0 COMMENT 预占库存, version INT NOT NULL DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE inventory_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, order_no VARCHAR(32) NOT NULL, change_type TINYINT NOT NULL COMMENT 1预占 2扣减 3释放 4入库, change_qty INT NOT NULL, before_qty INT NOT NULL, after_qty INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_sku (sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;库存预占的 SQL 不能写成“先 select 再 update”那样两个线程会同时读到剩余 1 个库存然后各自扣减成功最终库存变成负数。正确做法是用一条 UPDATE 语句带条件完成原子操作UPDATE inventory SET stock stock - #{qty}, reserved_stock reserved_stock #{qty} WHERE sku_id #{skuId} AND stock #{qty}这条语句利用 InnoDB 的行锁保证同一时刻只有一个事务能修改同一行stock #{qty}是防负数条件的最后一道防线。MyBatis 中定义这个方法update idreserveStock UPDATE inventory SET stock stock - #{qty}, reserved_stock reserved_stock #{qty} WHERE sku_id #{skuId} AND stock #{qty} /update调用后检查返回值等于 0 就说明预占失败。库存流水表里的before_qty和after_qty字段看起来多余但它是排查问题的审计数据。有人会问为什么不直接在事务里 select 出来再算 after因为并发下 select 到的值可能是过期的只有 UPDATE 完成后再查才是准确值而把 before 和 after 记到流水里最好在同一个事务里 UPDATE 后紧跟一次 SELECT而不是把两次操作拆到不同线程。3.3 运单与轨迹表路径数据怎么存才能支撑后续分析运单是把订单和车辆绑到一起的容器。一辆车可以配送多个订单所以需要运单主表、运单订单关联表和轨迹点表。CREATE TABLE waybill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL UNIQUE, vehicle_id BIGINT NOT NULL, driver_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待调度 1已派车 2配送中 3已完成 4已取消, planned_path_json TEXT COMMENT 算法生成的路径点集合, total_distance_meters INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE waybill_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_id BIGINT NOT NULL, order_no VARCHAR(32) NOT NULL, sequence INT NOT NULL COMMENT 配送顺序 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE track_point ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_id BIGINT NOT NULL, lng DECIMAL(9,6) NOT NULL, lat DECIMAL(9,6) NOT NULL, device_time DATETIME NOT NULL, speed_kmh DECIMAL(5,1) DEFAULT 0, INDEX idx_waybill_time (waybill_id, device_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;planned_path_json这个字段很多人会忽略但它极其重要。算法生成的最优路径要保留下来司机实际轨迹回传后才能做“计划 vs 实际”的对比否则你无法知道是算法算错了还是司机没按路线走。轨迹表按(waybill_id, device_time)建联合索引是因为查询永远是一个运单按时间排序单查waybill_id或device_time都无法覆盖这个查询场景。经纬度用DECIMAL(9,6)可以精确到 0.1 米足够车载 GPS 使用。4. 调度算法落地从贪心路径到遗传算法优化的实现与调参智能物流里“智能”最直接的体现就是调度算法输入仓库坐标和一批待配送订单坐标输出每辆车的配送顺序。最简单的算法是贪心就近配送先能用再优化。4.1 贪心算法先跑通就近配送的 Java 代码实现先写一个只能处理单车、不考虑载重的贪心方法从仓库出发每次都选择离当前点最近的未配送点直到所有点都被访问。public ListLong greedyRoute(Long depotId, ListDeliveryPoint deliveryPoints) { ListLong route new ArrayList(); if (deliveryPoints.isEmpty()) { return route; } DeliveryPoint current findDepot(depotId); boolean[] visited new boolean[deliveryPoints.size()]; int visitedCount 0; while (visitedCount deliveryPoints.size()) { int nextIndex -1; double minDist Double.MAX_VALUE; for (int i 0; i deliveryPoints.size(); i) { if (!visited[i]) { double d current.distanceTo(deliveryPoints.get(i)); if (d minDist) { minDist d; nextIndex i; } } } visited[nextIndex] true; route.add(deliveryPoints.get(nextIndex).getId()); current deliveryPoints.get(nextIndex); visitedCount; } return route; }findDepot可以从数据库或内存坐标构造一个DeliveryPoint但注意它不能出现在deliveryPoints集合里否则起点会被当成客户点重新访问。distanceTo方法建议先用 Haversine 公式计算两点球面距离代码量不大但比直接用经纬度做欧氏距离准得多public double distanceTo(DeliveryPoint other) { double dLat Math.toRadians(other.lat - this.lat); double dLng Math.toRadians(other.lng - this.lng); double a Math.sin(dLat / 2) * Math.sin(dLat / 2) Math.cos(Math.toRadians(this.lat)) * Math.cos(Math.toRadians(other.lat)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }贪心的复杂度是 O(n²)当配送点在 100 个以内时毫秒级就能跑完。但它有个明显问题每次只看局部最近可能为了一个离得近的点最终整体路径变长。比如 A 点在仓库东边 3 公里B 点在仓库西边 2 公里如果先去了 A再去 B 就要横穿仓库总距离反而增加。所以贪心只适合作为基线算法。4.2 遗传算法优化编码、适应度和交叉怎么设计遗传算法把一条配送顺序当成一个染色体通过种群迭代找到更短的总路径。它不保证全局最优但在 50 到 200 个配送点场景下通常明显优于贪心。核心变量是编码和适应度函数。public class Chromosome implements ComparableChromosome { private ListLong route; private double fitness; } public double calcFitness(ListLong route, MapLong, double[] coords, double[] depotCoord) { double totalDistance 0.0; for (int i 0; i route.size() - 1; i) { totalDistance distance(coords.get(route.get(i)), coords.get(route.get(i 1))); } totalDistance distance(coords.get(route.get(route.size() - 1)), depotCoord); return 1.0 / (totalDistance 1e-6); }适应度用了“总距离越小适应度越大”的转换1e-6是为了防止初始总距离为 0 导致除零。接下来是锦标赛选择、顺序交叉和交换变异这些步骤在普通 TSP 算法里都有成熟写法。一个容易踩的坑是交叉之后生成的子代可能包含重复点或缺失点。比如父代 A 的路线是 [1,2,3,4]父代 B 是 [3,1,4,2]简单交叉可能变成 [1,2,3,3]并不合法。因此必须用“顺序交叉”或“映射交叉”保证子代仍然是 1 到 N 的排列。遗传算法参数我习惯这么设置种群大小 100迭代 300 代交叉率 0.8变异率 0.05。但这不是万能数字。配送点少种群可以缩小到 50时间窗紧迭代次数要加大否则很难收敛到可行解。这个调参过程有点“玄学”所以每次修改参数我都会在同一批订单上跑一遍把结果和旧版本对比再决定要不要上线。4.3 车辆数、容量约束、时间窗对算法的影响贪心和基本遗传算法都默认只用一辆车、没有载重限制。真实物流场景里车辆数和容量约束几乎是必备条件。多车情况下算法可以拆成两个层次先把订单分配到各车辆再对每辆车的配送顺序做路径优化。如果一开始就把所有决策揉进一个染色体搜索空间会爆炸代码也难调试。约束对算法的影响调整建议车辆数订单分组是关键路径在组内优化先按区域或方向分桶再跑单车路径容量约束超载会造成不可行解在适应度函数里加罚分超容量一个订单罚很大值时间窗到达时间必须在最晚时间前每个点加入预计到达时间违反则直接判不可行行驶距离直线距离与实际道路差距大用地图距离矩阵替换 Haversine 计算时间窗约束是最容易让算法翻车的地方。假设客户要求在 18 点前送达算法给出的顺序是先送一个很远的客户再回来到达时间变成 20 点这一整条路径都不可用。这时候不能只靠适应度罚分最好在评价函数里先做完可行性检查不可行染色体直接淘汰而不是让它参与交叉污染下一代。5. 避坑指南从连接池满到重复调度五个高频问题排查这一章我会直接讲实际项目中容易遇到的问题每条按“现象 - 原因 - 解决”的顺序写方便你上线前对照排查。5.1 数据库连接池满导致系统卡死现象系统运行几天后页面请求全部转圈日志反复出现Connection is not available, request timed out after 30000ms。原因最常见的是把耗时操作放进了数据库事务里。比如有人在Transactional方法里调用路径算法算法计算几秒钟事务一直持有数据库连接。连接池总共 20 个连接几个慢事务就能占满新请求全部阻塞。解决事务里只做增删改和必要查询算法计算必须在事务外完成。先把订单数据查出来放在内存然后在无事务方法里跑路径算法最后开启事务把运单结果写库。另外把连接池配置调整成maximum-pool-size20, connection-timeout30000并监控连接池活跃数如果长期接近最大值说明代码里有连接泄漏。5.2 库存扣减在并发下出现负数现象电商大促压测时同一 SKU 的库存从 5 变成 -3订单却都创建成功了。原因代码先执行select stock from inventory在 Java 里判断if(stock qty)再执行update inventory set stock stock - qty。两个线程同时读到库存为 1都通过判断最后各自减 1结果变成 -1。解决放弃“先查后改”改用单条原子 UPDATEupdate idreduceStock UPDATE inventory SET stock stock - #{qty}, version version 1 WHERE sku_id #{skuId} AND stock #{qty} /update这条 SQL 利用数据库行锁串行化并发操作stock #{qty}保证扣减后不会小于 0。MyBatis 执行后返回影响行数为 0 就抛异常回滚。注意这个 UPDATE 必须和订单插入在同一个事务里否则库存扣成功、订单没插入数据还是对不上。5.3 算法算出的路径不走实际道路现象调度算法给客户 A 和客户 B 分配的路径距离很近但司机实际开车要绕行一条河因为 A 和 B 之间根本没有直行道。按算法路线走配送时间比预估多了 30 分钟。原因贪心和遗传算法默认使用 Haversine 直线距离而物流场景必须使用道路距离。直线距离是物理距离不是车辆可达距离。解决在设计阶段就把距离计算抽象成接口后续可以替换成地图 API 或离线路网数据。对于内部系统建议先接入地图服务商的距离矩阵接口拿到实际驾车距离和时间。如果公司有合规要求不能调外部 API也可以把历史轨迹点聚合成路网切片做一个本地距离矩阵缓存。至少不要在代码里写死 Haversine 公式否则算法优化得再漂亮司机也不认。5.4 定时任务重复执行导致运单重复分配现象每天早上 8 点的自动调度任务生成了两批运单同一张订单被分配给了两辆车。原因系统部署了两个实例Scheduled定时任务在每个实例上都会执行没有任何协调机制。解决最简单的方式是引入分布式锁或数据库锁。优惠券、库存这类场景已经有现成中间件但物流调度频次低用数据库行锁就够了调度任务启动时先尝试更新一张schedule_lock表更新成功才继续执行。伪代码如下int locked scheduleLockMapper.tryLock(daily_dispatch, workerName, timeoutSeconds); if (locked 1) { dispatchService.executeDailyDispatch(); scheduleLockMapper.releaseLock(daily_dispatch, workerName); }tryLock的 SQL 是UPDATE schedule_lock SET locked_by #{worker}, locked_at NOW() WHERE job_name #{jobName} AND (locked_at IS NULL OR locked_at DATE_SUB(NOW(), INTERVAL #{timeout} SECOND))。这样一台实例执行时其他实例更新不到行自然跳过。注意locked_at必须有超时兜底否则实例宕机后锁永远失效。5.5 Java 版本与 Spring Boot 版本不匹配导致项目启动失败现象新入职同事 clone 代码后第一次启动直接报UnsupportedClassVersionError: org/springframework/boot/... has been compiled by a more recent version of the Java Runtime。原因项目是 Spring Boot 2.7 编译目标 JDK 8但开发机默认环境是 JDK 17。Spring Boot 2.7 部分依赖在 JDK 17 下如果用了旧字节码版本就会启动失败反过来Spring Boot 3.x 要求 JDK 17你拿 JDK 8 去跑也会失败。解决每个项目根目录放一份.sdkmanrc或 Maventoolchains.xml明确 JDK 版本。项目里设置properties java.version1.8/java.version spring-boot.version2.7.18/spring-boot.version /properties另外mybatis-spring-boot-starter版本也要排查看Spring Boot 2.7 配 MyBatis 2.3.xSpring Boot 3.x 配 MyBatis 3.x。版本混装是启动失败的重灾区排查时先mvn dependency:tree看实际运行时版本再统一版本管理。6. 让系统真正可演进算法封装、结果验证与线上监控当你把前面几章跑通后系统已经有了能用、能上线的基础。最后一章想告诉你怎么让这套系统在三个月后还能继续优化而不是变成一个不敢动的大泥球。6.1 用策略模式把算法封装成可替换的调度器路径算法不能写死在DispatchService里。应该先定义一个统一接口public interface RouteScheduler { ListLong schedule(Long depotId, ListDeliveryPoint points); }贪心算法、遗传算法各写一个实现类通过 Spring 的Qualifier或配置项选择当前使用哪个。这样切换算法时业务层和数据库层完全不用改只要改一个配置项线上就能做 A/B 对比。6.2 验证算法用同一份历史订单做回归对比每次换算法我会从数据库导出最近 30 天的历史订单分别用旧算法和新算法跑一遍对比总里程、每车装载量、超时订单数。结果存成 JSON 文件或对比表。这个习惯很朴素但能避免“感觉新算法更优”的错觉。6.3 监控订单超时和调度失败最后给系统加两个监控一是扫描超过约定时间没有推进状态的订单自动触发预警二是用 Spring Actuator 暴露连接池、接口响应时间等指标。只要基础监控在算法出问题也能第一时间发现。我自己的经验是改调度算法前一定先导出一份历史订单数据跑完旧算法再去跑新算法把结果存成 JSON。这个习惯帮我避开了好几次“新算法上线后发现总里程更小但配送超时更多”的翻车。希望帮到你。本文还有配套的精品资源点击获取