高并发场景下解决商品超卖问题的五种方案与实践详解

在电商系统或任何需要扣减库存、资源数量的系统中,"超卖"现象是极其常见的并发一致性问题之一。本文将全面讲解超卖问题的本质及其常见的四种解决方案,涵盖原理、SQL设计、Java代码实现思路、适用场景和优缺点分析,帮助开发者建立起稳定可靠的并发数据修改能力。


一、超卖问题的本质:并发数据修改冲突

1. 什么是超卖?

"超卖"指的是在并发情况下,多个线程同时操作相同的库存数据,导致实际扣减总量超过原有库存数量,例如:

库存初始为10,多个线程并发读取库存后都判断库存充足,然后都进行了扣减,最终系统记录了15个订单,而库存变成了-5,这就产生了超卖。

2. 并发一致性的本质解决思路

无论使用乐观锁、悲观锁,还是CAS、本地锁、Redis锁等技术,其核心目的都是:

让关键修改逻辑串行化执行,本质是“加锁排队”

我们常说的“并发控制”,其实就是在适当的时机对资源进行锁定、判断或校验,确保不会出现“并发修改冲突”。


二、基础表结构设计

为了模拟真实的秒杀场景,我们设计了如下两张表:

1. 商品库存表 goods

用于记录每个商品的库存信息:

