取派件智能调度

04 · 核心亮点

亮点四 · 下单之后,谁去取?什么时候通知他?

MQ 驱动 + 排班/范围筛选 + AI 选人 + 实时与延迟推送的决策。亮点在决策逻辑,坑在 AI 那一行。

涉及模块:chain-cloud-ms-dispatch-service :18086 + aigc

亮点四 · 下单之后,谁去取?什么时候通知他?

业务问题: 客户刚下了一单,期望明天上午 9 点上门取件。 系统现在要立刻回答两个问题: ① 这个网点下,今天有排班、且能覆盖这个地址的快递员有谁?该派谁? ② 如果他 10 点才上班,现在就派单给他合适吗?还是等到 9 点再派?


一、业务场景

1.1 为什么调度必须异步

下单是同步链路(用户等着看到运费和下单成功), 派任务是异步链路(可能几秒、几分钟后才产生)。

如果同步做:下单接口要串行等 选快递员 → 生成任务 → 写库 → 回写订单,响应时间不可控,而且 调度服务一挂,所有人就没法下单。

所以架构上用的是 延迟消息:

java
// 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 快递员是怎么被筛出来的

筛选是两个维度的交集:

text
维度 1:服务范围   —— 这个快递员的多边形覆盖这个地址吗?(service-scope,type=2)
维度 2:排班       —— 今天这个时段他在班吗?(base 服务的排班表)

对应一次 Feign 调用:

java
// 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 完整调度流程(模块里唯一完整实现的流程)

java
// 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 延迟推送

这是这个模块最有业务含量的一段,也是最值得在面试里重点讲的。

业务背景: 快递员在手机上接单,如果提前太久派单,会出现两个问题:

  1. 任务过期——今天下午的单可能派给一个"明天早上才上门"的快递员,

客户等不及自己改约;

  1. 扎堆——所有当天的单在早上 8 点一次性推给快递员,

他会收到几百条推送,直接关掉通知。

但也不能推太晚: 太晚了快递员来不及安排,可能错过上门时间。

所以规则是:

text
剩余时间(now → 用户期望上门时间)
  ├─ ≤ dispatchTime(默认 2 小时)  →  立刻推(DEFAULT_DELAY = -1)
  └─ >  dispatchTime                →  延迟推,推送时刻 = 期望上门时间 − dispatchTime

举例(dispatchTime = 2 小时):

当前时间期望上门剩余推送时刻
10:3013:002.5h11:00(13:00 − 2h),延迟 30 分钟
11:3013:001.5h立即推送
22:00明天 09:0011h明天 07:00,延迟 9 小时

这个设计的三个亮点:

  1. 配置化 —— dispatchTime 存在 Redis(DISPATCH_CONFIGURATION:TIME),

可以热改,不同网点可以配不同的提前量(transport 服务的 DispatchConfigurationDTO 上有 @Min(1) @Max(48) 的校验)。

  1. 有兜底 —— DEFAULT_DELAY = -1 表示立即,

万一计算出的 delay 是负数(期望时间已过),也能推出去。

  1. 只对取件任务生效 —— 条件里有 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 幂等 只有注释,代码里零实现。

如果重做(三个层次,从轻到重):

java
// 方案 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(); }
java
// 方案 2:业务表唯一索引(最可靠,治本)
// cc_pickup_dispatch_task 上加唯一索引
//   uk_order_task (order_id, task_type)
// 依赖数据库做最终防线,即使幂等标记失效也不会产生脏数据
// 注意:Seata 场景下唯一索引冲突要能识别为"重复请求"而不是"系统异常"
java
// 方案 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 的签名是:

java
private Long selectCourierByAI(List<Long> courierIds, Integer taskType,
                               Double longitude, Double latitude)

传进去的是已经过滤好的候选集(有排班 + 覆盖范围内), 所以 AI 只在候选集里选。

这个分层是关键的,而且值得在面试里主动讲:

text
第一层(规则,绝对硬约束):有没有排班?覆不覆盖这个地址?   ← courier 服务
        ↓ 候选集
第二层(规则,硬约束):今天已经接了几单?会不会超时?        ← 应该加在这里
        ↓ 候选集
第三层(AI,软决策):距离、负载均衡、历史准时率              ← 只能在这一层

原则:AI 只能在硬约束划定的集合里做排序,绝不能突破硬约束。 AI 说"这个快递员最合适",如果他今天休息,那他就不合适—— 这是业务规则,不是模型能推翻的。

如果重做 AI 选人,特征应该是什么:

特征说明数据来源
直线距离快递员当前位置到收件地址快递员最近一次轨迹点
当日负载今天已接单数 / 目标单量任务表 count
地理邻近当前位置与该网点其它待派任务的聚集度聚类,避免来回跑
历史准时率过去 N 单的上门时间偏差轨迹时间戳 vs 期望时间
任务类型匹配度该快递员是否常跑这类(大件 / 文件 / 生鲜)历史任务标签
当日出勤时长剩余可工作时间排班表

dispatch 模块里其实已经把这些特征建模好了,只是没用上:

