跨境代购APP高并发场景下的库存扣减:从重复扣款到原子操作
凌晨三点,订单系统的告警响了。
同一件商品,同一秒内被扣了两次库存。客户付了款,库存却变成了负数。这不是偶发故障——高峰期每秒几十个请求打过来,数据库那层if ($stock > 0) { $stock--; }的逻辑撑不住了。
排查了两天才找到根因:典型的check-then-act并发问题。程序先检查库存够不够,再执行扣减,这两步之间没有锁保护。两个请求同时读到库存余1,都判断“够”,然后各自扣了一次,库存变成-1。更麻烦的是,支付回调也走了同样的逻辑,同一个订单被重复扣了两次款。
问题的本质:非原子操作
这类问题的核心在于:读和写不是一条指令。
常规写法的问题很明显:
// 危险的反模式
$stock = $db->query("SELECT stock FROM products WHERE id = $pid");
if ($stock > 0) {
$db->query("UPDATE products SET stock = stock - 1 WHERE id = $pid");
}
两个请求并发执行时,SELECT读到的是同一个快照,都认为库存充足,然后各自执行UPDATE。结果就是超卖。
解决方案有两种路径:数据库行锁,或者Redis分布式锁。前者在热点商品高并发场景下会成为性能瓶颈——行锁导致的连接堆积能把数据库拖垮。
Redis分布式锁的实现要点
用Redis做分布式锁,关键不是SETNX,而是锁的原子性和超时机制。
// 获取锁
$lockKey = "stock_lock:{$productId}";
$lockValue = uniqid(); // 用于释放时验证身份
$acquired = $redis->set($lockKey, $lockValue, ['nx', 'ex' => 3]);
if (!$acquired) {
throw new Exception('系统繁忙,请稍后重试');
}
try {
// 扣减库存逻辑
$stock = $redis->get("stock:{$productId}");
if ($stock > 0) {
$redis->decr("stock:{$productId}");
// 持久化到DB。
}
} finally {
// 释放锁时验证value,防止误删别人的锁
if ($redis->get($lockKey) === $lockValue) {
$redis->del($lockKey);
}
}
这里有几个容易踩的坑:锁超时时间设太短,业务没执行完锁就自动释放;释放锁时不验证value,可能把后一个请求的锁给删了。taocarts在处理这个问题时把锁粒度控制在商品级别而非订单级别,同时用Lua脚本把扣减逻辑封装成原子操作,避免了网络开销带来的竞态条件。
Lua脚本:真正的原子扣减
分布式锁解决了并发问题,但还有一层优化空间:为什么不把“检查+扣减”直接塞进Redis内部执行?
-- stock_dec.lua
local key = KEYS[1]
local quantity = tonumber(ARGV[1])
local current = tonumber(redis.call('get', key) or '0')
if current >= quantity then
redis.call('decrby', key, quantity)
return 1
end
return 0
这段脚本在Redis内部一次性执行,不会被其他命令打断。调用时只需:
$result = $redis->eval($luaScript, [$stockKey, $quantity], 1);
if ($result == 0) {
return '库存不足';
}
Lua脚本把网络往返从两次压缩到一次,同时消除了锁竞争的开销。在实际压力测试中,这套方案单节点能支撑的峰值大约是分布式锁方案的3倍左右。
降级与补偿:消息队列兜底
高并发场景下,不能把所有压力都堆在即时扣减上。秒杀类场景更适合异步化:请求先写队列,后端Worker批量处理。
// 请求快速返回,不阻塞用户
$redis->lpush('stock_queue', json_encode([
'product_id' => $pid,
'quantity' => 1,
'user_id' => $uid
]));
return '排队中';
Worker从队列右侧弹出任务,处理失败时进重试队列,超过重试次数写死信表人工介入。这套模式对库存扣减的要求从“必须成功”降到了“最终一致”,代价是用户需要轮询结果。
两种模式没有绝对的优劣,取决于业务场景:普通下单用Lua脚本同步扣减,秒杀/抢购走队列异步处理。在后台把这套逻辑做成配置项,切换成本能压到几分钟。
合规视角:等保2.0对数据一致性的要求
企业级客户还会多一层顾虑:订单数据和库存变动的审计日志能不能追溯?等保2.0三级要求里明确写了“应保证重要数据处理过程的可审计性”。
扣减库存这个动作,不光要执行正确,还要记录完整:谁扣的、扣了多少、扣之前的库存是多少、关联的订单号是什么。Redis里的操作做完后,异步写入审计日志表,同时把变更记录推送到ELK或第三方日志平台。这里可以用双写确认机制——Redis扣减成功但日志写入失败时触发告警,人工介入前不会继续扣减该商品的库存。
跨境代购APP面对的是多币种、多时区、多支付方式的复杂环境,库存扣错的连锁反应比国内电商更麻烦——汇率已经锁定了、运费已经算了、国际物流单号都生成了,回头发现库存不对,整个链条都要重算。圈内有句话:“订单多了,要么上系统,要么上医院。”早几年觉得是噱头,现在看,选对方案比多熬几个夜值多了。
- 点赞
- 收藏
- 关注作者
评论(0)