Series Article

Day06 · TMS 核心业务面试准备

核心业务流程

运单创建完整流程

流程概述:

flowchart LR
    A[OMS推送发货指令] --> B[智能分配渠道]
    B --> C[创建内部运单]
    C --> D[调用物流商API]
    D --> E[获取运单号和面单]
    E --> F[计算并落库最终预估运费]
    F -.异步.-> G[回调OMS]
    G --> H[打印面单]
    H --> I[揽收发货]

前置条件:

  • 订单已出库,WMS已确认发货
  • 已对接物流商API
  • 已配置物流渠道和费率

流程步骤:

步骤1:OMS推送发货指令

  • 触发方:OMS订单系统
  • 推送内容:订单号、商品信息、收货地址、包裹重量、尺寸
  • 系统动作:TMS接收发货指令

步骤2:智能分配物流渠道(关键技术点)

  • 系统动作:
    • 调用规则引擎,获取推荐渠道TOP3
    • 过滤条件:目的国、重量、含电、液体、时效要求
    • 对候选渠道做临时费用测算,用于成本评分,不写入费用表
    • 综合评分:成本40% + 时效35% + 可靠性25%
    • 返回推荐渠道列表和推荐原因

步骤3:选择物流渠道

  • 操作人:物流专员(或系统自动)
  • 操作内容:
    • 自动模式:系统自动选择第一推荐渠道
    • 人工模式:物流专员从候选渠道中选择
  • 系统动作:确定使用的渠道

步骤4:创建内部运单

  • 系统动作:
    • 生成内部运单号:WB-20250121-0001
    • 插入logistics_waybill表
    • 初始状态:0(待揽收)
    • 记录:订单ID、渠道ID、申报价值、重量

步骤5:调用物流商API创建外部运单

  • 系统动作:
    • 构建API请求参数(收件人、发件人、商品申报、重量)
    • 调用物流商API
    • 传递:订单信息、收货地址、商品申报、HS编码

步骤6:获取物流商运单号和面单

  • 系统动作:
    • 解析API响应
    • 获取tracking_no(物流商运单号)
    • 获取label_url(面单PDF/ZPL文件URL)
    • 更新运单记录

步骤7:计算并落库最终预估运费

  • 系统动作:
    • 根据最终选中的渠道、重量和目的国,查询费率表
    • 计算首重费用 + 续重费用
    • 计算材积重(如果材积重大于实际重量)
    • 得到该运单最终使用渠道的预估运费
    • 写入logistics_fee_record

步骤8:回调OMS更新订单状态

  • 系统动作:
    • 调用OMS接口
    • 传递:订单号、物流商运单号、物流渠道
    • OMS将订单状态从待发货更新为已发货
    • OMS记录发货时间

步骤9:打印面单

  • 操作人:物流专员
  • 操作内容:批量选择运单,点击”打印面单”
  • 系统动作:
    • 下载面单PDF/ZPL文件
    • 发送给打印机
    • 更新运单状态:已打印
    • 记录打印时间

步骤10:揽收发货

  • 操作人:物流商
  • 操作内容:上门揽收包裹,扫描运单号
  • 系统动作:
    • 接收物流商推送的揽收事件
    • 将运单状态从待揽收更新为已揽收
    • 写入第一条物流轨迹

关键技术点:

  • 智能规则引擎自动分配最优渠道,综合评分算法
  • 物流商API统一适配层,支持多家物流商
  • 首重+续重+材积重计费模型
  • 异步回调OMS,解耦系统依赖
  • 批量打印面单,提高操作效率

异常处理:

  • API调用失败:记录失败原因,进入人工处理队列,支持重试
  • 渠道不可用:自动切换到次选渠道
  • 面单下载失败:重试3次,仍失败则告警

步骤2和步骤7为什么都提到费用:

这两个地方的费用含义不同。

步骤2是在渠道推荐阶段做临时费用测算。系统需要知道每条候选渠道大概多少钱,才能做成本评分,但这个阶段还没有最终确认用哪条渠道,所以不应该写入费用表。

步骤7是在物流渠道已经确定、物流商运单也创建成功以后,对最终选中的渠道计算正式预估费用,并写入费用记录表。后续财务对账、利润分析、差异分析都以这条正式费用记录为准。

面试时怎么讲:

“我负责的 TMS 运单创建,核心是把 OMS 的订单发货需求转成真正可以交给物流商运输的运单。这个流程不是简单保存一条物流记录,因为它会涉及 WMS 出库结果、OMS 订单状态、物流商接口、面单打印、预估运费和后续轨迹同步。

具体来说,OMS 或 WMS 会把已经完成出库的订单推给 TMS,里面会带订单号、收件人地址、SKU 明细、包裹重量、尺寸、是否含电、是否液体等信息。TMS 收到以后,第一步不是马上调用物流商,而是先通过规则引擎选择物流渠道。因为跨境物流渠道很多,不同渠道对目的国、重量、品类、时效都有要求,比如含电商品不能随便走普通渠道,液体和粉末也有很多限制。

规则引擎推荐渠道时会临时测算候选渠道的费用,这个费用只用于评分和排序,不落费用表。等物流专员确认渠道,或者系统自动选择第一推荐渠道以后,TMS 才会创建内部运单,再调用物流商 API 创建外部运单,拿到物流商返回的 trackingNo 和面单地址。

这里比较容易出问题的是重复请求和接口超时。重复请求主要来自 TMS 侧,比如 OMS 重复发送发货消息、MQ 重复消费、多个操作员同时对同一个订单包裹创建运单,或者 TMS 集群里多个实例同时处理同一个任务。这个问题要在 TMS 侧做幂等,使用租户 ID、订单号、包裹号作为业务唯一键,先查本地是否已经创建过运单,再用分布式锁和数据库唯一索引兜底,避免同一个订单包裹重复调用物流商创建接口。

物流商接口超时是另一类问题。TMS 发起创建请求后,如果没有收到响应,并不能确定物流商那边一定失败了。可能物流商已经创建成功,只是响应超时或网络中断。这个时候不能简单地立刻再次创建,否则如果物流商没有做好幂等,就可能出现重复面单。更稳妥的做法是给物流商请求传递我们自己的业务单号或幂等请求号;如果物流商支持幂等创建,就依赖它返回同一张运单;如果物流商支持按客户订单号查询,就先查询确认;如果都不支持,就把本地运单标记为创建中或创建结果待确认,进入补偿任务或人工处理,而不是无脑重试。

拿到运单号以后,TMS 会计算最终选中渠道的正式预估费用,并写入费用记录表。这个费用后续用于财务对账和利润分析。然后 TMS 异步通知 OMS,把订单状态更新为已发货,并且把物流商、物流渠道、trackingNo 回写过去。仓库人员再批量打印面单,贴到包裹上,等待物流商揽收。

我在这个流程里重点做了四件事:第一是规则引擎选渠道;第二是物流商适配器,屏蔽不同物流商 API 差异;第三是运单创建幂等,区分 TMS 侧幂等和物流商侧幂等;第四是异步通知 OMS,避免外部系统影响主流程。如果面试官追问为什么要异步通知,我会说物流商 API 和 OMS 都可能不稳定,TMS 已经创建成功的运单不能因为 OMS 临时失败就丢掉,所以用 MQ 或本地消息表做最终一致更合适。“


物流轨迹追踪完整流程

流程概述:

flowchart LR
    A[定时任务拉取轨迹] --> B[标准化处理]
    B --> C[轨迹去重写库]
    C --> D[异常检测]
    D --> E[推送通知]
    C -.签收节点.-> F[回调OMS]

前置条件:

  • 运单已创建,已获取 tracking_no
  • 物流商支持轨迹查询API
  • 定时任务已配置

流程步骤:

步骤1:定时任务拉取轨迹

  • 触发方:XXL-Job 定时任务(每小时执行一次)
  • 系统动作:
    • 查询所有未完成的运单(状态未到已签收)
    • 按物流商分组,使用不同物流商的线程池并发拉取
    • 同一物流商内部按它的 API 批量上限拆批,避免超过接口限制

步骤2:调用物流商轨迹API

  • 系统动作:
    • 构建API请求(传递tracking_no列表)
    • 调用物流商轨迹查询接口
    • 解析返回的轨迹数据

