本文目录导读:

- 方案一:数据库乐观锁(最常用、性价比高)
- 方案二:数据库悲观锁(
SELECT ... FOR UPDATE) - 方案三:Redis 分布式锁(适合跨服务跨节点)
- 方案四:Redis Lua 脚本(最优选:原子性 + 高性能)
- 方案五:消息队列 + 最终一致性(适合秒杀/异步处理)
- 面试加分项:如何选择?
在Java库存扣减场景中,防超卖的核心是保证扣减操作的原子性(要么全部成功,要么全部失败,且中间状态不可见)。
这里整理了几种从简单到复杂、从单体到分布式的方案,并附带了核心代码示例和适用场景。
数据库乐观锁(最常用、性价比高)
这是单体应用或简单分布式场景的首选,核心思想是利用数据库的行锁和版本号,在更新时校验库存是否足够。
原理:在库存表中增加一个 version 字段,更新时,where 条件中加上 version = old_version,如果版本号不匹配(被其他线程改了),则更新失败,需要重试。
库存表结构(简化):
CREATE TABLE `product_stock` ( `id` bigint NOT NULL, `product_id` varchar(32) NOT NULL COMMENT '商品ID', `stock` int NOT NULL DEFAULT '0' COMMENT '当前库存', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `idx_product_id` (`product_id`) ) ENGINE=InnoDB;
核心Java代码:
// Service层
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
/**
* 使用乐观锁扣减库存
* @param productId 商品ID
* @param quantity 扣减数量
* @return true 扣减成功, false 库存不足或并发冲突
*/
public boolean deductStock(String productId, int quantity) {
int retryCount = 3; // 重试次数
while (retryCount-- > 0) {
// 1. 查询当前库存和版本号
Stock stock = stockMapper.selectByProductId(productId);
if (stock == null || stock.getStock() < quantity) {
return false; // 库存不足
}
// 2. 尝试更新(关键SQL:where version = oldVersion)
int rows = stockMapper.deductStockByVersion(
productId,
quantity,
stock.getVersion()
);
if (rows > 0) {
return true; // 扣减成功
}
// rows = 0 说明版本号冲突(被其他线程改了),重试
}
return false; // 重试耗尽,返回失败
}
}
Mapper XML:
<update id="deductStockByVersion">
UPDATE product_stock
SET stock = stock - #{quantity},
version = version + 1
WHERE product_id = #{productId}
AND version = #{version}
AND stock >= #{quantity} <!-- 防止负数库存 -->
</update>
优点:无锁等待,性能好;通过 stock >= #{quantity} 防止库存变负。
缺点:高并发下重试可能较多,不适合极端高并发(如秒杀瞬间百万QPS)。
数据库悲观锁(SELECT ... FOR UPDATE)
通过数据库行锁强制串行化操作,适合并发不高、但要求绝对严格的场景。
原理:在事务内,先 SELECT stock FOR UPDATE 锁定该行,其他事务必须等待,然后判断库存并更新。
核心代码:
@Transactional
public boolean deductStockWithLock(String productId, int quantity) {
// 1. 加锁查询(此时该行被锁定,其他线程等待)
// (必须加上 "FOR UPDATE",且事务不能提前关闭)
Stock stock = stockMapper.selectByProductIdWithLock(productId);
// (等价 SQL: SELECT * FROM product_stock WHERE product_id = ? FOR UPDATE)
if (stock == null || stock.getStock() < quantity) {
throw new RuntimeException("库存不足");
}
// 2. 更新库存(因为已经锁定,更新一定成功)
int rows = stockMapper.deductStock(productId, quantity);
// (等价 SQL: UPDATE product_stock SET stock = stock - #{quantity}
// WHERE product_id = #{productId} AND stock >= #{quantity})
return rows > 0;
}
注意:
FOR UPDATE必须放在事务中;并且如果索引使用不当可能锁表。
优点:绝对安全,无ABA问题。 缺点:性能差,容易死锁(尤其多个行锁顺序不一致时);会阻塞请求,适合低并发。
Redis 分布式锁(适合跨服务跨节点)
当应用多实例、需要跨 JVM 同步时,可用 Redis 实现分布式锁,结合本地缓存提高性能。
原理:获取一个互斥锁(product:lock:123),获取锁的线程才允许扣减库存,扣减完后释放锁。
核心代码(使用 Redisson 框架):
@Autowired
private RedissonClient redissonClient;
public boolean deductStockWithRedisLock(String productId, int quantity) {
// 1. 获取分布式锁(key: product:stock:lock:123)
RLock lock = redissonClient.getLock("product:stock:lock:" + productId);
try {
// 2. 尝试加锁,最多等待10秒,锁自动释放30秒(避免死锁)
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 3. 查库存(从 Redis 或 DB 查)
Integer stock = getStockFromRedis(productId);
if (stock < quantity) {
return false;
}
// 4. 更新库存(同时更新 Redis 和 DB)
updateStock(productId, quantity);
return true;
} else {
// 没获取到锁,说明其他线程在处理该商品,可以重试或返回失败
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
// 5. 释放锁(必须放在 finally)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
优点:跨进程、多节点通用,比数据库悲观锁性能好。 缺点:Redis 本身不是强一致性,极端情况(主节点宕机、主从切换)可能出问题;实现复杂。
Redis Lua 脚本(最优选:原子性 + 高性能)
如果库存预存在 Redis 中,Lua 脚本可以保证扣减操作的原子性,且无需网络往返。
原理:Redis 执行 Lua 脚本时是单线程、原子的,脚本内检查库存并扣减,然后返回结果。
核心代码:
@Autowired
private StringRedisTemplate redisTemplate;
// Lua 脚本:检查库存并扣减
// KEYS[1] = 商品库存键
// ARGV[1] = 扣减数量
private static final String DEDUCT_SCRIPT =
"local stock = redis.call('get', KEYS[1]) " +
"if (not stock or tonumber(stock) < tonumber(ARGV[1])) then " +
" return -1 " + // 库存不足
"end " +
"redis.call('decrby', KEYS[1], ARGV[1]) " +
"return redis.call('get', KEYS[1])"; // 返回剩余库存
public boolean deductStockByLua(String productId, int quantity) {
// 构建脚本参数
RedisScript<Long> script = new DefaultRedisScript<>(DEDUCT_SCRIPT, Long.class);
List<String> keys = Collections.singletonList("product:stock:" + productId);
// 执行 Lua 脚本(原子操作)
Long result = redisTemplate.execute(script, keys, String.valueOf(quantity));
if (result == null || result == -1L) {
return false; // 库存不足
}
// 注意:这里只更新了 Redis,DB 需要异步同步(用消息队列)
// 或者先扣 DB(用数据库锁),再用 Redis 做高性能缓存层
return true;
}
优点:极高并发、高吞吐、原子性,适合秒杀等场景。 缺点:只能保证 Redis 端的原子性,如果业务需要与 DB 强一致,需额外处理异步落库。
消息队列 + 最终一致性(适合秒杀/异步处理)
将请求去重、排队,然后异步扣减库存,牺牲强一致性换取高并发吞吐。
流程:
- 前端请求到订单系统,创建订单(状态“待支付”)。
- 订单消息发送到消息队列(如 RocketMQ),并持久化。
- 库存服务消费消息,使用数据库乐观锁扣减库存。
- 如果库存不足,回滚订单。
优点:削峰填谷,不直接冲击 DB,极端并发下系统更稳定。 缺点:存在最终一致时间窗口;实现复杂。
面试加分项:如何选择?
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单体应用,并发中等(<500 QPS) | 数据库乐观锁 | 简单、可靠、成本低 |
| 对一致性要求极高(如银行转账) | 数据库悲观锁(FOR UPDATE) |
绝对串行,无隐患 |
| 分布式多节点,并发高(>1000 QPS) | Redis Lua 脚本 + 消息队列 | 性能与一致性的最佳平衡 |
| 秒杀场景,流量巨大且允许少量失败 | Redis Lua 脚本(异步落库) | 扛住洪峰,核心是牺牲强一致性换吞吐 |
最后提醒:无论使用哪一种方案,核心SQL一定要加 WHERE stock >= #{quantity} 条件,这是防超卖的最后一道防线,防止因为代码 BUG 导致库存变负数。