Redis实战——优惠券秒杀:超卖问题一人一单问题

目录

  • 优惠券秒杀
    • Redis实现全局唯一ID
      • 业务场景
      • 代码实现:
    • 添加优惠券
      • 业务场景
      • 新增普通券
      • 新增秒杀券
      • 测试添加秒杀券
    • 实现秒杀下单
      • 业务分析
      • 流程代码编写
    • 超卖问题
      • 超买场景分析
      • 解决方案
      • 使用乐观锁
    • 一人一单
      • 业务场景
      • 解决步骤
    • 集群环境下的并发问题

优惠券秒杀

Redis实现全局唯一ID

业务场景

在各类购物App中,都会遇到商家发放的优惠券,当用户抢购商品时,生成的订单会保存到tb_voucher_order表中,而订单表如果使用数据库自增ID就会存在一些问题

  • id规律性太明显
  • 受单表数据量的限制
  • 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
  • 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性

那么这就引出我们的全局ID生成器了
全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性

  1. 唯一性
  2. 高可用
  3. 高性能
  4. 递增性
  5. 安全性

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息

ID组成部分
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID
那我们就根据我们分析的ID生成策略,来编写代码

代码实现:

  1. 生成起始时间
public static void main(String[] args) {//设置一下起始时间,时间戳就是起始时间与当前时间的秒数差LocalDateTime tmp = LocalDateTime.of(2022, 1, 1, 0, 0, 0);System.out.println(tmp.toEpochSecond(ZoneOffset.UTC));//结果为1640995200L
}
  1. 完整生成代码:
@Component
public class RedisIdWorker {@Autowiredprivate StringRedisTemplate stringRedisTemplate;//设置起始时间,我这里设定的是2022.01.01 00:00:00public static final Long BEGIN_TIMESTAMP = 1640995200L;//序列号长度public static final Long COUNT_BIT = 32L;public long nextId(String keyPrefix){//1. 生成时间戳LocalDateTime now = LocalDateTime.now();long currentSecond = now.toEpochSecond(ZoneOffset.UTC);long timeStamp = currentSecond - BEGIN_TIMESTAMP;//2. 生成序列号String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));long count = stringRedisTemplate.opsForValue().increment("inc:"+keyPrefix+":"+date);//3. 拼接并返回,简单位运算return timeStamp << COUNT_BIT | count;}
}

添加优惠券

业务场景

  • 每个店铺度可以发布优惠券,分为平价券和特价券,平价券可以任意购买,而特价券需要秒杀抢购
  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等
  • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
  • 平价券由于优惠力度并不是很大,所以是可以任意领取
  • 而代金券由于优惠力度大,所以像第二种券,就得限制数量,从表结构上也能看出,特价券除了具有优惠券的基本信息以外,还具有库存,抢购时间,结束时间等等字段

sql设计如下:

