当前位置:   article > 正文

dp秒杀优惠券

dp秒杀优惠券

1、全局id生成器

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显
  • 受单表数据量的限制

场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。

场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性

  1. @Component
  2. public class RedisIdWorker {
  3. /**
  4. * 开始时间戳
  5. */
  6. private static final long BEGIN_TIMESTAMP = 1640995200L;
  7. /**
  8. * 序列号的位数
  9. */
  10. private static final int COUNT_BITS = 32;
  11. private StringRedisTemplate stringRedisTemplate;
  12. public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
  13. this.stringRedisTemplate = stringRedisTemplate;
  14. }
  15. public long nextId(String keyPrefix) {
  16. // 1.生成时间戳
  17. LocalDateTime now = LocalDateTime.now();
  18. long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
  19. // 2.生成序列号
  20. long timestamp = nowSecond - BEGIN_TIMESTAMP;
  21. // 2.1.获取当前日期,精确到天
  22. // 2.2.自增长
  23. String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
  24. Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
  25. // 3.拼接并返回
  26. return timestamp << COUNT_BITS | count;
  27. }
  28. }

利用线程池创建300个并发线程,每个线程生成100个id,总耗时time = 2629ms

  1. @Test
  2. void testIdWorker() throws InterruptedException {
  3. CountDownLatch latch = new CountDownLatch(300);
  4. Runnable task = () -> {
  5. for (int i = 0; i < 100; i++) {
  6. long id = redisIdWorker.nextId("order");
  7. System.out.println("id = " + id);
  8. }
  9. latch.countDown();
  10. };
  11. long begin = System.currentTimeMillis();
  12. for (int i = 0; i < 300; i++) {
  13. es.submit(task);
  14. }
  15. latch.await();
  16. long end = System.currentTimeMillis();
  17. System.out.println("time = " + (end - begin));
  18. }

2、添加优惠券,实现秒杀下单

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

  1. @Service
  2. @Transactional
  3. public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
  4. @Resource
  5. private SeckillVoucherServiceImpl seckillVoucherService;
  6. @Resource
  7. private RedisIdWorker redisIdWorker;
  8. @Override
  9. public Result seckillVoucher(Long voucherId) {
  10. // 1、查询优惠券信息
  11. SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  12. // 2、判断秒杀是否开始
  13. if (voucher.getBeginTime().isAfter(LocalDateTime.now()) || voucher.getEndTime().isBefore(LocalDateTime.now())){
  14. return Result.fail("秒杀未开始");
  15. }
  16. // 3、判断库存是否充足
  17. if(voucher.getStock()<1) {
  18. return Result.fail("库存不足");
  19. }
  20. // 4、扣减库存
  21. voucher.setStock(voucher.getStock()-1);
  22. boolean success = seckillVoucherService.updateById(voucher);
  23. if(!success) {
  24. return Result.fail("库存不足");
  25. }
  26. // 5、创建订单
  27. VoucherOrder voucherOrder = new VoucherOrder();
  28. // 5.1.订单id
  29. long orderId = redisIdWorker.nextId("order");
  30. voucherOrder.setId(orderId);
  31. // 5.2.用户id
  32. Long userId = UserHolder.getUser().getId();
  33. voucherOrder.setUserId(userId);
  34. // 5.3.代金券id
  35. voucherOrder.setVoucherId(voucherId);
  36. save(voucherOrder);
  37. return Result.ok(orderId);
  38. }
  39. }

3、解决超卖问题

jmeter分析时记得在HTTP信息头管理器中加上token

测试1秒200个并发量,发现会出现超卖问题,100个订单扣减,库存剩下-9个。

乐观锁解决超卖问题

  1. // 4、扣减库存
  2. boolean success = seckillVoucherService.update()
  3. .setSql("stock= stock -1")
  4. .eq("voucher_id", voucherId)
  5. .gt("stock", 0)
  6. .update();

4、一人一单

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单

  1. // 根据用户id与优惠券id,判断订单是否存在
  2. Long useId = UserHolder.getUser().getId();
  3. Integer count = query().eq("user_id", useId).eq("voucher_id", voucherId).count();
  4. if(count > 0) {
  5. return Result.fail("用户已经购买过一次!");
  6. }
  7. // 4、扣减库存
  8. boolean success = seckillVoucherService.update()
  9. .setSql("stock= stock -1")
  10. .eq("voucher_id", voucherId)
  11. .gt("stock", 0)
  12. .update();

但是上述代码涉及到查询与修改,因此还是会有多线程安全问题。用jmeter测试,发现还是有相同用户id与优惠券id的订单超卖,没有达到一人一单的需求。

