1. 秒杀流程分析
优化秒杀流程之前,我们先来看一下之前秒杀的实现流程
我们分析一下:首先用户发送下单请求,通过Nginx负载均衡将请求发送到我们的tomcat服务器,服务器响应请求后开始查询优惠券等一系列操作,最后将结果返回给用户。
试想一下,我们的程序是不是同时完成这些操作。也就是说,一次只能响应一个用户的请求,当很多用户同时发送请求时,用户只能等前面一个用户完成相对应的下单流程才能进行下单操作,这样在高并发模式下我们程序的性能是不是会很差?
怎么解决呢?
我们是不是能这样设想一下,只要用户拥有下单资格,我们就能通知用户下单成功,然后我们重新启动 一个线程来完成用户下单的操作。每个用户只要有下单资格我们就通知用户下单成功,这样程序的响应速度就会加快,我们程序的性能就上去了吗。
2. Redis优化秒杀
OK,经过上面的分析,我们就能将想法转变为如下的流程图
首先我们判断库存是否充足,再判断用户是否已经下过订单,如果两者都满足就提示客户下单成功,这时我们将优惠券id,用户id,订单id添加到阻塞队列,重新启动一个新的线程来处理相关的业务逻辑。
3. 优化秒杀代码实现
3.1 流程分析
为了保证用户下单资格的原子性,我们使用lua脚本判断用户是否拥有下单资格。有的话返回0,否则返回其他标识。
接下来我们执行lua脚本,判断结果是否为0,不为零就返回异常信息,否则就将优惠券id,用户id,订单id添加到阻塞队列,执行异步下单,最后返回订单id
这里我们明确一下,一是将优惠券库存信息放入redis,使用的是String类型。二是保存优惠券id,用户id,订单id,这个我们需要使用的是Set集合类型。
3.2 需求分析
3.3 代码实现
- 将优惠券库存信息保存到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);
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]
-- 1.3.订单id
-- local orderId = ARGV[3]
-- 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)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
-- redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
3. 判断用户是否具有下单资格,创建订单
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> orderTask = new ArrayBlockingQueue<>(1024*1024);
/**
* 优惠券秒杀
*
* @param voucherId
* @return
*/
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
//使用lua脚本执行原子级别的操作,不会因为线程阻塞导致释放锁发生错误。
Long result = redisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString());
//拆箱
int res = result.intValue();
//1. 判断库存是否大于0和用户是否已经下单
if (res != 0) {
return Result.fail(res == 1 ? "库存不足" : "用户已下单");
}
//2. 创建订单
//2.1 设置id
VoucherOrder voucherOrder = new VoucherOrder();
long voucherOrderId = RedisIdWorker.nextId("voucherOrder");
voucherOrder.setId(voucherOrderId);
//2.2 设置user_id
voucherOrder.setUserId(userId);
//2.3 设置优惠券id
voucherOrder.setVoucherId(voucherId);
//2.4放入阻塞队列
orderTask.add(voucherOrder);
return Result.ok(voucherOrderId);
}
}
4. 异步执行下单操作
//定义阻塞队列,当队列为空时阻塞,不为空才执行相关操作
private BlockingQueue<VoucherOrder> orderTask = new ArrayBlockingQueue<>(1024*1024);
//定义一个线程池,异步执行下单操作
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//spring提供的PostConstruct注解:类初始化完毕就执行
@PostConstruct
public void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderRunnable());
}
//定义处理秒杀的线程,该线程应该在类初始化就应该开始执行任务————如何做到?
//使用spring提供的PostConstruct注解:类初始化完毕就执行
private class VoucherOrderRunnable implements Runnable{
@Override
public void run() {
while (true){
try {
//获取队列中的订单信息
VoucherOrder voucherOrder = orderTask.take();
//创建订单
createVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常",e);
}
}
}
}
private void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
RLock lock = redissonClient.getLock("lock:order" + userId);
//tryLock的三个参数:最大等待时间,锁释放时间,时间单位
boolean flag = lock.tryLock();//不设置参数默认不等待,释放时间三十秒
if(!flag){
return;
}
try {
//一人一单
int count = this.query().eq("user_id", userId)
.eq("voucher_id", voucherId).count();
if(count>0){
return ;
}
//当更新时查询的库存大于0时进行库存减一
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.gt("stock", 0)
.eq("voucher_id", voucherId).update();
if (!success) {
return;
}
//6. 创建订单
this.save(voucherOrder);
return ;
} finally {
lock.unlock();
}
}
4. Redis优化秒杀总结以及存在问题
我们分析一下使用阻塞队列完成异步秒杀存在的问题
-
内存限制问题:我们自定义的阻塞队列的大小是我们自己设置的,一旦订单数量过多,导致阻塞队列内存占满,此时就会有订单丢失的风险。
-
数据安全问题:一旦我们redis服务宕机,阻塞队列的内存就会被清空,用户下单的数据也会随之丢失,因此存在数据的安全问题。
如何解决使用阻塞队列引发的内存限制和数据安全问题呢?
我会在下一篇博客中跟小伙伴们继续分享。