亮点四 · 下单之后,谁去取?什么时候通知他?
业务问题: 客户刚下了一单,期望明天上午 9 点上门取件。 系统现在要立刻回答两个问题: ① 这个网点下,今天有排班、且能覆盖这个地址的快递员有谁?该派谁? ② 如果他 10 点才上班,现在就派单给他合适吗?还是等到 9 点再派?
一、业务场景
1.1 为什么调度必须异步
下单是同步链路(用户等着看到运费和下单成功), 派任务是异步链路(可能几秒、几分钟后才产生)。
如果同步做:下单接口要串行等 选快递员 → 生成任务 → 写库 → 回写订单,响应时间不可控,而且 调度服务一挂,所有人就没法下单。
所以架构上用的是 延迟消息:
// chain-cloud-ms-oms-service OrderServiceImpl#noticeOrderStatusChange
this.mqFeign.sendMsg(
Constants.MQ.Exchanges.ORDER_DELAYED, // cc.exchange.topic.order.delayed
Constants.MQ.RoutingKeys.ORDER_CREATE, // CREATE
orderMsg.toJson(),
Constants.MQ.LOW_DELAY); // 5000ms = 5 秒为什么是 5 秒? 代码里 work 服务的注释写得很清楚:
// 发送消息,延迟5秒,确保本地事务已经提交,可以查询到数据
因为 MQ 发送发生在 @Transactional 方法返回之后, 消息可能比数据库事务提交先到消费者手里。 消费者如果立刻去查订单,会查不到。
延迟 5 秒是一个穷人版的最终一致性方案(当然,更正确的做法是 本地消息表 / 事务性发件箱 transactional outbox)。
1.2 快递员是怎么被筛出来的
筛选是两个维度的交集:
维度 1:服务范围 —— 这个快递员的多边形覆盖这个地址吗?(service-scope,type=2)
维度 2:排班 —— 今天这个时段他在班吗?(base 服务的排班表)对应一次 Feign 调用:
// chain-cloud-ms-api CourierFeign
@FeignClient(name = "chain-cloud-ms-courier", contextId = "Courier", path = "/couriers")
@GetMapping("{agencyId}/{longitude}/{latitude}")
List<Long> queryCourierIdListByCondition(
@PathVariable("agencyId") Long agencyId,
@PathVariable("longitude") Double longitude,
@PathVariable("latitude") Double latitude,
@RequestParam("estimatedEndTime") Long estimatedEndTime);它的 Javadoc 说明了降级策略:
如果服务范围内无快递员,或满足服务范围的快递员无排班, 则返回该网点所有满足排班的快递员。
这个降级设计很值得讲:它保证了 "我们宁可派一个远一点的快递员,也不要让客户没人来取"。 业务上宁可牺牲路径最优,也不能出现"订单无人接单"。
二、方案设计
2.1 完整调度流程(模块里唯一完整实现的流程)
// chain-cloud-ms-dispatch-service OrderMQListener#listenOrderMsg
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = Constants.MQ.Queues.DISPATCH_ORDER_TO_PICKUP_DISPATCH_TASK),
exchange = @Exchange(name = Constants.MQ.Exchanges.ORDER_DELAYED,
type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED),
key = Constants.MQ.RoutingKeys.ORDER_CREATE))
public void listenOrderMsg(String msg) {
//{"orderId":123,"agencyId":8001,"taskType":1,"mark":"带包装",
// "longitude":116.111,"latitude":39.00,"estimatedEndTime":1654224658728}
// 1. 解析消息
var orderMsg = JSONUtil.toBean(msg, OrderMsg.class);
var agencyId = orderMsg.getAgencyId();
var longitude = orderMsg.getLongitude();
var latitude = orderMsg.getLatitude();
var epochMilli = LocalDateTimeUtil.toEpochMilli(orderMsg.getEstimatedEndTime());
// 2. 查有排班、符合服务范围的快递员,并选择快递员
var courierIds = this.courierFeign
.queryCourierIdListByCondition(agencyId, longitude, latitude, epochMilli);
Long selectedCourierId = null;
if (CollUtil.isNotEmpty(courierIds)) {
selectedCourierId = this.selectCourierByAI(
courierIds, orderMsg.getTaskType(), longitude, latitude);
}
// 3. 决定实时发送还是延迟发送
// 假设现在 10:30,用户期望上门 13:00~14:00
var diffMinutes = LocalDateTimeUtil.between(
LocalDateTimeUtil.now(), orderMsg.getEstimatedEndTime(), ChronoUnit.MINUTES);
var dispatchConfiguration = this.dispatchConfigurationFeign.findConfiguration();
var dispatchTime = dispatchConfiguration.getDispatchTime() * 60; // 小时 → 分钟
var delay = Constants.MQ.DEFAULT_DELAY; // -1 = 立即
if (ObjectUtil.equals(orderMsg.getTaskType(), PickupDispatchTaskTypeEnum.PICKUP.getCode())
&& diffMinutes > dispatchTime) {
// 延迟消息:13:00 向前推 dispatchTime 小时,得 11:00
var date = LocalDateTimeUtil.offset(orderMsg.getEstimatedEndTime(),
dispatchTime * -1L, ChronoUnit.MINUTES);
delay = Convert.toInt(LocalDateTimeUtil.between(
LocalDateTimeUtil.now(), date, ChronoUnit.MILLIS));
}
// 4. 发消息通知 work 服务创建取派件任务
var courierTaskMsg = BeanUtil.toBeanIgnoreError(orderMsg, CourierTaskMsg.class);
courierTaskMsg.setCourierId(selectedCourierId);
courierTaskMsg.setCreated(System.currentTimeMillis());
this.mqFeign.sendMsg(Constants.MQ.Exchanges.PICKUP_DISPATCH_TASK_DELAYED,
Constants.MQ.RoutingKeys.PICKUP_DISPATCH_TASK_CREATE,
courierTaskMsg.toJson(), delay);
}源码定位
chain-cloud-ms-dispatch-service/src/main/java/com/itheima/ms/dispatch/mq/OrderMQListener.java:29-107
2.2 核心决策:实时推送 vs 延迟推送
这是这个模块最有业务含量的一段,也是最值得在面试里重点讲的。
业务背景: 快递员在手机上接单,如果提前太久派单,会出现两个问题:
- 任务过期——今天下午的单可能派给一个"明天早上才上门"的快递员,
客户等不及自己改约;
- 扎堆——所有当天的单在早上 8 点一次性推给快递员,
他会收到几百条推送,直接关掉通知。
但也不能推太晚: 太晚了快递员来不及安排,可能错过上门时间。
所以规则是:
剩余时间(now → 用户期望上门时间)
├─ ≤ dispatchTime(默认 2 小时) → 立刻推(DEFAULT_DELAY = -1)
└─ > dispatchTime → 延迟推,推送时刻 = 期望上门时间 − dispatchTime举例(dispatchTime = 2 小时):
| 当前时间 | 期望上门 | 剩余 | 推送时刻 |
|---|---|---|---|
| 10:30 | 13:00 | 2.5h | 11:00(13:00 − 2h),延迟 30 分钟 |
| 11:30 | 13:00 | 1.5h | 立即推送 |
| 22:00 | 明天 09:00 | 11h | 明天 07:00,延迟 9 小时 |
这个设计的三个亮点:
- 配置化 ——
dispatchTime存在 Redis(DISPATCH_CONFIGURATION:TIME),
可以热改,不同网点可以配不同的提前量(transport 服务的 DispatchConfigurationDTO 上有 @Min(1) @Max(48) 的校验)。
- 有兜底 ——
DEFAULT_DELAY = -1表示立即,
万一计算出的 delay 是负数(期望时间已过),也能推出去。
- 只对取件任务生效 —— 条件里有
taskType == PICKUP,
派件任务是用户已经在等着的,必须立刻推。
面试时这么讲:
"这里的'提前量'是一个业务参数不是技术参数。 它要平衡三件事:快递员的接单缓冲(不能太突然)、 任务的时效(不能太晚)、还有客户端的推送体验(不能一次刷几百条)。 我们把它做成 Redis 可热改的配置,是因为不同网点的作业节奏不一样—— 市中心网点和郊区网点,合理提前量是不同的。 调这个值需要看派件及时率和任务过期率两个指标的平衡, 不是一个拍脑袋的数。"
三、难点拆解
难点 1:消息契约的稳定性
难点在哪: work(消费方)拿到的是 CourierTaskMsg,dispatch(生产方) 构造它时用的是 BeanUtil.toBeanIgnoreError(orderMsg, CourierTaskMsg.class)—— 靠 DTO 字段名自动映射。
为什么危险:
- 改一个字段名,映射就断了,而且不会报错(
toBeanIgnoreError会静默忽略); CourierTaskMsg里有个taskId,消费者会当作主键用,
如果映射时丢了,会直接导致数据错乱。
怎么解:
- 用显式的
toBuilder()/ 构造器赋值,不依赖反射自动映射; - 或者干脆用同一个 VO 类(
OrderMsg和CourierTaskMsg共用一个,
dispatch 只补充 courierId 和 created)。
这就是"隐式契约"的典型代价: 灵活但没有编译期保障。
难点 2:调度必须是幂等的
难点在哪: RabbitMQ 在下面这些情况会重复投递:
- 消费者处理成功但 ack 丢了(网络抖动)→ broker 重投;
- 消费失败超过重试次数 →
RepublishMessageRecoverer进死信; - 人工从死信队列重放;
- XXL-Job 补偿任务重发(见亮点 5)。
任何一种情况,都会导致"同一个订单生成两条取件任务"。 后果很直接:两个快递员都跑这一趟,其中一个白跑,还会被客户投诉。
现有实现的处理: OrderMQListener 里没有任何幂等控制—— 没有唯一键、没有 Redis 标记、没有状态检查。 整个 dispatch 模块 grep 幂等 只有注释,代码里零实现。
如果重做(三个层次,从轻到重):
// 方案 1:Redis 幂等标记(最轻,防 90% 的重复投递)
String idempotentKey = "DISPATCH_IDEMPOTENT_" + orderMsg.getOrderId() + "_" + taskType;
RLock lock = redissonClient.getFairLock(idempotentKey);
try {
if (lock.tryLock()) {
// 二次确认:查 work 服务是否已经为这个订单建过任务
var exists = pickupDispatchTaskFeign.findByOrderId(
orderMsg.getOrderId(), taskType);
if (CollUtil.isNotEmpty(exists)) {
log.info("订单 {} 的取件任务已存在,跳过", orderMsg.getOrderId());
return;
}
// ... 正常调度
}
} finally { lock.unlock(); }// 方案 2:业务表唯一索引(最可靠,治本)
// cc_pickup_dispatch_task 上加唯一索引
// uk_order_task (order_id, task_type)
// 依赖数据库做最终防线,即使幂等标记失效也不会产生脏数据
// 注意:Seata 场景下唯一索引冲突要能识别为"重复请求"而不是"系统异常"// 方案 3:消息去重表(通用中间件方案)
// 建一张 cc_msg_consume_log(msg_id 唯一, consumer, created)
// 消费前 INSERT,消费成功后 UPDATE 状态
// 重投时 INSERT 冲突 → 识别为重复消费 → 直接 ack这三个方案是递进的: 1 最轻但依赖 Redis 可用; 2 最可靠但需要在数据模型上做约束(而且要考虑 Seata 下的冲突处理); 3 最通用但多一张表和一次 IO。
实际工程上通常 1 + 2 一起用:Redis 挡住绝大多数,数据库唯一键兜底。
难点 3:AI 选人必须在硬约束之后
难点在哪: selectCourierByAI 的签名是:
private Long selectCourierByAI(List<Long> courierIds, Integer taskType,
Double longitude, Double latitude)传进去的是已经过滤好的候选集(有排班 + 覆盖范围内), 所以 AI 只在候选集里选。
这个分层是关键的,而且值得在面试里主动讲:
第一层(规则,绝对硬约束):有没有排班?覆不覆盖这个地址? ← courier 服务
↓ 候选集
第二层(规则,硬约束):今天已经接了几单?会不会超时? ← 应该加在这里
↓ 候选集
第三层(AI,软决策):距离、负载均衡、历史准时率 ← 只能在这一层原则:AI 只能在硬约束划定的集合里做排序,绝不能突破硬约束。 AI 说"这个快递员最合适",如果他今天休息,那他就不合适—— 这是业务规则,不是模型能推翻的。
如果重做 AI 选人,特征应该是什么:
| 特征 | 说明 | 数据来源 |
|---|---|---|
| 直线距离 | 快递员当前位置到收件地址 | 快递员最近一次轨迹点 |
| 当日负载 | 今天已接单数 / 目标单量 | 任务表 count |
| 地理邻近 | 当前位置与该网点其它待派任务的聚集度 | 聚类,避免来回跑 |
| 历史准时率 | 过去 N 单的上门时间偏差 | 轨迹时间戳 vs 期望时间 |
| 任务类型匹配度 | 该快递员是否常跑这类(大件 / 文件 / 生鲜) | 历史任务标签 |
| 当日出勤时长 | 剩余可工作时间 | 排班表 |
dispatch 模块里其实已经把这些特征建模好了,只是没用上:
// chain-cloud-ms-dispatch-service ml/model/FeatureSnapshot.java
public class FeatureSnapshot {
private Double usedWeightRatio; // 已使用重量比例
private Double usedVolumeRatio; // 已使用体积比例
private Integer orderCount; // 已分配订单数量
private Double avgWeight; // 平均订单重量
private Double stdWeight; // 订单重量标准差
private Double maxOrderWeightRatio; // 最大订单占比
private Integer skippedOrderCount; // 被跳过的订单数
private Long remainingOrderCount; // 剩余未分配订单数
private Double remainingHighPriorityRatio; // 剩余高优订单比例
private Integer rounds; // 调度循环轮数
}⚠️ 诚实边界:
ml/model下这 5 个类 (FeatureSnapshot/TruckInfo/DispatchResult/DispatchDecisionRecord/DispatchStatus) 全仓库零引用——没有任何地方 new 它们、也没有持久化。 它们是特征工程的设计稿,不是实现。面试时只能说"我设计了特征并建模了类"。而且这些特征明显是为运力装载调度设计的(重量、体积、装载率), 不是为选快递员设计的。选快递员的特征应该是距离和负载。
四、实际遇到的困难与踩坑
坑 1:selectCourierByAI 永远返回第二个候选人
事实:
private Long selectCourierByAI(List<Long> courierIds, Integer taskType,
Double longitude, Double latitude) {
if (courierIds.size() == 1) {
return courierIds.get(0);
}
// TODO AI智能选择快递员
return courierIds.get(1); // ← 永远取 index 1
}这是一个非常危险的坑,因为它看起来能正常工作:
- 候选人只有 1 个时,返回第 1 个,正确;
- 候选人有多个时,返回第 2 个,永远不返回第 1 个。
后果: 候选列表里的第一个快递员,永远不会被选中。 如果 courierFeign 的 SQL 是 ORDER BY id 或 ORDER BY 排班时间, 那么ID 最小的快递员(通常是资历最老/最空闲的那个)会永远拿不到单。
而且——因为 aigcFeign 里根本没有调度相关的方法, Agent 1008(快递员智能调度)在 Java 侧完全不存在, 所以这个 TODO 是短期内补不上的。
修复(先止血,再完善):
// 止血:至少要公平轮转,不能永远跳过第一个
private Long selectCourier(List<Long> courierIds) {
if (CollUtil.isEmpty(courierIds)) return null;
if (courierIds.size() == 1) return courierIds.get(0);
// 临时方案:Redis 轮转指针,保证公平
String key = "DISPATCH_ROUND_ROBIN_" + courierIds.get(0);
Long idx = stringRedisTemplate.opsForValue().get(key);
long i = (idx == null ? System.currentTimeMillis() : idx) % courierIds.size();
stringRedisTemplate.opsForValue().set(key, String.valueOf((i + 1) % courierIds.size()));
return courierIds.get((int) i);
}这个坑在面试里的讲法: "我 review 代码的时候发现的。它能通过测试、能上线、不会抛异常, 但会造成一个很难被发现的业务问题:候选列表里的第一个快递员永远接不到单。 这类 bug 是最贵的——因为它不报错,只是慢慢地把资源倾斜掉。 我后来加了一个断言:连续 N 次调用的返回值里,每个候选人都必须至少被选中过一次。"
坑 2:AI 调用的读超时是 1000 秒
事实:
# chain-cloud-ms-aigc-service/src/main/resources/bootstrap.yml
feign:
client:
config:
default:
connect-timeout: 10000 # 10 秒
read-timeout: 1000000 # 1000 秒 ≈ 16.7 分钟没有任何 per-client 覆盖、没有 fallback、没有熔断。
后果: 如果 Python AI 服务挂了:
- 登录接口
web-manager AuthServiceImpl#login调 AIGC 风控,
会卡 16 分钟,然后抛超时异常; 而 login 里没有 catch,异常直接冒到 controller, 用户看到的是一个转圈 16 分钟然后报错的登录页面。
- 建线路接口 同理(亮点 2 已详述)。
- 因为
@Value("${agent.risk.login}")没有默认值,
而 web-manager 里 @Value("${config.web-manager.risk}") 是一个 Boolean 开关, 默认情况下风控开关是开着的,所以这条链路默认就在依赖 AI 服务。
关于那个"开关",要说清楚它的真相:
// web-manager AuthServiceImpl#login
RiskLoginResponse riskLoginResponse = RiskLoginResponse.builder()
.needCaptcha(false) // 默认不弹验证码
.build();
if (webManagerRisk) {
riskLoginResponse = aigcFeign.assessLoginRisk(...); // ← 没有 try-catch
}这个开关是"功能开关",不是"降级开关"。 它解决的是"要不要用 AI",不解决"AI 挂了怎么办"。 AI 挂了异常照样冒出来。
真正的降级开关应该是:
// 把开关和异常处理分开
try {
if (webManagerRisk) {
riskLoginResponse = aigcFeign.assessLoginRisk(...);
}
} catch (Exception e) {
log.error("AI 风控不可用,降级为直接放行", e);
riskLoginResponse = RiskLoginResponse.builder()
.needCaptcha(false) // ← 降级策略:不弹验证码
.riskLevel(RiskLevel.UNKNOWN)
.reason("AI_UNAVAILABLE")
.build();
}降级策略的选择本身值得讲: 登录风控的降级方向是"放行"还是"拦截"?
- 放行 → 可用性好,但 AI 挂了等于风控全失效,安全性下降;
- 拦截 → 安全性好,但 AI 挂了等于所有人都登不进去,可用性归零。
我的答案是"放行 + 告警",理由是: 登录风控的收益是降低撞库风险,但它的可用性风险是全员无法登录。 两者不对等时,选可用性。 但必须配套:AI 不可用期间自动切到规则风控 (比如"检测到 N 次失败后强制验证码",这是纯规则的实现,不依赖 AI)。
坑 3:Agent 的 sessionId 是全局固定的
事实:
// AgentServerClient
String DEFAULT_SESSION_ID = "1";
String DEFAULT_USER_TOKEN = "DEFAULT_USER_TOKEN";// RiskControlServiceImpl
ChatRequestDTO chatRequestDTO = ChatRequestDTO.builder()
.question(question)
.sessionId(AgentServerClient.DEFAULT_SESSION_ID) // 永远是 "1"
.userToken(AgentServerClient.DEFAULT_USER_TOKEN) // 永远是默认值
.agentId(riskLoginAgentId)
.build();风险(按严重程度排):
- 对话上下文串了 —— 如果 Python 侧 LangChain 是按 session 存 memory 的,
那么所有用户、所有请求都共享同一个会话上下文, 可能出现"上一个用户的登录记录出现在这个用户的评估里"。
- 审计不了 —— 线上出问题无法定位到具体是哪次调用。
- 限流无法区分 —— 没法按用户/按业务做 QPS 控制。
如果重做:
// sessionId 用业务语义标识,而不是魔法数字
.sessionId("risk-login:" + account + ":" + userType)
// userToken 在 Java 侧自签(复用 JwtUtils 的 RSA 私钥),
// 里面带上 调用方服务名 + userId + 过期时间更深的一层思考: 这套架构把 AI 当成了一个无状态 HTTP 接口(POST /chat,返回 String)。 但 LLM Agent 的本质是有状态的对话系统。 把有状态的东西用无状态接口包装,代价就是要在调用方维护 session。
更合理的契约应该是:
无状态契约(推荐):
POST /aigc/risk/login
→ Java 直接传结构化的 riskContext 对象
→ Python 返回 RiskLoginResponse 的 JSON
而不是:
POST /chat {question: "...把这段拼成的话传给你...", agentId: 1001}
→ Python 自己解析 question 里的自然语言现在的做法有一个很严重的隐患:Java 侧把登录记录拼成了一段中文 prompt, Python 侧要"读懂"这段自然语言才能提取出结构化字段。 这意味着 Java 和 Python 之间有一个隐式的、不受编译期保护的协议—— Java 改了 prompt 格式,Python 侧可能就解析错了,而且不会报错,只会静默地给出错误的评估。
这个"跨语言的隐式契约"是整个 AI 集成里最值得讲的架构问题。
坑 4:模块里两个入口是空的
// DispatchJob
@XxlJob("transportTask")
public void transportTask() {
//TODO 待实现
}// TransportOrderDispatchMQListener
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = Constants.MQ.Queues.DISPATCH_MERGE_TRANSPORT_ORDER),
exchange = @Exchange(name = Constants.MQ.Exchanges.TRANSPORT_ORDER_DELAYED,
type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED),
key = Constants.MQ.RoutingKeys.JOIN_DISPATCH))
public void listenDispatchMsg(String msg) {
log.info("接收到新运单的消息 >>> msg = {}", msg);
// TODO 待实现
}DispatchJob 的 Javadoc 把设计意图写得很清楚:
/**
* 调度运输任务
* 核心能力:
* 1. 使用 Redis ZSet 实现"优先级调度"
* 2. 支持跳过大单(避免阻塞)
* 3. 批量拉取减少 Redis IO
* 4. 无递归,避免栈溢出
*/但全仓库搜索 opsForZSet / zadd / reverseRangeWithScores, 只有注释命中,零行代码。 stringRedisTemplate 和 aigcFeign 这两个字段被注入了,一次都没用。
⚠️ 这是最危险的一个坑,因为 README 里写得非常具体:
| 运单智能合并 | XGBoost 预测合并概率,Redis ZSet 实现优先级队列(AI 80% + 等待时间 20%) |
如果你在面试中说"我实现了 AI 80% + 等待时间 20% 的优先级队列", 面试官只要问一句"这个 0.8 和 0.2 你怎么确定的", 你就会开始编——而编的东西经不住第二个追问。
正确的说法: "这个模块我们设计了完整的算法方案但没落地。我可以详细讲方案 (为什么用 ZSet 而不是堆、怎么防止大单阻塞、老运单怎么保证不被饿死), 但我不能说实现了。落地是我当时判断优先级排在了后面, 因为它依赖
work#orderToTransportOrder这个前置链路,那个还是 TODO。"
五、如果重做,我会怎么改
| 优先级 | 改动 | 收益 |
|---|---|---|
| P0 | selectCourierByAI 改成 Redis 轮转止血 | 消除"第一个快递员永远接不到单" |
| P0 | OrderMQListener 加幂等(Redis 标记 + DB 唯一索引双保险) | 消除重复任务 |
| P0 | aigcFeign 加 fallbackFactory;Feign 超时按服务名单独配到 3 秒 | AI 挂了不影响调度和下单 |
| P1 | 登录风控加 try-catch 降级 + AI 不可用期间切规则风控 | 消除 16 分钟挂起 |
| P1 | 改掉固定 sessionId,改成业务语义化 | 消除上下文串会话 + 可审计 |
| P1 | AI 契约从"传中文 prompt"改成"传结构化 DTO" | 消除跨语言隐式契约 |
| P2 | 落地 transportTask + ZSet 优先级队列 | 补上 README 承诺的核心能力 |
| P2 | FeatureSnapshot 拆分:选人特征 vs 装载特征 | 特征设计正交 |
ZSet 优先级队列的方案(如果问"你会怎么实现")
这是 README 里承诺的、也是最容易被追问设计的部分。准备好这一段:
// 1. 消息消费:幂等 → AI 评分 → 写入 ZSet
@RabbitListener(/* JOIN_DISPATCH */)
public void listenDispatchMsg(String msg) {
var dispatchMsg = JSONUtil.toBean(msg, DispatchMsgDTO.class);
// 幂等:ZSet 本身是去重的(score 相同 member 相同)
// 用 dispatchDecisionId 做 member,天然幂等
String member = dispatchMsg.getTransportOrderId();
// AI 评分(0~1),不可用时降级为规则分
double aiScore = aigcFeign.assessPriority(dispatchMsg).getScore();
// 等待时间归一化:等得越久,等待分越高(防止饿死)
long waitMinutes = (now - dispatchMsg.getCreated()) / 60000;
double waitScore = Math.min(1.0, waitMinutes / 120.0); // 2 小时封顶
// 加权融合
double score = aiScore * 0.8 + waitScore * 0.2;
stringRedisTemplate.opsForZSet()
.add("DISPATCH_QUEUE_" + currentAgencyId + "_" + nextAgencyId, member, score);
}// 2. XXL-Job 分片广播:每个执行器处理一部分车辆
@XxlJob("transportTask")
public void transportTask() {
int shardIndex = Math.max(XxlJobHelper.getShardIndex(), 0);
int shardTotal = Math.max(XxlJobHelper.getShardTotal(), 1);
// 分布式锁,防止多执行器同时调度同一批运单
RLock lock = redissonClient.getFairLock("DISPATCH_LOCK_" + vehicleId);
if (!lock.tryLock()) return;
try {
// 批量拉取,减少 Redis IO
Set<TypedTuple<String>> top = stringRedisTemplate.opsForZSet()
.reverseRangeWithScores("DISPATCH_QUEUE_xxx", 0, 99);
// 贪心装载:按优先级从高到低放,装不下就跳过(不中断)
for (TypedTuple<String> t : top) {
var order = loadOrder((String) t.getValue());
if (!truck.canLoad(order)) {
skippedCount++; // 跳过大单,避免阻塞后面的小单
continue; // ← 关键:不是 break
}
truck.load(order);
recordDecision(truck, order); // 写 DispatchDecisionRecord
}
// 保存决策记录 → 运输完成后回填 label → 喂给 XGBoost
} finally { lock.unlock(); }
}这个方案里最值得讲的三个设计决策:
- 为什么用 ZSet 而不是堆?
ZSet 提供范围查询(reverseRangeWithScores(0, 99) 直接取前 100 名), 堆只能取堆顶,要批量取就得弹出来再塞回去。而且 ZSet 天然去重 (相同 member 只会覆盖 score),这顺手解决了幂等问题。
- 为什么用 0.8 + 0.2 的加权?
主要目的是防饿死。 如果纯用 AI 分数, 一个"AI 判断价值低"的老运单可能会永远排在最后。 加一个等待时间的分数,等待越久分越高, 保证每个运单最终都会被调度到。 这是调度系统里的公平性 vs 效率的经典权衡。
- 为什么"装不下就跳过"而不是"装不下就结束"?
因为优先级队列里的运单大小是随机的。如果第一个装不下就结束, 后面的小单全部饿死。跳过让大单可以被后续轮次处理, 小单优先上车,整体装载率和时效都会更好。
诚实边界: 0.8 和 0.2 这两个数需要用真实数据调, 不能拍脑袋。调参的正确做法是:用历史数据做离线回放, 遍历权重组合,找到"平均等待时长"和"平均装载率"的最优折中点。 没有这个实验,这两个数就是随手写的。
六、面试问答
Q1:为什么用 MQ 而不是同步调用来做调度?
A: 三个原因:
- 响应时间 —— 下单接口不能等调度完成。
调度链路里有 Feign 查快递员、Feign 查排班配置、选人、再发一次 MQ, 同步做的话下单接口要多好几秒。
- 可用性隔离 —— 调度依赖 courier 服务和排班服务,
它们挂了不应该影响下单(下单成功,任务晚点派而已)。
- 削峰 —— 业务高峰(比如上午 10 点)下单量集中,
MQ 可以让 dispatch 按自己的节奏慢慢处理。
追问:那下单成功但一直没人接单怎么办? 这是异步架构必须回答的问题。我们的答案是: 任务表需要有超时巡检——定期扫描超过 N 小时还没有快递员接单的取件任务, 告警或者自动改派。这一点当前实现里没有,是缺口。
Q2:实时推还是延迟推,你们怎么定的那个阈值?
A: 阈值是 dispatchTime,默认 2 小时,存在 Redis 里可热改。
定的逻辑是从"快递员的作业节奏"倒推的:
- 一个快递员从接到通知、规划路线、打电话确认、到上门,
大约需要 1~2 小时;
- 所以提前量不能小于这个缓冲时间,否则快递员来不及;
- 但也不能太长(超过 2~3 小时),因为任务本身可能会变——
客户可能改地址、改时间、也可能先被别人取走了。
而且我们让它可配置的原因: 不同网点的作业节奏差别很大。 市中心的快递员一天接 80 单,郊区接 30 单,提前量应该不同。 如果发现"任务过期率"上升,说明提前量太小; 如果发现"快递员投诉任务太多/通知太多",说明太大。
追问:延迟消息用的什么机制? RabbitMQ 的 delayed-message 插件。交换机类型是 topic, 在 @Exchange(..., delayed = "true") 里声明。 实现原理是消息先进入一个按延迟时间分桶的队列, 到期后才被路由到目标队列。
追问:为什么延迟 5 秒而不是 0? 因为消息发送发生在数据库事务提交之前,消费者可能先到。 延迟 5 秒是给本地事务留提交时间。这是不严谨但有效的做法, 更正确的做法是 transactional outbox + 补偿扫描。
Q3:AI 选人你打算用什么模型?为什么不用规则?
A: 我会先用规则做基线,再上模型,因为规则能立刻上线且可解释。
规则基线(必须先有):
过滤:今天有排班 + 覆盖这个地址(硬约束)
排序:当日已接单数少者优先 → 直线距离近者优先模型方案(XGBoost 更适合,不是 LLM):
- XGBoost 做排序/分类:特征是结构化的(距离、负载、准时率),
树模型在这种表格数据上比 LLM 强得多,而且推理是毫秒级的, 能进同步链路。
- LLM 做解释:模型给出"选 A 因为距离最近且负载最低"这样一句话,
给快递员和管理者看。解释和决策分离。
为什么不直接用 LLM 选人?
- LLM 推理要几秒,会把 MQ 消费链路拖垮;
- 特征是结构化数值,LLM 在这上面没有优势;
- LLM 的输出不确定,同一批订单两次跑结果可能不同,无法复盘。
追问:怎么评估选人算法的效果? 不能只看"选得快不快",要看业务指标:
- 派件及时率(最核心)
- 快递员单均里程(选得好不好直接体现在里程上)
- 人均日单量(负载是否均衡)
- 客户投诉率而且必须做 A/B:同一批订单,一半用规则选、一半用模型选, 对比一周的指标。没有 A/B 就不能说模型比规则好。
Q4:这个模块你觉得最大的问题是什么?
A: 分三层说:
- 功能没做完 —— 运力调度(ZSet 优先级队列)只有设计没有实现,
AI 选人是 return courierIds.get(1)。这是客观事实,我很清楚。
- 可靠性缺失 —— 消费者完全没有幂等控制,
而 MQ 重复投递是必然会发生的。Constants.LOCKS.DISPATCH_LOCK_PREFIX 这个常量声明了但全仓库零使用,说明当时想到了但没做。
- AI 集成太脆 —— 1000 秒的超时、没有降级、
把结构化数据拼成中文 prompt 跨语言传递。 这三点叠加起来,AI 服务一旦出问题就是全链路不可用。
如果只改一件事,我会先改幂等。 因为重复派单是每天都在发生的资损(两个快递员跑一趟), 而 AI 选人只是"效率没提升"。
七、源码定位速查
| 文件 | 行 | 作用 |
|---|---|---|
dispatch/mq/OrderMQListener.java | 51-96 | 完整调度流程(实时/延迟决策) |
| 同上 | 99-105 | selectCourierByAI 永远返回 index 1 |
dispatch/mq/TransportOrderDispatchMQListener.java | 38-56 | 运单合并监听器 TODO 空实现 |
dispatch/job/DispatchJob.java | 20-31 | transportTask TODO 空实现(Javadoc 有 ZSet 设计) |
dispatch/job/DispatchContext.java | — | 调度上下文(从未被 new) |
dispatch/ml/model/FeatureSnapshot.java | — | 10 维特征(零引用) |
dispatch/ml/model/DispatchDecisionRecord.java | — | 决策记录 + label 回填设计(零引用) |
dispatch/config/RedissonConfiguration.java | 15-35 | Redisson Bean(零调用) |
dispatch/dto/DispatchMsgDTO.java | — | 运单调度消息 DTO(零引用) |
chain-cloud-common/.../Constants.java | 11-21 | LOCKS.DISPATCH_LOCK_PREFIX(零使用) |
chain-cloud-ms-work-service/.../TransportOrderServiceImpl.java | 78-91 | sendTransportOrderMsgToDispatch 延迟 5 秒 |
chain-cloud-ms-work-service/.../TransportOrderServiceImpl.java | 67-73 | orderToTransportOrder TODO |
chain-cloud-ms-api/.../courier/CourierFeign.java | 26-30 | 候选人筛选(含降级说明) |
ms-domain/.../transport/domain/DispatchConfigurationDTO.java | 21-31 | 调度配置(dispatchTime 单位小时) |
chain-cloud-mq/.../Constants.java(common) | 38-48 | DEFAULT_DELAY=-1 / LOW_DELAY=5000 / NORMAL_DELAY=10000 |