java
// 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 永远返回第二个候选人

事实:

java
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 是短期内补不上的。

修复(先止血,再完善):

java
// 止血:至少要公平轮转,不能永远跳过第一个
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 秒

事实:

yaml
# 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 服务挂了:

  1. 登录接口 web-manager AuthServiceImpl#login 调 AIGC 风控,

会卡 16 分钟,然后抛超时异常; 而 login 里没有 catch,异常直接冒到 controller, 用户看到的是一个转圈 16 分钟然后报错的登录页面。

  1. 建线路接口 同理(亮点 2 已详述)。
  1. 因为 @Value("${agent.risk.login}") 没有默认值,

而 web-manager 里 @Value("${config.web-manager.risk}") 是一个 Boolean 开关, 默认情况下风控开关是开着的,所以这条链路默认就在依赖 AI 服务。

关于那个"开关",要说清楚它的真相:

java
// web-manager AuthServiceImpl#login
RiskLoginResponse riskLoginResponse = RiskLoginResponse.builder()
        .needCaptcha(false)                      // 默认不弹验证码
        .build();
if (webManagerRisk) {
    riskLoginResponse = aigcFeign.assessLoginRisk(...);   // ← 没有 try-catch
}

这个开关是"功能开关",不是"降级开关"。 它解决的是"要不要用 AI",不解决"AI 挂了怎么办"。 AI 挂了异常照样冒出来。

真正的降级开关应该是:

java
// 把开关和异常处理分开
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 是全局固定的

事实:

java
// AgentServerClient
String DEFAULT_SESSION_ID = "1";
String DEFAULT_USER_TOKEN = "DEFAULT_USER_TOKEN";
java
// RiskControlServiceImpl
ChatRequestDTO chatRequestDTO = ChatRequestDTO.builder()
        .question(question)
        .sessionId(AgentServerClient.DEFAULT_SESSION_ID)     // 永远是 "1"
        .userToken(AgentServerClient.DEFAULT_USER_TOKEN)     // 永远是默认值
        .agentId(riskLoginAgentId)
        .build();

风险(按严重程度排):

  1. 对话上下文串了 —— 如果 Python 侧 LangChain 是按 session 存 memory 的,

那么所有用户、所有请求都共享同一个会话上下文, 可能出现"上一个用户的登录记录出现在这个用户的评估里"。

  1. 审计不了 —— 线上出问题无法定位到具体是哪次调用。
  2. 限流无法区分 —— 没法按用户/按业务做 QPS 控制。

如果重做:

java
// sessionId 用业务语义标识,而不是魔法数字
.sessionId("risk-login:" + account + ":" + userType)
// userToken 在 Java 侧自签(复用 JwtUtils 的 RSA 私钥),
// 里面带上 调用方服务名 + userId + 过期时间

更深的一层思考: 这套架构把 AI 当成了一个无状态 HTTP 接口(POST /chat,返回 String)。 但 LLM Agent 的本质是有状态的对话系统。 把有状态的东西用无状态接口包装,代价就是要在调用方维护 session。

更合理的契约应该是:

text
无状态契约(推荐):
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:模块里两个入口是空的

java
// DispatchJob
@XxlJob("transportTask")
public void transportTask() {
    //TODO 待实现
}
java
// 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 把设计意图写得很清楚:

