ThinkPHP代运系统已付款未采购并发回调陷阱深度解析
前两周技术群里一位同行兄弟抛了个问题:一笔订单后台显示“已付款”,客户等了三天,物流端始终没动静。顺链路排查——支付网关回调正常、资金确实到账,但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的采购引擎用数据库事务把这套逻辑包装在一起,防重表和采购状态更新是原子提交的——要么全部成功,要么全部回滚。
“幽灵订单”追查到最后,往往不是代码写得不够严谨,而是状态机的边界条件没有覆盖全。支付网关回调并发、订单状态机僵死、采购接口不幂等,这三个问题交叉在一起时,排查成本远超开发成本。在阿里云ECS上部署ThinkPHP代运系统时,把锁和状态机的逻辑做扎实,比后续加多少个监控告警都管用。
说句实话,客户根本不在乎你用什么框架。他们只在意下单后多久能收到,物流能不能实时查到,出了问题该找谁。系统里每一条“幽灵订单”,都是一个被透支掉的信任。
有更好的架构思路欢迎交流。你在实际项目中遇到过这类订单“卡住”的问题吗?
