Taocarts 知识

轮询 vs 回调:没有银弹,只有取舍

📅 2026-02-03 系统功能介绍

一次物流轨迹丢失事故,我重构了数据同步方案

晚上十一点,客服在群里@我:“客户 A 的包裹显示‘已交航’五天了,再不更新要退款。”查了一下系统,最后一条轨迹确实是五天前的“离开处理中心”。打电话给物流商客服,对方说“这个单号在我们系统里已经签收了”。签收了?系统里根本没收到回调。

这家快递商的回调接口挂了一整天,200 多个包裹的状态卡在半路。客户投诉爆棚,客服挨个去官网查单号手动更新。那次之后,我把物流追踪模块彻底重写了。

轮询 vs 回调:没有银弹,只有取舍

方案 A:纯轮询(定时任务拉取)。简单可靠,不依赖第三方回调。代价是时效性差——每小时轮询一次,客户看到“已签收”时可能已经过了几十分钟。而且日单量上去后,轮询的 API 调用量成倍增长,每单从入库到签收平均要轮询几十次,对接 5 家物流商就是几百万次调用,账单感人。

方案 B:纯回调(Webhook)。实时性好,物流节点变化后秒级推送。致命缺陷:物流商回调可能重复、乱序、甚至彻底丢失。而且不是所有物流商都提供回调接口,EMS 早期版本就只有查询 API 没有回调。

方案 C:混合模式(回调为主 + 补偿轮询)。最终采用的方案。正常情况靠回调实时更新,同时每个运单维护一个“最后更新时间”,超过阈值(比如 24 小时没新轨迹)就触发补偿轮询。回调接口做幂等处理,轮询频率动态调整——临近签收阶段提高频率,长时间没变化则降低。

适配器模式:统一 5 家快递商的杂乱数据

各家 API 的字段命名、状态码、返回结构完全不同。EMS 的“已收寄”到了顺丰叫“已揽收”,DHL 的“PROCESSED”对应中通的“运输中”。前端没法直接展示这些原始状态。

适配器层的核心逻辑:

// 物流商适配器接口
interface LogisticsAdapter {

public function track($trackingNumber): TrackingEvent;

public function parseWebhook($payload): TrackingEvent;
}

// EMS 适配器实现
class EMSAdapter implements LogisticsAdapter {

private $statusMap = [

'收寄' => 'picked_up',

'离开处理中心' => 'in_transit',

'到达处理中心' => 'arrived',

'海关放行' => 'customs_cleared',

'投递并签收' => 'delivered'

];

public function parseWebhook($payload) {

$event = new TrackingEvent();

$event->status = $this->statusMap[$payload['event']] ?? 'unknown';

$event->location = $payload['location'] ?? '';

$event->timestamp = strtotime($payload['time']);

$event->rawData = $payload;

return $event;

}
}

隐性知识点:很多新手把状态映射写死在业务代码里,换一家物流商就得改代码。用适配器模式 + 配置表,新增快递公司只需要写一个适配器类,注册到工厂即可。taocarts 的物流模块内置了 8 家主流物流商的适配器,新增渠道时后台配置一下接口地址和状态映射就行。

状态机 + 幂等:挡住重复和乱序

回调接口最头疼的两个问题:同一事件推了三次(重复),或者先收到“已签收”再收到“派送中”(乱序)。状态机负责维护合法跃迁路径,幂等键负责去重。

// 物流状态机定义
class LogisticsStateMachine {

private $transitions = [

'pending' => ['picked_up', 'exception'],

'picked_up' => ['in_transit', 'exception'],

'in_transit' => ['arrived_destination', 'customs_cleared', 'exception'],

'arrived_destination' => ['delivering', 'exception'],

'customs_cleared' => ['delivering', 'exception'],

'delivering' => ['delivered', 'exception'],

'delivered' => [],

// 终态

'exception' => []

// 终态

];

public function canTransition($from, $to) {

return in_array($to, $this->transitions[$from] ?? []);

}
}

// 回调处理(幂等 + 状态校验)
function handleCallback($trackingNumber, $eventId, $newStatus, $rawData) {

$idempotentKey = "logistics:{$trackingNumber}:{$eventId}";

if (redis()->exists($idempotentKey)) {

return ['code' => 200, 'msg' => 'duplicate'];

}

$currentStatus = getCurrentStatus($trackingNumber);

if (!$stateMachine->canTransition($currentStatus, $newStatus)) {

// 非法跃迁:记录日志,不更新

logger()->warning("Invalid transition", [

'tracking' => $trackingNumber,

'from' => $currentStatus,

'to' => $newStatus

]);

return ['code' => 200, 'msg' => 'ignored'];

}

// 更新状态和轨迹详情

updateTracking($trackingNumber, $newStatus, $rawData);

redis()->setex($idempotentKey, 86400, 1); // 24小时防重

// 推送 WebSocket 给前端

pushToClient($trackingNumber, $newStatus);

return ['code' => 200, 'msg' => 'ok'];
}

加上状态机后,乱序回调直接被过滤掉,客户再也没见过“已签收又变派送中”的奇葩状态。

补偿轮询:兜住丢失的回调

即使加了幂等和状态机,回调丢失仍然可能发生。补救措施:每个运单记录 last_poll_at,后台任务每隔一小时扫描一次,找出 last_poll_at 超过 24 小时且状态未达终态的单号,主动调用查询 API 补全轨迹。轮询频率根据状态动态调整:刚发货时每天查一次,预计送达前 2 天每小时查一次。

这套兜底机制上线后,那次“回调接口挂了一整天”的事故里,补偿轮询在 2 小时内自动补上了全部轨迹,客服没收到一个手动查询请求。

结语

物流追踪看着简单,无非是调 API 存数据。但真的对接三五家快递商,跑上几个月,就会遇到回调乱序、重复、丢失、状态码不统一、轮询性能爆炸等一系列问题。适配器模式解耦差异,状态机挡住乱序,幂等键过滤重复,补偿轮询兜底丢失——把这四样东西落实好,才算一个能上生产的物流追踪系统。

你的系统对接了几家物流商?遇到过最离谱的轨迹数据是什么?评论区聊聊。

wechat wechat qr