点评day05 秒杀优化-利用消息队列实现异步写入数据库

复制代码
在秒杀操作中,有很多操作是要去操作数据库的,而且还是一个线程串行执行, 这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行。有一个方案:使用异步编排来做,或者说开启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也可以实现消息队列,但是这个我不想看了,因为肯定现实都是用专业的消息队列,所以就到这里,明天开始新的业务。

相关推荐
一个响当当的名号2 小时前
lectrue16 二阶段锁
jvm·数据库
山北雨夜漫步4 小时前
点评day04 Redisson
java·jvm
Andy Dennis4 小时前
Java&Go 内存管理
java·jvm·go
Dylan的码园1 天前
从软件工程师看计算机是如何工作的
java·jvm·windows·java-ee
百锦再1 天前
HashMap、Hashtable、TreeMap异同深度详解
jvm·spring boot·struts·spring cloud·缓存·kafka·tomcat
好学且牛逼的马2 天前
从“大师杰作”到“并发基石”:JUC(java.util.concurrent)发展历程与核心知识点详解(超详细·最终补全版)
jvm
知识即是力量ol2 天前
Java 虚拟机:JVM篇
java·jvm·八股
Zzz 小生2 天前
LangChain Tools:工具使用完全指南
jvm·数据库·oracle
wuqingshun3141592 天前
什么是浅拷贝,什么是深拷贝,如何实现深拷贝?
java·开发语言·jvm