黑马点评-超卖问题
什么是超卖问题?
想象一个场景:
黑马点评搞秒杀活动,某商品库存 100 件。活动开始后,15 个用户同时下单,最后系统显示卖出了 101 件,库存变成了 - 1 —— 这就是超卖。
超卖本质
:多线程
环境下,库存扣减操作未正确同步,导致 实际销量 > 库存量的严重业务错误。针对这一问题常见解决方案就是加锁
为什么会超卖?
用代码模拟一下问题:
// 错误的扣减库存方式
public boolean seckill(Long voucherId) {// 1. 查询库存SeckillVoucher voucher = seckillVoucherService.getById(voucherId);int stock = voucher.getStock();// 2. 判断库存是否充足if (stock > 0) {// 3. 扣减库存(stock=stock-1)voucher.setStock(stock - 1);seckillVoucherService.updateById(voucher);}
}
问题出在 “查询→判断→扣减
” 这三步不是原子操作:
- 线程 1 查询到库存 = 1,还没扣减;
- 线程 2 也查询到库存 = 1,也进入扣减逻辑;
- 最后库存变成 - 1,超卖了。
解决方案
解决方案对比:悲观锁 vs 乐观锁
方案 | 实现原理 | 适用场景 | 性能影响 | 实现复杂度 |
---|---|---|---|---|
悲观锁 | “先锁定再操作”(如synchronized ) | 高冲突场景,数据强一致性要求高 | 较高(串行执行) | 低 |
乐观锁 | “先操作再验证”(版本号/条件 判断) | 低冲突场景,读多写少 | 低(并行执行) | 中 |
分布式锁 | Redis/ZooKeeper,全局锁 控制 | 分布式系统,高并发秒杀 | 中(网络开销) | 高 |
悲观锁实战
public synchronized boolean seckill(Long voucherId) {// 同上:查询→判断→扣减
}
优点:简单,绝对不会超卖。
缺点:并发高时,大家都在等锁,响应慢
,像排队结账排成长龙。
乐观锁实战:黑马点评解决方案
核心代码实现
public boolean seckill(Long voucherId) {// 1. 查询优惠券库存SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2. 判断秒杀是否开始/结束if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {throw new RuntimeException("秒杀尚未开始!");}if (voucher.getEndTime().isBefore(LocalDateTime.now())) {throw new RuntimeException("秒杀已结束!");}// 3. 库存不足直接返回if (voucher.getStock() < 1) {return false;}// 4. 乐观锁扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // SET stock = stock - 1.eq("voucher_id", voucherId) // WHERE voucher_id = #{voucherId}.gt("stock", 0) // AND stock > 0.update();return success;
}
方案优势
- 无锁竞争:避免线程阻塞,提高并发能力
- 轻量级:数据库级别实现,无需额外组件
- 简单有效:SQL条件天然保证库存安全
潜在问题:高并发下的低成功率
当100线程同时抢10个商品时:
- 乐观锁成功率 ≈ 10%
- 90%请求失败需重试或放弃
进阶方案:Redis分布式锁
实现原理
核心代码:
public boolean seckillWithRedisLock(Long voucherId) {// 1. 获取分布式锁String lockKey = "lock:voucher:" + voucherId;String clientId = UUID.randomUUID().toString();try {// 尝试获取锁(设置10秒超时)Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);if (!Boolean.TRUE.equals(locked)) {return false; // 获取锁失败}// 2. 执行库存扣减return doSeckill(voucherId);} finally {// 3. 释放锁(Lua脚本保证原子性)String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +"then return redis.call('del', KEYS[1]) " +"else return 0 end";redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),Collections.singletonList(lockKey),clientId);}
}
性能优化:库存预减方案
架构设计
实现步骤
1. 预热库存到Redis:
redisTemplate.opsForValue().set("stock:voucher:"+voucherId, 100);
2. 预减库存:
Long stock = redisTemplate.opsForValue().decrement("stock:voucher:"+voucherId);
if (stock < 0) {// 库存不足,回滚redisTemplate.opsForValue().increment("stock:voucher:"+voucherId);return false;
}
3. 异步落库:
@Async
public void asyncUpdateStock(Long voucherId) {seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
}
方案选型指南
场景 | 推荐方案 | QPS支持 | 注意事项 |
---|---|---|---|
低并发(<1000QPS) | 乐观锁 | 1k-5k | 控制重试次数 |
中并发(5k-10kQPS) | Redis分布式锁 | 5k-10k | 设置锁超时 |
高并发(>10kQPS) | 库存预减+异步 | 10k+ | 保证最终一致性 |
超高并发 | Redis+Lua脚本 | 50k+ | 监控Redis负载 |
最佳实践总结
1. 多层防护:
- 前端:按钮防重、验证码
- 网关:请求限流
- 服务:库存预减
- 数据库:最终一致性
2. 监控指标:
// 监控关键指标
meterRegistry.counter("seckill.requests").increment();
meterRegistry.counter("seckill.success").increment();
meterRegistry.counter("seckill.failures").increment();
3. 回退机制:
// 库存回补
if (updateFailed) {redisTemplate.opsForValue().increment("stock:voucher:"+voucherId);
}
超卖问题本质是并发控制的艺术,选择合适方案需平衡性能、一致性和复杂度。建议从乐观锁起步,随业务增长逐步升级到分布式方案!