步骤3:轨迹数据标准化

  • 系统动作:
    • 不同物流商的轨迹格式不同,需要标准化
    • 提取关键信息:节点时间、位置、状态码、描述
    • 映射为统一的轨迹节点码(如:PICKUPIN_TRANSITDELIVERED

步骤4:写入数据库

  • 系统动作:
    • 查询该运单已有的轨迹记录
    • 去重:如果轨迹节点已存在(相同时间+位置),跳过
    • 插入新的轨迹记录到logistics_track
    • 更新运单状态(根据最新轨迹节点)

步骤5:异常检测(关键技术点)

  • 系统动作:
    • 检测异常节点:EXCEPTIONRETURNEDLOST
    • 检测超时:运单创建超过最长时效,仍未签收
    • 检测滞留:同一位置停留超过3天
    • 标记异常运单:is_exception = 1

步骤6:推送通知

  • 系统动作:
    • 异常运单:发送站内信+邮件通知物流专员
    • 签收运单:发送站内信通知运营专员
    • 重要节点(清关、派送中):推送消息

步骤7:回调OMS更新订单状态

  • 系统动作:
    • 如果轨迹节点是”已签收”,回调OMS
    • OMS将订单状态从运输中更新为已签收
    • OMS记录签收时间

关键技术点:

  • 定时任务批量拉取轨迹,避免API限流
  • 轨迹数据标准化,统一不同物流商的格式
  • 异常检测算法,自动识别异常运单
  • 去重机制,避免重复写入相同轨迹
  • 异步推送通知,不阻塞主流程

异常处理:

  • API调用失败:记录失败日志,下次继续拉取
  • 轨迹数据异常:记录原始数据,人工处理
  • 超时未签收:自动标记异常,通知物流专员

面试时怎么讲:

“跨境订单发货以后,卖家最关心的是包裹现在在哪里、有没有清关异常、有没有长时间不动、是否已经签收。如果没有 TMS,运营人员可能要一个个去物流商网站查单,效率非常低,而且异常发现很晚,等买家投诉时才知道包裹出了问题。

所以我们在 TMS 里做了轨迹追踪流程。运单创建成功以后,系统会保存 trackingNo。后续通过 XXL-Job 定时任务,每小时扫描未完成的运单,比如待揽收、运输中、派送中的运单。扫描时不是一条一条调用,而是先按物流商分组,因为同一家物流商通常支持批量查询,而且每家物流商都有自己的限流规则。

物流商返回的轨迹格式差异很大,有的叫 route,有的叫 event,有的状态码是数字,有的是英文枚举。所以我会在物流商适配器里把它们转换成系统统一的轨迹节点,比如 PICKUP 表示已揽收,IN_TRANSIT 表示运输中,CUSTOMS 表示清关中,DELIVERED 表示已签收。这样业务层就不用关心是哪家物流商返回的格式。

写入轨迹前要做去重,因为定时任务每小时都跑,物流商可能重复返回历史轨迹。如果不去重,轨迹表会出现大量重复数据。我们可以用 waybillIdtrackTimetrackCodelocation 做幂等判断,也可以加唯一索引兜底。

轨迹写完以后,还会做异常检测。比如轨迹节点本身是 EXCEPTIONLOSTRETURNED,或者超过渠道最长时效还没有签收,或者三天没有新轨迹,都可以标记为异常。异常以后系统会发站内信、邮件或者消息通知物流专员,让运营及时处理。如果轨迹节点是已签收,TMS 再异步通知 OMS 更新订单签收状态。

这块面试时可以突出三个点:第一,定时任务分片和批量查询解决效率问题;第二,轨迹标准化解决多物流商格式差异;第三,异常检测解决业务风险,不只是把轨迹查回来展示。“


物流费用核算完整流程

流程概述:

flowchart LR
    A[创建运单时预估运费] --> B[物流商出账单]
    B --> C[导入账单]
    C --> D[自动对账]
    D --> E[差异分析]
    E --> F[人工确认]
    F -.推送.-> G[FMS生成应付账款]

前置条件:

  • 已配置物流费率表
  • 物流商提供账单文件(Excel/CSV)
  • 已对接财务系统

流程步骤:

步骤1:创建运单时预估运费

  • 系统动作:
    • 查询费率表:根据渠道ID + 目的国
    • 计算实际重量:包裹称重
    • 计算材积重:(长×宽×高) / 材积系数
    • 取较大值:计费重量 = MAX(实际重量, 材积重)
    • 计算首重费用:首重克数 × 首重单价
    • 计算续重费用:(计费重量 - 首重) / 续重单位 × 续重单价
    • 预估运费 = 首重费用 + 续重费用
    • 写入logistics_fee_record
    • 同步更新运单主表中的预估费用字段,便于列表展示和利润分析

步骤2:物流商出账单

  • 触发方:物流商(每月1号)
  • 操作内容:物流商发送上月账单(Excel文件)
  • 账单内容:运单号、发货日期、目的国、重量、运费、附加费

步骤3:导入账单

  • 操作人:财务专员
  • 操作内容:上传物流商账单文件
  • 系统动作:
    • 使用 EasyExcel 按批读取账单文件,避免一次性加载导致 OOM
    • 提取:运单号、实际运费、附加费
    • 批量更新logistics_fee_record
    • 填充actual_feefuel_surchargepeak_surcharge字段

步骤4:自动对账(关键技术点)

  • 系统动作:
    • 查询所有有actual_fee的运单
    • 对比:预估运费 vs 实际运费
    • 计算差异:diff_amount = actual_fee - estimated_fee
    • 计算差异率:diff_rate = diff_amount / estimated_fee × 100%
    • 标记差异类型:
      • 正常:差异率 < 5%
      • 轻微差异:5% ≤ 差异率 < 10%
      • 重大差异:差异率 ≥ 10%

步骤5:差异分析

  • 系统动作:
    • 统计差异原因:
      • 重量差异:物流商复重与我们称重不一致
      • 材积重差异:物流商测量尺寸与我们不一致
      • 附加费:燃油附加费、旺季附加费、偏远地区附加费
      • 费率变更:物流商调整费率,我们未及时更新
    • 生成差异报告

步骤6:人工确认

  • 操作人:财务专员
  • 操作内容:
    • 查看差异报告
    • 正常差异:直接确认
    • 重大差异:联系物流商核实,调整费率表
  • 系统动作:标记对账状态:已确认

步骤7:生成应付账款

  • 系统动作:
    • 汇总本月所有已确认的运单费用
    • 按物流商分组,生成应付账款
    • 推送给FMS财务系统
    • FMS生成付款单,安排付款

关键技术点:

  • 首重+续重+材积重计费模型
  • 自动对账算法,识别差异
  • 差异分析,统计差异原因
  • EasyExcel 批量导入账单,避免大文件 OOM
  • SQL 直接对账和 Java 业务层对账两种实现方式
  • 与FMS财务系统集成

面试时怎么讲:

“TMS 里计算的费用不是为了替物流商决定最终收费,最终付款还是以物流商账单为准。我们做费用核算,是为了在发货前预估成本,在发货后做账单校验,在财务侧生成应付账款。

整个流程可以分成两个阶段。第一个阶段是创建运单时的预估费用。系统会根据物流渠道、目的国、重量段查询费率表,然后计算首重、续重和材积重。这里一定要讲材积重,因为跨境物流里很多商品体积大但重量轻,比如抱枕、玩具、泡沫类商品,物流商不会只按实际重量收费,而是会比较实际重量和材积重,取较大的那个作为计费重量。TMS 的预估费用就是根据这个计费重量算出来的。

第二个阶段是物流商账单出来以后做对账。物流商一般会按月或者按账期提供账单,里面有 trackingNo、实际重量、实际费用、燃油附加费、偏远地区附加费等。财务人员导入账单后,系统按 trackingNo 和账单批次匹配原来的费用记录,计算 actualFeeestimatedFee 的差异金额、差异率。差异小于阈值,比如 5%,可以自动确认;差异比较大的,就生成差异单,让财务或物流专员核实。

差异不一定代表系统算错了,常见原因有仓库称重和物流商复重不一致、物流商按材积重收费、目的地是偏远地区、旺季附加费或燃油附加费变化、平台费率表没有及时更新。这个点在面试里很重要,因为它能说明你理解真实物流费用为什么会变化。

最后,对账确认后的费用会推送给 FMS 财务系统,生成物流商维度的应付账款。这样 TMS 就不仅是发货工具,还参与了订单利润分析和财务结算闭环。“


技术亮点

智能规则引擎:自动推荐物流渠道

为什么需要智能规则引擎

跨境卖家通常对接 5-20 条物流渠道,每次发货时如果人工选渠道:

  • 需要了解每条渠道的限制(含电/重量/目的国)
  • 需要比较各渠道的价格和时效
  • 容易出错(选了不允许含电的渠道发含电产品)
  • 效率低(每单都要人工判断)

智能规则引擎:系统根据配置的规则,自动为每个包裹推荐最优渠道。

规则引擎工作流程

第一步:过滤层(排除不适用的渠道)

public List<LogisticsChannel> filterChannels(ShipmentRequest request) {
    // 1. 查询所有正常状态的渠道
    List<LogisticsChannel> channels = channelMapper.selectList(
        new LambdaQueryWrapper<LogisticsChannel>()
            .eq(LogisticsChannel::getStatus, 1)
            .eq(LogisticsChannel::getIsDeleted, 0)
    );
    
    // 2. 过滤:目的国覆盖
    channels = channels.stream()
        .filter(channel -> {
            List<String> countryCodes = JSON.parseArray(
                channel.getCountryCodes(), String.class);
            return countryCodes.contains("ALL") 
                || countryCodes.contains(request.getCountryCode());
        })
        .collect(Collectors.toList());
    
    // 3. 过滤:重量限制
    channels = channels.stream()
        .filter(channel -> 
            request.getWeightG() >= channel.getMinWeightG() 
            && request.getWeightG() <= channel.getMaxWeightG()
        )
        .collect(Collectors.toList());
    
    // 4. 过滤:含电检查
    if (request.getHasBattery()) {
        channels = channels.stream()
            .filter(LogisticsChannel::getAllowBattery)
            .collect(Collectors.toList());
    }
    
    // 5. 过滤:液体检查
    if (request.getHasLiquid()) {
        channels = channels.stream()
            .filter(LogisticsChannel::getAllowLiquid)
            .collect(Collectors.toList());
    }
    
    // 6. 过滤:时效要求
    if (request.getMaxDaysRequired() != null) {
        channels = channels.stream()
            .filter(channel -> 
                channel.getMinDays() <= request.getMaxDaysRequired()
            )
            .collect(Collectors.toList());
    }
    
    return channels;
}

第二步:综合评分(对候选渠道打分)

public List<ChannelScore> calculateScores(List<LogisticsChannel> channels, 
                                          ShipmentRequest request) {
    List<ChannelScore> scores = new ArrayList<>();
    
    // 1. 计算每个渠道的预估运费
    Map<Long, BigDecimal> feeMap = new HashMap<>();
    for (LogisticsChannel channel : channels) {
        BigDecimal fee = calculateFee(channel, request);
        feeMap.put(channel.getId(), fee);
    }
    
    // 2. 找出最低运费和最短时效
    BigDecimal minFee = feeMap.values().stream()
        .min(BigDecimal::compareTo)
        .orElse(BigDecimal.ZERO);
    
    BigDecimal minDays = channels.stream()
        .map(c -> (c.getMinDays() + c.getMaxDays()) / 2.0)
        .min(Double::compareTo)
        .orElse(1.0);
    
    // 3. 计算每个渠道的综合评分
    for (LogisticsChannel channel : channels) {
        ChannelScore score = new ChannelScore();
        score.setChannelId(channel.getId());
        score.setChannelName(channel.getChannelName());
        
        // 成本评分(40%)
        BigDecimal fee = feeMap.get(channel.getId());
        BigDecimal costScore = minFee.divide(fee, 4, RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(100));
        score.setCostScore(costScore);
        
        // 时效评分(35%)
        BigDecimal avgDays = BigDecimal.valueOf(
            (channel.getMinDays() + channel.getMaxDays()) / 2.0);
        BigDecimal timeScore = BigDecimal.valueOf(minDays)
            .divide(avgDays, 4, RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(100));
        score.setTimeScore(timeScore);
        
        // 可靠性评分(25%)
        BigDecimal reliabilityScore = getReliabilityScore(channel.getId());
        score.setReliabilityScore(reliabilityScore);
        
        // 综合评分
        BigDecimal totalScore = costScore.multiply(BigDecimal.valueOf(0.4))
            .add(timeScore.multiply(BigDecimal.valueOf(0.35)))
            .add(reliabilityScore.multiply(BigDecimal.valueOf(0.25)));
        score.setTotalScore(totalScore);
        
        scores.add(score);
    }
    
    // 4. 按综合评分降序排序
    scores.sort((a, b) -> b.getTotalScore().compareTo(a.getTotalScore()));
    
    return scores;
}

第三步:返回TOP3推荐

public List<ChannelRecommendation> recommendChannels(ShipmentRequest request) {
    // 1. 过滤候选渠道
    List<LogisticsChannel> channels = filterChannels(request);
    
    if (channels.isEmpty()) {
        throw new BusinessException("没有适用的物流渠道");
    }
    
    // 2. 计算综合评分
    List<ChannelScore> scores = calculateScores(channels, request);
    
    // 3. 返回TOP3
    return scores.stream()
        .limit(3)
        .map(score -> {
            ChannelRecommendation rec = new ChannelRecommendation();
            rec.setChannelId(score.getChannelId());
            rec.setChannelName(score.getChannelName());
            rec.setEstimatedFee(calculateFee(score.getChannelId(), request));
            rec.setEstimatedDays(getEstimatedDays(score.getChannelId()));
            rec.setTotalScore(score.getTotalScore());
            return rec;
        })
        .collect(Collectors.toList());
}

可靠性评分计算

private BigDecimal getReliabilityScore(Long channelId) {
    // 查询近30天该渠道的运单数据
    LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30);
    
    // 总运单数
    Long totalCount = waybillMapper.selectCount(
        new LambdaQueryWrapper<LogisticsWaybill>()
            .eq(LogisticsWaybill::getChannelId, channelId)
            .ge(LogisticsWaybill::getCreateTime, thirtyDaysAgo)
    );
    
    if (totalCount == 0) {
        return BigDecimal.valueOf(80); // 新渠道默认80分
    }
    
    // 正常签收数
    Long deliveredCount = waybillMapper.selectCount(
        new LambdaQueryWrapper<LogisticsWaybill>()
            .eq(LogisticsWaybill::getChannelId, channelId)
            .ge(LogisticsWaybill::getCreateTime, thirtyDaysAgo)
            .eq(LogisticsWaybill::getStatus, WaybillStatus.DELIVERED.getCode())
            .eq(LogisticsWaybill::getIsException, 0)
    );
    
    // 签收率 = 正常签收数 / 总运单数 × 100
    return BigDecimal.valueOf(deliveredCount)
        .divide(BigDecimal.valueOf(totalCount), 4, RoundingMode.HALF_UP)
        .multiply(BigDecimal.valueOf(100));
}

面试时怎么讲

“智能规则引擎这个点,面试时不要一上来就讲代码,我会先讲业务背景。

跨境卖家发货时不是只有一个快递可以选,而是会同时对接很多物流商和很多渠道。比如同样发美国,有经济小包、专线、快递、海外仓派送;同样是耳机,有的带电池,有的不带电池;同样是 500 克,有的渠道便宜但慢,有的渠道贵但稳定。如果每一单都靠人工选渠道,效率低,而且很容易选错。

所以我在 TMS 里做了一个物流渠道推荐规则。它的目标不是替业务人员拍脑袋决定,而是把人工选渠道时会考虑的因素规则化,让系统先筛掉不能用的渠道,再从能用的渠道里推荐最优的几个。

我的实现分三层。

第一层是过滤层,也就是先排除不符合条件的渠道。这里会判断渠道状态是否启用、目的国是否覆盖、包裹重量是否在渠道允许范围内、商品是否含电或液体、时效要求是否满足。比如一个包裹目的国是德国,商品含电池,那系统必须排除不支持德国或不支持带电的渠道。这个层的重点是保证不出错。

第二层是费用和时效计算。过滤之后的渠道都是可用的,但是每条渠道的成本和时效不一样。系统会根据费率表做临时费用测算,里面会涉及首重、续重和材积重。这里的费用只用于渠道评分,不写入费用表。时效则取渠道配置的平均时效,比如最小时效和最大时效的平均值。这里的重点是把不同渠道放到一个可比较的维度上。

第三层是综合评分。我们不是只看便宜,因为最便宜的渠道可能很慢,也可能丢件率高。所以评分里会综合成本、时效、可靠性。成本评分可以用最低运费除以当前渠道运费,时效评分可以用最短时效除以当前渠道时效,可靠性评分可以根据近 30 天签收率、异常率来算。最后按权重加权,比如成本 40%、时效 35%、可靠性 25%,返回 TOP3 给物流专员选择,或者在自动模式下选择第一名。

这个方案实施时有几个问题要考虑。

  1. 第一,规则不能写死在代码里,否则运营调整渠道时还要发版,所以渠道限制、费率、权重都应该配置化。
  2. 第二,规则查询不能每次都查很多表,所以渠道和费率这类读多写少的数据可以放 Redis 缓存。 第三,评分权重不是永远固定的,不同租户可能不一样,有的卖家更看重成本,有的更看重时效,所以权重最好支持租户级配置。

如果面试官问这个亮点的价值,我会说它解决了人工选择物流渠道效率低、容易选错、成本不可控的问题。系统上线后,运营人员不需要每一单都查渠道规则,系统直接推荐可用渠道,并且能解释推荐原因,比如这条渠道成本低、时效满足、近 30 天签收率高。这样不只是提升效率,也降低了发错渠道导致的退件和延误风险。“


物流商 API 统一适配层

为什么需要统一适配层

不同物流商的API接口完全不同:

  • 请求格式不同:有的用JSON,有的用XML
  • 认证方式不同:有的用API Key,有的用OAuth
  • 字段名不同:运单号有的叫tracking_no,有的叫waybill_no
  • 响应格式不同:有的返回JSON,有的返回XML

如果每个物流商都单独对接,代码会非常混乱,难以维护。

统一适配层设计

设计思路:

flowchart LR
    A[业务层] --> B[统一接口]
    B --> C[适配器工厂]
    C --> D[具体物流商适配器]
    D --> E[物流商API]

统一接口定义:

public interface LogisticsCarrierAdapter {
    
    /**
     * 创建运单
     */
    CreateWaybillResponse createWaybill(CreateWaybillRequest request);
    
    /**
     * 查询轨迹
     */
    List<TrackNode> queryTrack(String trackingNo);
    
    /**
     * 取消运单
     */
    boolean cancelWaybill(String trackingNo);
    
    /**
     * 获取面单
     */
    byte[] getLabel(String trackingNo, LabelFormat format);
}

适配器工厂:

@Component
public class LogisticsAdapterFactory {
    
    // 构造器注入
    // 项目启动后 所有的 LogisticsCarrierAdapter 接口的实现类都会被加载到Spring Boot容器中
    // 然后 这边注入的类型也是 LogisticsCarrierAdapter 所以Spring Boot就会自动从容器中取出相关LogisticsCarrierAdapter 这个类型的Bean
    // 然后把这些Bean 放到这个Map中 Key: Bean的名称 Value: LogisticsCarrierAdapter 的实现类
    private final Map<String, LogisticsCarrierAdapter> adapterMap;
    
    public LogisticsAdapterFactory(Map<String, LogisticsCarrierAdapter> adapterMap) {
        this.adapterMap = adapterMap;
    }
    
    public LogisticsCarrierAdapter getAdapter(String carrierCode) {
        LogisticsCarrierAdapter adapter = adapterMap.get(carrierCode);
        if (adapter == null) {
            throw new BusinessException("不支持的物流商:" + carrierCode);
        }
        return adapter;
    }
}

具体适配器示例(顺丰国际):

@Component("SF_INT")
public class SFInternationalAdapter implements LogisticsCarrierAdapter {
    
    @Autowired
    private RestTemplate restTemplate;
    
