想象一下,双11零点刚过,服务器警报声此起彼伏。你引以为傲的秒杀系统,明明库存只有100件,结果后台一查,竟然卖出了105件。这多出来的5件“幽灵商品”,就是典型的超卖(Overselling)现象。对于电商、票务甚至任何涉及资源分配的系统来说,超卖不仅是技术故障,更是信誉灾难。
很多初级开发者遇到这个问题,第一反应往往是:“加个分布式锁!”或者“把数据库隔离级别调高!”。但如果你深入思考,会发现这些方案要么性能瓶颈严重,要么治标不治本。今天,我们不谈那些枯燥的理论定义,而是像剥洋葱一样,从MySQL底层的锁机制讲起,再到实战中如何优雅地解决超卖问题。我会带你走进数据库引擎的内部,看看它是如何处理并发竞争的,并给你一套经过生产环境验证的解决方案。
为什么简单的 SELECT 和 UPDATE 会失效?
让我们先还原一个最常见的错误写法。假设我们有一个商品表 products,字段包括 id, name, stock。
-- 伪代码逻辑
BEGIN TRANSACTION;
SELECT stock FROM products WHERE id = 1; -- 读到 stock = 10
-- 业务逻辑判断:stock > 0 ?
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 更新为 9
COMMIT;
在高并发下,如果有两个请求几乎同时到达:
- 线程A读取 stock = 10。
- 线程B读取 stock = 10。
- 线程A执行 UPDATE,stock 变为 9。
- 线程B执行 UPDATE,stock 变为 9。
你看,两次扣减,库存只减少了一次。这就是著名的丢失更新(Lost Update)问题。
有人可能会说:“我在UPDATE的时候加个条件不就行了?”
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0;
这个SQL确实能防止库存变成负数,但它能防止超卖吗?在读已提交(Read Committed, RC)隔离级别下,MySQL的行为是这样的:
- 线程A执行
UPDATE ... WHERE stock > 0。此时它获取了行锁。 - 线程B也执行同样的语句。因为A持有锁,B必须等待。
- A提交后释放锁。B获得锁,重新评估条件。
- 如果此时库存已经被其他线程扣减到0,B的条件
stock > 0不满足,更新行数为0,扣减失败。
看起来没问题?等等,这里有个陷阱。如果在可重复读(Repeatable Read, RR)隔离级别下(MySQL默认),情况会变得微妙。RR级别下,MVCC(多版本并发控制)会让线程B在开始事务时快照读取到的数据保持不变,直到事务结束。虽然 UPDATE 语句本身是最新读(Latest Read),但在某些复杂的组合查询或先查后更的场景中,依然可能产生逻辑漏洞。更重要的是,单纯的SQL判断无法处理“预占库存”这种复杂业务场景。
所以,我们需要更底层的理解:MySQL是如何保证一致性的? 这就引出了我们今天的主角——事务隔离级别和锁机制。
MySQL事务隔离级别:不仅仅是四个名词
很多教程只会列出RC、RR、RU、UT这四个缩写,告诉你谁读脏数据,谁不可重复读。但在解决超卖问题时,我们需要理解它们背后的锁行为和MVCC实现。
1. 读未提交(Read Uncommitted, RU)
这是最低级别的隔离。线程A修改数据但未提交,线程B就能读到。
- 对超卖的影响:完全不可用。B可能基于A未提交的错误数据进行计算,导致最终状态混乱。比如A买了100件,B看到库存没了,但也允许B下单,因为A还没回滚或提交确认。
2. 读已提交(Read Committed, RC)
Oracle和PostgreSQL的默认级别,MySQL支持但不推荐作为默认。
- 核心特性:每次
SELECT都会读取最新的已提交数据。 - 锁行为:RC级别下,普通
SELECT不加锁(通过MVCC快照实现),但UPDATE、DELETE会立即加排他锁(X锁)。 - 解决超卖的潜力:在RC级别下,上述的
UPDATE ... WHERE stock > 0是非常安全的。因为每次更新前,数据库都会检查当前行的最新版本。如果两个事务并发更新同一行,后提交的那个会因为版本号冲突或条件不满足而失败。RC是解决简单超卖问题的最佳隔离级别之一,因为它减少了锁的持有时间,提高了并发度。
3. 可重复读(Repeatable Read, RR)
MySQL的默认级别。
- 核心特性:在一个事务内,多次
SELECT看到的数据是一致的(快照读)。 - 锁行为:RR级别下,
SELECT使用MVCC快照,不加锁;但UPDATE/DELETE依然加排他锁。此外,RR引入了间隙锁(Gap Lock)和临键锁(Next-Key Lock),用于防止幻读。 - 对超卖的隐患:虽然RR保证了事务内的一致性,但在处理高并发扣减库存时,如果业务逻辑是“先SELECT再UPDATE”,在RR下,第二次SELECT看到的还是旧数据(快照),除非你使用
SELECT ... FOR UPDATE。如果你没用FOR UPDATE,就会出现上面提到的“丢失更新”。如果你用了FOR UPDATE,则会阻塞其他事务,导致性能急剧下降,形成死锁热点。
4. 串行化(Serializable, ST)
最高级别,强制所有事务串行执行。
- 后果:没有超卖,因为没有并发。但吞吐量极低,秒杀场景下直接崩盘。绝对不要在生产环境的秒杀中使用ST。
专家建议:对于纯库存扣减,RC级别配合原子性UPDATE语句往往比RR级别配合悲观锁具有更高的性能。但如果业务复杂,涉及多步操作,则需要引入更高级的锁策略。
实战方案一:基于数据库乐观锁的原子更新
这是最简单、最直接的解决方案,适用于大多数中小型电商系统。核心思想是:不要依赖SELECT的结果做判断,而是让数据库在执行UPDATE时自己判断条件。
原理
利用MySQL的行版本号(在RR级别下通过MVCC隐式维护,或在RC下直接对比)或显式的版本号字段。
代码实现
假设我们的表结构如下:
CREATE TABLE product_inventory (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id VARCHAR(64) NOT NULL COMMENT 'SKU ID',
stock INT NOT NULL DEFAULT 0 COMMENT '当前库存',
version INT NOT NULL DEFAULT 0 COMMENT '版本号,用于乐观锁'
);
方式A:利用版本号(经典乐观锁)
// Java伪代码
public boolean deductStock(String skuId, int quantity) {
// 1. 先获取当前版本号(可选,为了前端展示或日志,非必须用于原子操作)
// ProductInventory inventory = mapper.selectBySku(skuId);
// 2. 关键步骤:原子更新
// 只有当版本号匹配且库存充足时,才执行扣减
int rowsAffected = mapper.updateStockByVersion(skuId, quantity, inventory.getVersion());
if (rowsAffected > 0) {
return true;
} else {
// 并发冲突,库存不足或版本不一致
throw new BusinessException("库存不足或抢购人数过多,请稍后重试");
}
}
对应的Mapper XML:
<update id="updateStockByVersion">
UPDATE product_inventory
SET stock = stock - #{quantity},
version = version + 1
WHERE sku_id = #{skuId}
AND stock >= #{quantity}
AND version = #{version}
</update>
解析:
这里的关键在于 AND version = #{version} 和 AND stock >= #{quantity}。
即使多个事务同时进入,MySQL会在执行UPDATE时加排他锁。第一个成功的事务会更新version为N+1。第二个事务虽然也拿到了锁,但它检查 version = N 时发现不匹配(因为已经被第一个事务改成了N+1),或者检查 stock 发现不足,从而更新行数为0。
这种方法避免了先查后更带来的竞态条件,是真正的乐观锁。
方式B:无版本号,直接原子判断(更简洁)
如果不想维护version字段,可以直接利用stock本身的属性:
<update id="deductStockDirect">
UPDATE product_inventory
SET stock = stock - #{quantity}
WHERE sku_id = #{skuId}
AND stock >= #{quantity}
</update>
注意:这种方式依赖于MySQL的原子性。在RC级别下,这是安全的。在RR级别下,只要你不使用 SELECT ... FOR UPDATE 先查一遍,而是直接执行这条UPDATE,它也是安全的。因为UPDATE语句本身会加锁并检查当前行的最新值。
优点:
- 实现极其简单。
- 并发性能好,因为只有发生冲突时才需要排队。
- 避免了长事务持有锁的问题。
缺点:
- 如果并发极高,大量请求会失败,用户体验可能是“抢购失败”。需要通过重试机制或前端提示优化体验。
- 无法知道具体是哪个事务失败了(除非记录日志)。
实战方案二:Redis预扣减 + MySQL异步落库
对于千万级QPS的秒杀场景,数据库直接扛不住。这时候,我们需要缓存前置。Redis的单线程模型保证了操作的原子性,非常适合做库存扣减的第一道防线。
架构流程
- 预热:活动开始前,将库存加载到Redis中(
SET stock 100)。 - 扣减:用户请求到来,先执行Lua脚本在Redis中扣减库存。
- 如果扣减成功,返回“抢购成功”,允许创建订单。
- 如果扣减失败,直接返回“已售罄”。
- 异步同步:创建一个消息队列(如RocketMQ/Kafka),将扣减成功的请求发送出去。
- 落库:消费者监听消息,将订单写入MySQL,并更新数据库中的库存(使用方案一的原子UPDATE作为兜底)。
Redis Lua脚本示例
Lua脚本在Redis中是原子执行的,不会被其他命令插入。
-- keys[1] is the stock key
-- ARGV[1] is the quantity to deduct
local stock = tonumber(redis.call('get', KEYS[1]))
if stock < tonumber(ARGV[1]) then
return 0 -- 库存不足
else
redis.call('decrby', KEYS[1], ARGV[1])
return 1 -- 扣减成功
end
为什么还需要MySQL兜底?
你可能会问:“既然Redis扣成功了,为什么还要MySQL再扣一次?”
这是因为数据一致性和容灾。
- 持久化:Redis内存数据可能丢失(尽管有RDB/AOF,但仍有风险)。MySQL是最终持久化存储。
- 业务关联:订单必须存在于数据库中,才能查询、退款、售后。
- 最终一致性:Redis扣减成功后,只是获得了“排队资格”。真正完成交易的是MySQL中的订单记录。如果Redis扣减成功,但后续写入MySQL失败(网络抖动、DB宕机),我们需要有补偿机制。
代码实现:Spring Boot + Redis + MQ
@Service
public class SeckillService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
public boolean seckill(String skuId, int userId, int quantity) {
String stockKey = "seckill:stock:" + skuId;
// 1. Lua脚本原子扣减Redis库存
Long result = redisTemplate.execute(
(RedisCallback<Long>) connection -> {
byte[] script = redisTemplate.getStringSerializer().serialize(
"if redis.call('get', KEYS[1]) >= tonumber(ARGV[1]) then " +
" return redis.call('decrby', KEYS[1], ARGV[1]) " +
"else " +
" return -1 " +
"end"
);
// 实际生产中建议使用完整的Lua脚本对象以提高效率
return connection.executeCommand(RedisCommands.EVAL, script, 1,
redisTemplate.getStringSerializer().serialize(stockKey),
redisTemplate.getStringSerializer().serialize(String.valueOf(quantity)));
}
);
if (result == null || result == -1) {
return false; // 库存不足
}
// 2. 发送消息到MQ,异步创建订单
OrderMessage message = new OrderMessage();
message.setSkuId(skuId);
message.setUserId(userId);
message.setQuantity(quantity);
rabbitTemplate.convertAndSend("seckill_queue", message);
return true; // 抢购成功,等待异步结果
}
}
MySQL端的幂等处理
在消费者端,必须确保幂等性。因为MQ消息可能重复投递。
@RabbitListener(queues = "seckill_queue")
public void handleOrder(OrderMessage message) {
String orderId = generateOrderId(message.getUserId(), message.getSkuId());
// 检查订单是否已存在(幂等性)
if (orderMapper.exists(orderId)) {
return;
}
try {
// 1. 创建订单
orderMapper.insert(createOrder(message, orderId));
// 2. 扣减数据库库存(再次兜底,防止Redis和DB不一致)
int affected = productMapper.deductStock(message.getSkuId(), message.getQuantity());
if (affected == 0) {
// 库存不足,回滚订单(或者标记为异常,人工介入)
orderMapper.cancelOrder(orderId);
}
} catch (Exception e) {
// 记录日志,告警
log.error("Seckill failed", e);
}
}
进阶:分布式锁 vs 乐观锁,选哪个?
很多人会问:“为什么不直接用Redisson分布式锁?”
分布式锁(Pessimistic Locking)
RLock lock = redissonClient.getLock("stock_lock_" + skuId);
try {
lock.lock();
// 1. SELECT stock FROM products WHERE id = 1 FOR UPDATE;
// 2. Check stock
// 3. UPDATE products SET stock = stock - 1
} finally {
lock.unlock();
}
缺点:
- 性能差:所有请求串行化执行,QPS上限取决于数据库的处理能力。
- 死锁风险:如果业务逻辑复杂,容易引发死锁。
- 维护成本高:需要处理锁超时、续期等问题。
适用场景:
- 库存量极大,但并发量相对较小的普通商品购买。
- 需要严格串行化的复杂财务操作。
结论:在秒杀等高并发场景下,乐观锁(原子UPDATE)或 Redis预扣减 远远优于分布式锁。分布式锁是“悲观”的,假设冲突一定会发生;而乐观锁假设冲突很少发生,只在发生时处理。对于库存扣减,冲突是常态,但我们可以接受少量失败并重试,而不是让所有请求排队等待。
常见误区与陷阱
1. “我在SELECT后面加了FOR UPDATE,为什么还超卖?”
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 加锁
-- 业务逻辑耗时较长...
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
如果你使用了 SELECT ... FOR UPDATE,你就进入了悲观锁模式。此时,其他事务必须等待你的事务提交才能读取或修改该行。
- 问题:如果你的业务逻辑(如计算优惠、调用第三方接口)很慢,这个锁会持有很久,导致系统吞吐量极低,甚至数据库连接池耗尽。
- 超卖原因:通常不是因为锁没生效,而是因为锁的范围不对或事务隔离级别配置错误。在某些情况下,如果使用
SERIALIZABLE级别,可能会因为锁升级导致性能崩溃。
2. “我用Redis扣了库存,但MySQL没扣成,怎么办?”
这是经典的最终一致性问题。
- 解决方案:
- 本地消息表:在MySQL中创建一个消息表,与订单事务一起提交。然后由定时任务扫描消息表,发送到MQ。
- 对账系统:每天凌晨运行对账程序,比较Redis(或MQ堆积)、MySQL订单表和库存表。如果发现不一致(如Redis显示0,MySQL还有库存),则触发补偿流程。
- 超时取消:如果用户下单后15分钟未支付,自动释放库存。这时需要在Redis和MySQL中同时释放。
3. “库存扣减到负数了?”
永远不要相信客户端传来的数据。
- 原则:所有库存校验必须在服务端进行。
- SQL防护:
UPDATE ... SET stock = stock - 1 WHERE stock >= 1。这条SQL保证了库存永远不会小于0。
总结:构建坚不可摧的库存系统
面对高并发下的超卖问题,没有银弹,只有最适合场景的组合拳。
轻量级场景(普通电商,QPS < 1000):
- 直接使用MySQL的乐观锁(
UPDATE ... WHERE stock >= quantity)。 - 设置合理的隔离级别为RC,减少锁开销。
- 前端做好失败重试和友好提示。
- 直接使用MySQL的乐观锁(
中量级场景(秒杀,QPS 1k - 10k):
- Redis预扣减 + MySQL异步落库。
- Redis负责快速拦截大部分请求,保护数据库。
- MySQL负责最终持久化和一致性校验。
- 引入消息队列削峰填谷。
重量级场景(双11,QPS > 10k):
- 多层架构:Nginx限流 -> CDN静态化 -> 网关层鉴权 -> Redis集群扣减 -> 分库分表 -> 异步消息处理。
- 库存拆分:将一个大商品的库存拆分成多个小库存单元(Sharding),分散压力。
- 冷热分离:热数据在Redis,冷数据在HBase/TiDB。
记住,技术的本质是权衡。在性能和一致性之间,在用户体验和系统稳定性之间,找到那个平衡点。希望这篇文章能帮你理清思路,不再为超卖问题头疼。如果你在实际操作中遇到具体的报错或性能瓶颈,欢迎随时交流,我们一起拆解。
