22.高并发秒杀

本文介绍了在电商抢购场景中,如何使用MySQL和Redis解决超卖及一人多单问题。通过lua脚本实现库存的原子性扣减,利用分布式锁确保同一用户只能抢购一次,同时展示了JMeter进行压力测试的步骤。

MySQL版本

1.数据准备

#抢购活动表
DROP TABLE IF EXISTS `voucher`;
CREATE TABLE `voucher`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `voucher_id` int(11) NULL DEFAULT NULL,
  `amount` int(11) NULL DEFAULT NULL,
  `start_time` datetime(0) NULL DEFAULT NULL,
  `end_time` datetime(0) NULL DEFAULT NULL,
  `is_valid` int(11) NULL DEFAULT NULL,
  `create_date` datetime(0) NULL DEFAULT NULL,
  `update_date` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;
#订单表
DROP TABLE IF EXISTS `voucher_order`;
CREATE TABLE `voucher_order`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(100) NULL DEFAULT NULL,
  `voucher_id` int(11) NULL DEFAULT NULL,
  `diner_id` int(11) NULL DEFAULT NULL,
  `status` tinyint(1) NULL DEFAULT NULL COMMENT '订单状态:-1=已取消 0=未支付 1=已支付 2=已消费 3=已过期',
  `order_type` int(11) NULL DEFAULT NULL COMMENT '订单类型:0=正常订单 1=抢购订单',
  `create_date` datetime(0) NULL DEFAULT NULL,
  `update_date` datetime(0) NULL DEFAULT NULL,
  `is_valid` int(11) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;

插入抢购活动

http://localhost:8080/seckill/addSeckillVoucher
{
    "voucherId":1,
    "amount":100,
    "startTime":"2023-01-30 08:00:00",
    "endTime":"2023-03-30 08:00:00"
}

2.超卖(库存为负数)

@Override
public ServerResponse doSeckill(VoucherOrderParams voucherOrderParams) {
    //参数校验
    if (Objects.isNull(voucherOrderParams)) {
        return ServerResponse.createByErrorMessage("参数错误");
    }

    Integer voucherId = voucherOrderParams.getVoucherId();
    if (Objects.isNull(voucherId) || voucherId < 0) {
        return ServerResponse.createByErrorMessage("请选择需要抢购的代金券");
    }

    Integer dinerId = voucherOrderParams.getDinerId();
    if (Objects.isNull(dinerId) || dinerId < 0) {
        return ServerResponse.createByErrorMessage("食客参数错误");
    }

    //判断此代金券是否加入抢购
    Voucher voucher = voucherMapper.findVoucherByVoucherId(voucherId);
    if (Objects.isNull(voucher)) {
        return ServerResponse.createByErrorMessage("该代金券并未有抢购活动");
    }

    //判断是否有效
    if (voucher.getIsValid() == 0) {
        return ServerResponse.createByErrorMessage("该活动已结束");
    }

    //判断是否开始、结束
    Date now = new Date();
    if (now.before(voucher.getStartTime())) {
        return ServerResponse.createByErrorMessage("该抢购还未开始");
    }
    if (now.after(voucher.getEndTime())) {
        return ServerResponse.createByErrorMessage("该抢购已结束");
    }

    //判断是否卖完
    /**
      * 超卖问题产生原因
      * 高并发情况下,比如120个请求同时过来,读取库存大于0,那么都会正常执行下去
      * 会扣120次库存,同一个人产生120个订单
      */
    if (voucher.getAmount() < 1) {
        return ServerResponse.createByErrorMessage("该券已经卖完了");
    }

    //判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
    /**
     * 一个人多单问题产生原因
     * 高并发情况下,比如20个请求同时过来,读取记录为空,那么都会正常执行下去
     * 会扣20次库存,同一个人产生20个订单
     */
    int voucherOrderCountFromDb = voucherOrderMapper.findVoucherOrderByDinerIdAndVoucherId(voucherOrderParams);
    if (voucherOrderCountFromDb > 0) {
        return ServerResponse.createByErrorMessage("该用户已抢到该代金券,无需再抢");
    }

    //扣库存
    int count = voucherMapper.decreaseStock(voucherId);
    if (count == 0) {
        return ServerResponse.createByErrorMessage("该券已经卖完了");
    }

    //下单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setDinerId(dinerId);
    voucherOrder.setVoucherId(voucherId);
    String orderNo = UUID.randomUUID().toString();
    voucherOrder.setOrderNo(orderNo);
    voucherOrder.setOrderType(1);
    voucherOrder.setStatus(0);
    int saveCount = voucherOrderMapper.saveVoucherOrder(voucherOrder);
    if (saveCount == 0) {
        return ServerResponse.createByErrorMessage("用户抢购失败");
    }
    return ServerResponse.createBySuccessMessage("抢购成功");
}

