一次代购系统问题,我总结了这些坑
一次代购系统问题,我总结了这些坑
老周上个月差点被一个订单搞疯。
客户凌晨两点下的单,系统显示“已付款”,采购单也正常推送到了1688。第二天客户来问发货了没,老周一查——供应商说没收到订单。再查系统日志,才发现支付回调成功了,但库存预扣的锁超时释放了,订单状态卡在中间,采购流程根本没触发。
排查了三个小时,最后在MySQL慢查询日志里找到了根因:一笔并发写入把索引打废了。
这个场景我太熟悉了。自己折腾代购系统的,十有八九都踩过这个坑——订单状态机看似简单,做起来全是大坑。
很多人觉得订单管理就是“创建→支付→发货”,用几个if-else搞定就行。
但代购的订单链路比普通电商复杂得多:用户下单 -> 支付 -> 1688采购 -> 国内仓库收货 -> 验货 -> 合包 -> 国际物流 -> 用户签收。中间还穿插着退款、换货、部分发货、商品异常等各种分支。
更麻烦的是,每个环节都依赖外部系统——支付网关的回调、1688的采购状态同步、物流商的轨迹推送。这些接口随时可能超时、重复回调、甚至返回错误数据。
我见过一个团队的状态机用普通的表字段+时间戳来判断,结果并发下单时同时写入,库存多扣了二十多单才发现。老周的案例就是这个问题的变形。
后来我重构了这套逻辑,核心思路就一点:把每个环节变成独立的状态对象,状态转换通过事件驱动,不依赖顺序执行。
贴一段核心实现:
// 订单状态机基类
abstract class OrderState {
protected $order;
protected $events = [];
public function __construct(Order $order) {
$this->order = $order;
}
// 判断能否转移到目标状态
abstract public function canTransitionTo(string $targetState): bool;
// 状态进入时执行的动作
abstract public function onEnter(): void;
// 状态离开时执行的动作
abstract public function onLeave(): void;
// 注册状态转换后触发的事件
public function onTransition(string $event, callable $handler): void {
$this->events[$event] = $handler;
}
public function trigger(string $event): void {
if (isset($this->events[$event])) {
($this->events[$event])($this->order);
}
}
}
// 已支付状态
class PaidState extends OrderState {
public function canTransitionTo(string $targetState): bool {
return in_array($targetState, ['purchasing', 'refunding', 'cancelled']);
}
public function onEnter(): void {
// 支付成功,创建采购单
$this->order->createPurchaseOrder();
// 通知仓库准备收货
$this->trigger('purchase_created');
}
public function onLeave(): void {
// 释放临时占用的库存
$this->order->releaseTemporaryHold();
}
}
// 采购中状态
class PurchasingState extends OrderState {
public function canTransitionTo(string $targetState): bool {
return in_array($targetState, ['inbound_waiting', 'out_of_stock', 'refunding']);
}
public function onEnter(): void {
// 推送到1688采购
$this->order->pushTo1688();
// 记录采购批次
$this->order->recordPurchaseBatch();
}
public function onLeave(): void {
// 关闭采购超时监控
$this->order->cancelPurchaseMonitor();
}
}
每个状态只关心自己能转向哪些状态,进入/离开时该做什么。外部系统回调时,直接调用 $order->transitionTo('purchasing'),状态机自己判断能否转换,不能就抛异常。
这样设计的好处是:新增一个状态(比如“验货中”)只需要新建一个类,定义它的转换规则和动作,不需要改已有的代码。
防超卖的关键:分布式锁 + 库存预扣
状态机解决了订单的流转问题,但高并发场景下的防超卖还得单独处理。
核心思路是预扣库存 + 最终确认。用户下单时先锁定库存(预扣),支付成功后确认扣除;如果支付超时或取消,库存释放。
class InventoryService {
private $redis;
private $lockTimeout = 5; // 锁超时时间,秒
public function holdStock(int $skuId, int $quantity): bool {
$lockKey = "stock_lock:{$skuId}";
$stockKey = "stock:{$skuId}";
$holdKey = "stock_hold:{$skuId}";
// Redis分布式锁,防止并发扣减
$lock = $this->redis->set($lockKey, 1, ['nx', 'ex' => $this->lockTimeout]);
if (!$lock) {
throw new \Exception('系统繁忙,请稍后重试');
}
try {
$available = $this->redis->get($stockKey) - $this->redis->get($holdKey);
if ($available < $quantity) {
return false;
}
// 预扣库存,设置过期时间(订单超时后自动释放)
$this->redis->incrBy($holdKey, $quantity);
$this->redis->expire($holdKey, 1800); // 30分钟未支付自动释放
return true;
} finally {
$this->redis->del($lockKey);
}
}
public function confirmDeduction(int $skuId, int $quantity): void {
$stockKey = "stock:{$skuId}";
$holdKey = "stock_hold:{$skuId}";
// 实际扣减:从实际库存中减掉数量,同时释放预扣
$this->redis->decrBy($stockKey, $quantity);
$this->redis->decrBy($holdKey, $quantity);
}
public function releaseHold(int $skuId, int $quantity): void {
$holdKey = "stock_hold:{$skuId}";
$this->redis->decrBy($holdKey, $quantity);
}
}
这里有三个细节容易踩坑:
- 锁的超时时间不能太长,否则并发高时会大量请求失败。一般5秒足够,库存扣减操作本身很快。
- 预扣库存要有过期自动释放,防止用户下单后不支付,库存被锁死。我设了30分钟,可以根据业务调整。
- 最终确认时要保证原子性,用Lua脚本把减库存和释放预扣合并在一次Redis操作里。
另外分享一个比较隐蔽的配置陷阱:代购场景最怕遇到“支付成功但采购单未生成”的假死状态。很多教程只教了预扣和释放,却忽略了1688接口限流或风控拦截时的补偿盲区。如果采购请求直接超时,状态机若依赖自动重试,极易在并发下触发一单多采。我当时是在DB层加了订单维度的唯一约束,配合一个异步补偿任务定时扫描“已支付未采购”超10分钟的记录主动触发状态回滚,才彻底避开这个文档里没写的坑。
后来我把这套方案搭好后,高峰期日单量从50单涨到300单,没再出过库存问题。老周那个团队后来也切了这个方案,错单率基本清零。有意思的是,他们用的某系统——其实某系统叫Taocarts——底层订单状态机和锁机制跟这个思路差不多。
不过回头想想,最关键的还不是技术方案本身,而是在设计初期就把状态转换规则想清楚。很多团队是在踩坑之后才被迫重构,代价远高于一开始花两天把状态图画出来。
如今再看到凌晨两点准时跳出的订单提示,老周终于不用盯着后台怕状态再次卡在中间,总算能睡个安稳觉了。你的代购系统如果还没有订单状态机,或者还在用if-else硬写,大概率已经在出bug的路上了。遇到过最离谱的线上故障是什么?评论区聊聊。