在电商系统或任何需要扣减库存、资源数量的系统中,"超卖"现象是极其常见的并发一致性问题之一。本文将全面讲解超卖问题的本质及其常见的四种解决方案,涵盖原理、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:
输出 "库存扣减成功"
否则:
输出 "库存扣减失败(可能存在并发冲突)"
优点
- 能明确感知到数据是否被修改;
- 更适合有“读 → 修改 → 写”链路的业务流程;
- 无需加锁,性能较好。
缺点
- 并发较高时更新失败率高;
- 多步操作需要包裹在事务中,否则容易中间出错。
方案三:数据前后值对比验证是否一致
原理
执行更新后,通过对比扣减前后的库存数是否符合预期,间接判断是否存在并发篡改。
实现逻辑
这里要启动事务(非常的极端的情况下有可能没卖完)。
- 查询商品库存 → 记为
beforeStock
; - 更新库存(不带约束);
- 再次查询库存 → 记为
afterStock
; - 校验是否
beforeStock - buyNum == afterStock
。
Java 示例
变量 goodsId ← "商品ID"
变量 num ← "本次需要扣减的库存数量"
// 第一步:扣减库存前,查询当前库存
变量 beforeGoods ← 查询数据库:
SELECT * FROM 商品表 WHERE 商品ID = goodsId
变量 beforeGoodsNum ← beforeGoods.库存数量
// 第二步:执行扣减库存操作(这里要启动事务)
执行 SQL:
UPDATE 商品表
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
辅助表控制整体逻辑的一致性。
相当于构造一把“逻辑锁”,所有关键逻辑必须在拿到锁后才能执行。
执行流程
- 构造唯一业务
safe_key
,如"GOODS_ID:12345"
; - 查询或创建
concurrency_safe
记录; - 执行业务逻辑(如库存扣减);
- 更新
concurrency_safe.version
; - 如果更新失败,回滚事务,抛出并发冲突异常。
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:
输出 "库存不足,操作失败"
返回
// 执行扣减库存(注意:这里暂未加任何并发控制)
执行 SQL:
UPDATE 商品表
SET 库存数量 = 库存数量 - num
WHERE 商品ID = goodsId
}
// Step 5:尝试使用“乐观锁”方式更新保护表的版本号
变量 updateCount ← 执行 SQL:
UPDATE 并发保护表
SET version = version + 1
WHERE id = protectEntry.id AND version = protectEntry.version
// Step 6:判断是否更新成功(即是否存在并发冲突)
如果 updateCount == 1:
输出 "扣减成功"
否则:
抛出异常:ConcurrencyFailException("系统繁忙,请重试")
优点
- 非侵入式,可以封装保护旧逻辑;
- 灵活适配跨多个业务操作的“事务性并发保护”。
缺点
- 多表维护,结构复杂;
- 比较适合事务整体保护场景,性能较差。
四、基于 Redis 的几种解决超卖方法(附原理与实现建议)
方案五:Redis 单线程原子性(预扣库存)
原理
Redis 是单线程的,操作是原子性的,可以通过原子命令如 DECRBY
实现库存扣减操作。由于 Redis 中单条命令不会被打断,因此可以在 Redis 中将库存作为临时计数器,避免数据库并发冲突。
实现流程
-
系统启动时,将数据库中的库存同步到 Redis;
-
秒杀请求到达时,先在 Redis 扣减库存:
DECRBY goods_stock:1001 1
-
如果库存 ≥ 0,允许下单,进入异步下单逻辑;
-
异步任务最终将库存同步到数据库(最终一致性);
-
如果库存 < 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 上锁,让操作串行执行,常用于关键流程控制。
实现方式
- 秒杀请求到来,使用商品ID作为锁键,尝试加锁;
- 加锁成功后读取库存,扣减、下单;
- 执行完成释放锁;
- 加锁失败则稍后重试或失败返回。
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)异步处理,数据库扣减、订单生成在后台慢慢执行。
执行流程
- Redis 扣减库存;
- 如果成功,将订单数据封装进消息体投递到 MQ;
- 消费端接收消息,执行业务下单、持久化;
- 数据最终一致;
- 若消费失败,可重试/记录失败日志。
优点
- 把高并发请求削峰到消息队列;
- Redis 快速响应,系统抗压能力增强;
- 数据最终一致性,可容忍短暂延迟。
缺点
- 系统复杂度提高;
- MQ 投递失败、幂等处理需格外小心;
- 对业务逻辑解耦要求高。
五、对比总结:哪些场景选哪些方法?
方案 | 实现层级 | 并发能力 | 数据一致性 | 实现复杂度 | 推荐使用场景 |
---|---|---|---|---|---|
SQL条件扣减 | 数据库层 | 中等 | 强一致性 | 简单 | 入口单一、业务清晰 |
乐观锁版本 | 数据库层 | 中等 | 强一致性 | 一般 | 多步骤事务、版本控制 |
数据前后对比 | 应用层 | 低 | 强一致性 | 一般 | 老系统改造临时方案 |
并发保护表 | 应用 + DB | 中 | 强一致性 | 复杂 | 大系统改造封装逻辑 |
Redis 扣减 | 缓存层 | 高 | 最终一致性 | 一般 | 秒杀/活动场景 |
Redis Lua | 缓存层 | 高 | 最终一致性 | 一般 | 扣减逻辑多样化 |
Redis分布式锁 | 缓存层 | 低 | 强一致性 | 中等 | 串行关键操作 |
Redis + MQ | 多层组合 | 超高 | 最终一致性 | 高 | 高频流量+异步处理 |
六、推荐组合实践方案(企业常用)
对于高并发电商系统秒杀/抢购,建议组合如下:
- 库存维护:Redis 原子扣减 + Lua 脚本
- 并发削峰:消息队列(Kafka/RabbitMQ)异步下单
- 持久化:消费者落库,库存写回数据库
- 容错保障:失败消息重试 + 死信队列 + 数据对账任务
七、写在最后
并发修改问题是系统设计中的硬骨头,尤其是在高并发业务场景中,错误的操作不仅可能造成数据不一致,还可能引发连锁的业务和财务风险。
本文所介绍的四种方案从数据库原子更新到应用层逻辑校验,从乐观锁控制到逻辑锁封装,基本涵盖了大多数应用场景。你可以根据实际业务、团队协作方式和项目可维护性,灵活选择合适的方式加以实现。