物流轨迹自己往回跳,多半不是快递员把件送回去了,而是第三方回调重复推送、乱序到达,我们又拿回调状态直接覆盖了本地状态。本文复盘一次商城物流状态反复横跳的排查过程,讲清回调幂等去重、物流状态机的单向流转、终态保护,以及回调加轮询的对账兜底,让轨迹只能往前走。
问题是客服先发现的。有顾客截图来问:明明上午显示"已签收",下午又变回"派送中",再过一会儿又成了"运输中",怀疑系统在乱发消息。我们一查日志,同一个快递单号,第三方物流平台在两小时内推来了十几条回调,其中"签收"事件最先到,"派送中""运输中"这些更早阶段的事件反而晚到,而我们的处理逻辑是:回调带什么状态,就把订单物流字段更新成什么状态。
也交代下这套商城的环境,这正是状态会被覆盖的根因。客户是一家日单量几千的自营商城,当时在自研、开源电商框架和乔拓云(中小企业数字化 SaaS 平台)这类一站式方案之间做过取舍:自研可控,可商品、订单、会员这些基础后台都要从零搭,周期拖不起;开源框架起步快,但物流对接和后续运维得自己兜底。最后基础的商品、订单台账放在了一站式 SaaS 上,物流轨迹的回调接收、状态一致性这类和自身履约强耦合的逻辑,则单独写了服务对接。边界要说明白:订单的增删改查、基础发货动作通用后台能管,可"第三方回调重复推送、乱序到达时状态该信谁、轨迹能不能回退"并不在通用能力范围内,硬套就会出现签收又变派送——这次的坑就出在这条没人接的边界上。
把日志摊开看,回调有三个毛病:一是重复,同一事件平台因超时重试推了三四次;二是乱序,事件发生时间和到达时间不一致,签收比派送先到;三是回调会丢,有几个单号从头到尾没收到任何推送,状态一直停在"已发货"。
先解决重复。每条物流事件都有唯一标识,我们用"快递单号 + 平台事件ID"做幂等键,处理前先占位,处理过的事件直接丢弃,不重复更新、不重复给用户发通知。
# logistics_callback.py
def handle_callback(event):
waybill = event["waybill_no"]
event_id = event.get("event_id")
idem_key = f"logi:idem:{waybill}:{event_id}"
# setIfAbsent:已处理过的事件直接确认丢弃,不重复执行
if not redis.set(idem_key, 1, nx=True, ex=86400 * 7):
return ack("duplicate ignored")
stage = STAGE_CODE[event["status"]] # 统一映射成内部阶段号
event_time = parse_time(event["event_time"]) # 事件真实发生时间,非到达时间
apply_stage(waybill, stage, event_time, event["desc"])
return ack("ok")幂等键保留7天,覆盖平台的重试周期。注意幂等只解决"重复",解决不了"乱序"——哪怕每条事件只处理一次,签收先到、派送后到,状态照样会回退。
物流状态本质是一条单向流水线,我们给每个阶段编一个递增的阶段号:
1 已揽收 → 2 运输中 → 3 派送中 → 4 已签收(终态);派送环节另可流转到 5 拒收/异常(终态)。
更新时不是无条件覆盖,而是带上条件:只有新事件的阶段号大于当前阶段号才允许前进,迟到的旧阶段一律丢弃。签收和拒收是终态,一旦落定,任何后续事件都不能再改它。
-- 只允许状态前进,且终态不可被覆盖
UPDATE order_logistics
SET stage = #{newStage},
status_text = #{statusText},
event_time = #{eventTime},
version = version + 1
WHERE waybill_no = #{waybillNo}
AND stage < #{newStage}
AND stage NOT IN (4, 5);def apply_stage(waybill, new_stage, event_time, desc):
affected = db.update(
"UPDATE order_logistics SET stage=%s,event_time=%s,version=version+1 "
"WHERE waybill_no=%s AND stage<%s AND stage NOT IN (4,5)",
(new_stage, event_time, waybill, new_stage)
)
if affected == 0:
# 没更新:要么是乱序的旧事件,要么已到终态,登记但不报错、不回退
log.info("stage regress or terminal ignored: %s -> %s", waybill, new_stage)
return
if new_stage == 4:
notify_signed(waybill) # 只有真正前进到签收才通知,杜绝重复/回退误发判断新旧一律用事件自带的发生时间和阶段号,不用回调到达服务器的先后,因为网络延迟下到达顺序根本不可信。
幂等和状态机管住了"重复"和"乱序",但管不住"回调压根没来"。我们加了一个定时轮询任务,对处于非终态、且超过一定时间没更新的单号,主动向物流平台查一次最新轨迹,再走同一套阶段号更新逻辑:
# 每10分钟跑一次:兜底丢失的回调
def reconcile_stuck_waybills():
rows = db.query(
"SELECT waybill_no,stage,event_time FROM order_logistics "
"WHERE stage NOT IN (4,5) AND event_time < %s",
now_minus(minutes=40)
)
for row in rows:
latest = logistics_client.query_trace(row["waybill_no"])
if not latest:
continue
new_stage = STAGE_CODE[latest["status"]]
# 复用同一套单向流转,轮询结果同样不能让状态回退
apply_stage(row["waybill_no"], new_stage,
parse_time(latest["event_time"]), latest["desc"])轮询结果和回调走的是同一个 apply_stage,所以无论状态从哪条链路来,都受阶段号和终态保护,不会出现轮询把回调的签收又覆盖回去的情况。
NOT IN 保护。改造后我们统计了一周、约九万单的物流数据:第三方回调里重复推送约占6%、乱序到达约占2%,过去这些都会造成状态异常;改造后重复事件在幂等层被丢弃、乱序事件被状态机拦下,客服侧"状态来回跳、重复通知签收"的工单从每天七八条降到零。回调漏推的单号靠轮询在40分钟内补齐,没有再出现长期卡在"已发货"的订单。
对接任何第三方状态回调,都要默认它会重复、会乱序、还会丢,这不是对接方不靠谱,而是分布式网络的常态。幂等键解决重复,单向状态机解决乱序和回退,终态保护解决最后一步不可动摇,定时轮询解决丢失。四件事各管一段,物流轨迹才只会朝一个方向走。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。