简介:基于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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>这里版本匹配是第一个坑。如果你用 Spring Boot 3.x,MyBatis starter 必须是 3.x 分支;如果用 2.7.x,2.3.2 是稳定选择。JDK 版本也要对齐:Spring Boot 2.7 支持 JDK 8 和 11,Spring Boot 3.x 要求 JDK 17。项目里曾经因为本机java -version显示 17,却引入了 Spring Boot 2.3 的旧依赖,启动时直接报UnsupportedClassVersionError,最后统一降级 JDK 8 才稳定下来。
spring: datasource: url: jdbc:mysql://localhost:3306/smart_logistics?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;order_status用 TINYINT 而不是字符串,一是省空间,二是索引更快。状态流转不能靠纸上约定,要在 Service 层用状态机校验。下面这段代码是兼容 JDK 8 的写法:
private static final Map<Integer, Set<Integer>> 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); Set<Integer> 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;库存预占的 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 id="reserveStock"> 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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 '配送顺序' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;planned_path_json这个字段很多人会忽略,但它极其重要。算法生成的最优路径要保留下来,司机实际轨迹回传后,才能做“计划 vs 实际”的对比,否则你无法知道是算法算错了还是司机没按路线走。轨迹表按(waybill_id, device_time)建联合索引,是因为查询永远是一个运单按时间排序,单查waybill_id或device_time都无法覆盖这个查询场景。经纬度用DECIMAL(9,6)可以精确到 0.1 米,足够车载 GPS 使用。
4. 调度算法落地:从贪心路径到遗传算法优化的实现与调参
智能物流里“智能”最直接的体现就是调度算法:输入仓库坐标和一批待配送订单坐标,输出每辆车的配送顺序。最简单的算法是贪心就近配送,先能用,再优化。
4.1 贪心算法先跑通:就近配送的 Java 代码实现
先写一个只能处理单车、不考虑载重的贪心方法:从仓库出发,每次都选择离当前点最近的未配送点,直到所有点都被访问。
public List<Long> greedyRoute(Long depotId, List<DeliveryPoint> deliveryPoints) { List<Long> 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 Comparable<Chromosome> { private List<Long> route; private double fitness; } public double calcFitness(List<Long> route, Map<Long, 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-size=20, connection-timeout=30000,并监控连接池活跃数,如果长期接近最大值,说明代码里有连接泄漏。
5.2 库存扣减在并发下出现负数
现象:电商大促压测时,同一 SKU 的库存从 5 变成 -3,订单却都创建成功了。
原因:代码先执行select stock from inventory,在 Java 里判断if(stock >= qty),再执行update inventory set stock = stock - qty。两个线程同时读到库存为 1,都通过判断,最后各自减 1,结果变成 -1。
解决:放弃“先查后改”,改用单条原子 UPDATE:
<update id="reduceStock"> 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.version>1.8</java.version> <spring-boot.version>2.7.18</spring-boot.version> </properties>另外mybatis-spring-boot-starter版本也要排查看:Spring Boot 2.7 配 MyBatis 2.3.x;Spring Boot 3.x 配 MyBatis 3.x。版本混装是启动失败的重灾区,排查时先mvn dependency:tree看实际运行时版本,再统一版本管理。
6. 让系统真正可演进:算法封装、结果验证与线上监控
当你把前面几章跑通后,系统已经有了能用、能上线的基础。最后一章想告诉你:怎么让这套系统在三个月后还能继续优化,而不是变成一个不敢动的大泥球。
6.1 用策略模式把算法封装成可替换的调度器
路径算法不能写死在DispatchService里。应该先定义一个统一接口:
public interface RouteScheduler { List<Long> schedule(Long depotId, List<DeliveryPoint> points); }贪心算法、遗传算法各写一个实现类,通过 Spring 的@Qualifier或配置项选择当前使用哪个。这样切换算法时,业务层和数据库层完全不用改,只要改一个配置项,线上就能做 A/B 对比。
6.2 验证算法:用同一份历史订单做回归对比
每次换算法,我会从数据库导出最近 30 天的历史订单,分别用旧算法和新算法跑一遍,对比总里程、每车装载量、超时订单数。结果存成 JSON 文件或对比表。这个习惯很朴素,但能避免“感觉新算法更优”的错觉。
6.3 监控订单超时和调度失败
最后给系统加两个监控:一是扫描超过约定时间没有推进状态的订单,自动触发预警;二是用 Spring Actuator 暴露连接池、接口响应时间等指标。只要基础监控在,算法出问题也能第一时间发现。
我自己的经验是:改调度算法前一定先导出一份历史订单数据,跑完旧算法,再去跑新算法,把结果存成 JSON。这个习惯帮我避开了好几次“新算法上线后发现总里程更小但配送超时更多”的翻车。希望帮到你。
本文还有配套的精品资源,点击获取