优惠卷的秒杀并发问题以及解决

目录

1.数据库表准备

2.实现优惠卷秒杀

1.新增一条秒杀优惠卷信息

2.进行秒杀操作

1.先查询秒杀优惠卷信息

2.用户实现秒杀操作

3.存在的问题

4.解决超卖和一个用户一单

总结


1.数据库表准备

主要需要优惠卷表、秒杀优惠卷表、抢购订单表、用户表

优惠卷表

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=3 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;

秒杀优惠卷表 ---- 专门存储需要秒杀的优惠卷的表

CREATE TABLE `tb_seckill_voucher` (
  `voucher_id` bigint(20) unsigned NOT NULL COMMENT '关联的优惠券的id',
  `stock` int(8) NOT NULL COMMENT '库存',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `begin_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
  `end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '失效时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`voucher_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='秒杀优惠券表,与优惠券是一对一关系';

抢购订单表  ----用户在抢购优惠卷表的时候生成记录

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;

 用户表 ---- 这里用户表主要是用于记录订单,以及在同一个用户进行抢购同一张优惠卷限制

CREATE TABLE `tb_user` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `phone` varchar(11) NOT NULL COMMENT '手机号码',
  `password` varchar(128) DEFAULT '' COMMENT '密码,加密存储',
  `nick_name` varchar(32) DEFAULT '' COMMENT '昵称,默认是用户id',
  `icon` varchar(255) DEFAULT '' COMMENT '人物头像',
  `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,
  UNIQUE KEY `uniqe_key_phone` (`phone`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1010 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;

2.实现优惠卷秒杀

通过上面数据库可以将大体框架构建好,下面就直接在实现类中写业务逻辑

1.新增一条秒杀优惠卷信息

这里要先保存优惠卷的信息,是在VoucherServiceImpl实现类中进行操作。再保存秒杀优惠卷的信息。因为优惠卷和秒杀优惠卷表式一对一的关系。因为这里涉及到两张表同时操作,这里开启事务必不可少

    @Override
    @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
    }

2.进行秒杀操作

1.先查询秒杀优惠卷信息

先查看该优惠卷是否开始抢购,根据当前时间和开始抢购时间作对比,再查看优惠卷的库存是否还有剩余,如果优惠卷的库存不足,直接返回错误信息

@Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠卷
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 秒杀是否开始, 如果没有开始或者已经结束无法下单
        // 获取现在的时间
        LocalDateTime time = LocalDateTime.now();
        if (time.isBefore(seckillVoucher.getBeginTime())) {
            return Result.fail("优惠卷秒杀未开始");
        }
        if (time.isAfter(seckillVoucher.getEndTime())) {
            return Result.fail("优惠卷秒杀已结束");
        }
        // 库存是否充足, 不足无法下单
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("优惠卷不足");
        }
    }
2.用户实现秒杀操作

在判断库存有剩余,用户进行下单操作,setSql("stock = stock -1") 将库存减少1个,eq("voucher_id", voucherId)判断是哪个优惠卷,使用update()方法进行修改。用户抢购成功后就创建订单,将基本信息存储到订单表中

 这里userId是在登录的时候就存在ThreadLocal中的,所以现在我们可以直接取到,关于订单ID,采用Redis自增策略全局ID生成,避免订单过多导查询统计不便

 全局ID生成

主要有以下几种:

                UUID

                Redis自增策略

                snowflake(雪花算法)

                数据库自增

这里采用数据库自增策略生成:每天一个key,方便统计订单量。ID构造是时间戳 + 计数器,这里需要再创建一个工具类RedisIdWorker

/**
 * @Author: 大黑
 * @Date: 2024/5/22 19:55
 */
@Component
public class RedisIdWorker {
    // 设置初始时间 --- 开始的时间戳
    private static final long BEGIN_TIMESTAMP = 1716336000L;
    // 定义位数
    private static final int COUNT_BITS = 32;

    private final StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
    public long nextId(String keyPrefix) {
        // 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSecond - BEGIN_TIMESTAMP;
        // 生成序列号
        // 获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 自增长
        // 这里的increment方法相当于redis命令中的setnx
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        // 拼接并返回
        // 因为这里返回的是Long型数据,而且是部分合在一起,这里就选择用位运算进行合并
        return timeStamp << COUNT_BITS | count;
    }
}
  // 扣减库存
  // 这里使用setSql直接进行数据操作
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                .update();
        if (!success) {
            return Result.fail("优惠卷不足");
        }

        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 订单Id,用户Id, 代金券Id --- 订单Id用全局生成器生成
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);

        this.save(voucherOrder);
        // 返回订单id
        return Result.ok(orderId);
3.存在的问题

以上是一般的逻辑,但是还需要考虑的问题是如果实在并发的情况下,或者是在集群的情况下可能带来的问题有:

                发生超卖问题

                一个用户可以抢购多次

                在集群的情况下一个用户还是可能会抢购多次

所以代码还需要进行改动

4.解决超卖和一个用户一单

这里解决超卖的思路就是在修改优惠卷秒杀表的时候添加一个判定条件,判断库存是否大于0,只需要在扣减库存的时候加上 gt("stock", 0)

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

 解决用户一人一单的思路在用户抢购时,扣减库存之前判断订单中该用户的数量是否超过1,然后就是加锁,这里考虑使用悲观锁(悲观锁:任务线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行(例如Synchronized,Lock都属于悲观锁)

// 判断用户是否已经抢过优惠卷了
        Integer count = lambdaQuery().eq(VoucherOrder::getUserId, UserHolder.getUser().getId())
                .eq(VoucherOrder::getVoucherId, voucherId)
                .count();
        if (count > 0) {
            //用户已经抢过了,不允许再抢
            return Result.fail("请勿重复抢购");
        }

完整代码 

把这一部分的代码封装一下,这里没有直接在createVoucherOrder方法上加锁,是因为在如果在方法上加锁锁的对象就是this,是这个实现类,就不能解决同一个用户重复抢购,这里如果都用同一把锁,那么相当于串行执行。所以这里把这部分逻辑封装起来,把锁加在方法上,这样就能避免串行。

 如果这里直接return createVoucherOrder()方法的话事务是会失效的,因为这个时候return createVoucherOrder()方法相当于是return this.createVoucherOrder(),是调用的该实现类的对象,而不是由Spring代理的对象,因为事务的提交是Spring进行提交的,这里要拿到代理对象就得使用 AopContext.currentProxy()

 @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    // 锁 -- 乐观锁和悲观锁
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠卷
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 秒杀是否开始, 如果没有开始或者已经结束无法下单
        // 获取现在的时间
        LocalDateTime time = LocalDateTime.now();
        if (time.isBefore(seckillVoucher.getBeginTime())) {
            return Result.fail("优惠卷秒杀未开始");
        }
        if (time.isAfter(seckillVoucher.getEndTime())) {
            return Result.fail("优惠卷秒杀已结束");
        }
        // 库存是否充足, 不足无法下单
        if (seckillVoucher.getStock() < 1) {
            return Result.fail("优惠卷不足");
        }
        // intern() 是返回字符串对象的规范表示,最初为空的字符串池由String类私有保护 -- 确保在值一样时,无论new了多少个对象都会被锁定
        synchronized (UserHolder.getUser().getId().toString().intern()) {
            // 拿到对象的代理对象 现在的直接调用createVoucherOrder其实是this.createVoucherOrder --- 拿到的是VoucherOrderServiceImpl对象
            // 获取到代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    // 当锁加在方法上时,作用对象是this,相当于整个方法 --- 任何一个用户来了都要加上这样一个锁,而且是同一把锁,相当于串行执行了
    @Transactional
    public Result createVoucherOrder(Long voucherId) {

        // 判断用户是否已经抢过优惠卷了
        Integer count = lambdaQuery().eq(VoucherOrder::getUserId, UserHolder.getUser().getId())
                .eq(VoucherOrder::getVoucherId, voucherId)
                .count();
        if (count > 0) {
            //用户已经抢过了,不允许再抢
            return Result.fail("请勿重复抢购");
        }
        // 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("优惠卷不足");
        }

        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 订单Id,用户Id, 代金券Id --- 订单Id用全局生成器生成
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);

        this.save(voucherOrder);
        // 返回订单id
        return Result.ok(orderId);
    }

总结

在并发的过程中会遇到各种数据上的问题,如果是在集群的情况下,到时候线程并行执行,上述的解决方案还是会出现用户重复抢购的情况。这个时候就得搬出分布式锁了。

  • 41
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值