第一次对接1688代采系统,我踩了个重复扣款的大坑
客户下单后,系统重复扣款两次。排查了两天才找到根因:支付回调处理逻辑里,先查询订单状态是否为“待支付”,如果是则更新为“已支付”并加余额。两个回调几乎同时到达,都查到了“待支付”,于是各自执行了一遍加款操作——典型的 check-then-act 并发问题。
那次之后,对接任何第三方接口,第一件事就是设计幂等性。1688代采系统面临的支付回调、1688订单状态同步、采购单创建,每个环节都可能重复触发。本文从幂等性设计开始,逐步搭建一个可靠的采购同步流程。
步骤一:幂等性设计——唯一请求ID防重
支付回调、1688回调、定时任务拉取,都可能重复触发同一个业务操作。最通用的防重方案:业务流水号 + 数据库唯一索引。
每次收到回调时,先生成一个唯一的 request_id(格式:{订单号}_{时间戳}_{随机数}),然后尝试插入一张idempotent_log表。插入成功则继续处理,失败说明已处理过,直接返回成功。
CREATE TABLE idempotent_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL UNIQUE,
-- 幂等键
biz_type VARCHAR(32) NOT NULL,
-- 支付回调/订单同步
biz_id VARCHAR(64) NOT NULL,
-- 关联订单号
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
function handleCallback($bizType, $bizId, $callbackData) {
$requestId = md5($bizType . $bizId . time() . mt_rand());
try {
DB::insert('INSERT INTO idempotent_log (request_id, biz_type, biz_id) VALUES (?, ?, ?)',
[$requestId, $bizType, $bizId]);
} catch (DuplicateKeyException $e) {
// 已处理过,直接返回成功
return ['code' => 0, 'msg' => 'already processed'];
}
// 执行业务逻辑(更新订单状态、加款等)
// 注意:业务操作也要幂等(见步骤二)
return ['code' => 0, 'msg' => 'success'];
}
注意事项:request_id 必须在业务操作之前插入,且插入和业务操作要在同一个数据库事务中,否则可能插入成功但业务操作失败,导致幂等记录永久存在。
步骤二:支付回调的幂等处理——状态机+乐观锁
有了请求ID防重,还不够。同一个订单的状态更新可能被多个不同来源触发(支付回调、人工补单、对账修复)。更安全的做法是:用状态机约束每次流转,更新时带当前状态条件。
// 支付回调处理(状态机更新)
function processPaymentCallback($orderId, $transactionId) {
// 先幂等检查(省略)
DB::beginTransaction();
// 乐观锁:只有当前状态为“待支付”才能改为“已支付”
$affected = DB::update("UPDATE orders SET status = '已支付', transaction_id = ?, pay_time = NOW()
WHERE order_id = ? AND status = '待支付'",
[$transactionId, $orderId]);
if ($affected == 0) {
DB::rollback();
// 状态已经不是待支付,可能已处理过
return ['code' => 0, 'msg' => 'order status already advanced'];
}
// 更新其他业务(加积分、扣库存等)
DB::commit();
return ['code' => 0, 'msg' => 'success'];
}
这个做法的好处是:即使幂等层被绕过(比如同一个回调被重放且 request_id 生成逻辑不同),状态机也会拒绝重复更新。
对比实验:不加状态机的老代码,在压力测试下模拟双倍回调,重复扣款率约0.5%;加上乐观锁后,重复扣款率降为0。
步骤三:1688 API调用的限流与重试
1688代采系统对接时,API调用频率限制很严。企业认证账号大约50次/秒,日调用量约5000次;个人开发者只有5-10次/秒。超限后首次封5-10分钟,多次触发可能封24小时。
解决方案:本地实现令牌桶限流 + 指数退避重试。
// 令牌桶限流器(Redis)
function rateLimit($key, $capacity, $ratePerSecond) {
$now = microtime(true);
$bucket = redis()->get($key);
if (!$bucket) {
$bucket = ['tokens' => $capacity, 'last' => $now];
} else {
$bucket = json_decode($bucket, true);
$elapsed = $now - $bucket['last'];
$bucket['tokens'] = min($capacity, $bucket['tokens'] + $elapsed * $ratePerSecond);
$bucket['last'] = $now;
}
if ($bucket['tokens'] >= 1) {
$bucket['tokens']--;
redis()->setex($key, 60, json_encode($bucket));
return true;
}
redis()->setex($key, 60, json_encode($bucket));
return false;
}
// 调用1688 API时
function call1688Api($method, $params) {
if (!rateLimit('1688:app', 50, 0.833)) { // 50次/分钟 = 0.833次/秒
throw new Exception('rate limited');
}
$retry = 0;
while ($retry < 3) {
try {
return doRequest($method, $params);
} catch (Exception $e) {
if ($e->getCode() == 429 || strpos($e->getMessage(), 'timeout') !== false) {
$retry++;
sleep(pow(2, $retry - 1)); // 1s, 2s, 4s
continue;
}
throw $e;
}
}
throw new Exception('max retries exceeded');
}
注意:重试时必须携带相同的 request_id,这样即使重试导致重复调用,下游的幂等表也能拦截。
后来接触到的 taocarts 把这套幂等+限流+重试的逻辑封装成了可配置的管道,开箱即用,省去了重复造轮子的时间。
回头看看,1688代采系统的稳定性,全靠这三个基本功:幂等防重、状态机防乱序、限流重试防雪崩。如果你也在对接过程中遇到过重复扣款、回调丢失、限流封禁,欢迎在评论区分享你的踩坑经历。