一人一单问题

什么是一人一单

一人一单是指每个用户只能购买一次商品,这种情况通常发生在限购活动中,比如双十一、618等大促销活动。一人一单的原因主要是为了防止用户恶意刷单,导致库存不足。

如何解决一人一单问题

查询数据库中对应订单记录数量,如果大于0则表示用户已经购买过,不能再次购买。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询代金券库存
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始
return Result.fail("秒杀未开始");
}

// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已结束
return Result.fail("秒杀已结束");
}

// 4. 判断库存是否足够
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}

// 5. 一人一单
Long userId = UserHolder.getUser().getId();

// 5.1 查询订单 会有并发问题
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();

// 5.2 判断是否存在
if (count > 0) {
// 用户已经购买过
return Result.fail("用户已经购买过一次!");
}

// 6. 扣减库存(CAS)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where voucher_id = #{voucherId}
.gt("stock", 0) // and stock > 0
// .eq("stock", voucher.getStock()) // where stock = #{stock}
.update(); // 原子性操作 不会存在都为1然后都修改的情况
if (!success) {
// 扣减库存失败
return Result.fail("库存不足!");
}

// 7. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);

// 7. 返回订单id
return Result.ok(orderId);
}

使用jmeter进行压测,发现存在并发问题,即多个请求同时进入时,会导致用户购买多次。

为什么会出现这种情况呢?因为query()查询订单记录不会有数据库锁进行隔离,会存在并发问题。

当第一次请求进入时,查询订单记录数量为0,此时其他请求也进入,查询订单记录数量也为0,然后多个请求都执行扣减库存操作,导致用户购买多次。

如何解决并发问题

加锁解决并发问题,保证查询订单记录和扣减库存操作的原子性。

使用悲观锁来处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询代金券库存
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始
return Result.fail("秒杀未开始");
}

// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已结束
return Result.fail("秒杀已结束");
}

// 4. 判断库存是否足够
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}

// 悲观锁 防止刷单购买
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 如果不获取代理对象,会调用this.func导致事务失效
// 因为spring通过代理模式来实现事务,如果直接调用this.func,那么就不会走代理,导致事务失效
return proxy.createVoucherOrder(voucherId);
}
}

@Transactional
public Result createVoucherOrder(Long voucherId) { // 注意,不能加方法锁,会导致串行化,性能下降
// 5. 一人一单
Long userId = UserHolder.getUser().getId();

// // 锁定用户id 使用intern来保证锁的唯一性
// // intern 如果常量池中有则返回常量池中的对象,如果没有则放入常量池并返回
// // 但是这里会有问题,因为如果锁释放了,但是事务还没有提交,那么其他线程就可以获取到锁,导致并发问题
// // 所以sync要放在事务外面 但是不能放在方法上,因为会导致串行化
// synchronized (userId.toString().intern()) {
// 5.1 查询订单(非原子操作) 会有并发问题
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();

// 5.2 判断是否存在
if (count > 0) {
// 用户已经购买过
return Result.fail("用户已经购买过一次!");
}

// 6. 扣减库存(CAS)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where voucher_id = #{voucherId}
.gt("stock", 0) // and stock > 0
// .eq("stock", voucher.getStock()) // where stock = #{stock}
.update(); // 原子性操作 不会存在都为1然后都修改的情况
if (!success) {
// 扣减库存失败
return Result.fail("库存不足!");
}

// 7. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);

// 7. 返回订单id
return Result.ok(orderId);
// }
}

注意,使用sync锁定用户id,但是不能放在方法上,因为会导致串行化,性能下降。

并且,userId要使用intern来保证锁的唯一性,intern会在常量池中查找是否存在该对象,如果存在则返回常量池中的对象,如果不存在则放入常量池并返回。如果不使用intern,那么每次都会创建新的对象,导致锁失效。

同时,方法使用@Transactional注解,保证事务的一致性。

由于spring通过代理模式来实现事务,如果直接调用this.func,那么就不会走代理,导致事务失效,所以这里使用代理对象来调用createVoucherOrder方法。

如何获取代理对象呢?通过AopContext.currentProxy()来获取代理对象。

注意添加注解让代理对象暴露出来:

1
@EnableAspectJAutoProxy(exposeProxy = true)

同时别忘了修改pom.xml:

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

进行jmeter压测,发现并发问题得到解决,用户只能购买一次商品。

alt text

数据库显示:

alt text

显示成功,用户只能购买一次商品。