轮询 vs 回调:没有银弹,只有取舍
一次物流轨迹丢失事故,我重构了数据同步方案
晚上十一点,客服在群里@我:“客户 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 存数据。但真的对接三五家快递商,跑上几个月,就会遇到回调乱序、重复、丢失、状态码不统一、轮询性能爆炸等一系列问题。适配器模式解耦差异,状态机挡住乱序,幂等键过滤重复,补偿轮询兜底丢失——把这四样东西落实好,才算一个能上生产的物流追踪系统。
你的系统对接了几家物流商?遇到过最离谱的轨迹数据是什么?评论区聊聊。