DROP TABLE IF EXISTS `tb_voucher`;
CREATE TABLE `tb_voucher` (`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',`shop_id` bigint(20) unsigned DEFAULT NULL COMMENT '商铺id',`title` varchar(255) NOT NULL COMMENT '代金券标题',`sub_title` varchar(255) DEFAULT NULL COMMENT '副标题',`rules` varchar(1024) DEFAULT NULL COMMENT '使用规则',`pay_value` bigint(10) unsigned NOT NULL COMMENT '支付金额,单位是分。例如200代表2元',`actual_value` bigint(10) NOT NULL COMMENT '抵扣金额,单位是分。例如200代表2元',`type` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '0,普通券;1,秒杀券',`status` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '1,上架; 2,下架; 3,过期',`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;-- ----------------------------
-- Table structure for tb_voucher_order
-- ----------------------------
DROP TABLE IF EXISTS `tb_voucher_order`;
CREATE TABLE `tb_voucher_order` (`id` bigint(20) NOT NULL COMMENT '主键',`user_id` bigint(20) unsigned NOT NULL COMMENT '下单的用户id',`voucher_id` bigint(20) unsigned NOT NULL COMMENT '购买的代金券id',`pay_type` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '支付方式 1:余额支付;2:支付宝;3:微信',`status` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',`pay_time` timestamp NULL DEFAULT NULL COMMENT '支付时间',`use_time` timestamp NULL DEFAULT NULL COMMENT '核销时间',`refund_time` timestamp NULL DEFAULT NULL COMMENT '退款时间',`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;

新增普通券

新增普通券,也就只是将普通券的信息保存到表中

/*** 新增普通券* @param voucher 优惠券信息* @return 优惠券id*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {voucherService.save(voucher);return Result.ok(voucher.getId());
}

新增秒杀券

新增秒杀券主要看addSeckillVoucher中的业务逻辑

/*** 新增秒杀券* @param voucher 优惠券信息,包含秒杀信息* @return 优惠券id*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {voucherService.addSeckillVoucher(voucher);return Result.ok(voucher.getId());
}

addSeckillVoucher中的业务逻辑
秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {// 保存优惠券save(voucher);// 保存秒杀信息SeckillVoucher seckillVoucher = new SeckillVoucher();// 关联普通券idseckillVoucher.setVoucherId(voucher.getId());// 设置库存seckillVoucher.setStock(voucher.getStock());// 设置开始时间seckillVoucher.setBeginTime(voucher.getBeginTime());// 设置结束时间seckillVoucher.setEndTime(voucher.getEndTime());// 保存信息到秒杀券表中seckillVoucherService.save(seckillVoucher);
}

测试添加秒杀券

请求路径http://localhost:8081/voucher/seckill, 请求方式POST,JSON数据如下

{"shopId":1,"title":"100元代金券","subTitle":"周一至周五可用","rules":"全场通用\\n无需预约\\n可无限叠加","payValue":8000,"actualValue":10000,"type":1,"stock":100,"beginTime":"2022-01-01T00:00:00","endTime":"2022-10-31T23:59:59"
}

查询数据库可以看到添加记录

实现秒杀下单

业务分析

分析一下怎么抢优惠券

  1. 首先提交优惠券id,然后查询优惠券信息
  2. 之后判断秒杀时间是否开始
  3. 开始了,则判断是否有剩余库存
  4. 有库存,那么删减一个库存
  5. 然后创建订单
  6. 无库存,则返回一个错误信息
  7. 没开始,则返回一个错误信息

对应的流程图如下
20240313-091531-xc.png

流程代码编写

  1. VoucherOrderController
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {@Autowiredprivate IVoucherOrderService voucherOrderService;@PostMapping("/seckill/{id}")public Result seckillVoucher(@PathVariable("id") Long voucherId) {return voucherOrderService.seckillVoucher(voucherId);}
}
  1. IVoucherOrderService
public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);
}
  1. seckillVoucher
@Autowired
private ISeckillVoucherService seckillVoucherService;@Autowired
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();//1. 查询优惠券queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);//2. 判断秒杀时间是否开始if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {return Result.fail("秒杀还未开始,请耐心等待");}//3. 判断秒杀时间是否结束if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {return Result.fail("秒杀已经结束!");}//4. 判断库存是否充足if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");}//5. 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id",voucherId).update();if (!success) {return Result.fail("库存不足");}//6. 创建订单VoucherOrder voucherOrder = new VoucherOrder();//6.1 设置订单idlong orderId = redisIdWorker.nextId("order");//6.2 设置用户idLong id = UserHolder.getUser().getId();//6.3 设置代金券idvoucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);voucherOrder.setUserId(id);//7. 将订单数据保存到表中save(voucherOrder);//8. 返回订单idreturn Result.ok(orderId);
}

超卖问题

超买场景分析

我们之前的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景,URL为 localhost:8081/voucher-order/seckill/13,请求方式为POST

注意使用Jmeter进行压测时,需要携带我们登录的token
20240313-095739-Ch.png

测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有166条数据,去优惠券表查看,库存为-66,超卖了66张
20240313-095808-rf.png

那么如何解决这个问题呢?先来看看我们的代码中是怎么写的

//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
//5. 扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if (!success) {return Result.fail("库存不足");
}

假设现在只剩下一张优惠券,线程1过来查询库存,判断库存数大于1,但还没来得及去扣减库存,此时库线程2也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题

解决方案

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案

  1. 悲观锁
    悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行
  • 例如Synchronized、Lock等,都是悲观锁
  • 悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
  1. 乐观锁
    乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改
  • 如果没有修改,则认为自己是安全的,自己才可以更新数据

  • 如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常

  • 乐观锁会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如CAS

  • 乐观锁的典型代表:就是CAS(Compare-And-Swap),利用CAS进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

int var5;
do {var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;

其中do while是为了操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次

使用乐观锁

该项目中的具体解决方式:
这里并不需要真的来指定一下版本号,完全可以使用stock来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同

@Override
public Result seckillVoucher(Long voucherId) {LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();//1. 查询优惠券queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);//2. 判断秒杀时间是否开始if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {return Result.fail("秒杀还未开始,请耐心等待");}//3. 判断秒杀时间是否结束if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {return Result.fail("秒杀已经结束!");}//4. 判断库存是否充足if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");}//5. 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).eq("stock",seckillVoucher.getStock()).update();if (!success) {return Result.fail("库存不足");}//6. 创建订单VoucherOrder voucherOrder = new VoucherOrder();//6.1 设置订单idlong orderId = redisIdWorker.nextId("order");//6.2 设置用户idLong id = UserHolder.getUser().getId();//6.3 设置代金券idvoucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);voucherOrder.setUserId(id);//7. 将订单数据保存到表中save(voucherOrder);//8. 返回订单idreturn Result.ok(orderId);
}

以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
20240313-100712-OA.png

那么我们继续完善代码,修改我们的逻辑,在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作

@Override
public Result seckillVoucher(Long voucherId) {LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();//1. 查询优惠券queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);//2. 判断秒杀时间是否开始if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {return Result.fail("秒杀还未开始,请耐心等待");}//3. 判断秒杀时间是否结束if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {return Result.fail("秒杀已经结束!");}//4. 判断库存是否充足if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");}//5. 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).eq("stock",seckillVoucher.getStock()).gt("stock", 0).update();if (!success) {return Result.fail("库存不足");}//6. 创建订单VoucherOrder voucherOrder = new VoucherOrder();//6.1 设置订单idlong orderId = redisIdWorker.nextId("order");//6.2 设置用户idLong id = UserHolder.getUser().getId();//6.3 设置代金券idvoucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);voucherOrder.setUserId(id);//7. 将订单数据保存到表中save(voucherOrder);//8. 返回订单idreturn Result.ok(orderId);
}

重启服务器,继续使用Jmeter进行测试,这次就能顺利将优惠券刚好抢空了,没有发生超卖的问题。
20240313-101100-SX.png

但是这种乐观锁的方式并不完美,需要查询数据库判断是否超卖,需要继续优化,比如使用多库存等。

一人一单

业务场景

需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
如果已存在,则不能下单,返回错误信息
如果不存在,则继续下单,获取优惠券
初步代码

    @Overridepublic Result seckillVoucher(Long voucherId) {LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();//1. 查询优惠券queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);//2. 判断秒杀时间是否开始if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {return Result.fail("秒杀还未开始,请耐心等待");}//3. 判断秒杀时间是否结束if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {return Result.fail("秒杀已经结束!");}//4. 判断库存是否充足if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");}// 一人一单逻辑Long userId = UserHolder.getUser().getId();int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();if (count > 0){return Result.fail("你已经抢过优惠券了哦");}//5. 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();if (!success) {return Result.fail("库存不足");}//6. 创建订单VoucherOrder voucherOrder = new VoucherOrder();//6.1 设置订单idlong orderId = redisIdWorker.nextId("order");//6.2 设置用户idLong id = UserHolder.getUser().getId();//6.3 设置代金券idvoucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);voucherOrder.setUserId(id);//7. 将订单数据保存到表中save(voucherOrder);//8. 返回订单idreturn Result.ok(orderId);}
  • 存在问题:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后,执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的,那我们这里使用悲观锁来解决这个问题

解决步骤

  • 初步代码,我们把一人一单逻辑之后的代码都提取到一个createVoucherOrder方法中,然后给这个方法加锁
  • 不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
private synchronized Result createVoucherOrder(Long voucherId) {// 一人一单逻辑Long userId = UserHolder.getUser().getId();int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();if (count > 0) {return Result.fail("你已经抢过优惠券了哦");}//5. 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();if (!success) {return Result.fail("库存不足");}//6. 创建订单VoucherOrder voucherOrder = new VoucherOrder();//6.1 设置订单idlong orderId = redisIdWorker.nextId("order");//6.2 设置用户idLong id = UserHolder.getUser().getId();//6.3 设置代金券idvoucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);voucherOrder.setUserId(id);//7. 将订单数据保存到表中save(voucherOrder);//8. 返回订单idreturn Result.ok(orderId);
}

但是这样加锁,锁的细粒度太粗了,在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住,现在的情况就是所有用户都公用这一把锁,串行执行,效率很低,我们现在要完成的业务是一人一单,所以这个锁,应该只加在单个用户上,用户标识可以用userId

@Transactional
public Result createVoucherOrder(Long voucherId) {// 一人一单逻辑Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();if (count > 0) {return Result.fail("你已经抢过优惠券了哦");}//5. 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();if (!success) {return Result.fail("库存不足");}//6. 创建订单VoucherOrder voucherOrder = new VoucherOrder();//6.1 设置订单idlong orderId = redisIdWorker.nextId("order");//6.2 设置用户idLong id = UserHolder.getUser().getId();//6.3 设置代金券idvoucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);voucherOrder.setUserId(id);//7. 将订单数据保存到表中save(voucherOrder);//8. 返回订单idreturn Result.ok(orderId);}//执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
}

由于toString的源码是new String,所以如果我们只用userId.toString()拿到的也不是同一个用户,需要使用intern(),如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。

public static String toString(long i) {if (i == Long.MIN_VALUE)return "-9223372036854775808";int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);char[] buf = new char[size];getChars(i, size, buf);return new String(buf, true);
}

但是以上代码还是存在问题,问题的原因在于当前方法被Spring的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题

@Override
public Result seckillVoucher(Long voucherId) {LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();//1. 查询优惠券queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);//2. 判断秒杀时间是否开始if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {return Result.fail("秒杀还未开始,请耐心等待");}//3. 判断秒杀时间是否结束if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {return Result.fail("秒杀已经结束!");}//4. 判断库存是否充足if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {return createVoucherOrder(voucherId);}
}

但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务,这里可以使用AopContext.currentProxy()来获取当前对象的代理对象,然后再用代理对象调用方法,记得要去IVoucherOrderService中创建createVoucherOrder方法

Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);
}

该方法会用到一个依赖,我们需要导入一下

<dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId>
</dependency>

同时在启动类上加上@EnableAspectJAutoProxy(exposeProxy = true)注解

@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {public static void main(String[] args) {SpringApplication.run(HmDianPingApplication.class, args);}}

重启服务器,再次使用Jmeter测试,200个线程并发,但是只能抢到一张优惠券,目的达成

集群环境下的并发问题

*通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了

  1. 我们将服务启动两份,端口分别为8081和8082
  2. 然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)

具体操作,我们使用POSTMAN发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。

失败原因分析:

由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥

20240313-105839-Il.png

这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/752425.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

06.Web APIs中正则表达式的使用

一、正则表达式 正则表达式&#xff08;Regular Expression&#xff09;是一种字符串匹配的模式&#xff08;规则&#xff09; 使用场景&#xff1a; 例如验证表单&#xff1a;手机号表单要求用户只能输入11位的数字 (匹配)过滤掉页面内容中的一些敏感词(替换)&#xff0c;或…

15届蓝桥杯第三期模拟赛所有题目解析

文章目录 &#x1f9e1;&#x1f9e1;t1_奇数次数&#x1f9e1;&#x1f9e1;思路代码 &#x1f9e1;&#x1f9e1;t2_台阶方案&#x1f9e1;&#x1f9e1;思路代码 &#x1f9e1;&#x1f9e1;t3_约数个数&#x1f9e1;&#x1f9e1;思路代码 &#x1f9e1;&#x1f9e1;t4_最…

AI基础知识(2)--决策树,神经网络

1.什么是决策树&#xff1f; 决策树是一类常见的机器学习方法&#xff0c;决策树是基于树的结构来进行决策。决策过程中提出的每一个问题都是对于属性的“测试”&#xff0c;决策的最终结论对应了我们希望的判定结果。一个决策树包含一个根节点&#xff0c;若干个内部节点和若…

无设备树platform设备驱动实验:platform驱动注册代码框架测试

一. 简介 前面两篇文章中&#xff0c;一篇实现 platform设备注册代码实现&#xff0c;文章如下&#xff1a; 无设备树platform设备驱动实验&#xff1a;platform设备注册代码实现-CSDN博客 一篇文章实现了 platform驱动注册代码框架&#xff0c;文章如下&#xff1a; 无设…

基于Python长时间序列遥感数据处理及在全球变化、物候提取、植被变绿与固碳分析、生物量估算与趋势分析等领域中的应用

植被是陆地生态系统中最重要的组分之一&#xff0c;也是对气候变化最敏感的组分&#xff0c;其在全球变化过程中起着重要作用&#xff0c;能够指示自然环境中的大气、水、土壤等成分的变化&#xff0c;其年际和季节性变化可以作为地球气候变化的重要指标。此外&#xff0c;由于…

Java推荐算法——特征加权推荐算法(以申请学校为例)

加权推荐算法 文章目录 加权推荐算法1.推荐算法的简单介绍2.加权推荐算法详细介绍3.代码实现4.总结 1.推荐算法的简单介绍 众所周知&#xff0c;推荐算法有很多种&#xff0c;例如&#xff1a; 1.加权推荐&#xff1a;分为简单的特征加权&#xff0c;以及复杂的混合加权。主要…

如何让intellij idea支持一个目录多个springtboot或maven项目

一、背景 有的时候&#xff0c;我们希望intellij idea 能像 eclipse 一样有workspace的概念&#xff0c;能在一个workspace目录里面引入多个项目&#xff0c;如&#xff1a; 我们有项目a、项目b&#xff0c;现在的项目几乎都是springboot项目&#xff08;即maven项目&#xf…

论文阅读——RSGPT

RSGPT: A Remote Sensing Vision Language Model and Benchmark 贡献&#xff1a;构建了一个高质量的遥感图像描述数据集&#xff08;RSICap&#xff09;和一个名为RSIEval的基准评估数据集&#xff0c;并在新创建的RSICap数据集上开发了基于微调InstructBLIP的遥感生成预训练…

Python学习01 python开发的准备工作 1.Python解释器的下载 2.Pycharm集成开发环境的安装

0.按照步骤&#xff0c;快速进行python的开发准备工作 1. Python解释器的下载 下载地址 https://www.python.org/ 选择对应你的系统的安装包 2.记得勾选这里将python加入你的路径中 3.有如下四个程序表明安装成功 4.点击上图中的第二个程序打开窗口检查解释器能否正常工作 输…

uniapp修改头像,选择图片

一、页面效果 二、手机上的效果 使用过的实例&#xff1a; 手机上就会显示类似如下&#xff1a; 三、代码 <view class"cleaner-top" click"chooseImg"><view class"cleaner-avatar"><image :src"imgArr" mode"…

鸿蒙Harmony应用开发—ArkTS声明式开发(容器组件:FlowItem)

瀑布流组件的子组件&#xff0c;用来展示瀑布流具体item。 说明&#xff1a; 该组件从API Version 9开始支持。后续版本如有新增内容&#xff0c;则采用上角标单独标记该内容的起始版本。仅支持作为Waterflow组件的子组件使用。 子组件 支持单个子组件。 接口 FlowItem() 使…

挑战杯 机器视觉目标检测 - opencv 深度学习

文章目录 0 前言2 目标检测概念3 目标分类、定位、检测示例4 传统目标检测5 两类目标检测算法5.1 相关研究5.1.1 选择性搜索5.1.2 OverFeat 5.2 基于区域提名的方法5.2.1 R-CNN5.2.2 SPP-net5.2.3 Fast R-CNN 5.3 端到端的方法YOLOSSD 6 人体检测结果7 最后 0 前言 &#x1f5…

常用芯片学习——DS3231M芯片

DS3231M RTC实时时钟 芯片介绍 DS3231M是一款低成本、极其精确的 I2C 实时时钟 &#xff08;RTC&#xff09;。该设备集成了电池输入&#xff0c;并在设备主电源中断时保持准确的计时。微型电子机械系统 &#xff08;MEMS&#xff09; 谐振器的集成提高了器件的长期精度&…

Tomcat Seeion 集群

部署&#xff1a;nginx服务器&#xff1a;11-11&#xff1b;tomcat1:11-3; tomcat2:11-6 nginx服务器11-11做搭建&#xff1a; [rootmcb-11 ~]# systemctl stop firewalld [rootmcb-11 ~]# setenforce 0 [rootmcb-11 ~]# yum install epel-release.noarch -y [rootmcb…

关于 NXP PCA85073A 实时时钟读取数据时出现 IIC 传输失败的原因解析和解决方法

一、前言 对使用 I2C 传输的 RTC 外设 PCA85073&#xff0c;在 I2C 传输过程中若有复位信号输入&#xff0c;则有概率出现 I2C 死锁的状态&#xff0c;即 SCL为高&#xff0c;SDA一直为低的现象。 二、I2C 基本协议 在分析问题出现的原因之前&#xff0c;我…

前端基础篇-深入了解 JavaScript(JSON、BOM、DOM 和事件监听)

&#x1f525;博客主页&#xff1a; 【小扳_-CSDN博客】 ❤感谢大家点赞&#x1f44d;收藏⭐评论✍ 文章目录 1.0 JS - JSON 2.0 JS - BOM 2.1 Window 浏览器窗口对象 2.2 Location 地址栏对象 3.0 JS - DOM 3.1 获取 HTML 元素对象 3.2 调用 Element 对象的属性、方法 4.0 事件…

威联通(QNAP) TS-466C NAS 开箱评测,4盘位NAS,N6005,存储服务器

威联通(QNAP) TS-466C 四盘位NAS (Network Attached Storage:网络附属存储) 开箱评测 之前用的TS-551经过几轮系统升级后明显感觉性能跟不上了&#xff0c;变卡了&#xff0c;所以升级一下&#xff0c;换了TS-466C。 威联通迁移NAS还挺方便的&#xff0c;只有将原先NAS里的硬…

Spring Boot项目怎么从Nacos注册中心上获取其他服务列表信息?

一、前言 在spring boot项目开发过程中&#xff0c;为了进行微服务之间的调用&#xff0c;我们一般会使用注册中心&#xff0c;比如Nacos。假设我们有一个业务需求&#xff0c;应用A需要从Nacos注册中心上获取服务信息进行分析&#xff0c;需要怎么实现呢&#xff1f; 二、开…

数据结构(四)——串的定义和基本操作

四、串 4.1 串的定义和实现 4.1.2 串的定义 串&#xff1a;即字符串&#xff08;String&#xff09;是由零个或多个字符组成的有限序列。例&#xff1a;T‘iPhone 11 Pro Max?’子串&#xff1a;串中任意个连续的字符组成的子序列。 Eg&#xff1a;’iPhon…

蓝桥杯每日一题:血色先锋队

今天浅浅复习巩固一下bfs 答案&#xff1a; #include<iostream> #include<algorithm> #include<cstring>using namespace std; typedef pair<int,int> PII;const int N510; int n,m,a,b; int dist[N][N]; PII q[N*N]; int hh0,tt-1;int dx[]{1,0,-1,…