CREATE TABLE goods (
    id          VARCHAR(32) PRIMARY KEY COMMENT '商品ID',
    name        VARCHAR(100) NOT NULL COMMENT '商品名称',
    stock       INT NOT NULL COMMENT '当前库存',
    version     BIGINT DEFAULT 0 COMMENT '版本号(用于乐观锁)',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='商品库存表';

2. 并发保护表 concurrency_safe

用于方案四,辅助保护并发修改操作:

CREATE TABLE concurrency_safe (
    id         VARCHAR(32) PRIMARY KEY COMMENT '主键ID',
    safe_key   VARCHAR(255) NOT NULL UNIQUE COMMENT '需要保护的数据唯一标识',
    version    BIGINT DEFAULT 0 COMMENT '版本号(乐观锁)',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='并发安全控制辅助表';

三、五种解决方案详解


方案一:基于 SQL 条件判断的扣减库存

原理

在执行 UPDATE 时,直接加上库存是否充足的判断条件,使得 SQL 原子地控制扣减行为。

SQL 示例
UPDATE goods 
SET stock = stock - #{buyNum} 
WHERE id = #{goodsId} 
  AND stock >= #{buyNum};
Java 示例(伪代码)
public boolean deductStock(String goodsId, int buyNum) {
    int affectedRows = goodsMapper.updateStock(goodsId, buyNum);
    return affectedRows == 1;
}
优点
  • 实现简单;
  • 利用数据库原子性,无需加锁;
  • 性能高,适合高并发场景。
缺点
  • 如果有多个修改库存的地方,必须保证所有路径都使用相同的更新方式,否则可能绕开限制,导致数据不一致。

方案二:基于乐观锁的版本号控制

原理

通过 version 字段记录数据版本号,每次读取版本后更新时带上原始版本号作为条件,如果版本匹配则更新成功,否则表示数据被修改,更新失败。

SQL 示例
UPDATE goods 
SET stock = stock - #{buyNum}, version = version + 1 
WHERE id = #{goodsId} 
  AND version = #{currentVersion};
Java 示例
变量 goodsId ← "商品ID"
变量 num ← "本次需要扣减的库存数量"

// 第一步:查询当前商品库存信息
变量 goods ← 查询数据库:
               SELECT * FROM 商品表 WHERE 商品ID = goodsId

// 第二步:获取当前版本号
变量 expectedVersion ← goods.version

// 第三步:尝试使用乐观锁更新库存
变量 affectedRows ← 执行更新操作:
               UPDATE 商品表
               SET 库存 = 库存 - num,
                   版本号 = 版本号 + 1
               WHERE 商品ID = goodsId AND 版本号 = expectedVersion

// 第四步:判断更新结果
如果 affectedRows == 1:
    输出 "库存扣减成功"
否则:
    输出 "库存扣减失败(可能存在并发冲突)"

优点
  • 能明确感知到数据是否被修改;
  • 更适合有“读 → 修改 → 写”链路的业务流程;
  • 无需加锁,性能较好。
缺点
  • 并发较高时更新失败率高;
  • 多步操作需要包裹在事务中,否则容易中间出错。

方案三:数据前后值对比验证是否一致

原理

执行更新后,通过对比扣减前后的库存数是否符合预期,间接判断是否存在并发篡改。

实现逻辑

这里要启动事务(非常的极端的情况下有可能没卖完)。

  1. 查询商品库存 → 记为 beforeStock
  2. 更新库存(不带约束);
  3. 再次查询库存 → 记为 afterStock
  4. 校验是否 beforeStock - buyNum == afterStock
Java 示例
变量 goodsId ← "商品ID"
变量 num ← "本次需要扣减的库存数量"

// 第一步:扣减库存前,查询当前库存
变量 beforeGoods ← 查询数据库:
               SELECT * FROM 商品表 WHERE 商品ID = goodsId

变量 beforeGoodsNum ← beforeGoods.库存数量

// 第二步:执行扣减库存操作(这里要启动事务)
执行 SQLUPDATE 商品表
    SET 库存数量 = 库存数量 - num
    WHERE 商品ID = goodsId

// 第三步:再次查询扣减后的库存
变量 afterGoods ← 查询数据库:
              SELECT * FROM 商品表 WHERE 商品ID = goodsId

变量 afterGoodsNum ← afterGoods.库存数量

// 第四步:验证扣减是否符合预期,防止出现“库存扣成负数”或“超卖”
如果 beforeGoodsNum - num == afterGoodsNum:
    输出 "库存扣减成功"
否则:
    输出 "库存扣减失败,该线程没有抢到"

优点
  • 灵活适配一些特殊流程,例如批量扣减或不可加版本控制的表;
  • 适合老系统补救场景。
缺点
  • 对性能影响较大,尤其在高并发下。

方案四:使用并发辅助表进行版本控制保护

并发保护表 concurrency_safe

(这种方案对于快速修改适配一些老代码比较适用)
用于方案四,辅助保护并发修改操作:

CREATE TABLE concurrency_safe (
    id         VARCHAR(32) PRIMARY KEY COMMENT '主键ID',
    safe_key   VARCHAR(255) NOT NULL UNIQUE COMMENT '需要保护的数据唯一标识',
    version    BIGINT DEFAULT 0 COMMENT '版本号(乐观锁)',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='并发安全控制辅助表';

原理

将业务修改逻辑“包裹”在一个并发受控区域,通过 concurrency_safe 辅助表控制整体逻辑的一致性。

相当于构造一把“逻辑锁”,所有关键逻辑必须在拿到锁后才能执行。

执行流程
  1. 构造唯一业务 safe_key,如 "GOODS_ID:12345"
  2. 查询或创建 concurrency_safe 记录;
  3. 执行业务逻辑(如库存扣减);
  4. 更新 concurrency_safe.version
  5. 如果更新失败,回滚事务,抛出并发冲突异常。
Java 示例
变量 goodsId ← "商品ID"
变量 num ← "本次需要扣减的库存数量"

// Step 1:生成保护数据的唯一标识符 safeKey
变量 safeKey ← 拼接字符串 "GoodsPO:" + goodsId

// Step 2:根据 safeKey 查询并发保护表 t_concurrency_safe
变量 protectEntry ← 查询数据库:
    SELECT * FROM 并发保护表 WHERE safe_key = safeKey

// Step 3:若不存在保护记录,则创建(首次初始化)
如果 protectEntry == null:
    创建新的保护记录 ← new 记录(safeKey)
    插入数据库:
        INSERT INTO 并发保护表(safe_key) VALUES (safeKey)

// Step 4:执行业务逻辑(库存扣减) —— 这部分操作需要被保护
{
    // 查询商品库存信息
    变量 goods ← SELECT * FROM 商品表 WHERE 商品ID = goodsId

    // 校验库存是否充足
    如果 goods.库存数量 == 0:
        输出 "库存不足,操作失败"
        返回

    // 执行扣减库存(注意:这里暂未加任何并发控制)
    执行 SQLUPDATE 商品表
        SET 库存数量 = 库存数量 - num
        WHERE 商品ID = goodsId
}

// Step 5:尝试使用“乐观锁”方式更新保护表的版本号
变量 updateCount ← 执行 SQLUPDATE 并发保护表
    SET version = version + 1
    WHERE id = protectEntry.id AND version = protectEntry.version

// Step 6:判断是否更新成功(即是否存在并发冲突)
如果 updateCount == 1:
    输出 "扣减成功"
否则:
    抛出异常:ConcurrencyFailException("系统繁忙,请重试")

优点
  • 非侵入式,可以封装保护旧逻辑;
  • 灵活适配跨多个业务操作的“事务性并发保护”。
缺点
  • 多表维护,结构复杂;
  • 比较适合事务整体保护场景,性能较差。

四、基于 Redis 的几种解决超卖方法(附原理与实现建议)


方案五:Redis 单线程原子性(预扣库存)

原理

Redis 是单线程的,操作是原子性的,可以通过原子命令如 DECRBY 实现库存扣减操作。由于 Redis 中单条命令不会被打断,因此可以在 Redis 中将库存作为临时计数器,避免数据库并发冲突。

实现流程
  1. 系统启动时,将数据库中的库存同步到 Redis;

  2. 秒杀请求到达时,先在 Redis 扣减库存

    DECRBY goods_stock:1001 1
    
  3. 如果库存 ≥ 0,允许下单,进入异步下单逻辑;

  4. 异步任务最终将库存同步到数据库(最终一致性);

  5. 如果库存 < 0,代表超卖,Redis回滚(可选)。

Java 示例
Long stock = redisTemplate.opsForValue().decrement("goods_stock:" + goodsId, 1);
if (stock < 0) {
    // 回滚库存
    redisTemplate.opsForValue().increment("goods_stock:" + goodsId, 1);
    throw new RuntimeException("库存不足");
}
// 发送异步下单消息或执行下单逻辑
优点
  • 性能极高,适用于超高并发场景;
  • 几乎没有数据库压力;
  • 原子性保障不超卖。
缺点
  • Redis 是内存数据库,服务重启、崩溃可能造成库存丢失;
  • 数据最终一致性依赖异步落库,需保障可靠性;
  • 有预热、回写机制,增加系统复杂度。

方案六:Redis + Lua 脚本(更安全的原子扣减)

原理

使用 Lua 脚本将“检查库存 + 扣减 +判断是否成功”逻辑打包为一条命令,确保原子执行。

示例 Lua 脚本
-- KEYS[1]:库存 key
-- ARGV[1]:扣减数量
local stock = redis.call('GET', KEYS[1])
if not stock then
    return -1
end

if tonumber(stock) < tonumber(ARGV[1]) then
    return 0
else
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
end
Java 执行脚本
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(luaScript);
script.setResultType(Long.class);

Long result = redisTemplate.execute(script, 
    Collections.singletonList("goods_stock:" + goodsId), 
    String.valueOf(buyNum));

if (result == 1) {
    // 扣减成功
} else {
    // 扣减失败
}
优点
  • 所有逻辑原子执行,避免并发风险;
  • 更安全、易于控制失败流程。

方案七:Redis 分布式锁(悲观锁风格)

原理

使用 Redis 作为分布式锁中间件,针对特定商品 ID 上锁,让操作串行执行,常用于关键流程控制。

实现方式
  1. 秒杀请求到来,使用商品ID作为锁键,尝试加锁;
  2. 加锁成功后读取库存,扣减、下单;
  3. 执行完成释放锁;
  4. 加锁失败则稍后重试或失败返回。
Java 示例

使用 Redisson:

RLock lock = redissonClient.getLock("lock:goods:" + goodsId);
try {
    if (lock.tryLock(1, 3, TimeUnit.SECONDS)) {
        // 加锁成功,执行扣减库存逻辑
    } else {
        throw new RuntimeException("系统繁忙,请稍后再试");
    }
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}
优点
  • 操作简单,适合老业务快速改造;
  • 分布式环境可用,适合微服务。
缺点
  • 性能一般,高并发下吞吐有限;
  • 有锁失效风险(Redisson有Watchdog可规避);
  • 单商品时长时间持锁会拖慢全局秒杀效率。

方案八:Redis 预减 + 消息队列异步落库(最终一致性)

原理

结合方案五的思路,使用 Redis 快速预减库存,然后将下单逻辑通过 MQ(如 RabbitMQ/Kafka/RocketMQ)异步处理,数据库扣减、订单生成在后台慢慢执行。

执行流程
  1. Redis 扣减库存;
  2. 如果成功,将订单数据封装进消息体投递到 MQ;
  3. 消费端接收消息,执行业务下单、持久化;
  4. 数据最终一致;
  5. 若消费失败,可重试/记录失败日志。
优点
  • 把高并发请求削峰到消息队列;
  • Redis 快速响应,系统抗压能力增强;
  • 数据最终一致性,可容忍短暂延迟。
缺点
  • 系统复杂度提高;
  • MQ 投递失败、幂等处理需格外小心;
  • 对业务逻辑解耦要求高。

五、对比总结:哪些场景选哪些方法?

方案实现层级并发能力数据一致性实现复杂度推荐使用场景
SQL条件扣减数据库层中等强一致性简单入口单一、业务清晰
乐观锁版本数据库层中等强一致性一般多步骤事务、版本控制
数据前后对比应用层强一致性一般老系统改造临时方案
并发保护表应用 + DB强一致性复杂大系统改造封装逻辑
Redis 扣减缓存层最终一致性一般秒杀/活动场景
Redis Lua缓存层最终一致性一般扣减逻辑多样化
Redis分布式锁缓存层强一致性中等串行关键操作
Redis + MQ多层组合超高最终一致性高频流量+异步处理

六、推荐组合实践方案(企业常用)

对于高并发电商系统秒杀/抢购,建议组合如下:

  • 库存维护:Redis 原子扣减 + Lua 脚本
  • 并发削峰:消息队列(Kafka/RabbitMQ)异步下单
  • 持久化:消费者落库,库存写回数据库
  • 容错保障:失败消息重试 + 死信队列 + 数据对账任务

七、写在最后

并发修改问题是系统设计中的硬骨头,尤其是在高并发业务场景中,错误的操作不仅可能造成数据不一致,还可能引发连锁的业务和财务风险。

本文所介绍的四种方案从数据库原子更新应用层逻辑校验,从乐观锁控制逻辑锁封装,基本涵盖了大多数应用场景。你可以根据实际业务、团队协作方式和项目可维护性,灵活选择合适的方式加以实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值