Redis的优惠券秒杀问题(六)超卖问题、一人一单问题
Redis的优惠券秒杀问题(六)超卖问题、一人一单问题
超卖问题
问题描述
接上一篇文章。在上一篇文章中,我们实现了秒杀下单的业务,有请求过来,只要库存充足,就进行减库存,生成订单的操作。
但是这样子在多线程高并发的场景下,一定会出现问题。
使用Jmeter进行压测
我们可以用 jmeter工具 复现一下场景,具体配置如下
配置authorization
运行程序,登入用户后,打开F12,获取authorization 的值
启动 Jmeter,进行压测,我们这里开了200个线程
我们这里设定有100个库存,所以正常的情况应该会有一半的线程(100个)的HTTP请求出现异常,但是这里显然不是!
查看数据库 tb_sckill_voucher表
stock为负数,超卖问题发生!
订单表 tb_voucher_order表 也是如此
发生超卖问题原因分析
解决方案
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁!!!
所以我们现在要研究是就是要加什么类型的锁?要怎么加锁?在哪里加锁?
悲观锁与乐观锁
悲观锁 | 添加同步锁,让线程串行执行 |
优点 | 简单粗暴 |
缺点 | 性能一般 |
乐观锁 | 不加锁,在更新时判断是否有其它线程在修改 |
优点 | 性能好 |
缺点 | 存在成功率低的问题 |
先提一下,并不是说有一种锁叫乐观锁、叫悲观锁,“悲观”、“乐观”只是用来形容一种思想,一种方式!!!
相较于悲观锁而言, 乐观锁机制采取了更加宽松的加锁机制。自然性能方面会优于悲观锁!
在这个问题中我们用乐观锁来解决!最常见的方式有两个“版本号”、“CAS”
1. 版本号
但是,显然,在之前数据库设计的时候,没有version这个字段。
我们如果想要用这个方案也可以,但是比较麻烦!
2. CAS法
CAS是乐观锁的一种实现,CAS全称是比较和替换,CAS的操作主要由以下几个步骤组成
- 先查询原始值
- 操作时比较原始值是否修改
- 如果修改,则操作失败,禁止更新操作,如果没有发生修改,则更新为新值
伪代码
do{
备份旧数据;
基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))
CAS三大问题(题外话!)
扯点别的,CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题:
- ABA问题
- 循环时间长开销大
- 只能保证一个共享变量的原子操作
CAS三大问题的解决方案
CAS的三个问题及解决方案_渣一个的博客-CSDN博客_业务层cas 解决死循环https://blog.csdn.net/weichi7549/article/details/107734843
代码实现
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
LocalDateTime nowTime = LocalDateTime.now();
// 2. 判断秒杀是否开始
if (nowTime.isBefore(voucher.getBeginTime())) {
return Result.fail("活动未开始!");
}
// 3. 判断秒杀是否结束
if (nowTime.isAfter(voucher.getEndTime())) {
return Result.fail("活动已结束!");
}
// 4. 判断库存
if (voucher.getStock() < 1) {
return Result.fail("已买完!");
}
// 5. 减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0) // CAS方案(乐观锁)!
.update();
if (!success) {
return Result.fail("库存不足");
}
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
核心是我们在减库存的时候,判断一下stock是否还大于0。
.gt("stock", 0)
我们这边是只要库存是大于0的就都可以购买,所以对于这个点,乐观锁的判断可以适当放宽,只对库存为0时的“减库存”操作加锁,对应的SQL:
update tb_seckill_voucher
set stock = stock - 1
where voucher_id = 18 and stock > 0
一人一单问题
问题描述
什么是一人一单问题?简单的来说就是模拟为了防止黄牛”屯“货而设计的,每一个用户ID,只能下一单!如下图,同一个用户下了很多单!!!
所以我们要修改秒杀业务,要求同一个优惠券,一个用户只能下一单
流程设计
解决方案
我们先获取一下用户的id,如果是相同的用户id“同时”执行到这里,只能允许一个进入该逻辑,执行减库存,生成订单的逻辑!其它的必须在此阻塞。
代码实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
LocalDateTime nowTime = LocalDateTime.now();
// 2. 判断秒杀是否开始
if (nowTime.isBefore(voucher.getBeginTime())) {
return Result.fail("活动未开始!");
}
// 3. 判断秒杀是否结束
if (nowTime.isAfter(voucher.getEndTime())) {
return Result.fail("活动已结束!");
}
// 4. 判断库存
if (voucher.getStock() < 1) {
return Result.fail("已买完!");
}
Long userId = UserHolder.getUser().getId();
// 如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用
// 对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 一人一单
Long userId = UserHolder.getUser().getId();
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0){
return Result.fail("用户已经购买过一次了!");
}
// 5. 减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0) // CAS方案(乐观锁)!
.update();
if (!success) {
return Result.fail("库存不足");
}
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
voucherOrder.setUserId(userId);
// 6.3代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
}
代码中技术点分析
1. intern()
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
...
}
intern() 方法返回字符串对象的规范化表示形式
对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。
String.intern()是一个Native方法,它的作用是:如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用(不会新new一个),否则,将新的字符串放入常量池,并返回新字符串的引用。
在该业务场景中,会有多个线程会同时执行该逻辑。会生成多个userId,这里是要给相同的userId加锁!但是toString()方法会生成一个新的字符串对象。
如果不使用 intern() ,尽管这些字符串的值相同,它们的内存地址也会不同!!!所以并不会把它们认定为相同的字符串!
2. 事务失效问题 currentProxy()
如果我们这里不生成其代理对象,则会导致事务失效
synchronized (userId.toString().intern()) {
return createVoucherOrder(voucherId);
}
在Spring中,事务的实现方式,是对当前类(VoucherOrderServiceImpl)做了动态代理!用其代理对象去做事务处理!
但是这里如果是上述代码, 实际上是this.createVoucherOrder(voucherId),这个this指的是VoucherOrderServiceImpl,是非代理对象,是没有事务功能的!!!所以如果代码这样子写,@Transactional标注的事务会失效!!!
解决办法
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
获取到当前对象的代理对象
AopContext.currentProxy()
然后再用该代理对象来调用方法
proxy.createVoucherOrder(voucherId);
这么改完之后还要在启动类上面加上注解,用来暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true) // 默认是关闭的
@EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理对象!
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 实操Deepseek接入个人知识库
· 易语言 —— 开山篇
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 【全网最全教程】使用最强DeepSeekR1+联网的火山引擎,没有生成长度限制,DeepSeek本体