系列博客目录
22.秒杀优化-异步秒杀思路
目前的代码如下:
@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("库存不足!");
}
return createVoucherOrder(voucherId);
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单
Long userId = UserHolder.getUser().getId();
// 创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁
boolean isLock = redisLock.tryLock();
// 判断
if(!isLock){
// 获取锁失败,直接返回失败或者重试
return Result.fail("不允许重复下单!");
}
try {
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
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);
} finally {
// 释放锁
redisLock.unlock();
}
}
如下图所示,平均耗时400多ms,所以速度很慢。
目前的思路如下图所示:现在是串行进行下面图中的每一步,里面有四步都需要走数据库,还有写操作,所以很慢。比如现在饭店里有个小姐姐,她既要负责接待顾客,点单,下单,做饭,上菜,她一个人负责完整的业务。现在的效率很低,所以我们可以再请一个人,小姐姐负责照顾顾客,点单下单,后厨拿到单之后负责做菜,做好菜,再上菜即可。我们应该把下面流程分为两部分,一个是秒杀资格判断,一个是减库存+创建订单。JAVA一个线程没法同时处理两部分事情,所以要在原来线程的基础上开辟一个新的线程。
优化后的流程图如下:由于秒杀资格判断需要数据库中的数据,如果还是每次从MySQL中拿数据,太慢了,所以可以使用Redis来存储一部分数据,用于秒杀资格的判断。这时候为了分离两部分,我们需要有个“小票”使得两部分连接起来,可以使用阻塞队列,用来记录谁买了什么,还要返回订单id,让顾客可以直接买单了,这时候订单可能还没有创建或者写入数据库,但未来我们保证可以完成创建。
如何使用Redis实现判断秒杀库存和校验一人一单。
判断秒杀库存,只需把秒杀券的库存通过String类型放入Redis即可。
校验一人一单,要针对每个秒杀券保存用户id,还要保持value的唯一性。所以使用Set类型的Redis。
上图中,为了保证原子性,使用了Lua脚本。
23.秒杀优化-基于Redis完成秒杀资格判断
本部分需要完成下面需求的前两点。
@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);
// 新增:保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
编写Lua脚本,为之后调用做准备。
-- 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.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为0
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");
//TODO 保存阻塞队列 之后再编写这部分代码
// 3.返回订单id
return Result.ok(orderId);
}
24.秒杀优化-基于阻塞队列实现秒杀异步下单
.......
// 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);
为了在这个类一初始化,就开始执行VoucherOrderHandler代码(也就是开始执行VoucherOrderHandler里的run方法,线程一被创建就执行run方法)。使用@PostConstruct注解
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true){
try {
// 1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();//有元素才会往下执行,要不然会卡在这里。
// 2.创建订单
createVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
private void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
// 创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 尝试获取锁
boolean isLock = redisLock.tryLock();
// 判断
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
try {
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
log.error("不允许重复下单!");
return;
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
log.error("库存不足!");
return;
}
// 7.保存订单 不像之前需要创建订单了,订单已经创建完毕了
save(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
总结
- 秒杀业务的优化思路是什么?
- 先利用Redis完成库存余量、一人一单判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
- 基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题(我们现在基于内存保存数据,如果不设置阻塞队列的大小可能会导致内存溢出,但设置了大小,又可能会不够用)
- 数据安全问题(我们现在基于内存保存数据,如果内存宕机,数据就消失了,可能返回了订单ID,但是订单的信息不会在未来保存在数据库中)。