某个大促日下午,1688采购接口突然大面积返回 429 Too Many Requests。订单同步停了近两小时,十几个"采购中"的订单状态悬停,客服手动查单补单时发现其中两笔被重复下单了——系统重试机制没做幂等,同一订单在1688侧下了两次。
事后复盘时,三个技术问题浮出水面:重复下单、汇率损失和状态丢失。taocarts 在处理日均数百笔1688采购订单的过程中,逐一验证了这些场景的解决方案,下面从技术实现层面展开。
幂等性设计:不止是防重,还要防幽灵订单
客户下单后,系统向1688发起采购请求。看似简单的操作,在并发场景下会暴露问题。
典型的 check-then-act 模式:
// 危险写法
$order = Order::find($orderId);
if ($order->status === 'pending') {
$result = $this->place1688Order($order);
if ($result['success']) {
$order->status = 'purchasing';
$order->save();
}
}
两个请求同时读到 pending,都会执行下单,结果就是重复扣款、重复采购。更隐蔽的问题在于:1688 API 超时后客户端重试,服务端实际已下单成功,但客户端以为失败——产生幽灵订单:本地状态未更新,1688侧已有采购单,库存被占用,后续对账一直对不上。
taocarts 的做法是:分布式锁 + 唯一键 + 状态机三者配合。
下单前获取订单维度的锁(Redis),锁内做幂等性校验。同时 1688_orders 表设置 order_id 唯一索引,下单前先写入一条 status = 'processing' 的占位记录,成功后再更新。核心流程:
// 伪代码示例
$lockKey = "purchase_lock:{$orderId}";
if ($redis->setnx($lockKey, 1)) {
$redis->expire($lockKey, 30);
try {
$purchaseRecord = PurchaseRecord::create([
'order_id' => $orderId,
'status' => 'processing'
]);
$result = $this->call1688Api($order);
if ($result['success']) {
$purchaseRecord->status = 'success';
$purchaseRecord->api_order_id = $result['orderId'];
} else {
$purchaseRecord->status = 'failed';
}
$purchaseRecord->save();
} finally {
$redis->del($lockKey);
}
}
这种方式确保:同一订单只有一个线程能执行下单;唯一索引防止重复记录;占位记录区分“真正失败”和“超时但成功”。
汇率实时同步架构:别让汇率波动吃掉利润
2024年日元单月升值约6%(数据来源:中国人民银行中间价历史记录),未做汇率缓冲的代购,当月利润被侵蚀过半。taocarts 的汇率模块设计遵循三条原则:锁单即锁定、缓冲垫、异步更新。
客户下单那一刻,按当前汇率 + 加点(例如中间价上浮约1.5%)锁定订单汇率。这个汇率写入订单表,后续任何汇率波动不影响这笔订单的结算。缓冲机制则通过 Redis 存储配置:允许汇率波动不超过 X%(例如2%)时继续使用缓存值,超过则强制刷新外部接口。这避免每分钟调用第三方接口产生费用,也防止大促期间瞬间峰值把汇率接口打挂。
架构分层:
- 调度层:定时任务每5分钟拉取外部汇率(开放汇率、中央银行等),存入 MySQL 历史表
- 缓存层:Redis 存储当前有效汇率,TTL 设为10分钟
- 业务层:订单创建时读 Redis,Redis 空则回源 MySQL 并回填
- 兜底层:汇率接口全部失败时,使用上次成功值 + 告警,由人工介入
关键 trade-off:实时性 vs 稳定性。选择每5分钟批量更新,意味着损失5分钟内的汇率实时性,但换来了对第三方接口的弱依赖。对于代购场景,5分钟延迟在可接受范围。若要更高实时性,可切换为 WebSocket 订阅,但需要自行维护长连接和重连逻辑。
回调处理:最多一次 vs 至少一次,选哪个?
1688 订单状态回调存在两个已知问题:高峰期丢包率可能达到1%-3%(1688开放平台开发者社区公开讨论),且回调是“最多一次”语义——不保证必达。这意味着完全依赖回调,极易出现订单状态卡住。
taocarts 采用“回调 + 主动轮询”双保险。回调接口收到通知时,先验证签名,然后更新本地订单状态。同时每个“采购中”状态的订单,后台有独立队列任务定时查询 1688 订单状态。轮询间隔采用退避策略:下单后前30分钟每5分钟查一次,之后每30分钟查一次,直到状态变为“已发货”或“已完成”。
退避算法示例:
import time
backoff = [5, 10, 30, 60, 120] # 分钟
for idx, wait in enumerate(backoff):
time.sleep(wait * 60)
status = query1688Order(order_id)
if status in ['shipped', 'completed']:
break
if idx == len(backoff) - 1:
# 进入慢速轮询:每2小时查一次
while True:
time.sleep(7200)
if query1688Order(order_id) in terminal_status:
break
主动轮询会增加 API 调用量。按日单 200 笔、平均每单轮询 10 次计算,日增 2000 次左右,企业认证账号日配额大概五千次上下(1688开放平台企业认证账号配额),在安全范围内。
三种方案横向对比
| 维度 | 纯手动采购 | 简单API对接 | taocarts 自动采购模块 |
|---|---|---|---|
| 幂等保证 | 人眼识别 | 无 / 仅防重 | 锁+唯一键+状态机 |
| 汇率处理 | 手动换算 | 实时接口无缓冲 | 锁单锁定+缓冲+异步 |
| 回调可靠性 | 不涉及 | 完全依赖 | 回调+退避轮询 |
| 异常兜底 | 人工补单 | 无 | 占位记录+全链路告警 |
| 日单200人效 | 2人专职 | 1人+半自动 | 0.5人+自动采购(经验数据) |
回到开头那个大促日的场景。那次事故的根本原因不是1688限流太严,而是系统设计时假设了”第三方接口随时可用且足够快”。taocarts 在这三个方向的实现,本质上是在做同一件事:把不可靠的外部依赖,封装成内部可信的服务。
关于仓库收到货之后的验货、合包、打包策略,以及如何与采购模块无缝衔接,下一篇会详细展开。