    @Override
    public CreateWaybillResponse createWaybill(CreateWaybillRequest request) {
        // 1. 构建顺丰API请求参数
        SFCreateOrderRequest sfRequest = new SFCreateOrderRequest();
        sfRequest.setOrderId(request.getOrderNo());
        sfRequest.setConsignee(request.getReceiverName());
        sfRequest.setConsigneeAddress(request.getReceiverAddress());
        sfRequest.setConsigneePhone(request.getReceiverPhone());
        sfRequest.setConsigneeCountry(request.getReceiverCountryCode());
        sfRequest.setParcelWeight(request.getWeightG());
        sfRequest.setDeclaredValue(request.getDeclareValue());
        sfRequest.setGoodsList(request.getGoodsList());
        
        // 2. 签名
        String sign = generateSign(sfRequest);
        sfRequest.setSign(sign);
        
        // 3. 调用顺丰API
        String url = "https://api.sf-express.com/v1/order/create";
        SFCreateOrderResponse sfResponse = restTemplate.postForObject(
            url, sfRequest, SFCreateOrderResponse.class);
        
        // 4. 解析响应,转换为统一格式
        if (!"SUCCESS".equals(sfResponse.getCode())) {
            throw new BusinessException("顺丰API调用失败:" + sfResponse.getMessage());
        }
        
        CreateWaybillResponse response = new CreateWaybillResponse();
        response.setTrackingNo(sfResponse.getWaybillNo());
        response.setLabelUrl(sfResponse.getLabelUrl());
        return response;
    }
    
    @Override
    public List<TrackNode> queryTrack(String trackingNo) {
        // 1. 调用顺丰轨迹查询API
        String url = "https://api.sf-express.com/v1/track/query";
        SFTrackRequest request = new SFTrackRequest();
        request.setWaybillNo(trackingNo);
        request.setSign(generateSign(request));
        
        SFTrackResponse response = restTemplate.postForObject(
            url, request, SFTrackResponse.class);
        
        // 2. 转换为统一格式
        return response.getRoutes().stream()
            .map(route -> {
                TrackNode node = new TrackNode();
                node.setTrackTime(route.getAcceptTime());
                node.setLocation(route.getAcceptAddress());
                node.setDescription(route.getRemark());
                node.setTrackCode(mapToStandardCode(route.getOpCode()));
                return node;
            })
            .collect(Collectors.toList());
    }
    
    private String mapToStandardCode(String sfOpCode) {
        // 将顺丰的操作码映射为统一的轨迹节点码
        switch (sfOpCode) {
            case "10": return "PICKUP";        // 已揽收
            case "20": return "IN_TRANSIT";    // 运输中
            case "30": return "CUSTOMS";       // 清关中
            case "40": return "OUT_FOR_DELIVERY"; // 派送中
            case "50": return "DELIVERED";     // 已签收
            case "99": return "EXCEPTION";     // 异常
            default: return "UNKNOWN";
        }
    }
}

业务层调用:

@Service
public class WaybillService {
    
    @Autowired
    private LogisticsAdapterFactory adapterFactory;
    
    public void createWaybill(Long orderId, Long channelId) {
        // 1. 查询渠道信息
        LogisticsChannel channel = channelMapper.selectById(channelId);
        LogisticsCarrier carrier = carrierMapper.selectById(channel.getCarrierId());
        
        // 2. 获取适配器
        LogisticsCarrierAdapter adapter = adapterFactory.getAdapter(carrier.getCarrierCode());
        
        // 3. 构建请求
        CreateWaybillRequest request = buildRequest(orderId);
        
        // 4. 调用统一接口
        CreateWaybillResponse response = adapter.createWaybill(request);
        
        // 5. 保存运单信息
        saveWaybill(orderId, channelId, response);
    }
}

面试时怎么讲

“物流商 API 统一适配层这个点,我会先讲为什么必须这么做。

TMS 对接的物流商不是一家,不同物流商的接口差别非常大。有的创建运单接口叫 createOrder,有的叫 createShipment;有的字段叫 trackingNo,有的叫 waybillNo;有的认证用 API Key,有的要签名,有的还要 OAuth;返回的轨迹节点格式也不一样。如果业务代码里到处写 if carrierCode 等于顺丰就这样调,等于 DHL 就那样调,代码很快会变得非常乱。

所以我把物流商对接做成了统一适配层。核心思想是:业务层只认识我们自己定义的统一接口,不直接认识任何一家物流商。这个统一接口一般包括创建运单、查询轨迹、取消运单、获取面单、批量查询轨迹这几个方法。然后每一家物流商单独写一个适配器类,比如 SFInternationalAdapter、DhlAdapter、YunTuAdapter。

每个适配器做三件事。第一,把系统内部的请求对象转换成物流商要求的请求格式,比如字段名转换、单位转换、申报信息组装、签名参数生成。第二,调用物流商真实 API,并处理超时、失败码、认证失败这些问题。第三,把物流商响应转换成系统内部统一的响应对象,比如统一返回 trackingNo、labelUrl、轨迹节点列表、错误原因。

这里可以顺便讲 Spring 的 Map 注入。适配器类可以用 @Component("SF_INT") 这样的方式注册到 Spring 容器,工厂里注入 Map<String, LogisticsCarrierAdapter>。Spring 启动时会把所有实现了 LogisticsCarrierAdapter 的 Bean 收集成一个 Map,key 是 Bean 名称,value 是适配器对象。这样业务层根据 carrierCode 就可以拿到对应适配器。

这个设计的好处是扩展性强。新增一家物流商时,只需要新增一个适配器类,实现统一接口,再把 Bean 名称配置成物流商编码,不需要改 TMS 运单创建、轨迹拉取这些主流程。业务代码稳定,变化都被限制在适配器层。

实际做的时候还要考虑一些细节。比如物流商接口账号和密钥不能明文写在代码里,要配置化并加密存储;接口调用要设置超时时间,不能无限等待;失败响应要保留原始报文,方便排查;轨迹节点要做标准化映射,否则后面异常检测和 OMS 状态更新都不好做。

面试时我会总结成一句话:这个适配层解决的是多物流商 API 差异带来的代码耦合问题,用适配器模式和 Spring 工厂模式把变化隔离起来,让 TMS 的核心业务流程不依赖具体物流商。“


物流轨迹异常检测与预警

为什么需要异常检测

物流过程中可能出现各种异常:

  • 包裹丢失:长时间没有轨迹更新
  • 清关异常:卡在海关,无法通关
  • 派送失败:地址错误,无人签收
  • 包裹退回:买家拒收,包裹退回

如果不及时发现和处理,会导致:

  • 买家投诉,差评
  • 平台扣分,影响店铺
  • 经济损失,需要赔偿

异常检测规则

规则1:超时未签收

@Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点执行
public void checkTimeout() {
    // 查询所有未签收的运单
    List<LogisticsWaybill> waybills = waybillMapper.selectList(
        new LambdaQueryWrapper<LogisticsWaybill>()
            .lt(LogisticsWaybill::getStatus, WaybillStatus.DELIVERED.getCode())
            .eq(LogisticsWaybill::getIsException, 0)
    );
    
    for (LogisticsWaybill waybill : waybills) {
        // 查询渠道的最长时效
        LogisticsChannel channel = channelMapper.selectById(waybill.getChannelId());
        int maxDays = channel.getMaxDays().intValue();
        
        // 计算已过天数
        long daysPassed = ChronoUnit.DAYS.between(
            waybill.getCreateTime().toLocalDate(), 
            LocalDate.now()
        );
        
        // 如果超过最长时效+3天,标记为异常
        if (daysPassed > maxDays + 3) {
            markException(waybill.getId(), "超时未签收");
            sendAlert(waybill, "运单超时未签收,已超过预计时效" + (daysPassed - maxDays) + "天");
        }
    }
}

规则2:轨迹滞留

@Scheduled(cron = "0 0 3 * * ?")  // 每天凌晨3点执行
public void checkStagnation() {
    // 查询所有未签收的运单
    List<LogisticsWaybill> waybills = waybillMapper.selectList(
        new LambdaQueryWrapper<LogisticsWaybill>()
            .lt(LogisticsWaybill::getStatus, WaybillStatus.DELIVERED.getCode())
            .eq(LogisticsWaybill::getIsException, 0)
    );
    
    for (LogisticsWaybill waybill : waybills) {
        // 查询最新轨迹
        LogisticsTrack latestTrack = trackMapper.selectOne(
            new LambdaQueryWrapper<LogisticsTrack>()
                .eq(LogisticsTrack::getWaybillId, waybill.getId())
                .orderByDesc(LogisticsTrack::getTrackTime)
                .last("LIMIT 1")
        );
        
        if (latestTrack == null) {
            continue;
        }
        
        // 计算距离最新轨迹的天数
        long daysSinceLastTrack = ChronoUnit.DAYS.between(
            latestTrack.getTrackTime().toLocalDate(), 
            LocalDate.now()
        );
        
        // 如果超过3天没有新轨迹,标记为滞留
        if (daysSinceLastTrack > 3) {
            markException(waybill.getId(), "轨迹滞留");
            sendAlert(waybill, "运单轨迹滞留,已" + daysSinceLastTrack + "天无更新");
        }
    }
}

规则3:异常节点检测

public void processTrack(Long waybillId, List<TrackNode> nodes) {
    for (TrackNode node : nodes) {
        // 检测异常节点
        if (isExceptionNode(node.getTrackCode())) {
            // 标记运单为异常
            markException(waybillId, node.getDescription());
            
            // 发送告警
            LogisticsWaybill waybill = waybillMapper.selectById(waybillId);
            sendAlert(waybill, "物流异常:" + node.getDescription());
        }
        
        // 保存轨迹
        saveTrack(waybillId, node);
    }
}

private boolean isExceptionNode(String trackCode) {
    // 异常节点码
    Set<String> exceptionCodes = Set.of(
        "EXCEPTION",      // 异常
        "RETURNED",       // 退回
        "LOST",           // 丢失
        "CUSTOMS_FAILED", // 清关失败
        "DELIVERY_FAILED" // 派送失败
    );
    return exceptionCodes.contains(trackCode);
}

告警通知

private void sendAlert(LogisticsWaybill waybill, String message) {
    // 1. 发送站内信
    messageService.send(
        waybill.getTenantId(),
        "物流异常告警",
        message + "\n运单号:" + waybill.getWaybillNo() + "\n订单号:" + waybill.getOrderNo()
    );
    
    // 2. 发送邮件
    emailService.send(
        getLogisticsManagerEmail(waybill.getTenantId()),
        "物流异常告警",
        message
    );
    
    // 3. 记录告警日志
    logAlertRecord(waybill.getId(), message);
}

面试时怎么讲

“跨境物流周期比较长,包裹可能在国内揽收、国际运输、清关、末端派送等环节出现问题。如果系统只是展示轨迹,不主动识别异常,运营人员只能等买家投诉或者人工巡查,这样处理会很被动。

所以我在 TMS 里把物流异常分成了几类规则来处理。

第一类是异常节点检测。物流商返回轨迹时,有些节点本身就代表异常,比如清关失败、派送失败、包裹退回、疑似丢件。这种情况不需要等定时任务再判断,拉取轨迹时就可以直接识别节点码,把运单标记为异常。

第二类是超时未签收。每个物流渠道都会配置一个预计时效,比如 7 到 12 天。系统每天扫描未签收运单,如果创建时间已经超过最长时效,再加上一个缓冲期,比如 3 天,还没有签收,就标记为超时未签收。这个规则适合发现那些没有明显异常节点,但是已经明显超期的包裹。

第三类是轨迹滞留。比如一个包裹卡在某个中转仓或海关,连续三天没有新轨迹,即使还没超过总时效,也应该提醒运营关注。这个规则主要看最新轨迹时间和当前时间的间隔。

检测出异常以后,系统不会只改一个状态,而是要做后续动作。比如发送站内信或邮件给物流专员,写入异常告警日志,在运单列表上打异常标识。如果是严重异常,还可以进入人工处理队列,运营人员联系物流商或通知买家。

技术实现上,我会把异常检测规则做成独立方法,而不是散落在轨迹写入代码里。轨迹拉取任务负责获取和标准化轨迹,异常检测模块负责判断是否异常,通知模块负责发送消息。这样规则增加时比较好维护,比如后面要新增”清关超过 5 天未更新”这样的规则,只需要加一个检测规则。

如果面试官追问为什么不用实时回调,我会说有些物流商支持 Webhook,可以实时推送轨迹;但很多物流商不支持,或者推送不稳定,所以系统仍然需要定时拉取作为兜底。真实项目里通常是 Webhook + 定时任务补偿,两套机制一起保证轨迹完整。“


XXL-Job 定时任务与分片拉取

为什么需要定时任务

TMS系统有很多需要定期执行的任务:

  • 拉取物流轨迹:每小时拉取一次
  • 检测超时运单:每天检查一次
  • 检测轨迹滞留:每天检查一次
  • 同步物流费率:每周同步一次

如果用手动触发,效率低,容易遗漏。用定时任务自动执行,可靠性高。

定时任务列表

任务1:拉取物流轨迹

@XxlJob("pullLogisticsTrack")
public void pullTrack() {
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();
    Long lastId = 0L;
    int pageSize = 500;
    
    while (true) {
        List<LogisticsWaybill> page = waybillMapper.selectTrackPullPage(
            shardTotal,
            shardIndex,
            lastId,
            pageSize
        );
        
        if (page.isEmpty()) {
            break;
        }
        
        Map<String, List<LogisticsWaybill>> groupByCarrier = page.stream()
            .collect(Collectors.groupingBy(LogisticsWaybill::getCarrierCode));

        List<CompletableFuture<Void>> futures = new ArrayList<>();
        for (Map.Entry<String, List<LogisticsWaybill>> entry : groupByCarrier.entrySet()) {
            String carrierCode = entry.getKey();
            List<LogisticsWaybill> carrierWaybills = entry.getValue();

            CompletableFuture<Void> future = CompletableFuture.runAsync(
                () -> pullCarrierTrack(carrierCode, carrierWaybills),
                trackDispatchExecutor
            );
            futures.add(future);
        }

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
        lastId = page.get(page.size() - 1).getId();
    }
}

private void pullCarrierTrack(String carrierCode, List<LogisticsWaybill> waybills) {
    int batchSize = carrierConfigService.getTrackBatchSize(carrierCode);
    List<List<LogisticsWaybill>> batches = Lists.partition(waybills, batchSize);
    List<CompletableFuture<Void>> futures = new ArrayList<>();
    
    for (List<LogisticsWaybill> batch : batches) {
        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            carrierInvokeGuard.execute(carrierCode, () -> {
                pullTrackBatch(carrierCode, batch);
                return null;
            });
        }, trackBatchDispatchExecutor);
        futures.add(future);
    }

    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}

