异步秒杀

异步秒杀

秒杀是一个非常经典的场景,秒杀的核心是高并发下的库存扣减。

正常操作是将进行如查询库存、一人一单、扣减库存等操作。
但是在高并发的情况下,这些操作可能有有性能低下的问题。

这时需要使用异步秒杀的方式来解决这个问题。

优化

异步秒杀的核心是将秒杀的操作进行异步化处理。

使用 Redis 来完成查询库存以及一人一单的操作。

这个操作完成后将所需信息写入阻塞队列中,然后返回必要信息给用户,如订单号等,这时业务逻辑已经完成。

之后的写入数据库操作的时效性就不那么重要了,可以使用异步的方式来完成,降低数据库的压力。

alt text

代码实现

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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// lua脚本
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}

// 阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

// 在类创建后立刻执行
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

// 异步秒杀
private class VoucherOrderHandler implements Runnable {

@Override
public void run(){
while (true) {
try {
// 1. 获取阻塞队列中的订单
VoucherOrder voucherOrder = orderTasks.take();
// 2. 创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}

private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 1. 获取用户
Long userId = voucherOrder.getUserId();
// 2. 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 3. 获取锁
boolean isLock = lock.tryLock();
// 4. 判断获取锁是否成功
if (!isLock) {
// 获取锁失败
log.error("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
lock.unlock();
}
}

private IVoucherOrderService proxy;

@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户id
Long userId = UserHolder.getUser().getId();

// 1. 执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), // 代金券id
userId.toString() // 用户id
);
// 2. 判断结果是否为0
int r = result.intValue();
if (r != 0) {
// 2.1. 不为0,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 2.2. 为0,有购买资格,把下单信息保存到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3. 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 2.4. 用户id
voucherOrder.setUserId(userId);
// 2.5. 代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6. 放入阻塞队列
orderTasks.add(voucherOrder);

// 3. 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();

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

@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) { // 注意,不能加方法锁,会导致串行化,性能下降
// 5. 一人一单
Long userId = voucherOrder.getUserId();

// 5.1 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();

// 5.2 判断是否存在
if (count > 0) {
// 用户已经购买过
log.error("用户已经购买过");
return;
}

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

// 7. 创建订单
save(voucherOrder);
}

其中lua脚本的内容如下:

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
-- 1. 参数列表
-- 1,1. 优惠券id
local voucherId = ARGV[1]
-- 1,2. 用户id
local userId = ARGV[2]

-- 2. 数据key
-- 2.1. 库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2. 订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3. 脚本业务
-- 3.1. 判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2. 库存不足,返回1
return 1
end
-- 3.3. 判断用户是否下单过 SISMEMBER orderKey userId
if (redis.call('sismember', orderKey, userId) == 1) then
-- 3.4. 下单过,返回2
return 2
end
-- 3.4. 扣减库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5. 下单成功,保存订单记录 sadd orderKey userId
redis.call('sadd', orderKey, userId)

总结

秒杀业务的优化思路:

  1. 先利用 Redis 完成库存余量、一人一单判断,完成抢单业务
  2. 再将下单业务放入阻塞队列,利用独立线程异步下单

基于阻塞队列的异步秒杀的问题:

  1. 内存限制问题:
    阻塞队列是基于内存的,内存有限,阻塞队列的长度有限制
  2. 数据安全问题:
    如果阻塞队列满了,后续的请求会被拒绝
    如果在执行阻塞队列的过程中,发生了异常,可能会导致数据丢失