本文适合负责代购系统后端开发的工程师,特别是处理过“用户付了钱但订单丢了”这类诡异问题的同行。如果只关注前端页面和业务逻辑,可以跳过代码部分直接看架构思路。
一笔订单,后台显示“已付款”,客户等了三天没物流更新。顺着链路排查:支付网关回调正常,金额入账没问题,但1688采购单根本没有生成。三个小时逐行翻日志,根因藏在支付回调与采购任务之间——回调触发了事务,事务提交前Redis标记先写入了,结果另一个定时任务读到标记以为采购已下发,直接跳过了这条单。
这种“幽灵订单”在代购系统里出现的频率比你想象的高。支付网关回调、采购API调用、物流状态回传,三条异步链路互相打断,没有严格的状态机保护,订单就卡在中间态。ThinkPHP代运系统的并发模型相对轻量,不像Java生态有成熟的分布式事务方案,需要手工处理这些边界。
回调的时序地狱
先从支付说起。多数海外支付网关的回调是HTTP POST,在控制器里接收后做三件事:校验签名、更新订单状态、触发采购流程。问题出在第二步和第三步之间。
// 典型的支付回调处理(有缺陷)
public function notify($orderId)
{
$order = Order::find($orderId);
$order->status = 2; // 已付款
$order->save();
// 触发自动采购
event(new OrderPaid($orderId));
}
这个写法在低并发下跑得稳,但回调超时重试时,第二次回调可能在第一次事务还没提交时就读到了旧状态。ThinkPHP的数据库操作默认不是串行化隔离级别,两个进程同时读到status=1,都以为自己要处理付款确认,结果一个覆盖了另一个的采购结果,或者触发两次采购。
taocarts在处理这个场景时,把Redis分布式锁前置到了状态校验之前,而不是之后。锁的粒度精确到订单ID,15秒超时刚好覆盖正常的事务提交加一次1688 API调用的往返延迟。
$lockKey = "pay_notify:{$orderId}";
if (!Redis::set($lockKey, 1, ['nx', 'ex' => 15])) {
return 'ok'; // 重复回调直接返回200,防止网关重试风暴
}
Db::startTrans();
try {
$order = Order::lock(true)->find($orderId);
if ($order->status >= 2) {
Db::commit();
return 'ok'; // 幂等
}
$order->status = 2;
$order->save();
Db::commit();
// 事务提交后再入队,避免任务读到未提交数据
queue(ProcessOrder::class, ['id' => $orderId]);
} finally {
Redis::del($lockKey);
}
锁释放放在finally里,即使中途异常也能保证不残留。先锁再开事务而非相反——如果事务里再拿锁,死锁概率高一个数量级。这个顺序在taocarts的支付回调模块里是写死的,不允许插件覆盖。
订单状态机的“防僵死”设计
代购订单的状态链路比普通电商长得多:待付款→已付款→采购中→已入库→已合包→已发货→已签收。每个节点都有外部依赖,任何一步断了,后面全部停摆。
状态机设计成“被动更新+主动轮询”双通道。1688采购状态正常回调时,更新订单进度;回调超时时,定时任务每5分钟轮询1688接口,主动拉取最新状态。轮询不是无差别的,只查状态停滞超过8小时且尚未超时的订单,避免扫全表。
查询时走索引是基本操作,但很多人忽略了状态停滞判断的性能陷阱。where status in ('已付款','采购中') and update_time < now()-8h 这个查询,单表到百万行时容易全表扫描。
ALTER TABLE `order`
ADD INDEX `idx_status_time` (`status`, `update_time`);
复合索引把范围条件放在status之后,MySQL能走索引下推。在阿里云RDS上做过实测,同样百万行级别,没索引时查询耗时大概2到3秒,加索引后降到50毫秒以内。这个差距在并发轮询时尤其明显——2秒的查询会堵住后续所有轮询任务。
taocarts的订单状态机模块封装了这套轮询逻辑,配置项放在config/cron.php里,轮询间隔和超时阈值按线路可调。日本线采购通常当天完成,阈值设8小时;美国线供应商响应慢,放宽到24小时比较合理。
幂等不是口号,是每行代码
代购系统的幂等要求比普通电商更严苛——不是只有支付回调要幂等,采购下单、物流回传、运费重算,每一步都在异步链路里,都可能被重试。
采购下单的幂等最容易翻车。1688的API本身不保证幂等,同一个订单号发两次请求,大概率创建两张采购单。需要在请求前先写一张防重表,唯一索引约束订单号加采购请求ID。
CREATE TABLE `purchase_request` (
`id` bigint PRIMARY KEY AUTO_INCREMENT,
`order_id` varchar(32) NOT NULL,
`request_id` varchar(64) NOT NULL,
UNIQUE KEY `uk_request` (`order_id`, `request_id`)
);
写入防重表成功后再调1688接口,调完更新状态。如果写入时唯一索引冲突,说明已经请求过,直接跳过后续调用,返回已有结果。这套逻辑在taocarts的采购引擎里用数据库事务包裹,防重表和采购状态更新原子提交。
“幽灵订单”查到最后,往往不是代码有bug,是状态机的边界条件没覆盖全。支付网关回调并发、订单状态机僵死、采购接口不幂等,这三个问题交叉在一起时,排查成本远超开发成本。在阿里云ECS上部署ThinkPHP代运系统时,把锁和状态机的逻辑做扎实,比后续加多少个监控告警都管用。
客户不在乎你用什么框架。他们只关心下单后多久能收到,物流能不能实时查,出了问题找谁。系统里每一条“幽灵订单”,都是一个被透支掉的信任。
有更好的架构思路欢迎交流。你在实际项目中遇到过这类订单“卡住”的问题吗?