Java并发扣库存案例实现:从基础到企业级解决方案
文章导读目录
-
为什么并发扣库存是Java开发者必会的硬技能?

-
基础实现:单机版synchronized与Lock方案
-
进阶方案:Redis + Lua脚本保证原子性
-
分布式场景:数据库乐观锁与悲观锁抉择
-
高性能方案:分段锁与库存预扣机制
-
常见问题FAQ(含面试高频问题)
为什么并发扣库存是Java开发者必会的硬技能?
在电商秒杀、票务系统、库存管理等场景中,“超卖”是致命问题,假设一个商品库存只有100件,若1000个用户同时发起购买请求,如何保证最终扣减成功且库存不为负数?这就是并发扣库存需要解决的核心矛盾。
案例场景:某电商平台iPhone促销,库存50台,瞬间涌入5000请求,每个请求扣减1台库存。
核心挑战:
- 原子性:扣减操作(读取→判断→更新)必须不可中断
- 一致性:多个线程/服务间的库存数据必须同步
- 性能:高并发下不能把系统锁死
基础实现:单机版synchronized与Lock方案
1 使用synchronized关键字
public class LocalInventoryService {
private int stock = 100;
public synchronized boolean deductStock() {
if (stock <= 0) return false;
stock--;
return true;
}
}
缺点:synchronized是JVM级别的重量级锁,高并发下线程排队效率低。
2 使用ReentrantLock
private final Lock lock = new ReentrantLock();
public boolean deductStock() {
lock.lock();
try {
if (stock <= 0) return false;
stock--;
return true;
} finally {
lock.unlock();
}
}
优势:可中断、可尝试获取锁,比synchronized灵活
局限性:仅适用于单进程应用,分布式场景失效
进阶方案:Redis + Lua脚本保证原子性
Redis的Lua脚本执行是原子性的,可有效解决分布式高并发扣库存问题。
1 核心脚本(lua/stock.lua)
local key = KEYS[1] -- 库存key
local quantity = tonumber(ARGV[1]) -- 扣减数量
local current = redis.call('get', key)
if not current or tonumber(current) < quantity then
return 0 -- 库存不足
end
redis.call('decrby', key, quantity)
return 1
2 Java调用代码
public class RedisInventoryService {
@Autowired
private StringRedisTemplate redisTemplate;
public boolean deductStock(Long goodsId, Integer quantity) {
String luaScript = Files.readString(Paths.get("lua/stock.lua"));
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script,
List.of("stock:" + goodsId),
quantity.toString());
return result != null && result == 1;
}
}
为什么用Lua?
- 单个Lua脚本在Redis中是原子执行,等同于事务
- 避免了多次网络请求(get→check→decr)的竞态条件
- 性能极高,单机可达数万QPS
分布式场景:数据库乐观锁与悲观锁抉择
1 数据库悲观锁(SELECT ... FOR UPDATE)
BEGIN; SELECT stock FROM product WHERE id=? FOR UPDATE; -- 业务判断stock>=1 UPDATE product SET stock=stock-1 WHERE id=?; COMMIT;
优点:绝对安全,数据库锁级别保证
缺点:行锁持有时间长,并发下极易死锁,压测1000并发时数据库CPU飙升
2 数据库乐观锁(CAS思路)
UPDATE product SET stock = stock - 1 WHERE id = ? AND stock >= 1;
关键:利用数据库行锁自带的原子操作,stock=stock-1本身是原子减法
配套检查:stock >= 1 确保不出现负数
效率:远高于悲观锁,适合秒杀场景,但需配合重试机制
3 企业级最优实践(混合方案)
- 前端用Redis Lua拦截80%请求(快速过滤库存不足)
- 数据库用乐观锁做最终一致性确认
- 少量请求可能发生“扣Redis成功但数据库失败”,通过补偿任务回滚Redis
高性能方案:分段锁与库存预扣机制
当单商品库存过大(如100万件),单一Redis key会成为热点,可以引入库存分片思想。
1 分段哈希设计
// 库存分10段,每段10万件
public class SegmentInventoryService {
private static final int SEGMENT_COUNT = 10;
public boolean deductStock(Long goodsId, int quantity) {
int segmentId = ThreadLocalRandom.current().nextInt(SEGMENT_COUNT);
String segmentKey = "stock:" + goodsId + ":segment:" + segmentId;
// 用Lua脚本扣减该段库存,若不足则循环尝试其他段
}
}
效果:分散了单key压力,Redis热点从1个变成10个
注意:需配合“库存余量汇总”监控,避免前端展示不准确
2 预扣+异步结算模式
- 预扣:秒杀成功时先在Redis中扣减并锁定30分钟
- 结算:支付成功则同步数据库并释放预扣
- 回滚:超时未支付则自动归还Redis库存
优点:用户体验极佳(秒杀秒出结果),数据库毫秒级写入
常见问题FAQ
Q1:synchronized和ReentrantLock哪个更适合高并发扣库存?
A:两者单机下都可,但ReentrantLock支持尝试锁定(tryLock),避免线程无限等待,实际生产单机场景已较少见,更多转向分布式方案。
Q2:Redis扣库存一定会比数据库快吗?
A:Redis纯内存+单线程模型,纯扣减操作可达5万QPS,但若需“扣库存+写订单”的事务,数据库的ACID特性不可替代,正确做法是:Redis做高性能预扣,DB做最终数据落盘。
Q3:如何测试并发扣库存是否安全?
A:使用Jmeter或Locust进行1000并发请求,验证最终库存是否为0,更专业的做法:写单元测试,用CountDownLatch模拟500线程同时扣减,断言库存最终值=初始值-批量总数。
Q4:如果Redis挂了,怎么办?
A:建议降级方案:1) Redis集群(哨兵/Cluster)解决单点;2) 降级到数据库乐观锁兜底;3) 本地内存缓存+热点检测,防止缓存雪崩。
Q5:用户连续点击(多次扣库存)如何处理?
A:前端做按钮防重复(1秒内禁止重发),后端基于用户ID+商品ID做幂等校验(如用Redis setnx,30秒内唯一key)。
一套可落地的架构参考
用户请求 → 限流层(Nginx+Lua限流)→ Redis Lua扣库存(高性能拦截)
↓ 成功 ↓ 失败
订单写入MQ 返回“库存不足”
↓
数据库乐观锁最终扣减
↓
支付成功 → 释放预扣锁
支付失败 → 回滚库存
关键点:
- 不使用数据库悲观锁(秒杀场景不要用
FOR UPDATE) - Redis Lua脚本必须幂等(库存不足返回0而非抛异常)
- 补偿机制:如订单超时、支付失败,都需设计库存回退
通过本文的案例实现,你将能够应对从单体应用到分布式系统的并发扣库存场景,建议在生产环境中先进行压测,确保性能与一致性达到平衡。