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内存不是持久化的,每当服务重启或者宕机的情况,阻塞队列中的所有任务就会丢失,或者刚从阻塞队列中拿到一个任务尚未处理,突然发生异常,这个订单任务就会丢失。