Redis实战(黑马点评--异步秒杀消息队列)
异步秒杀思路
- 我们先来回顾一下下单流程
- 当用户发起请求,此时会先请求Nginx,Nginx反向代理到Tomcat,而Tomcat中的程序,会进行串行操作,分为如下几个步骤
- 查询优惠券
- 判断秒杀库存是否足够
- 查询订单
- 校验是否一人一单
- 扣减库存
- 创建订单
- 在这六个步骤中,有很多操作都是要去操作数据库的,而且还是一个线程串行执行,这样就会导致我们的程序执行很慢,所以我们需要异步程序执行,那么如何加速呢?
-
优化方案:
我们将耗时较短的逻辑判断放到Redis中,例如:库存是否充足,是否一人一单这样的操作,只要满足这两条操作,那我们是一定可以下单成功的,不用等数据真的写进数据库,我们直接告诉用户下单成功就好了。然后后台再开一个线程,后台线程再去慢慢执行队列里的消息,这样我们就能很快的完成下单业务。
-
但是这里还存在两个难点
- 我们怎么在Redis中快速校验是否一人一单,还有库存判断
- 我们校验一人一单和将下单数据写入数据库,这是两个线程,我们怎么知道下单是否完成。
- 我们需要将一些信息返回给前端,同时也将这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询下单逻辑是否完成
- 我们现在来看整体思路:当用户下单之后,判断库存是否充足,只需要取Redis中根据key找对应的value是否大于0即可,如果不充足,则直接结束。如果充足,则在Redis中判断用户是否可以下单,如果set集合中没有该用户的下单数据,则可以下单,并将userId和优惠券存入到Redis中,并且返回0,整个过程需要保证是原子性的,所以我们要用Lua来操作,同时由于我们需要在Redis中查询优惠券信息,所以在我们新增秒杀优惠券的同时,需要将优惠券信息保存到Redis中
- 完成以上逻辑判断时,我们只需要判断当前Redis中的返回值是否为0,如果是0,则表示可以下单,将信息保存到queue中去,然后返回,开一个线程来异步下单,其阿奴单可以通过返回订单的id来判断是否下单成功
Redis完成秒杀资格判断
- 需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否秒杀成功
步骤一:
修改保存优惠券相关代码
@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); // 保存秒杀优惠券信息到Reids,Key名中包含优惠券ID,Value为优惠券的剩余数量 stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString()); }
步骤二:
编写Lua脚本
lua的字符串拼接使用..
,字符串转数字是tonumber()
-- 订单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 return 1 end -- 判断用户是否下单 if (redis.call('sismember', orderKey, userId) == 1) then return 2 end -- 扣减库存 redis.call('incrby', stockKey, -1) -- 将userId存入当前优惠券的set集合 redis.call('sadd', orderKey, userId) return 0
修改业务逻辑
@Override public Result seckillVoucher(Long voucherId) { //1. 执行lua脚本 Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), UserHolder.getUser().getId().toString()); //2. 判断返回值,并返回错误信息 if (result.intValue() != 0) { return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单"); } long orderId = redisIdWorker.nextId("order"); //TODO 保存阻塞队列 //3. 返回订单id return Result.ok(orderId); }
基于阻塞队列实现秒杀优化
- 修改下单的操作,我们在下单时,是通过Lua表达式去原子执行判断逻辑,如果判断结果不为0,返回错误信息,如果判断结果为0,则将下单的逻辑保存到队列中去,然后异步执行
- 需求
- 如果秒杀成功,则将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
步骤一:
创建阻塞队列
阻塞队列有一个特点:当一个线程尝试从阻塞队列里获取元素的时候,如果没有元素,那么该线程就会被阻塞,直到队列中有元素,才会被唤醒,并去获取元素
阻塞队列的创建需要指定一个大小
private final BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
那么把优惠券id和用户id封装后存入阻塞队列
@Override public Result seckillVoucher(Long voucherId) { //1. 执行lua脚本 Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), UserHolder.getUser().getId().toString()); //2. 判断返回值,并返回错误信息 if (result.intValue() != 0) { // 2.1 不为0,表示没有购买资格 return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单"); } // 2.2 为0,有购买资格,把下单信息保存到阻塞队列 VoucherOrder voucherOrder = new VoucherOrder(); // 2.3 订单id long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); // 2.4 用户id voucherOrder.setUserId(UserHolder.getUser().getId()); // 2.5 代金券id voucherOrder.setVoucherId(voucherId); // 2.6 放入阻塞队列 orderTasks.add(voucherOrder); //3. 返回订单id return Result.ok(orderId); }
步骤二:
实现异步下单功能
- 先创建一个线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
- 创建线程任务,秒杀业务需要在类初始化之后,就立即执行,所以这里需要用到
@PostConstruct
注解@PostConstruct private void init() { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } 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); } } } }
-
编写创建订单的业务逻辑
private IVoucherOrderService proxy; private void handleVoucherOrder(VoucherOrder voucherOrder) { //1. 获取用户 Long userId = voucherOrder.getUserId(); //2. 创建锁对象,作为兜底方案 RLock redisLock = redissonClient.getLock("order:" + userId); //3. 获取锁 boolean isLock = redisLock.tryLock(); //4. 判断是否获取锁成功 if (!isLock) { log.error("不允许重复下单!"); return; } try { //5. 使用代理对象,由于这里是另外一个线程, proxy.createVoucherOrder(voucherOrder); } finally { redisLock.unlock(); } }
查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功
private static final ThreadLocal<Object> currentProxy = new NamedThreadLocal("Current AOP proxy");
但是我们可以将proxy放在成员变量的位置,然后在主线程中获取代理对象
@Override public Result seckillVoucher(Long voucherId) { Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), UserHolder.getUser().getId().toString()); if (result.intValue() != 0) { return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单"); } long orderId = redisIdWorker.nextId("order"); //封装到voucherOrder中 VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setVoucherId(voucherId); voucherOrder.setUserId(UserHolder.getUser().getId()); voucherOrder.setId(orderId); //加入到阻塞队列 orderTasks.add(voucherOrder); //主线程获取代理对象 proxy = (IVoucherOrderService) AopContext.currentProxy(); return Result.ok(orderId); }
Redis消息队列
- 什么是消息队列?字面意思就是存放消息的队列,最简单的消息队列模型包括3个角色
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
- 使用队列的好处在于
解耦
:举个例子,快递员(生产者)把快递放到驿站/快递柜里去(Message Queue)去,我们(消费者)从快递柜/驿站去拿快递,这就是一个异步,如果耦合,那么快递员必须亲自上楼把快递递到你手里,服务当然好,但是万一我不在家,快递员就得一直等我,浪费了快递员的时间。所以解耦还是非常有必要的 - 那么在这种场景下我们的秒杀就变成了:在我们下单之后,利用Redis去进行校验下单的结果,然后在通过队列把消息发送出去,然后在启动一个线程去拿到这个消息,完成解耦,同时也加快我们的响应速度
- 这里我们可以直接使用一些现成的(MQ)消息队列,如kafka,rabbitmq等,但是如果没有安装MQ,我们也可以使用Redis提供的MQ方案
基于List实现消息队列
- 基于List结构模拟消息队列
- 消息队列(Message Queue),字面意思就是存放消息的队列,而Redis的list数据结构是一个双向链表,很容易模拟出队列的效果
- 队列的入口和出口不在同一边,所以我们可以利用:LPUSH结合RPOP或者RPUSH结合LPOP来实现消息队列。
-
不过需要注意的是,当队列中没有消息时,RPOP和LPOP操作会返回NULL,而不像JVM阻塞队列那样会阻塞,并等待消息,所以我们这里应该使用BRPOP或者BLPOP来实现阻塞效果
-
基于List的消息队列有哪些优缺点?
- 优点
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保障
- 可以满足消息有序性
- 缺点
- 无法避免消息丢失(经典服务器宕机)
- 只支持单消费者(一个消费者把消息拿走了,其他消费者就看不到这条消息了)
- 优点
基于PubSub的消息队列
- PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费和可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息
SUBSCRIBE channel [channel]
:订阅一个或多个频道PUBLISH channel msg
:向一个频道发送消息-
PSUBSCRIBE pattern [pattern]
:订阅与pattern格式匹配的所有频道
Subscribes the client to the given patterns. Supported glob-style patterns: h?flo subscribes to hello, hallo and hxllo h*llo subscribes to hllo and heeeello h[ae]llo subscribes to hello and hallo, but not hillo Use \ to escape special characters if you want to match them verbatim.
-
基于PubSub的消息队列有哪些优缺点
- 优点:
- 采用发布订阅模型,支持多生产,多消费
- 缺点:
- 不支持数据持久化
- 无法避免消息丢失(如果向频道发送了消息,却没有人订阅该频道,那发送的这条消息就丢失了)
- 消息堆积有上限,超出时数据丢失(消费者拿到数据的时候处理的太慢,而发送消息发的太快)
- 优点:
基于Stream的消息队列
- Stream是Redis 5.0引入的一种新数据类型,可以时间一个功能非常完善的消息队列
- 发送消息的命令
XADD key [NOMKSTREAM] [MAXLEN|MINID [=!~] threshold [LIMIT count]] *|ID field value [field value ...]
NOMKSTREAM 如果队列不存在,是否自动创建队列,默认是自动创建 [MAXLEN|MINID [=!~] threshold [LIMIT count]] 设置消息队列的最大消息数量,不设置则无上限 *|ID 消息的唯一id,*代表由Redis自动生成。格式是”时间戳-递增数字”,例如”114514114514-0” field value [field value …] 发送到队列中的消息,称为Entry。格式就是多个key-value键值对
举例
# 创建名为users的队列,并向其中发送一个消息,内容是{name=jack, age=21},并且使用Redis自动生成ID XADD users * name jack age 21
读取消息的方式之一:XREAD
XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]
[COUNT count] 每次读取消息的最大数量 [BLOCK milliseconds] 当没有消息时,是否阻塞,阻塞时长 STREAMS key [key …] 要从哪个队列读取消息,key就是队列名 ID [ID …] 起始ID,只返回大于该ID的消息 0:表示从第一个消息开始 $:表示从最新的消息开始
例如:使用XREAD读取第一个消息
云服务器:0>XREAD COUNT 1 STREAMS users 0 1) 1) "users" 2) 1) 1) "1667119621804-0" 2) 1) "name" 2) "jack" 3) "age" 4) "21"
例如:XREAD阻塞方式,读取最新消息
XREAD COUNT 2 BLOCK 10000 STREAMS users $
在业务开发中,我们可以使用循环调用的XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下
while (true){ //尝试读取队列中的消息,最多阻塞2秒 Object msg = redis.execute("XREAD COUNT 1 BLOCK 2000 STREAMS users $"); //没读取到,跳过下面的逻辑 if(msg == null){ continue; } //处理消息 handleMessage(msg); }
注意:当我们指定其实ID为$时,代表只能读取到最新消息,如果当我们在处理一条消息的过程中,又有超过1条以上的消息到达队列,那么下次获取的时候,也只能获取到最新的一条,会出现漏读消息的问题
- STREAM类型消息队列的XREAD命令特点
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有漏读消息的风险
基于Stream的消息队列—消费者组
- 消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列,具备以下特点
- 消息分流
- 队列中的消息会分留给组内的不同消费者,而不是重复消费者,从而加快消息处理的速度
- 消息标识
- 消费者会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识之后读取消息,确保每一个消息都会被消费
- 消息确认
- 消费者获取消息后,消息处于pending状态,并存入一个pending-list,当处理完成后,需要通过XACK来确认消息,标记消息为已处理,才会从pending-list中移除
- 消息分流
- 创建消费者组
XGROUP CREATE key groupName ID [MKSTREAM]
key 队列名称 groupName 消费者组名称 ID 起始ID标识,$代表队列中的最后一个消息,0代表队列中的第一个消息 MKSTREAM 队列不存在时自动创建队列
其他常见命令
# 删除指定的消费者组 XGROUP DESTORY key groupName # 给指定的消费者组添加消费者 XGROUP CREATECONSUMER key groupName consumerName # 删除消费者组中指定的消费者 XGROUP DELCONSUMER key groupName consumerName # 从消费者组中读取消息 XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [keys ...] ID [ID ...]
group 消费者组名称 consumer 消费者名,如果消费者不存在,会自动创建一个消费者 count 本次查询的最大数量 BLOCK milliseconds 当前没有消息时的最大等待时间 NOACK 无需手动ACK,获取到消息后自动确认(一般不用,我们都是手动确认) STREAMS key 指定队列名称 ID 获取消息的起始ID >:从下一个未消费的消息开始(pending-list中) 其他:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
消费者监听消息的基本思路
while(true){ // 尝试监听队列,使用阻塞模式,最大等待时长为2000ms Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >") if(msg == null){ // 没监听到消息,重试 continue; } try{ //处理消息,完成后要手动确认ACK,ACK代码在handleMessage中编写 handleMessage(msg); } catch(Exception e){ while(true){ //0表示从pending-list中的第一个消息开始,如果前面都ACK了,那么这里就不会监听到消息 Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 STREAMS s1 0"); if(msg == null){ //null表示没有异常消息,所有消息均已确认,结束循环 break; } try{ //说明有异常消息,再次处理 handleMessage(msg); } catch(Exception e){ //再次出现异常,记录日志,继续循环 log.error(".."); continue; } } } }
- STREAM类型消息队列的XREADGROUP命令的特点
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读风险
- 有消息确认机制,保证消息至少被消费一次
List | PubSub | Stream | |
---|---|---|---|
消息持久化 | 支持 | 不支持 | 支持 |
阻塞读取 | 支持 | 支持 | 支持 |
消息堆积处理 | 受限于内存空间, 可以利用多消费者加快处理 |
受限于消费者缓冲区 | 受限于队列长度, 可以利用消费者组提高消费速度,减少堆积 |
消息确认机制 | 不支持 | 不支持 | 支持 |
消息回溯 | 不支持 | 不支持 | 支持 |
Stream消息队列实现异步秒杀下单
- 需求:
- 创建一个Stream类型的消息队列,名为stream.orders
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
步骤一:
创建一个Stream类型的消息队列,名为stream.orders
XGROUP CREATE stream.orders g1 0 MKSTREAM
步骤二:
修改Lua脚本,新增orderId参数,并将订单信息加入到消息队列中
-- 订单id local voucherId = ARGV[1] -- 用户id local userId = ARGV[2] -- 新增orderId,但是变量名用id就好,因为VoucherOrder实体类中的orderId就是用id表示的 local id = ARGV[3] -- 优惠券key local stockKey = 'seckill:stock:' .. voucherId -- 订单key local orderKey = 'seckill:order:' .. voucherId -- 判断库存是否充足 if (tonumber(redis.call('get', stockKey)) <= 0) then return 1 end -- 判断用户是否下单 if (redis.call('sismember', orderKey, userId) == 1) then return 2 end -- 扣减库存 redis.call('incrby', stockKey, -1) -- 将userId存入当前优惠券的set集合 redis.call('sadd', orderKey, userId) -- 将下单数据保存到消息队列中 redis.call("sadd", 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', id) return 0