首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >库存超卖是怎么发生的?一个最小库存扣减服务的三种并发控制实现

库存超卖是怎么发生的?一个最小库存扣减服务的三种并发控制实现

原创
作者头像
用户7093268
发布2026-08-19 18:54:24
发布2026-08-19 18:54:24
880
举报

做交易类业务(电商、票务、点餐、仓储出库),库存扣减是绕不开的一小段代码。它看起来只有"查库存、判断、扣减"三步,但并发一上来就会超卖。这篇文章用 Go + MySQL 写一个最小可用的库存扣减服务,把三种主流方案的实现、原理和代价讲清楚:单条原子 SQL、乐观锁、Redis 预减库存。

一、先复现问题:超卖是怎么发生的

表结构尽量简化:

代码语言:sql
复制
CREATE TABLE stock (
    sku_id   BIGINT PRIMARY KEY,
    quantity INT NOT NULL,
    version  INT NOT NULL DEFAULT 0
);

最容易写出来的扣减代码是这样的:

代码语言:go
复制
// 错误示范: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 件,两个请求同时进来,时间线是这样的:

代码语言:txt
复制
时刻  事务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

代码语言:go
复制
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

代码语言:go
复制
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 异步落库:

代码语言:lua
复制
-- 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 1
代码语言:go
复制
func 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 窗口。但这个方案换来的是一堆运维债,用之前要认清楚:

  • 一致性从"强一致"降级为"最终一致":Redis 和 DB 之间永远存在一个时间差,对账任务(定期比对两边库存)不是可选项,是必须项。
  • Redis 默认 AOF everysec,宕机可能丢 1 秒数据;主从切换可能丢已同步前的扣减记录。这些丢失要设计兜底方向:宁可少卖(Redis 库存偏低),不能超卖。
  • 库存预热、热点 key、MQ 堆积监控,每一项都是额外的运维成本。

所以它的适用面其实很窄:单品 QPS 真正到了 MySQL 行锁扛不住的量级,才值得引入。不到那个量,方案一就是终点。

五、别忘了幂等:防超卖之外,还要防重复扣减

并发控制解决的是"多人同时买",还有一个正交的问题:"同一个人买两次"。用户双击、网络重试、MQ 重投,都可能导致同一笔订单扣两次库存。前端置灰按钮不可靠,必须在服务端兜住。常见做法是给每次扣减一个全局唯一的 request_id,落一张去重表:

代码语言:sql
复制
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 删除。

问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档