关于创建订单一人一单问题

本文最后更新于:2025年6月25日 晚上

场景

界面上显示秒杀券,用户可以抢购,但是每位用户只能抢购一张。

秒杀券表(tb_seckill_voucher)结构如下:

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 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT COMMENT='秒杀优惠券表,与优惠券是一对一关系';

优惠券订单表(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 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;

用户抢券的方法(seckillCreate.java)

   @Override
   @Transactional
    public Result seckillCreate(Long voucherId) {
        // 查询优惠券信息
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断到没到抢券的时间
        xxx
        
        return createOrder(voucherId);
}

创建订单(createOrder.java)简单代码如下:

public Result createOrder(Long voucherId){
		// 当前登录的用户
		UserDTO user = UserHolder.getUser();
		// 查库
        Integer count = query().eq("user_id", user.getId()).eq("voucher_id", voucherId).count(); //<X>
        if (count > 0) {
            return Result.fail("每人最多只能持有一张,不要贪心哦!");
        }

        // 创建订单信息
        VoucherOrder voucherOrder = new VoucherOrder();
		xxx
        this.save(voucherOrder); // <Y>

        return Result.ok(orderId);
}

但是这样的话,多线程并发会出现一个用户购买多张券的情况。即在刚开始时,库里还没有任何订单,同一时间可能存在多个线程在 处并没有查到该用户存在的订单,然后执行 处就会出现同一人拥有多张订单的情况。

由于在 处是新增订单数据,所以没法采用乐观锁(乐观锁适用于删除或修改),所以只能采用悲观锁。

尝试一:

@Override
@Transactional
public Result seckillCreate(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 判断到没到抢券的时间
    xxx

        return createOrder(voucherId);
}

public synchronized Result createOrder(Long voucherId){
		// 当前登录的用户Id
		Long userId = UserHolder.getUser().getUserId();
		// 查库
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("每人最多只能持有一张,不要贪心哦!");
        }

        // 创建订单信息
        VoucherOrder voucherOrder = new VoucherOrder();
		xxx
        this.save(voucherOrder);

        return Result.ok(orderId);
}

在方法上加 synchronized ,但是这样的话锁的范围是整个方法,锁的对象是 this 整个方法都会加上锁,粒度太粗,导致任何一个用户都会加锁,而且是同一把锁,串行执行,严重影响性能。我们想要的是按照同一用户来加锁,即同一个用户才加锁。且真正的事务是在创建订单那里,第一步查券不需要事务,把用户抢券那个方法的事务放到创建订单的方法上。

尝试二:

@Override
public Result seckillCreate(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 判断到没到抢券的时间
    xxx

        return createOrder(voucherId);
}

@Transactional
public Result createOrder(Long voucherId){
		// 当前登录的用户Id
		Long userId = UserHolder.getUser().getUserId();
    	// String.toString()
    	synchronized(userId.toString().intern()) {	// <X>
            Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if (count > 0) {
                return Result.fail("每人最多只能持有一张,不要贪心哦!");
            }

            // 创建订单信息
            VoucherOrder voucherOrder = new VoucherOrder();
            xxx
            this.save(voucherOrder);

            return Result.ok(orderId);
        }
}

注意, 处,String.toString()方法返回的是new String() ,这样的话每次返回的都是一个新的String对象,就算是同一个用户也会使用不同的锁,所以要使用 <intern()> ,从字符串池中取值。

这样还存在一个问题,就是当前的事务是加在方法上的,且交由Spring进行提交的。那么,如果当前方法执行完了,锁已经释放了,还没来的及提交,另外一个线程进来了。然后查询数据库,发现还没数据,又进行了新增订单,就出现了多个订单的情况。

尝试三:

@Override
public Result seckillCreate(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 判断到没到抢券的时间
    xxx
        
  	// 当前登录的用户Id
  	Long userId = UserHolder.getUser().getUserId();
    synchronized(userId){
        return createOrder(voucherId);		// <Z>        
    }
}

@Transactional
public Result createOrder(Long voucherId){
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("每人最多只能持有一张,不要贪心哦!");
        }

        // 创建订单信息
        VoucherOrder voucherOrder = new VoucherOrder();
        xxx
        this.save(voucherOrder);
        return Result.ok(orderId);
}

至此,应该就没问题了吧。上锁,生成订单,提交事务,最后再释放锁,已经能确保线程安全了。但是。。。。线程安全就能确保业务安全了吗?就能按照我们所想的方式完成了吗?NoNoNo,这里还有一个事务问题。注意 处,这里其实调用的是this.createOrder(voucherId),也就是说调用的是当前这个实例的对象,并不是它的代理,Spring事务管理用的是当前实例的代理对象来进行事务处理,所以没有事务功能,事务失效的几种可能性之一。

尝试四:

@Override
public Result seckillCreate(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
    // 判断到没到抢券的时间
    xxx
        
    Long userId = UserHolder.getUser().getId();
    String userIdIntern = userId.toString().intern();
    synchronized (userIdIntern){
        IVoucherOrderService iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy();
        return iVoucherOrderService.createOrder(voucherId, seckillVoucher, userId);
    }
}

@Transactional
public Result createOrder(Long voucherId){
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("每人最多只能持有一张,不要贪心哦!");
        }

        // 创建订单信息
        VoucherOrder voucherOrder = new VoucherOrder();
        xxx
        this.save(voucherOrder);
        return Result.ok(orderId);
}

使用AopContext.currentProxy()获取当前代理的对象,使用代理对象的事务方法来执行就可以了。使用AopContext需要导入依赖:

<!-- aop代理 -->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>XML

并且需要在启动类上添加注解:@EnableAspectJAutoProxy(exposeProxy = true)

验证

可以看到,只有一个请求成功且返回了订单ID,其他全部失败,


关于创建订单一人一单问题
https://codeofhh.cn/2023/05/02/关于创建订单一人一单问题1/
作者
hhu
发布于
2023年5月2日
许可协议