在秒杀操作中,有很多操作是要去操作数据库的,而且还是一个线程串行执行, 这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行。有一个方案:使用异步编排来做,或者说开启N多线程,N多个线程,一个线程执行查询优惠卷,一个执行判断扣减库存,一个去创建订单等等,然后再统一做返回,但是如果访问的人很多,那么线程池中的线程可能一下子就被消耗完了,而且使用上述方案,最大的特点在于时效性,查询优惠卷和之后的操作时一个时效性很高的步骤,我们如果有N多个线程很难控制它们之间的时效性。这种方案适用于时效性不高的业务,比如只要确定他能做这件事,然后后边慢慢做就可以了,不需要他一口气做完这件事,所以采取以下方案:
1.优化方案:
我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序就超级快了,而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池。这是基本流程
这里其实在redis方面可以避免超卖和一人多单问题的,这是因为Redis的单线程性质:
Redis 的核心命令执行线程只有一个,所有客户端的请求(包括 Lua 脚本)都会被放到一个先进先出的队列 里,严格按顺序执行, Redis 不会让两个脚本 "同时" 执行里面的逻辑,一个脚本的所有步骤(判断 + 扣减 + 存用户)执行完,另一个才会开始,所以绝对不会出现 "两个请求都扣减库存" 的情况,更不会出现一人多单的情况。
**Lua 脚本解决的是「Redis 层面」的原子性问题,但异步下单是「数据库层面」的操作,两者的场景和保障范围完全不同,需要在异步下单同样加分布式锁,**两者不是 "重复操作",而是 "分层防护(兜底)"------Lua 脚本挡第一道(高性能),分布式锁挡第二道(保数据一致性)。
Lua 脚本只保证:
在 Redis 里,「扣库存 + 记录用户」是原子的,不会出现超卖(库存扣到 0 就停)、一人多单(用户进 Set 就不让重复扣)。
但 Lua 脚本管不到后续的异步下单环节,因为异步下单是 "Redis 校验通过后" 的独立操作,会面临以下「Lua 脚本覆盖不到的风险」:
Lua 脚本执行成功后,会把 "下单任务" 丢到消息队列(比如 RabbitMQ/RocketMQ),异步消费创建订单。这个环节可能出现:
- 消息队列重复投递(比如消费端确认超时,MQ 会重发);
- 网络抖动导致的重试(比如消费端执行到一半断网,重启后重新拉取任务);
- 多实例部署下,同一个任务被多个消费端同时获取(MQ 消费位点同步延迟)。
实现:这是lua脚本
Lua
```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)
return 0
local stockKey = 'seckill:stock:' .. voucherId,..是拼接

上次我们调用lua脚本是直接在类里以成员变量承接,然后直接调用的,今天我们来一个其他方法:首先

然后在resourse目录下建立一个lua脚本文件

然后在初始化代码块中加载一个lua脚本:
java
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation((org.springframework.core.io.Resource) new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
DefaultRedisScript<Long> :这是 Spring Data Redis 提供的类,用于封装 Lua 脚本。泛型 <Long> 指定了脚本执行后返回值的类型是 Long。
setLocation(...) :指定了 Lua 脚本文件的位置。这里使用 ClassPathResource 表示脚本文件 seckill.lua 放在项目的 classpath 下。
setResultType(Long.class) :明确告诉 Spring,这个 Lua 脚本执行后返回的结果是一个 Long 类型的值,Spring 会自动将 Redis 返回的结果进行类型转换。
然后调用的话和以前一样,我们这个lua脚本里没有key,所以传一个空集合,这是基本代码:
java
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
long orderId = redisWorker.nextId("order");
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
String.valueOf(voucherId),String.valueOf(userId), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
//TODO 保存阻塞队列
//返回订单id
return Result.ok(orderId);
}
完成保存到阻塞队列:
首先创建一个阻塞队列(jvm自带的)
java
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
BlockingQueue<VoucherOrder>,说明这个阻塞队列里是一个VoucherOrder类型,后面ArrayBlockingQueue是BlockingQueue的一个实现。
然后保存信息到阻塞队列:
java
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单id
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);
//返回订单id
return Result.ok(orderId);
}
放到阻塞队列里就需要有线程去不断的拿出来去完成写入数据库:
我们创建一个单线程,并且给一个任务,这个任务就是去不断地阻塞的拿队列里的VoucherOrder,然后给它完成后续操作
java
//异步处理线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
// 线程任务
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);
}
}
}
那什么时候进行这个任务呢,很明显只要这个类初始化完成,随时可能进行,所以在初始化完成后就应该开始执行了,我们可以通过这个方法控制:
java
//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
接下来实现handleVoucherOrder(voucherOrder)这个方法即可,这个方法其实和之前我们写过的一样,这里我直接复制以下然后改动:
java
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//使用redisson创建锁对象
RLock lock = redissonClient.getLock(NAME + userId);
//获取锁对象
boolean isLock = lock.tryLock();
if (!isLock){
return Result.fail("不允许重复下单");
}
//获取成功执行下面的函数
try {
IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
Result voucherOrder = o.createVoucherOrder(voucherId);
return voucherOrder;
}finally {
lock.unlock();
}
}
首先就是那个获取锁对象,需要userId,我这个代码是对的,因为我提前把userId给弄成了成员变量,并且已经在主线程赋值了,当然我们其实可以直接利用传入的VoucherOrder拿到这个userId,但惟独不能使用线程工具类拿,因为我们拦截器存入的是当前线程,是主线程,这里我们开启的异步线程,是拿不到的。
然后就是现在return也没必要,我们直接用日志记录以下就行了
其次:IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();这是我们想让事务生效采取的方法,但是由于spring的事务是放在threadLocalz中,此时的是多线程,这个是字线程,事务会失效,处理的办法还是和第一个一样,可以在主线程得到声明成员变量。(当然也可以给它传入阻塞队列。
3.jvm阻塞队列存在的问题:


要解决这些问题就要用到专业的消息队列,我先学的是卡夫卡,其实redis也可以实现消息队列,但是这个我不想看了,因为肯定现实都是用专业的消息队列,所以就到这里,明天开始新的业务。