3.一人多单问题

@Override
public ServerResponse doSeckill(VoucherOrderParams voucherOrderParams) {
    //......
    //判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
    /**
       * 一个人多单问题产生原因
       * 高并发情况下,比如20个请求同时过来,读取记录为空,那么都会正常执行下去
       * 会扣20次库存,同一个人产生20个订单
       */
    int voucherOrderCountFromDb = voucherOrderMapper.findVoucherOrderByDinerIdAndVoucherId(voucherOrderParams);
    if (voucherOrderCountFromDb > 0) {
        return ServerResponse.createByErrorMessage("该用户已抢到该代金券,无需再抢");
    }

    //扣库存
    int count = voucherMapper.decreaseStock(voucherId);
    if (count == 0) {
        return ServerResponse.createByErrorMessage("该券已经卖完了");
    }

    //下单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setDinerId(dinerId);
    voucherOrder.setVoucherId(voucherId);
    String orderNo = UUID.randomUUID().toString();
    voucherOrder.setOrderNo(orderNo);
    voucherOrder.setOrderType(1);
    voucherOrder.setStatus(0);
    int saveCount = voucherOrderMapper.saveVoucherOrder(voucherOrder);
    if (saveCount == 0) {
        return ServerResponse.createByErrorMessage("用户抢购失败");
    }
    //......
}

Redis版本

1.数据准备

#订单表
DROP TABLE IF EXISTS `voucher_order`;
CREATE TABLE `voucher_order`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(100) NULL DEFAULT NULL,
  `voucher_id` int(11) NULL DEFAULT NULL,
  `diner_id` int(11) NULL DEFAULT NULL,
  `status` tinyint(1) NULL DEFAULT NULL COMMENT '订单状态:-1=已取消 0=未支付 1=已支付 2=已消费 3=已过期',
  `order_type` int(11) NULL DEFAULT NULL COMMENT '订单类型:0=正常订单 1=抢购订单',
  `create_date` datetime(0) NULL DEFAULT NULL,
  `update_date` datetime(0) NULL DEFAULT NULL,
  `is_valid` int(11) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;

插入抢购活动

http://localhost:8080/seckill/addSeckillVoucher
{
    "voucherId":1,
    "amount":100,
    "startTime":"2023-01-30 08:00:00",
    "endTime":"2023-03-30 08:00:00"
}

3.超卖(库存为负数)
(1).问题分析

@Transactional(rollbackFor = Exception.class)
@Override
public ServerResponse doSeckill(VoucherOrderParams voucherOrderParams) {
    //......

    //判断是否卖完
    /**
     * 超卖问题产生原因
     * 高并发情况下,比如120个请求同时过来,读取库存大于0,那么都会正常执行下去
     * 会扣120次库存,同一个人产生120个订单
     */
    if (voucher.getAmount() < 1) {
        return ServerResponse.createByErrorMessage("该券已经卖完了");
    }

    //判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
    /**
     * 一个人多单问题产生原因
     * 高并发情况下,比如20个请求同时过来,读取记录为空,那么都会正常执行下去
     * 会扣20次库存,同一个人产生20个订单
     */
    int voucherOrderCountFromDb = voucherOrderMapper.findVoucherOrderByDinerIdAndVoucherId(voucherOrderParams);
    if (voucherOrderCountFromDb > 0) {
        return ServerResponse.createByErrorMessage("该用户已抢到该代金券,无需再抢");
    }

    //扣库存
    /**
     * 库存扣为负数问题产生原因
     * 先查后扣两步操作,不是原子操作
     * 使用Redis+lua解决超卖问题
     */
    long count = redisTemplate.opsForHash().increment(key,"amount",-1);

    //下单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setDinerId(dinerId);
    voucherOrder.setVoucherId(voucherId);
    String orderNo = UUID.randomUUID().toString();
    voucherOrder.setOrderNo(orderNo);
    voucherOrder.setOrderType(1);
    voucherOrder.setStatus(0);
    int saveCount = voucherOrderMapper.saveVoucherOrder(voucherOrder);
    if (saveCount == 0) {
        return ServerResponse.createByErrorMessage("用户抢购失败");
    }
    return ServerResponse.createBySuccessMessage("抢购成功");
}

