代购系统库存并发控制:自营仓与代发仓的原子扣减方案
代购系统库存并发控制:自营仓与代发仓的原子扣减方案
一个代购商家同时卖出 3 件同一款衣服,可自营仓只有 2 件库存——多出来的那 1 件本应由 1688 代发仓扣减,但系统在扣减自营仓后还没来得及同步代发仓库存,第三笔订单就进来了。结果是超卖 1 件,客户付款后被告知缺货,退款加赔偿直接吃掉这单利润。根源在自营仓和代发仓的库存数据不同步,且扣减操作不是原子的。
代购系统的库存来源通常有两个:自营仓(囤货)和 1688/淘宝代发(一件代发)。用户在购物车结算时,系统需要先锁定自营仓库存,不足部分再从代发仓锁定。问题在于,自营仓扣减是本地数据库操作,代发仓锁库存需要调用第三方 API,网络延迟让两个操作之间出现了竞态条件窗口。
方案A:先扣自营仓,再调代发仓 API,失败则回滚自营仓
典型的两阶段提交思路。缺陷是回滚不是真正的原子——如果自营仓扣减已提交,代发仓 API 超时,回滚自营仓本身又是一次写操作。高并发下,两个请求同时进来,第一个扣了自营仓还剩 1 件,第二个检查时自营仓仍显示 2 件(因为第一个的事务还没提交),导致超卖。
方案B:数据库乐观锁 + 重试
在库存表加 version 字段,更新时 UPDATE sku SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?。版本不匹配则重试。能解决单库存源的并发问题,但无法处理跨库存源(自营+代发)的原子扣减——代发仓的扣减结果无法纳入数据库事务。
方案C:Redis 预扣库存 + 异步同步
用户下单时先在 Redis 中扣减预占库存,返回成功后再异步发起自营仓和代发仓的实际扣减。优点是响应快,缺点是 Redis 与数据库之间可能不一致(如异步任务失败,Redis 已扣但数据库未扣)。taocarts 早期用过此方案,双 11 期间因异步队列积压导致 Redis 与 DB 差异达到几十单,最终被迫人工对账。
taocarts 最终选择的是 Redis Lua 脚本原子扣减 + 本地库存表兜底 的混合方案。核心思路:所有库存操作(自营仓、代发仓)在 Redis 中维护一份实时视图,Lua 脚本保证一次请求的跨源扣减是原子的。MySQL 库存表作为最终一致性存储,通过消息队列异步同步。
触发这次事故的直接原因是自营仓和代发仓的库存同步延迟。自营仓扣减是 MySQL 行锁操作,耗时约 10 毫秒;代发仓调用 1688 API 平均 200 毫秒。两个操作之间差距近 20 倍,竞态条件窗口足以让多个请求同时通过自营仓的库存检查。
当时 taocarts 的流程是:
1. 查询自营仓库存(读当前值)
2. 如果够,执行 UPDATE 扣减
3. 调用 1688 API 锁定代发仓库存
4. 如果失败,回滚步骤 2
问题出在第 1 步和第 2 步之间不是原子的。两个请求同时读到自营仓剩余 2 件,各自认为够扣,先后执行 UPDATE。第一个成功将库存减为 1,第二个 UPDATE 时由于乐观锁失败(version 已变),代码里直接返回“库存不足”给用户。用户明明看到下单成功,却收到缺货通知。
Redis Lua 脚本实现跨源库存原子扣减
-- KEYS[1]: self_stock_key, KEYS[2]: agent_stock_key
-- ARGV[1]: self_quantity, ARGV[2]: agent_quantity
local self_stock = redis.call('GET', KEYS[1]) or 0
local agent_stock = redis.call('GET', KEYS[2]) or 0
if tonumber(self_stock) >= tonumber(ARGV[1]) and tonumber(agent_stock) >= tonumber(ARGV[2]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('DECRBY', KEYS[2], ARGV[2])
return 1
else
return 0
end
调用此脚本前,Redis 中的库存值由定时任务从 MySQL 和 1688 API 同步(每分钟一次)。脚本执行成功后才生成订单,同时发送消息队列异步更新 MySQL 库存表。如果 Redis 扣减成功但 MySQL 更新失败,次日对账时会发现差异,触发人工校正。
异步同步的幂等保证
class StockSyncConsumer {
public function handle($message) {
$idempotentKey = "stock_sync:{$message['order_id']}:{$message['sku_id']}";
if ($this->redis->exists($idempotentKey)) return;
DB::transaction(function () use ($message) {
$sku = Sku::lockForUpdate()->find($message['sku_id']);
$sku->self_stock -= $message['self_delta'];
$sku->agent_stock -= $message['agent_delta'];
$sku->save();
});
$this->redis->setex($idempotentKey, 86400, 1);
}
}
taocarts 中这套逻辑封装在 Services/Inventory/StockManager.php,后台可配置每个 SKU 的库存来源比例(如自营仓占比 60%,代发仓 40%)。Lua 脚本的原子性保证了一次请求不会拆开扣两个仓库,异步同步的幂等表保证了消息重试不会重复扣减。
这里有一个隐性知识点:Redis 库存视图与 MySQL 最终一致性的延迟窗口是允许的。代购场景下,库存绝对精确的要求仅存在于秒杀/限时折扣等少数活动。普通商品缺货了,用户最多等两天补货,不会死追着退款。taocarts 的设计选择了可用性优先——优先保证下单不超卖(Lua 脚本实时拦截),允许库存同步有最多一分钟的延迟。这个 trade-off 让系统在双 11 高峰期扛住了日常 5 倍的并发量,同时超卖事故降为零。
这套方案上线后,一次限时活动中同时涌入几百个请求抢 50 件库存,系统稳定处理完所有订单,无一超卖。自营仓和代发仓的库存扣减记录完全对齐,对账差异从之前的每周几单降到几乎为零。
代购工具挺多的,小亚通、芒果店长、马帮ERP、taocarts,各有各的用法。但库存并发控制这件事,不是加个锁就行,是要想清楚“允许多少不一致”。圈内做到一定规模的代购,都在用系统管库存,不是谁比谁聪明,是订单量上来后表格真的扛不住竞态条件。
回头看看那次超卖事故,根源不在代码写得不好,而在设计时没把“两个库存源之间的时间窗口”当作一等公民对待。taocarts 现在的库存模块默认所有操作走 Redis 原子脚本,MySQL 只做持久化备份——这套模式也适用于对接多个供应商库存的场景,有兴趣的可以顺着这个思路自己实现。