利用Redis和阻塞队列实现异步处理业务

113 阅读4分钟

B站"黑马点评"学习笔记

1.概述

正常执行一段业务逻辑是单线程串行的,例如课程中所举例的“订单秒杀功能”,整个业务流程可以分为“判断当前操作用户是否可下单”与“执行下单业务”两部分,在串行模式下,一个线程必然要先执行“判断”,再执行“下单”,在处于大量高并发请求的环境下,这种模式的效率必然是比较低下的,而这就引出了一种非常妙的方法——将整个流程分为两部分,用两条线程进行异步处理,化“串行”为“并行”。

2.将“判断”提高到内存层面

正常判断是否可下单必然是要对数据库层面进行查询的,效率相对而言较低,既然这样,我们可以将做判断所需要的数据(订单库存,已下单的用户)利用redis存进内存中,极大的提高效率。但因为涉及到多条redis指令操作,可能会发生线程安全问题,所以需要保证这部分代码的“原子性”。可以将这部分代码利用lua实现,利用lua脚本来保证这段代码的原子性。lua脚本中又恰好有调用redis的接口,所以可以用lua实现这部分逻辑。

-- 优惠券id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 订单key
local orderKey = 'seckill:order:' .. voucherId

--判断订单是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 库存不足,返回1
    return 1
end
-- 判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId)) then
    -- 存在,说明重复下单,返回2
    return 2
end
-- 减少库存
redis.call('incrby', stockKey, -1)
-- 下单,保存用户
redis.call('sadd', orderKey, userId)

return 0

然后在java代码中调用这部分lua脚本。DefaultRedisScript是Spring Data Redis提供的一个类,用于执行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);
}

然后利用StringRedisTemplate提供的execute方法调用:

Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(),
        voucherId.toString(), userId.toString()
     );

获得的结果是可以下单时,就将当前订单存进阻塞队列中

3.异步处理

接下来就是处理下单业务,因为异步处理,我们可以另开一条子线程用来处理下单逻辑,可以这样实现:

//定义一个内部类,实现runable接口
private class VoucherOrderHander implements Runnable{
    @Override
    public void run() {
        while (true){
            try {
                //有元素就取出,无元素就阻塞
                VoucherOrder voucherOrder = orderTasks.take();
                //创建订单
                handleVoucherOrder(voucherOrder);
            } catch (Exception e) {
                log.info("处理订单异常", e);
            }
        }
    }
}

然后定义线程池,向线程提交任务:

//创建一个单线程的线程池,来执行下单任务
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//此注解代表类一旦被初始化,就执行这部分代码,也就是执行线程池
@PostConstruct
private void init(){
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHander());
}

最关键的逻辑,定义阻塞队列,成为判断下单信息互通的枢纽:

//创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

这样一来,redis判断可以下单,存进阻塞队列;当阻塞队列为空时,子线程一直被阻塞,不处理业务逻辑;不为空时,子线程就开始进行业务逻辑的处理。实现整个业务逻辑的并行处理。值得注意的是,因为另外启用了一个线程,许多以ThreadLocal获得的数据或者以ThreadLocal为底层的方法都不可再用。

另外,阻塞队列有着jvm内存限制问题,高并发情况下,当有大量请求需要处理时,就可能超出内存限制,而且有数据安全问题,jvm内存不是持久化的,每当服务重启或者宕机的情况,阻塞队列中的所有任务就会丢失,或者刚从阻塞队列中拿到一个任务尚未处理,突然发生异常,这个订单任务就会丢失。