(2).解决方案

-- KEYS[1]指的voucher:1,KEYS[2]指的是amount
if (redis.call('hexists', KEYS[1], KEYS[2]) == 1) then
    -- 获取库存
	local stock = tonumber(redis.call('hget', KEYS[1], KEYS[2]));
	if (stock > 0) then
	   redis.call('hincrby', KEYS[1], KEYS[2], -1);
	   return stock;
	end;
    return 0;
end;
@Configuration
public class RedisTemplateConfiguration {
    @Bean
    public DefaultRedisScript<Long> stockScript() {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        //放在和application.yml 同层目录下
        redisScript.setLocation(new ClassPathResource("stock.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}
@Transactional(rollbackFor = Exception.class)
@Override
public ServerResponse doSeckill(VoucherOrderParams voucherOrderParams) {
    //......
    //判断是否卖完
    /**
     * 超卖问题产生原因
     * 高并发情况下,比如120个请求同时过来,读取库存大于0,那么都会正常执行下去
     * 会扣120次库存,同一个人产生120个订单
     */
    if (voucher.getAmount() < 1) {
        return ServerResponse.createByErrorMessage("该券已经卖完了");
    }

    //判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
    /**
     * 一个人多单问题产生原因
     * 高并发情况下,比如20个请求同时过来,读取记录为空,那么都会正常执行下去
     * 会扣20次库存,同一个人产生20个订单
     * 扣库存和下订单上锁解决一人多单问题
     */
    int voucherOrderCountFromDb = voucherOrderMapper.findVoucherOrderByDinerIdAndVoucherId(voucherOrderParams);
    if (voucherOrderCountFromDb > 0) {
        return ServerResponse.createByErrorMessage("该用户已抢到该代金券,无需再抢");
    }

    //扣库存
    /**
     * 库存扣为负数问题产生原因
     * 先查后扣两步操作,不是原子操作
     * 使用Redis+lua解决超卖问题
     */
    List<String> keys = new ArrayList<>();
    keys.add(key);
    keys.add("amount");
    Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys);
    if (amount == 0) {
        return ServerResponse.createByErrorMessage("该券已经卖完了");
    }

    //下单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setDinerId(dinerId);
    voucherOrder.setVoucherId(voucherId);
    String orderNo = UUID.randomUUID().toString();
    voucherOrder.setOrderNo(orderNo);
    voucherOrder.setOrderType(1);
    voucherOrder.setStatus(0);
    int saveCount = voucherOrderMapper.saveVoucherOrder(voucherOrder);
    if (saveCount == 0) {
        return ServerResponse.createByErrorMessage("用户抢购失败");
    }
    return ServerResponse.createBySuccessMessage("抢购成功");
}

3.一人多单问题
(1).问题分析

@Override
public ServerResponse doSeckill(VoucherOrderParams voucherOrderParams) {
    //......
    //判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
    /**
       * 一个人多单问题产生原因
       * 高并发情况下,比如20个请求同时过来,读取记录为空,那么都会正常执行下去
       * 会扣20次库存,同一个人产生20个订单
       */
    int voucherOrderCountFromDb = voucherOrderMapper.findVoucherOrderByDinerIdAndVoucherId(voucherOrderParams);
    if (voucherOrderCountFromDb > 0) {
        return ServerResponse.createByErrorMessage("该用户已抢到该代金券,无需再抢");
    }

    //扣库存
    List<String> keys = new ArrayList<>();
    keys.add(key);
    keys.add("amount");
    Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys);
    if (count == 0) {
        return ServerResponse.createByErrorMessage("该券已经卖完了");
    }

    //下单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setDinerId(dinerId);
    voucherOrder.setVoucherId(voucherId);
    String orderNo = UUID.randomUUID().toString();
    voucherOrder.setOrderNo(orderNo);
    voucherOrder.setOrderType(1);
    voucherOrder.setStatus(0);
    int saveCount = voucherOrderMapper.saveVoucherOrder(voucherOrder);
    if (saveCount == 0) {
        return ServerResponse.createByErrorMessage("用户抢购失败");
    }
    //......
}

(2).解决方案

@Transactional(rollbackFor = Exception.class)
@Override
public ServerResponse doSeckill(VoucherOrderParams voucherOrderParams) {
    //......
    //判断是否卖完
    /**
     * 超卖问题产生原因
     * 高并发情况下,比如120个请求同时过来,读取库存大于0,那么都会正常执行下去
     * 会扣120次库存,同一个人产生120个订单
     */
    if (voucher.getAmount() < 1) {
        return ServerResponse.createByErrorMessage("该券已经卖完了");
    }

    //判断登录用户是否已抢到(一个用户针对这次活动只能买一次)
    /**
     * 一个人多单问题产生原因
     * 高并发情况下,比如20个请求同时过来,读取记录为空,那么都会正常执行下去
     * 会扣20次库存,同一个人产生20个订单
     * 扣库存和下订单上锁解决一人多单问题
     */
    int voucherOrderCountFromDb = voucherOrderMapper.findVoucherOrderByDinerIdAndVoucherId(voucherOrderParams);
    if (voucherOrderCountFromDb > 0) {
        return ServerResponse.createByErrorMessage("该用户已抢到该代金券,无需再抢");
    }

    //Redisson分布式锁,锁一个账号只能购买一次
    String lockName = RedisKeyConstant.lock_key.getKey() + dinerId + ":" + voucherId;
    long expireTime = voucher.getEndTime().getTime() - now.getTime();
    RLock lock = redissonClient.getLock(lockName);
    try {
        //Redisson分布式锁处理
        boolean isLocked = lock.tryLock(expireTime, TimeUnit.MILLISECONDS);
        if (isLocked) {
            //扣库存
            /**
             * 库存扣为负数问题产生原因
             * 先查后扣两步操作,不是原子操作
             * 使用Redis+lua解决超卖问题
             */
            List<String> keys = new ArrayList<>();
            keys.add(key);
            keys.add("amount");
            Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys);
            if (amount == 0) {
                return ServerResponse.createByErrorMessage("该券已经卖完了");
            }

            //下单
            VoucherOrder voucherOrder = new VoucherOrder();
            voucherOrder.setDinerId(dinerId);
            voucherOrder.setVoucherId(voucherId);
            String orderNo = UUID.randomUUID().toString();
            voucherOrder.setOrderNo(orderNo);
            voucherOrder.setOrderType(1);
            voucherOrder.setStatus(0);
            int saveCount = voucherOrderMapper.saveVoucherOrder(voucherOrder);
            if (saveCount == 0) {
                return ServerResponse.createByErrorMessage("用户抢购失败");
            }
        }
    }catch (Exception e) {
        //手动回滚事务
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        //Redisson解锁
        lock.unlock();
    }
    return ServerResponse.createBySuccessMessage("抢购成功");
}

Jmeter使用

1.刷单
(1).添加线程组
在这里插入图片描述
在这里插入图片描述

(2).添加HTTP请求
在这里插入图片描述
在这里插入图片描述

(3).添加HTTP请求头信息
在这里插入图片描述
在这里插入图片描述

(4).查看结果树
在这里插入图片描述
在这里插入图片描述

(5).保存测试计划并进行测试
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.500用户并发
(1).添加线程组
在这里插入图片描述

(2).添加HTTP请求
dinnerId变量需要从CSV文件中读取。
在这里插入图片描述

{
    "voucherId":1,
    "dinerId":${dinerId}
}

(3).新建CSV文件
第一行为定义在请求体的变量。
在这里插入图片描述
在这里插入图片描述

(4).添加CSV数据文件设置
在这里插入图片描述
在这里插入图片描述

(5).添加HTTP请求头信息
在这里插入图片描述

(6).查看结果树
在这里插入图片描述

(7).保存测试计划并进行测试
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值