做交易类业务(电商、票务、点餐、仓储出库),库存扣减是绕不开的一小段代码。它看起来只有"查库存、判断、扣减"三步,但并发一上来就会超卖。这篇文章用 Go + MySQL 写一个最小可用的库存扣减服务,把三种主流方案的实现、原理和代价讲清楚:单条原子 SQL、乐观锁、Redis 预减库存。
一、先复现问题:超卖是怎么发生的
表结构尽量简化:
CREATE TABLE stock (
sku_id BIGINT PRIMARY KEY,
quantity INT NOT NULL,
version INT NOT NULL DEFAULT 0
);最容易写出来的扣减代码是这样的:
// 错误示范:check-then-act,并发下会超卖
func DeductNaive(tx *sql.Tx, skuID int64) error {
var qty int
err := tx.QueryRow(
"SELECT quantity FROM stock WHERE sku_id = ?", skuID).Scan(&qty)
if err != nil {
return err
}
if qty < 1 {
return errors.New("库存不足")
}
_, err = tx.Exec(
"UPDATE stock SET quantity = quantity - 1 WHERE sku_id = ?", skuID)
return err
}单跑没问题,问题出在并发。假设库存只剩 1 件,两个请求同时进来,时间线是这样的:
时刻 事务A 事务B
T1 SELECT → 读到 quantity = 1
T2 SELECT → 读到 quantity = 1
T3 判断 1 >= 1,通过
T4 判断 1 >= 1,通过
T5 UPDATE → quantity = 0
T6 UPDATE → quantity = -1 ← 超卖关键点:在 MySQL 默认的 RR 隔离级别下,普通 SELECT 是快照读,不加锁。两个事务各自基于"1 件库存"的快照做判断,都觉得自己合法,最后库存被扣成负数。这就是典型的 check-then-act 竞态:检查和动作之间隔了一条语句,足够另一个事务插进来。
修复思路也由此而来——把"检查"和"扣减"合成一个不可分割的操作。
二、方案一:单条原子 UPDATE
var ErrOutOfStock = errors.New("库存不足")
func DeductAtomic(db *sql.DB, skuID int64) error {
res, err := db.Exec(
"UPDATE stock SET quantity = quantity - 1 WHERE sku_id = ? AND quantity >= 1",
skuID,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrOutOfStock // 库存不足时 WHERE 条件不成立,0 行受影响
}
return nil
}原理:UPDATE 是单条语句,InnoDB 会对命中行加排他锁,"判断 quantity >= 1"和"扣减"在同一个原子操作内完成,不存在上面那个时间窗口。库存不足时 WHERE 条件不成立,RowsAffected 为 0,正好用来区分"没货"和"扣成功"。
这个方案的优点是简单、强一致、没有额外组件,是绝大多数中小并发场景的正解。代价是热点行竞争:所有针对同一个 SKU 的扣减都会排队等这把行锁,单行 TPS 有明确上限(量级大致在几百到一千出头,取决于硬件和事务长度)。日单量几万级的业务完全够用;真到了秒杀级别的单品热点,就要考虑库存分桶(把一件商品的库存拆成多行随机扣)或者下面的方案三。
三、方案二:乐观锁 version
func DeductOptimistic(db *sql.DB, skuID int64) error {
for i := 0; i < 3; i++ {
var qty, version int
err := db.QueryRow(
"SELECT quantity, version FROM stock WHERE sku_id = ?", skuID,
).Scan(&qty, &version)
if err != nil {
return err
}
if qty < 1 {
return ErrOutOfStock
}
res, err := db.Exec(
`UPDATE stock SET quantity = quantity - 1, version = version + 1
WHERE sku_id = ? AND version = ? AND quantity >= 1`,
skuID, version,
)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 1 {
return nil // 更新成功,说明期间没人动过这行
}
// version 被别人改过,重试
}
return errors.New("并发冲突,重试次数耗尽")
}思路是"先不加锁,提交时校验数据有没有被别人动过":UPDATE 带上读到的 version,如果期间有其他事务修改过,version 对不上,更新 0 行,重试。version 递增同时解决了 ABA 问题(值改回去也能被识别)。
要诚实地说:就"单纯扣库存"这个场景,方案一在任何意义上都优于方案二——乐观锁在冲突高时会退化成反复重试,白烧 CPU。乐观锁真正的用武之地,是不变量涉及多个字段或多张表、一条 UPDATE 表达不出来的场景,比如"扣库存的同时校验用户额度且写一条流水"这类复合约束。把它当作通用扣库存方案是过度设计。
四、方案三:Redis 预减库存 + 异步落库
秒杀级热点单品,所有请求都怼到 MySQL 一行上,行锁排队会把数据库拖垮。常见做法是把库存预热到 Redis,用 Lua 脚本在内存里原子扣减,再通过 MQ 异步落库:
-- deduct.lua KEYS[1]=库存key ARGV[1]=扣减数量
local cur = tonumber(redis.call('GET', KEYS[1]) or '-1')
if cur < 0 then
return -1 -- key 不存在:库存未预热,直接拒绝,防穿透
end
if cur < tonumber(ARGV[1]) then
return 0 -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1func DeductByRedis(rdb *redis.Client, mq Producer, skuID int64, num int) error {
res, err := deductScript.Run(ctx, rdb,
[]string{fmt.Sprintf("stock:%d", skuID)}, num).Int()
if err != nil {
return err
}
switch res {
case 1:
// Redis 已扣,发消息异步落库;消息要至少一次投递,消费者保证幂等
return mq.Send(DeductMsg{SkuID: skuID, Num: num})
case 0:
return ErrOutOfStock
default:
return errors.New("库存未就绪")
}
}Lua 脚本在 Redis 里是原子执行的,同样消除了 check-then-act 窗口。但这个方案换来的是一堆运维债,用之前要认清楚:
所以它的适用面其实很窄:单品 QPS 真正到了 MySQL 行锁扛不住的量级,才值得引入。不到那个量,方案一就是终点。
五、别忘了幂等:防超卖之外,还要防重复扣减
并发控制解决的是"多人同时买",还有一个正交的问题:"同一个人买两次"。用户双击、网络重试、MQ 重投,都可能导致同一笔订单扣两次库存。前端置灰按钮不可靠,必须在服务端兜住。常见做法是给每次扣减一个全局唯一的 request_id,落一张去重表:
CREATE TABLE deduct_log (
request_id VARCHAR(64) PRIMARY KEY, -- 唯一约束就是幂等防线
sku_id BIGINT NOT NULL,
num INT NOT NULL,
created_at DATETIME NOT NULL
);扣库存和插去重表放在同一个本地事务里:重复请求插入时撞主键冲突,事务回滚,直接返回"已处理"。MQ 消费端同理——消息是至少一次投递,消费必须幂等,去重表是最简单可靠的一种实现。
六、三个方案怎么选
按并发量级和一致性要求对号入座即可:
单条原子 UPDATE:实现成本最低,强一致,无额外组件。日单量几千到几万、没有单品秒杀热点的业务,用它就是正确答案,不要提前优化。
乐观锁 version:适合冲突率低、且不变量涉及多字段/多表的复合校验场景。单纯扣库存不需要它。
Redis 预减 + MQ 落库:单品热点 QPS 达到 MySQL 行锁扛不住的量级(秒杀、大促主推款)才引入,代价是最终一致、对账、Redis 可靠性兜底这一整套运维成本。
另外提一句真实业务里的常见扩展:库存通常不是"下单即扣",而是"预占—实扣—释放"的状态机(下单时预占,支付后实扣,超时未支付释放)。这本质上是给库存加了一个中间态,并发控制的底层逻辑不变,但状态流转本身值得单独设计。
写在最后
库存超卖的根因是一个经典的 check-then-act 竞态,修复的核心思想只有一句话:让检查和修改原子化。至于是用数据库行锁、版本号还是 Redis Lua 来实现这个原子性,取决于并发量级——而认清自己的真实量级,不为想象中的流量过度设计,可能比选对某个方案更重要。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。