1. 航空货运调度为什么不能照搬快递系统
先讲一个真实场景。前几年我接手了一个航空货运调度系统的项目,客户是一家做航空货运代理的公司,日均订单量在三千到五千单左右,每天要协调十几架次航班的舱位,还要安排几十辆货车做机场到市区的短驳配送。项目启动时,业务方负责人跟我说过一句话,我到现在印象都很深:"我们试过买市面上的快递管理系统,也试过让外包团队按快递物流的逻辑做,结果都用不起来。"
为什么用不起来?因为航空货运的调度逻辑和快递有本质区别。快递系统的核心是路由和分拣,货物从A地到B地,中间经过若干中转场,每轮都有固定的时间段和线路,系统只需要按地址和时效套路由模板。但航空货运的核心是运力匹配,运力又分为两个维度:空中的航班舱位和地面的车辆运力。航班有固定时刻,但舱位会根据当天的货物实际重量、体积、危险品限制动态收紧和释放;地面车辆要在航班落地之后的规定时间内完成提货和派送。货物能不能上某一个航班,不是看地址,而是看货物属性(是否含电池、是否活体、是否温控)、重量体积和当前舱位的实时余量。
所以这套系统的调度难点,不是"把货物运到哪",而是"在什么时间窗口内,把什么货物放到哪个航班、哪趟车上"。本文要分享的,就是基于SpringBoot落地这套航空货运调度订单配送系统的完整过程,包含数据模型设计、舱位扣减并发处理、Flowable工作流集成、以及我实际踩过的几个坑。如果你正在做物流调度、运力匹配、订单履约类的系统,这篇内容应该能帮你少走一些弯路。
1.1 业务角色和调度流程概览
动手写代码之前,一定要先把业务链路摸清楚。航空货运订单配送这套系统里,我梳理下来主要有这几个角色:
- 货主:下单,填写货物名称、件数、重量、体积、目的地、期望到达时间。
- 销售/客服:录入订单、跟客户确认报价、回答货物状态。
- 调度员:核心角色,负责把订单分配到具体航班舱位,并安排机场提货、车辆配送。
- 仓库操作员:负责货物入库、称重复核、安检、打板(把货物固定到航空托盘上)。
- 司机:接收配送任务,完成机场到收货人之间的运输。
- 管理员:配置航线、航班时刻、运价规则、账号权限。
完整流程大致是:货主下单 → 销售审核 → 仓库收货并称重复核 → 调度员根据航班时刻和舱位余量订舱 → 货物打板过安检 → 装机起飞 → 到达站提货 → 调度车辆配送 → 签收回单。这个流程里的每一个节点,都需要系统有明确的状态记录和操作痕迹,这也是我为什么在系统里引入Flowable工作流引擎的原因,后面会详细展开。
一个容易被忽略的点是:航空货运的代理由很多是"并单"操作。一个客户下的多件货物,可能会拆到不同航班;也有可能多个订单的货拼在一起,走同一个航班同一票主单。所以订单和运单不能是简单的1:1关系,订单是业务维度,运单是操作维度。这两个概念没理清楚,后面写表结构一定会返工。
2. 技术选型与整体架构:SpringBoot是底座,Flowable管流程
技术栈方面,我最终定的方案是:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis + RabbitMQ + Flowable 6.6 + XXL-Job + MinIO。JDK用的11,部署在四台8C16G的云服务器上,一台Nginx做负载均衡,两台应用节点,一台单独跑MySQL和Redis。
每个组件都不是白选的,我简单说一下理由:
| 组件 | 用途 | 选择理由 |
|---|---|---|
| Spring Boot | 应用框架 | 生态成熟,团队招人容易,起步快 |
| MyBatis-Plus | ORM | 复杂SQL好控制,分页、条件构造器省事 |
| MySQL 8.0 | 主数据库 | 数据量在百万级别,单库完全够用 |
| Redis | 缓存和分布式锁 | 航班时刻缓存、舱位预扣、热点数据 |
| RabbitMQ | 异步消息 | 订单状态变更通知、配送任务推送 |
| Flowable | 工作流引擎 | 订单全生命周期状态流转可控、可追溯 |
| XXL-Job | 定时任务 | 航班取消轮询、超时未支付自动取消 |
| MinIO | 文件存储 | 运单拍照、签收单、货物照片 |
2.1 项目模块划分
我没有用微服务,这个体量用微服务属于给自己找事。我按Maven多模块的方式拆了六个业务模块,代码分开,但部署还是一个应用,本质上就是减少模块间的耦合。
- cloud-order:订单管理,下单、审核、改单、取消。
- cloud-dispatch:调度核心,航班舱位管理、订舱分配、配载计算。
- cloud-delivery:地面配送,提货任务、派送任务、司机APP接口。
- cloud-workflow:Flowable相关,流程部署、任务处理、状态查询。
- cloud-common:公共包,统一返回体、异常处理、工具类。
- cloud-admin:后台管理,航线配置、用户权限、运价规则、数据看板。
模块间通过Spring Boot的@FeignClient互相调用?不用,直接引入依赖调用Service接口就够了。单体应用阶段,Feign是多余的,还容易出序列化和网络超时的幺蛾子。
2.2 为什么用Flowable来管订单状态
很多同学做订单系统,习惯用一个status字段从0到N,手动if/else推进。订单流程简单时这么干没问题,但航空货运的订单流转有大量分支和异常场景,比如:
- 订舱后航班取消,需要重新订舱。
- 货物安检不通过,需要退回改走其他航班。
- 快递员提货后客户临时修改收货地址。
- 一单多件部分签收。
用状态字段的硬编码方式处理这些情况,代码里会充斥大量状态判断,每加一种异常就要动主流程代码,时间长了根本维护不动。Flowable的好处是把状态流转和业务代码分离。流程节点、条件分支、会签、驳回都定义在BPMN文件里,改流程不一定要改Java代码。这个特性在航空货运这种异常流程特别多的业务里,价值巨大。
我用的方式是:Flowable管理订单状态机,业务数据存在自己的业务表里,Flowable的流程实例ID存到订单表的process_instance_id字段,通过流程实例ID做关联。当流程流转到某个节点时,监听器或ServiceTask去调用对应模块的业务方法。这样既利用了工作流引擎的流程控制能力,又不至于把全部业务逻辑都塞进Flowable导致调试困难。
3. 订单、运单、舱位的数据模型设计
这块是整篇文章里最实在的部分,数据模型设计直接决定了后续调度逻辑能不能写顺。我建议你照着这个思路建表,比网上很多《XX管理系统数据库设计》要贴合航空货运的真实业务。
3.1 订单和运单的主表设计
先说核心的订单表order_info,我保留了这些关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(32) | 订单号,规则:YYYYMMDD+随机数 |
| customer_id | bigint | 货主ID |
| goods_name | varchar(255) | 货物名称 |
| goods_type | tinyint | 货物类型:1普通 2含电池 3活体 4温控 5危险品 |
| total_weight | decimal(10,2) | 总重量,单位kg |
| total_volume | decimal(10,2) | 总体积,单位m³ |
| total_packages | int | 总件数 |
| origin_airport | varchar(10) | 始发机场三字码 |
| dest_airport | varchar(10) | 目的机场三字码 |
| expect_arrival_time | datetime | 期望到达时间 |
| status | tinyint | 业务状态(冗余字段,方便联表查询) |
| flow_process_id | varchar(64) | Flowable流程实例ID |
| deleted | tinyint | 逻辑删除 |
| create_time, update_time | datetime | 记录时间 |
这里我特别说明一下:status字段和Flowable的流程节点是有对应关系的,但业务表里必须冗余一个status字段。因为在列表查询时,如果用Flowable的流程表做条件过滤,性能很差,而且多表关联复杂。实际做法是:Flowable流程节点负责"流转",业务表status字段负责"查询"。每次流程节点变更时,通过监听器同步更新业务表的status。
运单表waybill_info是操作层面的主表,和订单是多对一关系:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| waybill_no | varchar(32) | 运单号 |
| order_id | bigint | 关联订单ID |
| master_waybill | varchar(32) | 主运单号(航空主单) |
| flight_no | varchar(16) | 航班号 |
| flight_date | date | 航班日期 |
| cargo_status | tinyint | 货物状态:1待入库 2已入库 3已安检 4已打板 5已装机 6已起飞 7已落地 8已提货 9已派送 10已签收 |
| space_id | bigint | 占用舱位ID |
| operation_remark | varchar(500) | 操作备注 |
这张表是整个配送链条的核心,货物状态每前进一步,司机、仓库操作员、调度员都在这里更新状态。
3.2 航班舱位表和预分配逻辑
舱位的核心表是flight_schedule(航班时刻表)和flight_space(航班舱位表)。
flight_schedule字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| flight_no | varchar(16) | 航班号 |
| origin_airport | varchar(10) | 始发机场 |
| dest_airport | varchar(10) | 目的机场 |
| depart_time | datetime | 计划起飞时间 |
| arrive_time | datetime | 计划到达时间 |
| week_days | varchar(20) | 执飞周期,如1,3,5表示周一三五 |
| aircraft_type | varchar(20) | 机型 |
| max_payload | decimal(10,2) | 最大业载(kg) |
| max_volume | decimal(10,2) | 最大舱位体积(m³) |
| status | tinyint | 1有效 0停飞 |
flight_space表记录每个航班的舱位余量:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| schedule_id | bigint | 航班时刻ID |
| flight_date | date | 航班日期(具体哪一天) |
| space_type | tinyint | 舱位类型:1主货舱 2腹舱 |
| available_weight | decimal(10,2) | 可用重量余量(kg) |
| available_volume | decimal(10,2) | 可用体积余量(m³) |
| version | int | 乐观锁版本号 |
注意flight_space是以"具体某一天的一个航班"为粒度生成的。航班时刻表是计划,flight_space是当天实际可用的运力。每天晚上由XXL-Job根据flight_schedule生成未来一周的舱位舱容初始数据。这样做的好处是:如果有临时加班机或取消航班,直接对flight_space做增删改,不影响基础航班计划表。
优化点:在flight_space上我建了一个唯一索引uk_schedule_id_flight_date_space_type,保证同一个航班同一天同一个舱型只有一条记录,这是后续做舱位预扣时防止超卖的第一道防线。
4. 调度核心逻辑:舱位分配与并发扣减
舱位分配是整个系统里最容易出bug的地方。我前后重构了三版,才把超卖、错配这些问题压下去。第四部分我会详细展开这块的完整实现思路。
4.1 预分配策略:先到先得还是优先级优先?
业务方对舱位分配提了一个很具体要求:不能简单按下单时间先到先得。因为航空货运的利润来源主要是高价值、高时效的货物,比如生鲜、医疗试剂、电子产品。如果舱位被低价值的普货占满,高价值货物来了没舱位,等于白白损失利润。
我最终实现的分配策略分两步:
第一步,按航班和航线预筛候选航班。系统会根据订单的始发地、目的地、期望到达时间,去flight_schedule里筛选出所有满足时效要求的航班,按起飞时间升序排列。如果订单期望当天到,就优先选当天最早起飞的航班匹配。
第二步,舱位分配按优先级加权。每个订单有一个优先级分数priority_score,计算规则是:
优先级分数 = 货物类型权重 + 客户等级权重 + 时效紧迫度权重其中货物类型权重:危险品、温控、活体这种特殊货物加30分;普通普货加0分。客户等级权重:VIP客户加20分,普通客户加0分。时效紧迫度权重:期望到达时间在24小时内的加50分,48小时内的加20分。综合分数高的订单,在舱位紧张时优先分配。
代码实现时,我用一个分配队列PriorityQueue<OrderCandidate>做排序,然后逐个尝试分配。每个订单需要满足两个硬性约束:
- 剩余可用重量 >= 订单重量
- 剩余可用体积 >= 订单体积
如果当前候选航班舱位不足,就顺延到下一个航班。这个逻辑听起来简单,但加上并发之后就会出现严重问题,下面细说。
4.2 并发扣减舱位的超卖问题
舱位分配是一个典型的"读-改-写"并发场景。假设两个订单同时看到了同一个航班剩余500kg舱位,订单A需要400kg,订单B需要300kg。如果没有并发控制,两个请求都读到500kg,各自的业务逻辑都觉得能分配,最后把可用余量改成了100kg(A扣完)和200kg(B扣完),实际上超卖了200kg。这在航空货运里是要出大事故的——超卖舱位意味着到了机场打板时货物装不上飞机,客户的货被拉下,赔偿是小事,信任损失是大事。
我的第一版实现是用synchronized锁住分配方法,在单实例部署下没出问题,但两台应用节点一上,synchronized就失效了。后来改成了MySQL乐观锁,在flight_space表加了version字段,更新SQL这样写:
UPDATE flight_space SET available_weight = available_weight - #{weight}, available_volume = available_volume - #{volume}, version = version + 1 WHERE id = #{spaceId} AND version = #{oldVersion} AND available_weight >= #{weight} AND available_volume >= #{volume}如果UPDATE影响行数为0,说明舱位余量不够或版本号冲突,此时要么重新读余量再匹配,要么直接换下一个航班。这个方案能解决分布式并发扣减问题,但有个性能隐患:当多个订单同时抢同一航班的舱位时,乐观锁冲突率很高,一个请求可能要重试好多次。
所以第二版我在乐观锁前面加了一层Redis预扣方案。具体流程:
- 调度请求进来,先尝试在Redis里用
DECRBY扣减航班的可用重量。 - 如果Redis剩余量足够,扣减成功,才继续走MySQL的乐观锁更新。
- MySQL更新成功,Redis扣减结果作为最终结果,不补偿。
- MySQL更新失败(比如版本冲突),则对Redis做
INCRBY补偿回滚,然后换航班重试。
为什么不再完全依赖乐观锁?因为DECRBY是原子操作,能快速挡掉大部分明显无法分配的请求,减少无谓的数据库写冲突。同时Redis扣减加上一个5分钟的过期保护key,防止极端情况下Redis和MySQL数据不一致。
这套方案上线后,舱位分配的并发冲突率从之前的30%降到了8%以内,调度接口的P99耗时从850ms降到了220ms。
4.3 航班取消和临时换机的处理
调度系统里最麻烦的事情之一就是航班变动。航空公司的航班计划经常会调整,今天飞的航班明天不飞了,或者机型换小了、业载能力降了。系统必须对这种变化做出反应。
我的做法是通过XXL-Job定时任务,每30分钟拉取一次航空公司的航班动态接口,比对本地flight_schedule和flight_space的数据:
- 如果航班取消,把对应
flight_space的可用余量置为0,同时批量查询该航班上所有已分配但未起飞的运单,将运单状态改回"待订舱",生成重新订舱任务,推送给调度员处理。 - 如果机型换小、业载降低,就把
flight_space的最大业载调小,同时检查已分配的货物是否超出新的业载上限。超出部分自动释放并重新进入待分配队列。
这里有个重要细节:换机处理不能全自动,必须保留人工确认环节。所以我在调度员工作台做了"航班变动待处理"列表,系统给出建议方案(建议哪些货物改签),调度员确认后才执行。自动化是提效的,但最终的决策权要留给业务人员,这个原则在很多企业级系统里都通用。
5. 订单全链路状态跟踪:Flowable落地细节
第五部分,我说说Flowable具体是怎么用的。市面上讲Flowable的文章不少,但大多停留在请假审批这种简单场景。航空货运的订单状态流转比请假复杂得多,我实际做下来总结出了一套可复用的拆解思路。
5.1 订单流转的流程定义
我定义了一个名为order_transport_flow的BPMN流程,包含以下几个节点,按照实际业务顺序串联并配有条件分支:
- 订单创建:流程启动,录入订单信息。
- 销售审核:审核订单信息是否完整,单价是否确认。审核不通过,走"驳回修改"分支回到创建节点。
- 仓库入库:货物到仓,仓库操作员登记实际重量、体积(这个很关键,经常会和下单预估不一致,实际数据影响舱位分配)。
- 调度订舱:实际重量体积确认后,调度员执行订舱操作,分配航班和舱位。
- 打板过检:货物按航班打板,过安检。安检不通过则走异常分支"安检退回",通知客服联系客户处理。
- 装机起飞:飞机起飞,货物状态更新为"已起飞"。
- 到达提货:航班落地,目的站提取货物。
- 配送签收:司机配送,客户签收,流程终结。
每个节点对应一个Flowable的UserTask或者ServiceTask。UserTask给具体的角色去处理(比如调度订舱节点,assignee是调度员角色),ServiceTask调用业务Service完成系统内部的数据更新(比如起飞后自动更新航班动态)。
部署流程定义的核心代码:
@Autowired private RepositoryService repositoryService; public void deployFlow() { Deployment deployment = repositoryService.createDeployment() .addClasspathResource("processes/order_transport_flow.bpmn20.xml") .name("订单运输流程") .deploy(); log.info("流程部署成功,ID: {}", deployment.getId()); }启动流程并关联业务订单的代码:
@Autowired private RuntimeService runtimeService; public String startOrderFlow(String orderNo, String userId) { Map<String, Object> variables = new HashMap<>(); variables.put("orderNo", orderNo); variables.put("initiator", userId); ProcessInstance processInstance = runtimeService .startProcessInstanceByKey("order_transport_flow", orderNo, variables); return processInstance.getId(); }这里有个非常重要的经验:流程变量variables里面不要放业务对象的大字段,只放必要的信息,比如订单号、操作人、金额这种基础类型。因为Flowable的流程变量存储在ACT_RU_VARIABLE表里,如果放了一个大的JSON对象,这个表会迅速膨胀,而且流程引擎的表本身不适合存大数据。正确的做法是流程变量只存业务主键(订单号),要查业务详情时通过订单号回查业务表。
流程节点完成和跳转的代码:
@Autowired private TaskService taskService; public void completeTask(String taskId, String operatorId, Map<String, Object> vars) { taskService.setAssignee(taskId, operatorId); taskService.complete(taskId, vars); }5.2 分支判断和网关配置
在Flowable的BPMN文件中,分支判断用的是表达式。比如"仓库入库"节点之后,要根据实重和预估重的偏差率决定是否走"调度订舱"正常分支还是走"重量确认异常"分支。BPMN里配置:
<bpmn:exclusiveGateway id="gateway_weight_check" name="重量校验网关" default="flow_normal" /> <bpmn:sequenceFlow id="flow_normal" sourceRef="gateway_weight_check" targetRef="dispatch_space" /> <bpmn:sequenceFlow id="flow_abnormal" sourceRef="gateway_weight_check" targetRef="weight_confirm_exception"> <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression"> ${weightDeviationRate > 15} </bpmn:conditionExpression> </bpmn:sequenceFlow>Java代码里just设置流程变量:
Map<String, Object> vars = new HashMap<>(); double deviationRate = Math.abs(actualWeight - estimatedWeight) / estimatedWeight * 100; vars.put("weightDeviationRate", deviationRate); taskService.complete(taskId, vars);Flowable会自己根据表达式判断走哪个分支。这套设计让业务流程调整变得非常灵活。比如业务方某天说:"重量偏差超过10%就要人工确认",以前是改Java代码重新发布,现在是改一下BPMN文件里的表达式阈值,重新部署流程定义即可,线上的存量流程不受影响。
5.3 超时未处理任务的自动催办
订单流程流转中,最怕节点卡住没人处理。比如调度订舱节点,如果调度员半天没操作,客户的货就滞留仓库。我加了一个超时提醒机制:在Flowable的UserTask上配置了flowable:dueDate扩展属性,任务创建时计算一个期望完成时间(比如订舱节点给2小时),并存入任务的dueDate。
然后用XXL-Job每分钟扫描一次ACT_RU_TASK表,找出超过dueDate还未完成的任务,给对应的处理人推送一条钉钉/企业微信通知,并支持升级通知给上级调度主管。一个小时后再未处理,进入升级告警。
这套机制上线后,调度节点的平均处理时长从4小时降到了1.5小时,客户投诉"没人管我的货"的情况基本消失。
6. 实际踩过的坑:N+1查询、缓存穿透、状态不一致
第六部分没有任何理论,全是我在这个项目上花了大量时间排查的问题,每一个都有真实的线上事故背景。
6.1 调度列表的N+1查询问题
调度员的工作台是一个列表页,默认展示未来24小时内所有待订舱的订单,每行要显示订单信息、客户名称、货物信息、匹配航班数。第一版我直接在Service层做了循环查询:
for (OrderInfo order : orderList) { // 查询客户信息 Customer customer = customerMapper.selectById(order.getCustomerId()); // 查询匹配航班列表 List<FlightSchedule> flights = flightScheduleMapper.selectMatchFlights(order); // 拼装返回 }这个逻辑在订单量少的时候没问题,但订单量到两千条以上时,这个接口响应时间直接飙升到6秒以上。原因就是:2000个订单×(1次客户查询+1次航班查询)=4000+次SQL,什么连接池都扛不住。
优化方案非常简单,先把客户ID列表收集起来批量查询:
List<Long> customerIds = orderList.stream() .map(OrderInfo::getCustomerId) .distinct() .collect(Collectors.toList()); Map<Long, Customer> customerMap = customerMapper.selectBatchIds(customerIds) .stream() .collect(Collectors.toMap(Customer::getId, c -> c));航班匹配查询我换成了批量一次查一个时间窗口内的所有航班,再在内存里按订单目的地分组过滤。经过这两步优化,接口响应时间降到了300ms以内。
这个N+1问题在MyBatis的collection标签嵌套查询中特别容易出现,建议你始终优先考虑批量查询后手动组装数据,可控性远高于依赖ORM层的自动嵌套映射。
6.2 航班时刻的缓存穿透问题
有段时间线上出现了一个奇怪的现象:每晚8点到9点之间,MySQL的CPU使用率会突然飙到90%以上。查了慢查询日志发现,大量SELECT * FROM flight_schedule WHERE origin_airport = ? AND dest_airport = ? AND status = 1的SQL,单条执行并不慢,但量太大了。
原因分析:每晚8点是业务方集中导入次日航班计划的时间,同时大量货主在这个时段集中下单。航班数据被业务方全量删除后重新导入,Redis缓存里的航班数据全部失效。而订单模块接口里有一段逻辑:下单时要查一次航班计划来校验航线是否存在,这个接口被货主端高频调用。缓存失效的一瞬间,几千个请求同时打到了数据库,形成了缓存穿透。
解决方法是双管齐下:
第一,给航班计划查询加上空值缓存。如果数据库里没查到数据,也往Redis里放一个空值,过期时间设为30秒,防止恶意高频查询穿透到数据库。
第二,加分布式锁做缓存重建。当缓存失效时,不是所有请求都去查数据库,而是第一个请求获取锁去查库回填缓存,其他请求短暂等待后直接走缓存。用Redisson的RLock实现,代码简洁可靠:
RLock lock = redissonClient.getLock("flight_schedule_lock:" + origin + "_" + dest); try { boolean acquired = lock.tryLock(3, TimeUnit.SECONDS); if (!acquired) { // 拿不到锁就查一次数据库(兜底) return flightScheduleMapper.selectValidFlights(origin, dest); } // 拿到锁,查库回填缓存 List<FlightSchedule> list = flightScheduleMapper.selectValidFlights(origin, dest); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(list), 30, TimeUnit.MINUTES); return list; } finally { lock.unlock(); }上线后,晚8点的数据库CPU峰值从90%降到了40%左右。
6.3 订单状态和Flowable流程实例不一致
最后一个坑比较隐蔽,也是在交付之后才被客户发现的:有些订单在业务表里的status是"已签收",但Flowable的流程实例卡在"配送签收"节点没有结束。后来排查发现,是因为completeTask过程和业务表更新不是原子操作。
我当时写的是:
// 先更新业务表状态为已签收 orderMapper.updateStatus(orderId, 10); // 再完成Flowable任务 taskService.complete(taskId, vars);如果第二步抛异常,第一步的已签收已经提交(如果不在同一个事务里),两边数据就产生了不一致。解决方案是把两步放在同一个Spring事务中:
@Transactional(rollbackFor = Exception.class) public void completeTaskWithBizUpdate(String taskId, Long orderId, Integer targetStatus, Map<String, Object> vars) { orderMapper.updateStatus(orderId, targetStatus); taskService.complete(taskId, vars); }但这里又涉及一个新的坑:Flowable的内部操作也有自己的事务,如果Spring事务先提交了,Flowable的任务还没完成,业务表状态先变了;或者反过来。我用的是把Flowable的complete操作放在事务的最后一步,并且将Flowable的JDBC连接加入到Spring的同一个事务管理中。具体做法是配置Flowable的DataSource和业务数据源共用同一条MySQL连接,并开启Spring托管事务。
如果你们项目的Flowable是独立数据库(比如内网单独一台MySQL),那跨库事务就比较难保证,建议采用异步对账的方式:定时任务扫描Flowable的任务表和业务表的状态,发现不一致时触发补偿更新。对账脚本虽然多写几百行代码,但能保证最终一致性,我当时的做法是两条路都走:同库事务 + 每日对账,双保险。
7. 部署架构与生产环境配置
代码写得再好,部署和调优跟不上照样崩。第七部分简单说说生产环境的部署方案和一些关键配置,给准备上线的同学做个参考。
7.1 服务部署和JVM参数
生产环境我用的方案是:Nginx(两台,Keepalived做高可用)→ Spring Boot应用(两台8C16G)→ MySQL(一台16C32G,主从没上,做了每日全量备份+binlog)→ Redis(一台8C16G)。应用节点通过systemd守护进程管理,启动参数:
JAVA_OPTS="-Xms8g -Xmx8g -Xmn4g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 \ -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/jvm/heapdump.hprof \ -Dspring.profiles.active=prod"G1垃圾回收器在8G堆上的停顿时间控制得比较好,适合这种业务接口对响应时间有要求的系统。HeapDump参数一定要开,线上OOM时没有dump文件,排查问题等于盲人摸象。
7.2 数据库连接池和Redis配置
连接池用HikariCP(Spring Boot默认),生产配置:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 max-lifetime: 1800000 connection-test-query: SELECT 1maximum-pool-size: 20是压测后定出来的值。之前按网上教程配了50,反而出现了数据库连接等待时间增加的问题,因为单库2C的写入能力有限,太多连接反而导致锁竞争。
Redis配置上要注意两点:一是给Key加上统一的业务前缀,比如cargo:flight:{id}、cargo:space:{id},方便排查和清理;二是Redis的maxmemory-policy要设置成allkeys-lru,防止缓存数据膨胀把Redis内存打爆。
7.3 监控和告警
生产环境必须做监控,不然系统出问题你都不知道。我用的方案是Prometheus + Grafana + Alertmanager。Spring Boot应用引入micrometer-registry-prometheus,暴露/actuator/prometheus端点。重点监控指标:
- 接口QPS和P99响应时间(调度接口、订单列表接口)。
- JVM堆内存使用率、Full GC次数和耗时。
- 数据库连接池活跃连接数和等待时间。
- Flowable的待办任务数量,超过一定阈值触发告警。
- XXL-Job任务执行成功率。
告警渠道用的是钉钉群机器人,配合Alertmanager的Webhook。这套监控上线后的实际效果:有一次航班时刻导入任务执行失败,监控在1分钟内就发出了告警,提前发现了一次潜在的运力数据异常,避免了次日出货混乱。
8. 写在最后的一些心得
这个项目从立项到稳定运行,前后花了四个多月。回头看,最有价值的不是写了多少行代码,而是对整个航空货运业务的理解。技术框架SpringBoot也好、Flowable也好,都是可替换的,但业务理解、数据模型设计、并发控制这些沉淀下来的思路,才是真正能复用和迁移的东西。
有几个经验想单独提一下。第一个是:做业务系统,先把业务流程图画清楚再动手写代码。这个项目第一版失败就是因为没和业务方把异常流程聊透,结果数据库表改了三轮。后来我把所有状态流转、分支条件、超时处理全部画成流程图和客户确认过,开发和联调效率至少提升了50%。
第二个是:不要过度设计。这个系统一开始业务方提了很多需求,包括要做司机端APP、要对接航司系统自动获取运价、要做BI数据分析,我全都没在第一个版本做。先把核心的订单、调度、配送链路跑通,才是最重要的。
第三个是:团队协作时要保持模块接口稳定。我的项目组里两个人负责调度模块,两个人负责订单和配送模块。每天下班前用接口定义文档做一次对齐,每周做一次代码Review,大大减少了联调时的返工。
如果后续要扩展,我最想做的方向是把舱位分配算法升级成基于运筹优化的自动配载,综合货物优先级、航班时刻、装卸成本做全局最优调度,而不是现在的优先级队列贪心策略。数据积累足够之后,也可以训练一个到港时效预测模型,让客户的期望到达时间更贴近实际运力情况。但这些都是后话了,先把系统稳定跑起来,业务模型跑顺,再一步步往智能化方向走。
希望这篇内容能给正在做物流调度、订单履约系统的朋友一些参考。如果你也在搞类似系统,欢迎多交流,踩过的坑互相分享一下,能帮大家都省点时间。