因此尝试加锁。由于存在较多的写操作,因此采用悲观锁。但如果对后面一大段设计增删改查的代码加锁,锁粒度太大。如下代码所示。

  1. @Transactional
  2. public synchronized Result createVoucherOrder(Long voucherId) {
  3. Long userId = UserHolder.getUser().getId();
  4. // 5.1.查询订单
  5. int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
  6. // 5.2.判断是否存在
  7. if (count > 0) {
  8. // 用户已经购买过了
  9. return Result.fail("用户已经购买过一次!");
  10. }
  11. // 6.扣减库存
  12. boolean success = seckillVoucherService.update()
  13. .setSql("stock = stock - 1") // set stock = stock - 1
  14. .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
  15. .update();
  16. if (!success) {
  17. // 扣减失败
  18. return Result.fail("库存不足!");
  19. }
  20. // 7.创建订单
  21. VoucherOrder voucherOrder = new VoucherOrder();
  22. // 7.1.订单id
  23. long orderId = redisIdWorker.nextId("order");
  24. voucherOrder.setId(orderId);
  25. // 7.2.用户id
  26. voucherOrder.setUserId(userId);
  27. // 7.3.代金券id
  28. voucherOrder.setVoucherId(voucherId);
  29. save(voucherOrder);
  30. // 7.返回订单id
  31. return Result.ok(orderId);
  32. }

存在两个问题:

1)且由于在createVoucherOrder代码外加上事务,事务包含锁,因此会导致加锁读操作后事务还未提交,就提前将所释放,下一个线程获取锁时,读取到的数据库的值为旧值,造成数据不一致性,因此需要在事务外加锁。

2)将synchronized加在方法上,相当于是对this加锁,因此多线程过来加的是一把锁,串行化,性能差。由于需求是一人一单,因此只需要对同一用户加锁。因此去除ThreadLocal中的userId进行加锁。但每次线程进入createVoucherOrder方法都会新建一个userId对象,所以其实本质上还是对不同的对象进行了加锁,userId.toString()的底层也是new一个string对象,但我们需要的是同一用户只有一把锁,因此需要intern() 这个方法从常量池中拿到数据。修改代码:

  1. @Override
  2. public Result seckillVoucher(Long voucherId) {
  3. // 1、查询优惠券信息
  4. SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  5. // 2、判断秒杀是否开始
  6. if (voucher.getBeginTime().isAfter(LocalDateTime.now()) || voucher.getEndTime().isBefore(LocalDateTime.now())){
  7. return Result.fail("秒杀未开始");
  8. }
  9. // 3、判断库存是否充足
  10. Integer stock = voucher.getStock();
  11. if(voucher.getStock()<1) {
  12. return Result.fail("库存不足");
  13. }
  14. Long userId = UserHolder.getUser().getId();
  15. synchronized (userId.toString().intern()) {
  16. return createVoucherOrder(voucherId);
  17. }
  18. }
  19. @Transactional
  20. public Result createVoucherOrder(Long voucherId) {
  21. Long userId = UserHolder.getUser().getId();
  22. // 根据用户id与优惠券id,判断订单是否存在
  23. int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
  24. if(count > 0) {
  25. return Result.fail("用户已经购买过一次!");
  26. }
  27. // 4、扣减库存
  28. boolean success = seckillVoucherService.update()
  29. .setSql("stock= stock -1")
  30. .eq("voucher_id", voucherId)
  31. .gt("stock", 0)
  32. .update();
  33. if (!success) {
  34. //扣减库存
  35. return Result.fail("库存不足!");
  36. }
  37. // 5、创建订单
  38. VoucherOrder voucherOrder = new VoucherOrder();
  39. // 5.1.订单id
  40. long orderId = redisIdWorker.nextId("order");
  41. voucherOrder.setId(orderId);
  42. // 5.2.用户id
  43. voucherOrder.setUserId(userId);
  44. // 5.3.代金券id
  45. voucherOrder.setVoucherId(voucherId);
  46. save(voucherOrder);
  47. return Result.ok(orderId);
  48. }

但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务

1)在启动类上加上

2)在pom.xml文件里加上依赖

  1. <dependency>
  2. <groupId>org.aspectj</groupId>
  3. <artifactId>aspectjweaver</artifactId>
  4. </dependency>

3)修改代码

  1. synchronized (userId.toString().intern()) {
  2. IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
  3. return proxy.createVoucherOrder(voucherId);
  4. }

记得将seckillVoucher方法上的事务注解取消,否则还是会出现上述问题。

查看数据库,成功实现一人一单

5、集群环境下的并发问题

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

开启两份服务,同一用户下单两次(负载均衡算法采用轮询),库存扣减两次。

有关锁失效原因分析

由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/盐析白兔/article/detail/645118
推荐阅读
相关标签
  

闽ICP备14008679号