private void pullTrackBatch(String carrierCode, List<LogisticsWaybill> waybills) {
    LogisticsCarrierAdapter adapter = adapterFactory.getAdapter(carrierCode);
    
    List<String> trackingNos = waybills.stream()
        .map(LogisticsWaybill::getTrackingNo)
        .collect(Collectors.toList());
    
    Map<String, List<TrackNode>> trackMap = adapter.batchQueryTrack(trackingNos);
    
    for (LogisticsWaybill waybill : waybills) {
        List<TrackNode> nodes = trackMap.get(waybill.getTrackingNo());
        if (nodes != null && !nodes.isEmpty()) {
            processTrack(waybill.getId(), nodes);
        }
    }
}

对应的分页查询可以用游标分页,避免一次把一个分片的所有未完成运单查出来:

SELECT id, tenant_id, carrier_code, tracking_no, status
FROM logistics_waybill
WHERE status < #{deliveredStatus}
  AND tracking_no IS NOT NULL
  AND id > #{lastId}
  AND MOD(id, #{shardTotal}) = #{shardIndex}
ORDER BY id
LIMIT #{pageSize};

任务2:检测超时运单

@XxlJob("checkTimeoutWaybill")
public void checkTimeout() {
    // 查询所有未签收的运单
    List<LogisticsWaybill> waybills = waybillMapper.selectList(
        new LambdaQueryWrapper<LogisticsWaybill>()
            .lt(LogisticsWaybill::getStatus, WaybillStatus.DELIVERED.getCode())
            .eq(LogisticsWaybill::getIsException, 0)
    );
    
    for (LogisticsWaybill waybill : waybills) {
        // 查询渠道的最长时效
        LogisticsChannel channel = channelMapper.selectById(waybill.getChannelId());
        int maxDays = channel.getMaxDays().intValue();
        
        // 计算已过天数
        long daysPassed = ChronoUnit.DAYS.between(
            waybill.getCreateTime().toLocalDate(), 
            LocalDate.now()
        );
        
        // 如果超过最长时效+3天,标记为异常
        if (daysPassed > maxDays + 3) {
            markException(waybill.getId(), "超时未签收");
            sendAlert(waybill, "运单超时未签收,已超过预计时效" + (daysPassed - maxDays) + "天");
        }
    }
}

任务3:检测轨迹滞留

@XxlJob("checkStagnation")
public void checkStagnation() {
    // 查询所有未签收的运单
    List<LogisticsWaybill> waybills = waybillMapper.selectList(
        new LambdaQueryWrapper<LogisticsWaybill>()
            .lt(LogisticsWaybill::getStatus, WaybillStatus.DELIVERED.getCode())
            .eq(LogisticsWaybill::getIsException, 0)
    );
    
    for (LogisticsWaybill waybill : waybills) {
        // 查询最新轨迹
        LogisticsTrack latestTrack = trackMapper.selectOne(
            new LambdaQueryWrapper<LogisticsTrack>()
                .eq(LogisticsTrack::getWaybillId, waybill.getId())
                .orderByDesc(LogisticsTrack::getTrackTime)
                .last("LIMIT 1")
        );
        
        if (latestTrack == null) {
            continue;
        }
        
        // 计算距离最新轨迹的天数
        long daysSinceLastTrack = ChronoUnit.DAYS.between(
            latestTrack.getTrackTime().toLocalDate(), 
            LocalDate.now()
        );
        
        // 如果超过3天没有新轨迹,标记为滞留
        if (daysSinceLastTrack > 3) {
            markException(waybill.getId(), "轨迹滞留");
            sendAlert(waybill, "运单轨迹滞留,已" + daysSinceLastTrack + "天无更新");
        }
    }
}

任务幂等性保证

// 拉取轨迹时,去重避免重复写入
private void saveTrack(Long waybillId, TrackNode node) {
    // 查询是否已存在相同的轨迹节点
    Long count = trackMapper.selectCount(
        new LambdaQueryWrapper<LogisticsTrack>()
            .eq(LogisticsTrack::getWaybillId, waybillId)
            .eq(LogisticsTrack::getTrackTime, node.getTrackTime())
            .eq(LogisticsTrack::getLocation, node.getLocation())
    );
    
    if (count > 0) {
        // 已存在,跳过
        return;
    }
    
    // 插入新轨迹
    LogisticsTrack track = new LogisticsTrack();
    track.setWaybillId(waybillId);
    track.setTrackCode(node.getTrackCode());
    track.setLocation(node.getLocation());
    track.setDescription(node.getDescription());
    track.setTrackTime(node.getTrackTime());
    track.setIsException(node.getIsException());
    trackMapper.insert(track);
}

面试时怎么讲

“TMS 里有很多动作不是用户点按钮触发的,而是系统需要定期自动执行,比如物流轨迹拉取、超时未签收检测、轨迹滞留检测、物流费率同步、失败消息补偿等。这些任务如果靠人工执行,一定会漏,而且数据量大了以后单机任务也跑不动。

所以我们用 XXL-Job 来统一管理定时任务。它的好处是可以在控制台配置执行周期,可以看到任务执行日志,可以失败重试,也支持分片广播。对 TMS 来说,最典型的任务就是拉取物流轨迹。

轨迹拉取任务的流程是这样的。第一步,任务启动后先拿到分片参数,比如当前是第几个分片,总共有几个分片。第二步,按运单 ID 取模查询当前分片的数据,同时必须做分页,不能一次把整个分片的数据都查出来。比如每次查 500 条,处理完以后用 lastId 继续查下一页,这样即使租户很多、订单量很大,也不会把内存打爆。

第三步,把当前页数据按物流商分组。分组后不是简单串行执行,而是按照物流商路由到各自的线程池。比如 DHL、UPS、云途、燕文可以同时拉取轨迹;同一家物流商内部再按照它的 API 限制拆成小批次,比如一次 50 条或 100 条。多个批次可以并发执行,但并发数必须受物流商配置控制,线程池的线程数不能随便配,要小于物流商允许的并发或 QPS,否则并发越高越容易被限流。

这里有几个关键问题要讲清楚。

  1. 第一是分页,因为只分片不分页,单个分片仍然可能有几十万条未完成运单。
  2. 第二是线程池隔离,不同物流商并行处理,某一家物流商慢不会拖慢其他物流商。
  3. 第三是限流,同一家物流商内部按批次和并发限制调用。
  4. 第四是幂等,因为定时任务可能失败重跑,也可能多实例并发执行,所以轨迹写入必须去重,不能重复插入。
  5. 第五是失败处理,某个物流商调用失败不能影响其他物流商,应该记录失败日志,下次任务继续补偿。

如果面试官问为什么不用 Spring 自带的 @Scheduled,我会说 @Scheduled 适合单机简单任务,但是在微服务多实例部署时,容易出现重复执行,也缺少可视化调度、分片、失败重试和执行日志。XXL-Job 更适合这种需要运维管理和分布式执行的后台任务。

这个亮点最后可以这样收尾:我通过 XXL-Job 分片解决多实例并行,通过游标分页解决单分片数据量过大,通过物流商线程池解决多物流商并发,通过批次大小和限流器控制单物流商调用频率,最后通过轨迹幂等和失败补偿保证任务重复执行也不会造成数据错误。“


运单创建幂等与物流状态机

TMS 最容易出事故的地方,不是页面上点一下”创建运单”,而是这个动作后面会调用外部物流商 API。

比如用户点了一次创建运单,但是网络超时了。前端不知道到底成功还是失败,于是用户又点了一次。如果系统没有幂等控制,就可能出现两张面单、两个物流商运单号、两笔物流费用,仓库贴错面单后还会影响 OMS 和财务对账。

所以 TMS 的运单创建必须解决两个问题:

  • 幂等性:同一个订单包裹只能创建一张有效运单。
  • 状态机:运单只能按照业务允许的顺序流转,不能从待揽收直接跳到已签收。

运单状态设计

状态含义常见触发动作
待创建TMS 已接收发货指令,还没有调用物流商OMS 或 WMS 推送发货任务
创建中已创建本地运单,正在调用物流商或等待确认调用物流商创建接口
已下单物流商 API 创建成功,已获得运单号创建运单成功
已打印面单已生成并打印仓库打印面单
已揽收物流商已扫描揽收物流商轨迹回传
运输中包裹在运输或清关中轨迹同步
派送中包裹进入末端派送轨迹同步
已签收买家签收轨迹同步
异常丢件、退回、超时、清关失败等异常检测
已取消运单取消成功人工取消或物流商取消接口

状态机代码示例

public enum WaybillStatus {
    WAIT_CREATE(0, "待创建"),
    CREATING(1, "创建中"),
    CREATED(2, "已下单"),
    PRINTED(3, "已打印"),
    PICKED_UP(4, "已揽收"),
    IN_TRANSIT(5, "运输中"),
    DELIVERING(6, "派送中"),
    DELIVERED(7, "已签收"),
    EXCEPTION(8, "异常"),
    CANCELED(9, "已取消");

    private final int code;
    private final String desc;

    WaybillStatus(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public int getCode() {
        return code;
    }

    public String getDesc() {
        return desc;
    }
}
@Service
public class WaybillStatusService {
	// 状态机的实现
	// 把允许跳转的初始状态 设置为Map的Key
	// 把当前状态能跳转到的状态集合 设置为Map的Value
    private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = Map.of(
        WaybillStatus.WAIT_CREATE.getCode(),
        Set.of(WaybillStatus.CREATING.getCode(), WaybillStatus.CANCELED.getCode()),
        WaybillStatus.CREATING.getCode(),
        Set.of(WaybillStatus.CREATED.getCode(), WaybillStatus.EXCEPTION.getCode(), WaybillStatus.CANCELED.getCode()),
        WaybillStatus.CREATED.getCode(),
        Set.of(WaybillStatus.PRINTED.getCode(), WaybillStatus.PICKED_UP.getCode(), WaybillStatus.CANCELED.getCode()),
        WaybillStatus.PRINTED.getCode(),
        Set.of(WaybillStatus.PICKED_UP.getCode(), WaybillStatus.CANCELED.getCode()),
        WaybillStatus.PICKED_UP.getCode(),
        Set.of(WaybillStatus.IN_TRANSIT.getCode(), WaybillStatus.EXCEPTION.getCode()),
        WaybillStatus.IN_TRANSIT.getCode(),
        Set.of(WaybillStatus.DELIVERING.getCode(), WaybillStatus.DELIVERED.getCode(), WaybillStatus.EXCEPTION.getCode()),
        WaybillStatus.DELIVERING.getCode(),
        Set.of(WaybillStatus.DELIVERED.getCode(), WaybillStatus.EXCEPTION.getCode()),
        WaybillStatus.EXCEPTION.getCode(),
        Set.of(WaybillStatus.IN_TRANSIT.getCode(), WaybillStatus.CANCELED.getCode())
    );

    private final LogisticsWaybillMapper waybillMapper;

    public WaybillStatusService(LogisticsWaybillMapper waybillMapper) {
        this.waybillMapper = waybillMapper;
    }

    @Transactional(rollbackFor = Exception.class)
    public void changeStatus(Long tenantId, Long waybillId, Integer targetStatus) {
        LogisticsWaybill waybill = waybillMapper.selectById(waybillId);
        if (waybill == null || !tenantId.equals(waybill.getTenantId())) {
            throw new BusinessException("运单不存在");
        }

        Integer currentStatus = waybill.getStatus();
        Set<Integer> allowedTargets = ALLOWED_TRANSITIONS.getOrDefault(currentStatus, Set.of());
        if (!allowedTargets.contains(targetStatus)) {
            throw new BusinessException("运单状态不允许从 " + currentStatus + " 流转到 " + targetStatus);
        }

        int rows = waybillMapper.updateStatusByVersion(
            tenantId,
            waybillId,
            currentStatus,
            targetStatus,
            waybill.getVersion()
        );
        if (rows == 0) {
            throw new BusinessException("运单状态已变化,请刷新后重试");
        }
    }
}

创建运单幂等设计

运单幂等要分成两层讲:TMS 端幂等物流商端幂等

TMS 端幂等解决的是我们自己的系统不要重复调用物流商。常见场景是 OMS 重复推送发货消息、MQ 重复消费、多个操作员同时处理同一个订单,或者 TMS 集群中多个实例同时拿到同一个任务。

物流商端幂等解决的是请求已经发给物流商以后,TMS 没有收到响应,重试时物流商会不会重复创建运单。这个能力取决于物流商是否支持幂等请求号、客户订单号去重、或者按客户订单号查询运单,不能假设所有物流商都支持。

TMS 端幂等:

ALTER TABLE logistics_waybill
ADD UNIQUE KEY uk_tenant_order_package (tenant_id, order_no, package_no);
@Service
public class WaybillCreateService {

    private final LogisticsWaybillMapper waybillMapper;
    private final LogisticsCarrierAdapterFactory adapterFactory;
    private final RedisLockClient redisLockClient;

    public WaybillCreateService(LogisticsWaybillMapper waybillMapper,
                                LogisticsCarrierAdapterFactory adapterFactory,
                                RedisLockClient redisLockClient) {
        this.waybillMapper = waybillMapper;
        this.adapterFactory = adapterFactory;
        this.redisLockClient = redisLockClient;
    }

    @Transactional(rollbackFor = Exception.class)
    public CreateWaybillResult create(CreateWaybillCommand command) {
        String idempotentKey = command.getTenantId() + ":" + command.getOrderNo() + ":" + command.getPackageNo();
        String lockKey = "lock:tms:create-waybill:" + idempotentKey;

        boolean locked = redisLockClient.tryLock(lockKey, 10);
        if (!locked) {
            throw new BusinessException("运单正在创建中,请稍后刷新");
        }

        try {
            LogisticsWaybill existing = waybillMapper.selectByTenantOrderPackage(
                command.getTenantId(),
                command.getOrderNo(),
                command.getPackageNo()
            );
            if (existing != null) {
                return CreateWaybillResult.from(existing);
            }

            LogisticsWaybill creatingWaybill = createLocalWaybill(command, WaybillStatus.CREATING);
            LogisticsCarrierAdapter adapter = adapterFactory.getAdapter(command.getCarrierCode());
            CarrierCreateWaybillResponse response = adapter.createWaybill(command.toCarrierRequest());
            waybillMapper.markCreated(
                creatingWaybill.getId(),
                response.getTrackingNo(),
                response.getLabelUrl()
            );
            return CreateWaybillResult.from(response);
        } finally {
            redisLockClient.unlock(lockKey);
        }
    }
}

物流商端幂等:

public CarrierCreateWaybillResponse createWithCarrierIdempotent(CreateWaybillCommand command) {
    LogisticsCarrierAdapter adapter = adapterFactory.getAdapter(command.getCarrierCode());
    String requestNo = command.getTenantId() + "-" + command.getOrderNo() + "-" + command.getPackageNo();

    try {
        CarrierCreateWaybillRequest request = command.toCarrierRequest();
        request.setCustomerOrderNo(requestNo);
        request.setIdempotentNo(requestNo);
        return adapter.createWaybill(request);
    } catch (CarrierTimeoutException ex) {
        if (adapter.supportQueryByCustomerOrderNo()) {
            Optional<CarrierCreateWaybillResponse> existed = adapter.queryByCustomerOrderNo(requestNo);
            if (existed.isPresent()) {
                return existed.get();
            }
        }
        throw new BusinessException("物流商创建结果待确认,请稍后补偿处理");
    }
}

物流商端幂等要按能力分级:

  • 如果物流商支持幂等号,创建请求里传 idempotentNo,重复请求返回同一张运单。
  • 如果物流商不支持幂等号,但支持按客户订单号查询,超时后先查订单号是否已有运单。
  • 如果两者都不支持,不要盲目重试创建,把本地运单标记为”创建结果待确认”,通过补偿任务或人工处理确认。

面试时怎么讲

“运单幂等和状态机这块,我会把它作为 TMS 里最容易体现工程经验的点来讲。因为创建运单不是单纯插入数据库,它会调用外部物流商 API,一旦出现重复消费、并发操作、接口超时或系统重试,就可能重复创建运单。如果重复创建了,就会出现两张面单、两个 trackingNo、两笔物流费用,仓库也可能贴错面单,后面 OMS 状态和财务对账都会乱。

我会把幂等分成两部分。第一部分是 TMS 端幂等,解决我们自己的系统不要重复调用物流商。比如 OMS 重复发消息、MQ 重复消费、两个操作员同时处理同一个订单,或者 TMS 集群多个实例同时执行同一任务。这种情况用租户 ID、订单号、包裹号组成业务唯一键,先查本地运单表;如果不存在,再加 Redis 分布式锁;最后用数据库唯一索引兜底。这样同一个订单包裹在 TMS 里只能有一张有效运单。

第二部分是物流商端幂等,解决请求发到物流商以后,TMS 没收到响应时该怎么办。这个能力不能想当然,因为不同物流商能力不一样。如果物流商支持幂等号,我会把 TMS 的业务单号作为 idempotentNo 传过去,重复请求应该返回同一张运单。如果物流商支持按客户订单号查询,那么创建接口超时以后,先调用查询接口确认是否已经创建成功。如果物流商既不支持幂等号,也不支持查询,就不能立即无脑重试,而是把本地运单标记为”创建结果待确认”,交给补偿任务或人工处理。

状态机解决的是另一个问题:运单状态不能乱跳。比如正常状态应该是待创建、已下单、已打印、已揽收、运输中、派送中、已签收。如果前端传一个”已签收”,后端不能直接相信,否则就可能绕过真实物流轨迹。我的做法是在后端维护状态允许流转表,每次更新状态时先查当前状态,再判断目标状态是否允许。同时用版本号做乐观锁,防止定时任务、物流商回调、人工操作同时更新同一张运单导致覆盖。

这个亮点面试时可以总结为:幂等保证同一个订单包裹不会重复创建运单,状态机保证运单只能按照业务流程正确流转。一个解决重复提交问题,一个解决状态安全问题。两者结合起来,才能保证 TMS 运单数据可信。“


物流商 API 调用治理:限流、熔断与降级

为什么要单独做调用治理

物流商 API 是 TMS 里最典型的外部依赖。它不只出现在轨迹拉取里,创建运单、取消运单、获取面单、查询轨迹、同步账单都可能调用物流商接口。

轨迹拉取任务里按物流商分组、分页、线程池并发,是批量任务场景下的具体实现。但在更通用的调用层面,TMS 还需要统一处理限流、超时、熔断和降级。否则高峰期大量创建运单请求同时进来,照样可能把某个物流商接口打到限流。

这一层可以理解成所有物流商 API 调用的统一保护层,创建运单和轨迹拉取都应该经过它。

调用治理策略

  • 批量调用:物流商支持批量查询时,一次传 50 到 100 个运单号。
  • 单物流商限流:每个物流商维护自己的 QPS、并发数、批量大小配置。
  • 线程池隔离:A 物流商接口慢,不能拖垮 B 物流商;创建运单和轨迹拉取也可以配置不同线程池。
  • 超时控制:创建运单、查询轨迹、获取面单分别设置不同超时时间。
  • 熔断降级:连续失败时短时间不再调用,等待下次定时任务触发。
  • 指数退避重试:不能失败后立刻疯狂重试,否则更容易触发物流商风控。
  • 失败队列:参数错误进入人工处理,临时失败进入重试或补偿任务。

调用保护流程

flowchart TD
    A[准备调用物流商API] --> B{是否超过单物流商限流}
    B -->|是| C[延迟排队或下次任务重试]
    B -->|否| D{熔断器是否打开}
    D -->|是| E[进入人工处理队列]
    D -->|否| F[进入该物流商线程池]
    F --> G[调用物流商API]
    G --> H{调用成功}
    H -->|是| I[记录成功和响应数据]
    H -->|否| J[按错误类型决定是否重试]
    J --> K[记录失败日志和告警]

代码示例

@Service
public class CarrierInvokeGuard {

    private final Map<String, Semaphore> carrierLimiters = new ConcurrentHashMap<>();
    private final Map<String, ExecutorService> carrierExecutors = new ConcurrentHashMap<>();

    public CarrierInvokeGuard(List<LogisticsCarrierConfig> carrierConfigs) {
        for (LogisticsCarrierConfig config : carrierConfigs) {
            carrierLimiters.put(config.getCarrierCode(), new Semaphore(config.getMaxConcurrent()));
            carrierExecutors.put(
                config.getCarrierCode(),
                new ThreadPoolExecutor(
                    config.getCoreThreads(),
                    config.getMaxThreads(),
                    60,
                    TimeUnit.SECONDS,
                    new ArrayBlockingQueue<>(config.getQueueSize()),
                    new ThreadPoolExecutor.CallerRunsPolicy()
                )
            );
        }
    }

    public <T> T execute(String carrierCode, Callable<T> action) {
        Semaphore limiter = carrierLimiters.get(carrierCode);
        if (limiter == null) {
            throw new BusinessException("物流商限流配置不存在");
        }

        boolean acquired = false;
        try {
            acquired = limiter.tryAcquire(2, TimeUnit.SECONDS);
            if (!acquired) {
                throw new BusinessException("物流商接口繁忙,请稍后重试");
            }
            Future<T> future = carrierExecutors.get(carrierCode).submit(action);
            return future.get(10, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            throw new BusinessException("物流商接口超时");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("物流商调用被中断");
        } catch (ExecutionException e) {
            throw new BusinessException("物流商接口调用失败");
        } finally {
            if (acquired) {
                limiter.release();
            }
        }
    }
}

面试时怎么讲

“物流商 API 调用治理这个点,我会把它讲成一个通用能力,而不是只服务于轨迹拉取。TMS 里创建运单、批量拉轨迹、取消运单、获取面单都会调用物流商接口。物流商接口不是我们自己的服务,有 QPS 限制,也可能超时、限流、返回异常,所以不能在业务代码里直接裸调。

我的做法是在物流商适配器外面再包一层调用保护。所有物流商 API 调用都先进入这一层,由它根据 carrierCode 找到对应物流商的配置,比如最大并发数、QPS、超时时间、批量大小、失败重试次数。这样业务代码只负责创建运单或拉取轨迹,不直接关心每家物流商的限流细节。

在批量拉轨迹的场景里,先按物流商分组,再把不同物流商的任务放到各自线程池里执行。同一个物流商内部再按它的批量上限拆批,比如一次最多 100 个 trackingNo。在线创建运单的场景里,也同样要进入这个调用保护层。如果某个物流商每秒只允许 20 次创建请求,我们就不能让 200 个并发请求直接打过去,而是要排队、限流或提示稍后重试。

如果物流商接口连续超时或大量失败,就触发熔断。熔断以后,轨迹拉取任务可以跳过这家物流商,等待下一轮任务;创建运单场景可以提示物流渠道暂不可用,或者让规则引擎切换备用渠道。这样做的目标不是让所有请求都立即成功,而是避免某一家物流商异常把整个 TMS 拖垮。

失败处理也要分类。参数错误,比如地址缺少邮编、申报信息不完整,重试没有意义,要进入人工处理。网络超时、物流商 500、临时限流,可以进入重试,但要用指数退避,不能立刻连续打。创建运单超时时还要结合前面讲的物流商端幂等,不能盲目重复创建。

面试官如果问”你们怎么知道物流商接口异常”,可以讲我们会记录每家物流商的成功率、平均耗时、超时次数、失败原因,并且在管理后台展示。如果某家物流商异常率升高,运营人员可以临时停用渠道,规则引擎推荐时就不再选择这条渠道。

这个亮点最后可以这样收尾:物流商调用治理是一层通用保护能力,轨迹拉取用它控制批量调用,创建运单用它控制高并发请求。通过限流、线程池隔离、超时、熔断、失败分类和重试补偿,保证单个物流商异常不会影响整个 TMS。“


RocketMQ 可靠消息与模块解耦

TMS 为什么需要消息队列

TMS 不是孤立系统,它和 OMS、WMS、FMS 都有交互:

  • WMS 出库完成后,通知 TMS 创建物流任务。
  • TMS 创建运单成功后,通知 OMS 更新发货信息。
  • TMS 拉到签收轨迹后,通知 OMS 更新订单签收状态。
  • TMS 费用对账完成后,通知 FMS 生成物流应付账款。

这些动作如果都用同步接口串起来,用户一次操作就会被多个系统阻塞。物流商 API 又是外部系统,稳定性不可控,所以更适合用 MQ 做异步解耦。

模块交互流程

flowchart LR
    WMS[WMS出库完成] -.发送消息.-> TMS[TMS创建运单任务]
    TMS -.运单创建成功.-> OMS[OMS更新发货信息]
    TMS -.轨迹签收消息.-> OMS2[OMS更新签收状态]
    TMS -.费用确认消息.-> FMS[FMS生成应付账款]

可靠消息落地方案

本地消息表不是 MQ 的替代品,而是用来解决”业务数据库提交成功,但 MQ 消息可能发送失败”的问题。

比如 TMS 创建运单成功以后,要通知 OMS 更新发货状态。如果直接在业务代码里先更新运单表,再发送 MQ,就可能出现两种异常:

  • 运单表提交成功,但发送 MQ 失败,OMS 永远不知道订单已经发货。
  • MQ 已经发出去了,但本地事务回滚,OMS 收到了一条并不存在的发货消息。

本地消息表的做法是:把业务数据和待发送消息放在同一个本地数据库事务里提交。只要运单创建成功,本地消息表里就一定会有一条待发送消息。后面由定时任务或消息发送器扫描本地消息表,把消息投递到 RocketMQ。

CREATE TABLE tms_event_outbox (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    event_type VARCHAR(64) NOT NULL,
    biz_id BIGINT NOT NULL,
    biz_no VARCHAR(64) NOT NULL,
    payload JSON NOT NULL,
    status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2发送失败',
    retry_count INT NOT NULL DEFAULT 0,
    next_retry_time DATETIME NOT NULL,
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    UNIQUE KEY uk_event_biz (tenant_id, event_type, biz_id),
    KEY idx_status_retry_time (status, next_retry_time)
);

本地消息表常见状态:

  • 0 待发送:业务事务提交成功,消息还没有投递到 MQ。
  • 1 已发送:消息已经成功投递到 MQ。
  • 2 发送失败:消息发送失败,但还可以重试。
  • 3 终止发送:超过最大重试次数,进入人工处理。

状态流转过程:

  • 创建运单成功时,在同一个事务里写 logistics_waybilltms_event_outbox,消息状态为 待发送
  • 消息发送任务扫描 待发送 和到达重试时间的 发送失败 消息。
  • 投递 RocketMQ 成功后,把状态改为 已发送
  • 投递失败后,增加 retry_count,计算下一次 next_retry_time,状态改为 发送失败
  • 如果超过最大重试次数,状态改为 终止发送,同时告警给运维或业务人员。

这里的定时任务起到补偿作用。它不是业务主流程的一部分,而是不断扫描本地消息表,把没有成功发出去的消息继续发送,直到成功或进入人工处理。

需要注意,本地消息表只能保证消息”一定会被发送出去”,不能保证消费者只消费一次。RocketMQ 本身可能重复投递,所以消费者还要做幂等。

本地消息表解决的是生产端可靠投递,消费日志表解决的是消费端幂等处理。两个配合起来,才能实现最终一致。

消费端幂等

MQ 至少一次投递时,消费者可能重复收到消息。OMS 或 FMS 消费 TMS 消息时,要用业务唯一键做幂等。

CREATE TABLE mq_consume_log (
    id BIGINT PRIMARY KEY,
    consumer_group VARCHAR(64) NOT NULL,
    message_key VARCHAR(128) NOT NULL,
    consume_time DATETIME NOT NULL,
    UNIQUE KEY uk_group_message (consumer_group, message_key)
);

面试时怎么讲

“TMS 不是孤立模块,它处在 OMS、WMS、FMS 中间。WMS 出库完成后要通知 TMS 发货;TMS 创建运单成功后要通知 OMS 回写 trackingNo;TMS 拉到签收轨迹后要通知 OMS 更新签收状态;物流费用确认后还要通知 FMS 生成应付账款。

这些交互如果全部做同步调用,会让链路变得很长。比如创建运单成功以后,如果同步调用 OMS 失败,难道要把已经在物流商创建成功的运单回滚吗?这其实很难,因为外部物流商不一定支持可靠回滚。更合理的方式是 TMS 先保证自己的运单创建成功,然后通过消息通知下游系统,失败后再补偿。

所以这里我采用最终一致的思路。比如运单创建成功后,在同一个本地事务里同时写运单表和本地消息表。消息表里记录事件类型、业务 ID、消息内容、发送状态、重试次数、下次重试时间。事务提交以后,再由定时任务或消息发送器把待发送消息投递到 RocketMQ。这样即使 RocketMQ 当时不可用,消息也已经存在数据库里,后续可以继续重试。

本地消息表的状态一般有待发送、已发送、发送失败、终止发送。刚插入时是待发送;发送 MQ 成功后改为已发送;发送失败后增加重试次数,并设置下一次重试时间;超过最大重试次数后改成终止发送并告警。定时任务的作用就是不断扫描待发送和到达重试时间的失败消息,保证消息最终能发出去,或者至少能被发现并人工处理。

消费端也必须做幂等。RocketMQ 是至少一次投递,消息可能重复消费。OMS 收到 TMS 的发货消息时,不能重复更新或重复产生业务记录。可以用 messageKey 或者 tenantId + eventType + bizId 记录消费日志,已经处理过的消息直接跳过。

如果面试官追问为什么不用分布式事务,我会说 TMS 这类场景更适合最终一致。因为物流商已经创建成功的运单很难和 OMS、FMS 做强一致回滚。我们要保证的是主流程可靠落库,消息可靠投递,下游失败可重试可补偿。这个比强行使用分布式事务更符合物流业务。“


Redis 缓存与更新策略

TMS 哪些数据适合缓存

TMS 有几类数据读多写少,非常适合缓存:

缓存对象缓存原因推荐 Key
物流渠道配置创建运单时频繁读取tms:channel:{tenantId}:{channelCode}
物流费率配置计算预估运费时频繁读取tms:rate:{tenantId}:{channelCode}:{countryCode}
禁运规则渠道过滤时频繁读取tms:forbid-rule:{tenantId}:{countryCode}
物流商配置调用 API 时需要认证信息tms:carrier-config:{tenantId}:{carrierCode}
热门运单轨迹运营或客服频繁查询tms:track:{tenantId}:{trackingNo}

这些数据不能永久缓存。渠道、费率、禁运规则一旦被运营人员修改,需要立刻删除对应缓存;轨迹缓存可以设置短 TTL,比如 5 到 10 分钟。

缓存更新策略

@Service
public class LogisticsRateService {

    private final LogisticsRateMapper rateMapper;
    private final RedisTemplate<String, LogisticsRate> redisTemplate;

    public LogisticsRateService(LogisticsRateMapper rateMapper,
                                RedisTemplate<String, LogisticsRate> redisTemplate) {
        this.rateMapper = rateMapper;
        this.redisTemplate = redisTemplate;
    }

    public LogisticsRate getRate(Long tenantId, Long channelId, String countryCode, Integer weightG) {
        String key = "tms:rate:" + tenantId + ":" + channelId + ":" + countryCode + ":" + weightG / 100;
        LogisticsRate cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }

        LogisticsRate rate = rateMapper.selectMatchedRate(tenantId, channelId, countryCode, weightG);
        if (rate == null) {
            throw new BusinessException("物流费率不存在");
        }
        redisTemplate.opsForValue().set(key, rate, Duration.ofMinutes(30));
        return rate;
    }

    @Transactional(rollbackFor = Exception.class)
    public void updateRate(LogisticsRate rate) {
        rateMapper.updateById(rate);
        String pattern = "tms:rate:" + rate.getTenantId() + ":" + rate.getChannelId() + ":" + rate.getCountryCode() + ":*";
        redisTemplate.delete(redisTemplate.keys(pattern));
    }
}

面试时怎么讲

“Redis 缓存这个点,我会先说明 TMS 里不是所有数据都适合缓存,适合缓存的是读多写少、变更频率低、查询频率高的数据。比如物流渠道配置、物流费率、禁运规则、物流商账号配置,这些数据在创建运单和规则推荐时会频繁读取,但运营人员不会每分钟都改。

以智能渠道推荐为例,如果每次创建运单都查渠道表、费率表、禁运规则表、物流商配置表,订单量上来以后数据库压力会比较大。尤其是批量发货时,一个租户可能一次提交几百个包裹,如果每个包裹都重复查同样的渠道和费率,性能会浪费。所以我会把渠道、费率、禁运规则放到 Redis。规则引擎先从缓存加载配置,缓存没有再查数据库。

轨迹查询也可以缓存。比如运营或客服频繁刷新某一个 trackingNo,实际轨迹不可能每秒变化。我们可以把轨迹列表缓存 5 到 10 分钟,减少对轨迹表的查询压力。这里要注意,轨迹缓存不能设置太长,因为客服希望看到相对新的物流状态。如果拉取到签收、异常这种关键节点,可以主动删除对应缓存。

缓存一致性上,我会采用更新数据库后删除缓存。比如运营修改了某条物流费率,先更新数据库,再删除对应 Redis Key。下次创建运单时缓存没有命中,会重新从数据库加载最新配置。这里不建议简单地更新缓存,因为费率可能涉及多个重量段和国家,删除缓存更简单,也更不容易产生脏数据。

如果面试官问 Redis 可能有什么问题,可以讲缓存穿透、击穿、雪崩。比如查询一个不存在的渠道,可以缓存空值防止穿透;热门配置过期时可以加互斥锁防止大量请求同时查数据库;大量 Key 过期时间不要完全一样,避免同一时间集中失效。

这个亮点可以总结为:Redis 在 TMS 里主要用于缓存物流规则和热点轨迹,目标是减少重复配置查询和高频轨迹查询,同时通过更新后删除缓存、短 TTL、空值缓存和互斥锁保证缓存使用相对安全。“


物流费用对账与差异分析

为什么预估费用和实际费用会不一致

TMS 创建运单时算出来的是预估费用,最终付款还是以物流商账单为准。差异一般来自几个地方:

  • 仓库称重和物流商复重不一致。
  • 物流商按材积重计费,我们只按实际重预估。
  • 目的地属于偏远地区,物流商额外收偏远费。
  • 旺季附加费、燃油附加费临时变化。
  • 物流商费率表更新了,但平台配置还没同步。

所以费用核算不是为了”替物流商决定价格”,而是为了提前估算利润、推荐渠道、发现异常账单、给 FMS 提供应付数据。

对账模型

flowchart TD
    A[运单预估费用] --> C[费用记录表]
    B[物流商账单导入] --> C
    C --> D[按运单号匹配]
    D --> E{差异率是否在阈值内}
    E -->|是| F[自动确认]
    E -->|否| G[生成差异单]
    G --> H[人工核实]
    F --> I[推送FMS应付账款]
    H --> I

EasyExcel 批量导入账单

物流商账单可能很大,如果一次性把 Excel 全部读入内存,容易造成 OOM。更稳妥的方式是使用 EasyExcel 监听器按行读取,内存中攒够一批就批量入库。

public class LogisticsBillImportListener extends AnalysisEventListener<LogisticsBillRow> {

    private static final int BATCH_SIZE = 1000;
    private final List<LogisticsBillRow> buffer = new ArrayList<>(BATCH_SIZE);
    private final LogisticsBillService logisticsBillService;
    private final String billBatchNo;
    private final Long tenantId;

    public LogisticsBillImportListener(LogisticsBillService logisticsBillService,
                                       String billBatchNo,
                                       Long tenantId) {
        this.logisticsBillService = logisticsBillService;
        this.billBatchNo = billBatchNo;
        this.tenantId = tenantId;
    }

    @Override
    public void invoke(LogisticsBillRow row, AnalysisContext context) {
        buffer.add(row);
        if (buffer.size() >= BATCH_SIZE) {
            logisticsBillService.batchImport(tenantId, billBatchNo, buffer);
            buffer.clear();
        }
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        if (!buffer.isEmpty()) {
            logisticsBillService.batchImport(tenantId, billBatchNo, buffer);
            buffer.clear();
        }
    }
}

自动对账的两种实现方式

方式一:SQL 直接对账

适合规则比较固定、对账逻辑主要是金额差异计算的场景。优点是速度快,可以直接批量更新差异金额、差异率和对账状态;缺点是复杂业务规则写在 SQL 里可读性差。

UPDATE logistics_fee_record
SET diff_amount = actual_fee - estimated_fee,
    diff_rate = CASE
        WHEN estimated_fee = 0 THEN 100
        ELSE ROUND((actual_fee - estimated_fee) / estimated_fee * 100, 2)
    END,
    reconcile_status = CASE
        WHEN estimated_fee = 0 THEN 3
        WHEN ABS((actual_fee - estimated_fee) / estimated_fee) <= 0.05 THEN 1
        WHEN ABS((actual_fee - estimated_fee) / estimated_fee) <= 0.10 THEN 2
        ELSE 3
    END,
    update_time = NOW()
WHERE tenant_id = #{tenantId}
  AND bill_batch_no = #{billBatchNo}
  AND actual_fee IS NOT NULL;

方式二:Java 业务层对账

适合对账规则比较复杂、需要根据不同物流商、不同费用项做特殊判断的场景。先从数据库查询平台预估费用和物流商账单数据,再在 Java 中按 trackingNo 做匹配和差异计算。

public List<FeeDiffResult> reconcileInJava(Long tenantId, String billBatchNo) {
    List<LogisticsFeeRecord> feeRecords = feeRecordMapper.selectByBillBatch(tenantId, billBatchNo);
    List<CarrierBillRecord> billRecords = billRecordMapper.selectByBillBatch(tenantId, billBatchNo);

    Map<String, LogisticsFeeRecord> feeMap = feeRecords.stream()
        .collect(Collectors.toMap(LogisticsFeeRecord::getTrackingNo, Function.identity()));

    List<FeeDiffResult> results = new ArrayList<>();
    for (CarrierBillRecord billRecord : billRecords) {
        LogisticsFeeRecord feeRecord = feeMap.get(billRecord.getTrackingNo());
        if (feeRecord == null) {
            results.add(FeeDiffResult.unmatched(billRecord));
            continue;
        }

        BigDecimal diffAmount = billRecord.getActualFee().subtract(feeRecord.getEstimatedFee());
        BigDecimal diffRate = diffAmount
            .divide(feeRecord.getEstimatedFee(), 4, RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(100));
        results.add(FeeDiffResult.matched(feeRecord, billRecord, diffAmount, diffRate));
    }
    return results;
}

面试时怎么讲

“物流费用对账这个亮点,我会先把业务边界讲清楚。TMS 计算的预估运费不是最终付款金额,最终付款金额一般以物流商账单为准。但是如果系统不做预估和对账,卖家就不知道每一单物流成本大概是多少,也不知道物流商账单是否异常,更没办法做订单利润分析。

所以我们把费用分成预估费用和实际费用。预估费用在创建运单时计算,依据是平台维护的物流费率表。系统会根据渠道、目的国、重量段找到对应费率,再计算首重、续重和材积重。这个费用会写入物流费用记录表,作为后续对账基准。

实际费用来自物流商账单。物流商一般按月或者按账期提供 Excel、CSV 或 API 账单,里面会包含 trackingNo、实际计费重量、基础运费、燃油附加费、偏远地区附加费、旺季附加费等。财务人员导入账单时,我会使用 EasyExcel 按行读取、分批入库,比如每 1000 行批量插入一次,避免大文件一次性加载到内存导致 OOM。

对账实现上有两种方式。第一种是 SQL 直接对账,适合规则比较固定的场景,比如直接用一条 update 语句计算差异金额、差异率和对账状态,性能比较好。第二种是 Java 业务层对账,先分别查询平台费用记录和物流商账单记录,然后在 Java 中按 trackingNo 做 map 匹配和循环对比。这种方式代码更清晰,也更适合不同物流商有不同费用规则的情况。

对账时,系统会计算差异金额和差异率。比如预估 20 元,实际 21 元,差异率 5%,这种可以认为是正常误差。如果预估 20 元,实际 35 元,就要生成差异单。差异原因可能有很多,比如仓库称重不准、物流商复重、材积重更高、偏远地区附加费、费率表没有及时更新。系统能自动计算差异,但差异原因很多时候需要人工确认。

对账结果通常分三类。第一类是正常差异,系统自动确认。第二类是轻微差异,可以批量确认。第三类是重大差异,要财务或物流专员核实,必要时联系物流商。确认以后,TMS 再把账单批次和费用汇总推送给 FMS,生成物流商维度的应付账款。

这个亮点面试时要强调两个价值。第一,费用预估服务于发货前的渠道推荐和利润预估。第二,实际账单对账服务于财务付款和成本控制。技术上可以讲 EasyExcel 批量导入避免 OOM,也可以讲 SQL 对账和 Java 对账两种实现方式。它不是为了和物流商争最终价格,而是为了让平台知道钱花在哪里,差异是否合理,是否需要调整费率和渠道策略。“


简历怎么写

项目描述

项目:跨境电商 SaaS 供应链管理平台 - 物流管理系统(TMS)
时间:2024.06 - 2024.12
角色:核心开发
技术:Spring Boot 3.2、MyBatis-Plus、MySQL 8.0、Redis、RocketMQ、XXL-Job、EasyExcel

这是个跨境电商的供应链 SaaS 平台,我负责物流管理模块。
支持多家物流商对接、智能渠道分配、运单创建、轨迹追踪、费用核算。
日均创建运单 3 万+,对接物流商 15 家,物流渠道 50+ 条。

核心难点:
1. 智能规则引擎自动分配最优物流渠道
2. 物流商API统一适配层
3. 物流轨迹异常检测
4. 首重+续重+材积重计费模型
5. 定时任务批量拉取轨迹
6. 运单创建幂等和物流状态机
7. 物流商API限流、熔断和线程池隔离
8. RocketMQ可靠消息和本地消息表
9. Redis缓存物流规则、费率和热点轨迹
10. EasyExcel批量导入账单和自动对账

核心亮点

1. 智能规则引擎自动分配最优物流渠道

问题:对接了15家物流商、50+条渠道,人工选择效率低、容易出错

方案:设计智能规则引擎,自动推荐最优渠道

具体实现:

  • 第一步:过滤层,根据目的国、重量、含电、液体、时效要求,排除不适用的渠道
  • 第二步:综合评分,计算成本评分(40%)、时效评分(35%)、可靠性评分(25%)
  • 成本评分:最低运费 / 本渠道运费 × 100
  • 时效评分:最短时效 / 本渠道时效 × 100
  • 可靠性评分:近30天正常签收率
  • 第三步:返回TOP3推荐,按综合评分降序排序

效果:

  • 渠道选择准确率 95%+
  • 人工选择时间从 5 分钟降低到 10 秒
  • 运费成本降低 15%(自动选择最优渠道)

技术细节:

public List<ChannelRecommendation> recommendChannels(ShipmentRequest request) {
    // 1. 过滤候选渠道
    List<LogisticsChannel> channels = filterChannels(request);
    
    // 2. 计算综合评分
    List<ChannelScore> scores = calculateScores(channels, request);
    
    // 3. 返回TOP3
    return scores.stream()
        .limit(3)
        .collect(Collectors.toList());
}

2. 物流商API统一适配层

问题:不同物流商API格式完全不同,每个物流商单独对接,代码混乱

方案:设计统一适配层,定义统一接口

具体实现:

  • 定义统一接口:创建运单、查询轨迹、取消运单、获取面单
  • 每个物流商实现一个适配器,负责格式转换
  • 业务层通过适配器工厂获取适配器,调用统一接口
  • 新增物流商时,只需实现新的适配器,不需要修改业务代码

效果:

  • 新增物流商时间从 3 天降低到 1 天
  • 代码复用率提升 80%
  • 维护成本降低 60%

技术细节:

public interface LogisticsCarrierAdapter {
    CreateWaybillResponse createWaybill(CreateWaybillRequest request);
    List<TrackNode> queryTrack(String trackingNo);
    boolean cancelWaybill(String trackingNo);
    byte[] getLabel(String trackingNo, LabelFormat format);
}

3. 物流轨迹异常检测

问题:物流过程中可能出现丢失、超时、滞留等异常,不及时发现会导致客服和运营处理被动

方案:设计异常检测算法,自动识别异常运单

具体实现:

  • 规则1:超时未签收,运单创建超过最长时效+3天,仍未签收
  • 规则2:轨迹滞留,超过3天没有新轨迹
  • 规则3:异常节点检测,识别EXCEPTION、RETURNED、LOST等异常节点
  • 检测到异常后,自动标记运单,发送站内信+邮件通知物流专员

效果:

  • 异常运单识别率 98%+
  • 异常处理时效从 2 天降低到 4 小时
  • 异常物流处理更及时,减少售后被动处理

技术细节:

@Scheduled(cron = "0 0 2 * * ?")
public void checkTimeout() {
    List<LogisticsWaybill> waybills = waybillMapper.selectList(
        new LambdaQueryWrapper<LogisticsWaybill>()
            .lt(LogisticsWaybill::getStatus, WaybillStatus.DELIVERED.getCode())
            .eq(LogisticsWaybill::getIsException, 0)
    );
    for (LogisticsWaybill waybill : waybills) {
        LogisticsChannel channel = channelMapper.selectById(waybill.getChannelId());
        int maxDays = channel.getMaxDays();
        long daysPassed = ChronoUnit.DAYS.between(
            waybill.getCreateTime().toLocalDate(), LocalDate.now());
        if (daysPassed > maxDays + 3) {
            markException(waybill.getId(), "超时未签收");
            sendAlert(waybill, "运单超时未签收");
        }
    }
}

4. 首重+续重+材积重计费模型

问题:物流费用计算复杂,涉及首重、续重、材积重,容易算错

方案:设计标准化计费模型

具体实现:

  • 计算实际重量:包裹称重
  • 计算材积重:(长×宽×高) / 材积系数(通常5000或6000)
  • 取较大值:计费重量 = MAX(实际重量, 材积重)
  • 计算首重费用:首重克数 × 首重单价
  • 计算续重费用:(计费重量 - 首重) / 续重单位 × 续重单价
  • 预估运费 = 首重费用 + 续重费用
  • 物流商出账单后,自动对账,识别差异

效果:

  • 运费预估准确率 95%+
  • 对账效率提升 10 倍
  • 运费差异率从 15% 降低到 5%

技术细节:

public BigDecimal calculateFee(LogisticsChannel channel, ShipmentRequest request) {
    // 1. 计算材积重
    BigDecimal volumeWeight = request.getLength()
        .multiply(request.getWidth())
        .multiply(request.getHeight())
        .divide(BigDecimal.valueOf(channel.getVolumeFactor()), 2, RoundingMode.HALF_UP);
    
    // 2. 取较大值
    BigDecimal chargeWeight = request.getWeightG().max(volumeWeight);
    
    // 3. 查询费率
    LogisticsRate rate = rateMapper.selectMatchedRate(
        channel.getTenantId(),
        channel.getId(),
        request.getCountryCode(),
        chargeWeight.intValue()
    );
    
    // 4. 计算首重费用
    BigDecimal firstFee = rate.getFirstWeightPrice();
    
    // 5. 计算续重费用
    BigDecimal extraWeight = chargeWeight.subtract(rate.getFirstWeightG());
    BigDecimal extraFee = extraWeight
        .divide(rate.getExtraWeightG(), 2, RoundingMode.HALF_UP)
        .multiply(rate.getExtraWeightPrice());
    
    return firstFee.add(extraFee);
}

5. 运单创建幂等和物流状态机

问题:OMS 重复消息、MQ 重复消费、多人并发操作或物流商接口超时,都可能导致重复创建运单和面单。

方案:使用业务唯一键、Redis 分布式锁、数据库唯一索引和状态机一起保证正确性。

具体实现:

  • TMS 端使用 tenantId + orderNo + packageNo 做业务唯一键。
  • 使用 Redis 分布式锁和数据库唯一索引,防止集群并发重复处理。
  • 创建运单前先查本地是否已有运单,有则直接返回。
  • 物流商端优先使用幂等请求号或客户订单号查询,无法确认时进入待确认补偿。
  • 运单状态只能按待创建、已下单、已打印、已揽收、运输中、已签收流转。
  • 状态更新使用乐观锁,避免多个任务同时改状态。

效果:

  • 避免重复运单、重复面单、重复费用。
  • 运单状态变更可控,排查问题时能看清楚每一步。
  • 外部物流商接口超时后,系统可以安全重试。

技术细节:

ALTER TABLE logistics_waybill
ADD UNIQUE KEY uk_tenant_order_package (tenant_id, order_no, package_no);

6. 物流商 API 调用隔离

问题:物流商接口不稳定,某一家物流商超时或限流,可能拖慢整个轨迹同步任务。

方案:按物流商做请求分组、线程池隔离、限流、超时控制和失败重试。

具体实现:

  • 按 carrierCode 分组批量查询轨迹。
  • 每个物流商维护独立线程池,不共用同一个执行队列。
  • 每个物流商配置最大并发数和超时时间。
  • 连续失败时短时间熔断,进入人工处理队列或下次任务重试。
  • 对可重试错误使用指数退避,避免短时间重复打爆物流商 API。

效果:

  • 单个物流商故障不会拖垮整个 TMS。
  • 定时任务执行时间更稳定。
  • 第三方接口失败时,系统有重试和人工兜底机制。

技术细节:

boolean acquired = carrierLimiter.tryAcquire(2, TimeUnit.SECONDS);
if (!acquired) {
    throw new BusinessException("物流商接口繁忙,请稍后重试");
}

7. RocketMQ 可靠消息和本地消息表

问题:TMS 创建运单、更新签收、生成应付账款都要通知其他系统,如果同步调用,链路长且容易互相影响。

方案:使用 RocketMQ 做异步解耦,重要消息先落本地消息表,再异步投递。

具体实现:

  • 运单创建成功后,在同一个数据库事务里写运单表和 tms_event_outbox。
  • 后台任务扫描待发送消息,投递 RocketMQ。
  • 投递失败记录 retry_count 和 next_retry_time,后续继续补偿。
  • OMS、FMS 消费消息时用 message_key 做幂等。
  • 消息内容只传必要字段,大对象通过业务 ID 回查。

效果:

  • 业务主流程不被其他系统阻塞。
  • MQ 短暂不可用时消息不会丢。
  • 消费端重复消费不会造成重复更新订单或重复生成应付账款。

技术细节:

CREATE TABLE tms_event_outbox (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    event_type VARCHAR(64) NOT NULL,
    biz_id BIGINT NOT NULL,
    payload JSON NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    retry_count INT NOT NULL DEFAULT 0,
    next_retry_time DATETIME NOT NULL
);

8. Redis 缓存物流规则和热点轨迹

问题:创建运单时需要频繁读取渠道、费率、禁运规则;运营和客服也会频繁刷新物流轨迹。

方案:把读多写少的物流配置和热点轨迹放入 Redis,数据库作为最终数据源。

具体实现:

  • 物流渠道配置缓存到 tms:channel:{tenantId}:{channelCode}
  • 物流费率缓存到 tms:rate:{tenantId}:{channelCode}:{countryCode}
  • 热门运单轨迹缓存到 tms:track:{tenantId}:{trackingNo}
  • 运营修改渠道或费率时主动删除缓存。
  • 轨迹缓存设置短 TTL,避免运营看到长期旧数据。

效果:

  • 减少创建运单时的配置查询次数。
  • 降低热门运单轨迹刷新对数据库的压力。
  • 规则配置变更后能及时生效。

技术细节:

String key = "tms:track:" + tenantId + ":" + trackingNo;
redisTemplate.opsForValue().set(key, trackList, Duration.ofMinutes(10));

9. EasyExcel 批量导入物流账单并自动对账

问题:物流商账单数据量大,普通 Excel 读取方式容易 OOM;账单导入后还需要和平台预估费用自动对账。

方案:使用 EasyExcel 监听器按行读取、分批入库,再根据 trackingNo 做自动对账。

具体实现:

  • 账单导入时按批读取,比如每 1000 行批量写入一次。
  • 每次导入生成 billBatchNo,后续对账按批次执行。
  • 简单差异计算可以使用 SQL 直接批量更新。
  • 复杂规则可以查询平台费用和账单费用,在 Java 业务层按 trackingNo 对比。

效果:

  • 避免大文件导入造成内存溢出。
  • 减少财务人工核对物流账单的工作量。
  • 能自动识别预估费用和实际费用差异。

面试高频问题

Q1:智能规则引擎是怎么工作的?

答:智能规则引擎分三步。

第一步是过滤层,根据目的国、重量、含电、液体、时效要求,排除不适用的渠道。

第二步是综合评分,对候选渠道计算成本评分、时效评分、可靠性评分,按40%、35%、25%的权重加权。成本评分是最低运费除以本渠道运费,时效评分是最短时效除以本渠道时效,可靠性评分是近30天的正常签收率。

第三步是返回TOP3推荐,按综合评分降序排序。

Q2:为什么要设计统一适配层?

答:因为不同物流商的API格式完全不同。

有的用JSON,有的用XML。有的用API Key认证,有的用OAuth。字段名也不同,运单号有的叫tracking_no,有的叫waybill_no。

如果每个物流商都单独对接,代码会非常混乱,难以维护。

统一适配层定义了统一接口,每个物流商实现一个适配器,负责格式转换。业务层只需要调用统一接口,不需要关心具体是哪家物流商。

这样新增物流商时,只需要实现新的适配器,不需要修改业务代码。

Q3:物流轨迹是怎么拉取的?

答:用定时任务每小时拉取一次。

先通过 XXL-Job 分片参数确定当前实例处理哪部分运单,再用游标分页查询未完成运单,避免一次性查出大量数据。

每一页数据按物流商分组,不同物流商进入各自线程池并发处理。同一个物流商内部再按照它的 API 限制拆成小批次,比如一次最多50或100个运单,避免超过接口限制。

拉取到的轨迹数据先做标准化处理,统一不同物流商的格式。然后去重,避免重复写入相同节点。

写入数据库后,进行异常检测,识别异常节点、超时、滞留等情况。如果检测到异常,自动推送通知给物流专员。

Q4:如何检测物流异常?

答:我们有三种异常检测规则。

第一种是超时未签收,定时任务每天检查所有未签收的运单,如果超过渠道最长时效+3天,标记为异常。

第二种是轨迹滞留,如果运单超过3天没有新轨迹,标记为滞留。

第三种是异常节点检测,拉取轨迹时,如果发现异常节点(如EXCEPTION、RETURNED、LOST),立即标记为异常。

检测到异常后,自动发送站内信和邮件通知物流专员,记录告警日志。

Q5:材积重是什么?为什么要计算材积重?

答:材积重是根据包裹体积计算出来的重量。

计算公式:材积重 = (长×宽×高) / 材积系数

材积系数通常是5000或6000,不同物流商可能不同。

为什么要计算材积重?因为有些商品体积大但重量轻,比如棉花、泡沫。如果只按实际重量计费,物流商会亏本。

所以物流商会取实际重量和材积重的较大值作为计费重量。

Q6:物流费用如何核算?

答:物流费用核算分两个阶段。

第一阶段是创建运单时预估运费,根据费率表计算首重+续重,如果材积重大于实际重量,按材积重计费。

第二阶段是物流商出账单后,导入账单文件,自动对账。对比预估运费和实际运费,计算差异率。差异率小于5%是正常,5%-10%是轻微差异,大于10%是重大差异。

差异原因主要是重量差异、材积重差异、附加费。人工确认后,生成应付账款,推送给财务系统。

Q7:如何保证定时任务的幂等性?

答:拉取轨迹时,写入前先去重。

查询是否已存在相同的轨迹节点(相同时间+位置),如果已存在,跳过。如果不存在,插入新轨迹。

这样即使定时任务重复执行,也不会重复写入相同的轨迹。

Q8:如何对接新的物流商?

答:实现一个新的适配器。

继承LogisticsCarrierAdapter接口,实现创建运单、查询轨迹、取消运单、获取面单四个方法。

在每个方法中,将统一格式转换为物流商的格式,调用物流商API,再将响应转换回统一格式。

最后在Spring容器中注册这个适配器,Bean名称为物流商编码。

这样业务层就可以通过适配器工厂获取这个适配器,调用统一接口。

Q9:物流轨迹如何标准化?

答:不同物流商的轨迹格式不同,需要映射为统一的轨迹节点码。

比如顺丰的操作码”10”映射为”PICKUP”(已揽收),“20”映射为”IN_TRANSIT”(运输中),“50”映射为”DELIVERED”(已签收)。

每个物流商的适配器中,都有一个mapToStandardCode方法,负责将物流商的操作码映射为统一的节点码。

这样业务层只需要处理统一的节点码,不需要关心具体是哪家物流商。

Q10:如何提高物流轨迹拉取的效率?

答:我们用了四个优化手段。

第一,XXL-Job 分片执行,按运单ID取模分配到不同实例。

第二,游标分页查询,每次只查一页,避免单个分片数据量过大导致内存压力。

第三,按物流商分组,不同物流商进入独立线程池并发执行。

第四,同一个物流商内部按批量上限拆批,多个批次可以并发执行,但通过线程池和限流器控制并发数量。

Q11:创建运单为什么要做幂等?怎么做?

答:因为创建运单不是普通的本地数据库操作,它会调用物流商 API。幂等要分 TMS 端和物流商端两层。

TMS 端幂等解决的是 OMS 重复消息、MQ 重复消费、多操作员并发、TMS 集群重复处理的问题。我们用 tenantId + orderNo + packageNo 作为业务唯一键,先查本地运单表,再加 Redis 分布式锁,最后用数据库唯一索引兜底。

物流商端幂等解决的是请求已经发给物流商,但 TMS 没收到响应的问题。如果物流商支持幂等请求号,就传 idempotentNo;如果支持按客户订单号查询,超时后先查询;如果都不支持,就把本地运单标记为创建结果待确认,交给补偿任务或人工处理。

不能简单地认为”超时就重试创建”,因为物流商可能已经创建成功,只是响应没有回来。

Q12:TMS 的状态机解决了什么问题?

答:状态机解决的是”状态不能乱跳”的问题。

比如运单正常应该从待创建、已下单、已打印、已揽收、运输中、派送中、已签收这样流转。如果前端传一个 status=已签收,后端不能直接相信参数,否则就可能绕过真实物流轨迹。

所以后端要维护允许流转表。每次更新状态时,先查询当前状态,再判断目标状态是否在允许列表里。同时使用乐观锁更新,避免定时任务、物流商回调、人工操作同时修改同一张运单。

状态机最好放在 Service 层或领域服务层,不应该只放在前端。因为前端校验可以被绕过,数据库约束也很难表达复杂业务流转,Service 层最适合承载业务规则。

Q13:按物流商分组后,为什么还需要限流?

答:按物流商分组只是批量任务里的第一步,它让我们知道哪些请求要调用同一家物流商,但它本身不会自动避免限流。

真正避免限流,要靠每家物流商自己的并发数、QPS、批量大小配置。比如某物流商限制每秒 20 次请求,我们就要给这个物流商单独配置令牌桶、信号量或限流器。

这个治理不只用于轨迹拉取,也用于创建运单、取消运单、获取面单等所有物流商 API 调用。

Q14:如果某个物流商接口一直超时,会不会影响其他物流商?

答:不应该影响。

我的方案是按物流商做线程池隔离。比如 DHL、UPS、云途、燕文各有自己的执行队列和超时时间。某一家物流商接口慢,只会占满它自己的线程池,不会占用其他物流商的线程。

再配合熔断机制,如果连续失败超过阈值,就短时间停止调用这家物流商,把任务放入失败队列或人工处理队列。这样可以避免定时任务一直卡在同一个外部接口上。

Q15:TMS 和 OMS、WMS、FMS 之间为什么适合用 MQ?

答:因为这些系统之间有明显的事件关系,但不适合全部同步调用。

WMS 出库完成后可以发消息给 TMS 创建物流任务;TMS 创建运单成功后发消息给 OMS 更新发货信息;TMS 签收后发消息给 OMS 更新签收状态;物流费用确认后发消息给 FMS 生成应付账款。

用 MQ 的好处是主流程不被下游系统阻塞。OMS 短暂不可用时,TMS 不需要回滚已经创建成功的运单,只要消息后续能补偿成功即可。

如果是非常强一致的场景,可以考虑分布式事务。但 TMS 这些场景大多数是最终一致,使用 MQ 加幂等和补偿更合理。

Q16:本地消息表和 RocketMQ 事务消息怎么选?

答:两个都可以。

RocketMQ 事务消息适合团队对 RocketMQ 比较熟悉、基础设施比较完善的场景。它能把本地事务和消息发送绑定起来。

本地消息表更容易落地。业务数据和消息记录写在同一个数据库事务里,只要数据库提交成功,消息就不会丢。消息表里一般会有待发送、已发送、发送失败、终止发送几个状态。

业务事务提交时插入待发送消息;定时任务扫描待发送消息并投递 MQ;发送成功后改为已发送;发送失败后记录重试次数和下次重试时间;超过最大重试次数后改为终止发送并告警。

它解决的是生产端可靠投递问题。消费端还要用 messageKey 或消费日志表做幂等,避免重复消费造成重复更新。

面试时可以说:我们项目采用本地消息表,因为它和业务库事务天然一致,实现简单,可观测性更好;如果公司基础设施成熟,也可以升级为 RocketMQ 事务消息。

Q17:TMS 哪些地方会用 Redis?

答:主要有三类。

第一类是配置缓存,比如物流渠道、费率、禁运规则、物流商账号配置。这些数据读多写少,适合缓存。

第二类是热点轨迹缓存。运营或客服频繁刷新某个 trackingNo 时,可以把轨迹列表缓存 5 到 10 分钟,降低数据库压力。

第三类是并发控制,比如创建运单时的短锁、批量任务的执行锁。

缓存一致性上,配置类数据采用更新数据库后删除缓存;轨迹类数据设置短 TTL,同时签收、异常等关键节点更新时主动删除缓存。

Q18:物流轨迹表数据量大了怎么优化?

答:轨迹表是 TMS 里增长最快的表之一。一个运单可能有 5 到 20 条轨迹,如果日均 3 万单,一个月就是几百万甚至上千万条轨迹。

优化可以从几个方向做。

第一,建好索引,常用查询是按 tenantId + waybillId + trackTime 查询。

第二,运单主表冗余最新轨迹时间、最新轨迹状态、是否异常。列表页不要每次 join 轨迹明细表。

第三,历史轨迹按月份归档或分表。最近 3 到 6 个月在线查询,老数据进入归档表。

第四,写入轨迹时做唯一约束或幂等判断,避免重复轨迹撑大数据量。

Q19:物流账单导入为什么要用 EasyExcel?

答:因为物流商账单可能很大,如果用普通方式一次性把 Excel 全部读到内存里,很容易 OOM。

EasyExcel 是按行读取的,内存占用低。我们可以在监听器里攒够一批,比如 1000 行,再批量写入数据库。这样既能处理大文件,又能减少逐行插入带来的数据库压力。

导入时还要记录账单批次号,用于后续对账和问题追踪。

Q20:自动对账用 SQL 做还是 Java 做?

答:两种都可以,取决于对账规则复杂度。

如果规则比较固定,只是计算预估费用和实际费用的差异,SQL 直接对账效率高,可以批量更新差异金额、差异率和对账状态。

如果不同物流商费用项不同、规则比较复杂,适合先查询平台费用和物流商账单,再在 Java 业务层按 trackingNo 做匹配和循环对比。Java 实现可读性更好,也更方便扩展特殊规则。


面试准备建议

必须能画的图

运单创建流程:

flowchart LR
    A[OMS推送] --> B[智能分配渠道]
    B --> C[创建运单]
    C --> D[调用物流商API]
    D --> E[获取面单]
    E --> F[计算最终预估运费]
    F -.异步.-> G[回调OMS]
    G --> H[打印面单]
    H --> I[揽收发货]

物流轨迹追踪流程:

flowchart LR
    A[定时任务拉取] --> B[标准化处理]
    B --> C[写入数据库]
    C --> D[异常检测]
    D --> E[推送通知]
    C -.签收.-> F[回调OMS]

智能规则引擎流程:

flowchart LR
    A[过滤层] --> B[综合评分]
    B --> C[返回TOP3推荐]
    A1[目的国和重量] --> A
    A2[含电液体限制] --> A
    A3[时效要求] --> A

必须能讲清楚的

  • 智能规则引擎是怎么工作的?
  • 为什么要设计统一适配层?
  • 如何检测物流异常?
  • 材积重是什么?为什么要计算材积重?
  • 物流费用如何核算?
  • 运单创建如何保证幂等?
  • TMS 如何和 OMS、WMS、FMS 解耦?
  • 物流商 API 限流、熔断怎么做?
  • 轨迹表数据量大了怎么优化?
  • EasyExcel 如何避免大文件导入 OOM?
  • 自动对账 SQL 实现和 Java 实现如何选择?

必须能写的 SQL 和代码

  • 运单表唯一索引:tenant_id + order_no + package_no
  • 轨迹表查询索引:tenant_id + waybill_id + track_time
  • 物流费率匹配 SQL:按渠道、目的国、重量段匹配
  • 运单状态机允许流转表
  • 物流商适配器接口
  • RocketMQ 消费端幂等表
  • 本地消息表重试任务
  • Redis 缓存 Key 设计
  • XXL-Job 分片分页查询 SQL
  • EasyExcel 监听器批量导入代码
  • 物流费用 SQL 对账语句

表达技巧

  1. 先讲业务,再讲技术
  2. 用具体例子说明(如材积重的计算)
  3. 主动展开:不要等面试官问,主动讲规则引擎、适配层、异常检测
  4. 准备追问:每个点都要准备2-3个追问
  5. 不要背书:要自然地表达

可能的追问和回答

  • 问:为什么不用分布式事务保证 TMS 和 OMS 强一致? 答:运单创建和订单发货状态更适合最终一致。TMS 创建运单成功后通过 MQ 通知 OMS,失败可以重试补偿。如果因为 OMS 短暂不可用就回滚物流商运单,反而更麻烦。
  • 问:物流商 API 返回成功,但回调 OMS 失败怎么办? 答:运单表先落库,本地消息表记录待通知事件,后续持续重试。OMS 消费时做幂等。
  • 问:规则引擎为什么不用 Drools? 答:当前规则主要是渠道过滤和评分,配置表加 Java 策略模式就够了。如果后续规则非常复杂、运营需要可视化编排,再考虑 Drools 或 QLExpress。
  • 问:费用差异超过 5% 一定是系统算错了吗? 答:不一定,可能是复重、材积重、偏远费、燃油附加费、旺季附加费导致。系统先生成差异单,再由财务或物流专员确认。

完整简历版

项目经历

项目名称:跨境电商 SaaS 供应链管理平台 - 物流管理系统(TMS)

项目时间:2024.06 - 2024.12

项目角色:核心开发

技术栈:Spring Boot 3.2、Spring Cloud Alibaba、MyBatis-Plus、MySQL 8.0、Redis、RocketMQ、XXL-Job、OpenFeign、EasyExcel

项目描述:

该项目是面向跨境卖家的 SaaS 供应链平台,TMS 模块负责物流渠道管理、智能渠道推荐、运单创建、物流商 API 对接、轨迹追踪、异常预警、物流费用核算和账单对账。系统对接 15 家物流商、50 多条物流渠道,日均处理运单 3 万+,支撑 OMS 发货、WMS 出库、FMS 物流费用应付账款等业务闭环。

核心业绩

使用规则引擎实现智能物流渠道推荐,提升发货渠道选择效率。

  • 根据目的国、重量、含电、液体、时效要求过滤不可用渠道。
  • 使用成本、时效、可靠性三类评分计算综合得分。
  • 返回 TOP3 推荐渠道,支持系统自动选择和人工确认。
  • 将人工选渠道时间从分钟级降低到秒级。

使用适配器模式实现物流商 API 统一接入,降低新增物流商成本。

  • 定义创建运单、查询轨迹、取消运单、获取面单的统一接口。
  • 每家物流商单独实现适配器,负责请求和响应格式转换。
  • 业务层通过工厂获取适配器,不直接依赖具体物流商。
  • 新增物流商时只新增适配器类,不改核心业务流程。

使用幂等 Key、Redis 锁和数据库唯一索引保证运单创建不重复。

  • 以 tenantId、orderNo、packageNo 作为业务唯一键。
  • TMS 端通过本地查询、Redis 分布式锁和数据库唯一索引防止重复处理。
  • 物流商端根据能力使用幂等号、客户订单号查询或创建结果待确认补偿。
  • 解决 OMS 重复消息、MQ 重复消费、集群并发和物流商超时导致的重复面单风险。

使用状态机控制运单状态流转,避免状态被非法篡改。

  • 设计待创建、已下单、已打印、已揽收、运输中、派送中、已签收、异常、已取消等状态。
  • 后端维护状态允许流转表,前端传入状态不直接信任。
  • 状态更新使用乐观锁,避免定时任务和人工操作并发覆盖。
  • 方便排查每张运单的流转过程。

使用 XXL-Job 分片任务实现物流轨迹批量同步和异常检测。

  • 每小时拉取未完成运单轨迹,使用分片加游标分页控制单次数据量。
  • 按物流商分组后路由到各自线程池,不同物流商并发拉取。
  • 同一物流商内部按照 API 批量上限拆批,并通过限流器控制频率。
  • 轨迹写入前按时间、位置、节点码去重。

使用物流商线程池隔离、限流和熔断保护外部 API 调用。

  • 每个物流商配置独立线程池和最大并发数。
  • 批量任务按物流商分组后进入对应调用通道。
  • 连续失败时熔断并进入人工处理或下次重试。
  • 防止单个物流商接口故障拖慢整个 TMS。

使用 RocketMQ 和本地消息表实现跨模块最终一致。

  • 运单创建成功后通过消息通知 OMS 更新发货信息。
  • 物流签收后通过消息通知 OMS 更新签收状态。
  • 物流费用确认后通过消息通知 FMS 生成应付账款。
  • 本地消息表保证业务数据提交后消息不丢,消费端通过 message_key 做幂等。

使用 Redis 缓存物流渠道、费率、禁运规则和热点轨迹。

  • 渠道和费率配置读多写少,缓存后减少数据库访问。
  • 运营修改配置后主动删除缓存,保证规则及时生效。
  • 热点 trackingNo 轨迹短时间缓存,降低运营和客服频繁刷新压力。
  • 结合索引优化提升渠道推荐和轨迹查询性能。

使用物流费用对账模型识别预估费用和实际账单差异。

  • 创建运单时计算首重、续重和材积重预估费用。
  • 使用 EasyExcel 分批导入物流商账单,避免大文件 OOM。
  • 支持 SQL 批量对账和 Java 业务层对账两种实现方式。
  • 根据差异率自动标记正常、轻微差异和重大差异。
  • 对账确认后推送 FMS 生成物流应付账款。

简历优化建议

如果只写一个 TMS 模块,不要把所有技术点一股脑塞进简历。比较稳的写法是选择 4 到 6 个最能讲深的点:

  • 智能规则引擎
  • 物流商 API 统一适配层
  • 运单幂等和状态机
  • XXL-Job 分片轨迹同步
  • RocketMQ 最终一致
  • 物流费用对账
  • EasyExcel 账单导入

最后提醒:

面试前把这份笔记看2-3遍,重点记住:

  • 运单创建流程(10步)
  • 物流轨迹追踪流程(7步)
  • 物流费用核算流程(7步)
  • 智能规则引擎(过滤+评分+推荐)
  • 物流商API统一适配层
  • 物流轨迹异常检测(3种规则)
  • 运单幂等和状态机
  • 物流商调用限流、熔断、线程池隔离
  • RocketMQ可靠消息和本地消息表
  • EasyExcel账单导入和自动对账