java
/**
 * 调度运输任务
 * 核心能力:
 * 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。"


五、如果重做,我会怎么改

优先级改动收益
P0selectCourierByAI 改成 Redis 轮转止血消除"第一个快递员永远接不到单"
P0OrderMQListener 加幂等(Redis 标记 + DB 唯一索引双保险)消除重复任务
P0aigcFeign 加 fallbackFactory;Feign 超时按服务名单独配到 3 秒AI 挂了不影响调度和下单
P1登录风控加 try-catch 降级 + AI 不可用期间切规则风控消除 16 分钟挂起
P1改掉固定 sessionId,改成业务语义化消除上下文串会话 + 可审计
P1AI 契约从"传中文 prompt"改成"传结构化 DTO"消除跨语言隐式契约
P2落地 transportTask + ZSet 优先级队列补上 README 承诺的核心能力
P2FeatureSnapshot 拆分:选人特征 vs 装载特征特征设计正交

ZSet 优先级队列的方案(如果问"你会怎么实现")

这是 README 里承诺的、也是最容易被追问设计的部分。准备好这一段:

java
// 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);
}
java
// 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(); }
}

这个方案里最值得讲的三个设计决策:

  1. 为什么用 ZSet 而不是堆?

ZSet 提供范围查询(reverseRangeWithScores(0, 99) 直接取前 100 名), 堆只能取堆顶,要批量取就得弹出来再塞回去。而且 ZSet 天然去重 (相同 member 只会覆盖 score),这顺手解决了幂等问题。

  1. 为什么用 0.8 + 0.2 的加权?

主要目的是防饿死。 如果纯用 AI 分数, 一个"AI 判断价值低"的老运单可能会永远排在最后。 加一个等待时间的分数,等待越久分越高, 保证每个运单最终都会被调度到。 这是调度系统里的公平性 vs 效率的经典权衡。

  1. 为什么"装不下就跳过"而不是"装不下就结束"?

因为优先级队列里的运单大小是随机的。如果第一个装不下就结束, 后面的小单全部饿死。跳过让大单可以被后续轮次处理, 小单优先上车,整体装载率和时效都会更好。

诚实边界: 0.8 和 0.2 这两个数需要用真实数据调, 不能拍脑袋。调参的正确做法是:用历史数据做离线回放, 遍历权重组合,找到"平均等待时长"和"平均装载率"的最优折中点。 没有这个实验,这两个数就是随手写的。


六、面试问答

Q1:为什么用 MQ 而不是同步调用来做调度?

A: 三个原因:

  1. 响应时间 —— 下单接口不能等调度完成。

调度链路里有 Feign 查快递员、Feign 查排班配置、选人、再发一次 MQ, 同步做的话下单接口要多好几秒。

  1. 可用性隔离 —— 调度依赖 courier 服务和排班服务,

它们挂了不应该影响下单(下单成功,任务晚点派而已)。

  1. 削峰 —— 业务高峰(比如上午 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: 我会先用规则做基线,再上模型,因为规则能立刻上线且可解释。

规则基线(必须先有):

text
过滤:今天有排班 + 覆盖这个地址(硬约束)
排序:当日已接单数少者优先 → 直线距离近者优先

模型方案(XGBoost 更适合,不是 LLM):

  • XGBoost 做排序/分类:特征是结构化的(距离、负载、准时率),

树模型在这种表格数据上比 LLM 强得多,而且推理是毫秒级的, 能进同步链路。

  • LLM 做解释:模型给出"选 A 因为距离最近且负载最低"这样一句话,

给快递员和管理者看。解释和决策分离。

为什么不直接用 LLM 选人?

  1. LLM 推理要几秒,会把 MQ 消费链路拖垮;
  2. 特征是结构化数值,LLM 在这上面没有优势;
  3. LLM 的输出不确定,同一批订单两次跑结果可能不同,无法复盘。

追问:怎么评估选人算法的效果? 不能只看"选得快不快",要看业务指标:

text
- 派件及时率(最核心)
- 快递员单均里程(选得好不好直接体现在里程上)
- 人均日单量(负载是否均衡)
- 客户投诉率

而且必须做 A/B:同一批订单,一半用规则选、一半用模型选, 对比一周的指标。没有 A/B 就不能说模型比规则好。

Q4:这个模块你觉得最大的问题是什么?

A: 分三层说:

  1. 功能没做完 —— 运力调度(ZSet 优先级队列)只有设计没有实现,

AI 选人是 return courierIds.get(1)。这是客观事实,我很清楚。

  1. 可靠性缺失 —— 消费者完全没有幂等控制,

而 MQ 重复投递是必然会发生的。Constants.LOCKS.DISPATCH_LOCK_PREFIX 这个常量声明了但全仓库零使用,说明当时想到了但没做。

  1. AI 集成太脆 —— 1000 秒的超时、没有降级、

把结构化数据拼成中文 prompt 跨语言传递。 这三点叠加起来,AI 服务一旦出问题就是全链路不可用。

如果只改一件事,我会先改幂等。 因为重复派单是每天都在发生的资损(两个快递员跑一趟), 而 AI 选人只是"效率没提升"。


七、源码定位速查

文件行作用
dispatch/mq/OrderMQListener.java51-96完整调度流程(实时/延迟决策)
同上99-105selectCourierByAI 永远返回 index 1
dispatch/mq/TransportOrderDispatchMQListener.java38-56运单合并监听器 TODO 空实现
dispatch/job/DispatchJob.java20-31transportTask TODO 空实现(Javadoc 有 ZSet 设计)
dispatch/job/DispatchContext.java—调度上下文(从未被 new)
dispatch/ml/model/FeatureSnapshot.java—10 维特征(零引用)
dispatch/ml/model/DispatchDecisionRecord.java—决策记录 + label 回填设计(零引用)
dispatch/config/RedissonConfiguration.java15-35Redisson Bean(零调用)
dispatch/dto/DispatchMsgDTO.java—运单调度消息 DTO(零引用)
chain-cloud-common/.../Constants.java11-21LOCKS.DISPATCH_LOCK_PREFIX(零使用)
chain-cloud-ms-work-service/.../TransportOrderServiceImpl.java78-91sendTransportOrderMsgToDispatch 延迟 5 秒
chain-cloud-ms-work-service/.../TransportOrderServiceImpl.java67-73orderToTransportOrder TODO
chain-cloud-ms-api/.../courier/CourierFeign.java26-30候选人筛选(含降级说明)
ms-domain/.../transport/domain/DispatchConfigurationDTO.java21-31调度配置(dispatchTime 单位小时)
chain-cloud-mq/.../Constants.java(common)38-48DEFAULT_DELAY=-1 / LOW_DELAY=5000 / NORMAL_DELAY=10000