Bug如山勤为径,代码似海苦作舟。友友们好,这里是苦瓜大王。今天学习的是黑马点评项目实战篇------Redis消息队列部分的学习,之前我们对秒杀用Redisson进行了分布式锁的优化,又进行了异步秒杀优化,但是当时我们用的是JVM的阻塞队列,今天将使用Redis消息队列对异步秒杀进一步优化。笔记如下,后续会一直更新黑马点评学习过程中的笔记、问题等,请多多支持哦!
- 之前学习了基于JVM的阻塞队列来完成异步秒杀,但是仍有缺陷
- 内存限制问题:在高并发情况下,大量订单需要创建时,可能导致超出JVM阻塞队列的上限
- 数据安全问题:JVM内存没有持久化安全机制,每当服务重启或者出现宕机的情况时,阻塞队列中的所有订单任务都会丢失
- 这一篇就是在解决这两个问题,最佳方案是使用消息队列
- 好处:解除耦合,提高效率
消息队列如何解决上面两个问题?
- 消息队列是在JVM以外的独立服务,不受JVM内存的限制
- 消息队列会对存储的数据进行持久化,服务重启和宕机之后数据也不受影响,并且会在消息投递给消费者之后做确认,如果没有得到确认,依然会存在消息队列里,下一次会继续投递让消费者处理,直到得到确认,确保消息至少被消费一次
常见的消息队列:
-
kafkaMQ等
-
但是这些都有一定成本,小型企业成本越低越好,可以利用redis实现消息队列
-
专用的消息队列还是建议学一下
文章目录
- 一、消息队列
- 二、Stream的消费者组模式
-
- [1. 创建消费者组](#1. 创建消费者组)
- [2. 从消费者组读取消息](#2. 从消费者组读取消息)
- 3.确认消息
- 4.查看pending-list
- 5.Java代码的基本思路
- 6.总结
- 三、秒杀优化------基于Stream消息队列实现异步秒杀
-
- [1. 创建Stream消息队列](#1. 创建Stream消息队列)
- [2. 改造Lua脚本](#2. 改造Lua脚本)
- [3. 改造业务逻辑](#3. 改造业务逻辑)
一、消息队列

- Redis实现消息队列

1.基于List实现消息队列

- 用BRPOP和BLPOP来实现,可以阻塞
bash
#监听li这个队列,指定监听的阻塞时间为20秒
BRPOP l1 20
#往l1存入1、2
LPUSH l1 1 2
#会弹出1
#再次获取
BRPOP l1 20
#会弹出2
#再次获取
BRPOP l1 20
#阻塞


2.基于PubSub的消息队列

- 天生就是阻塞式的


- 对可靠性要求较高的话,不建议使用PubSub

3.基于Stream的消息队列
- 是一种全新的数据类型 ,是支持数据的持久化的
- 发送消息

- 读取消息


bash
#往s1中存入消息
> XADD s1 * name jack
1773903918442-0
#查看消息数量
> XLEN s1
1
#从第一条消息开始读取s1的1条消息
> XREAD count 1 streams s1 0
s1
1773903918442-0
name
jack
#读取s1的最新消息
> XREAD count 1 streams s1 $
#返回nil是因为都读过了,没有最新消息了
nil
#读最新消息,0代表永久阻塞,什么时候有新消息就什么时候结束
> xread count 1 block 0 streams s1 $
- 可以反复读,读完了之后并不会删除消息
- 阻塞式循环读取最新消息

- 可能会出现漏读消息的情况
- 处理消息的过程中如果来了很多条消息,只能看到最新那一条
- 下面将讲解更好的消息读取方案,防止漏读

二、Stream的消费者组模式

- 命令
1. 创建消费者组

2. 从消费者组读取消息
- ID配置:一般0是异常时候配置,消费了但是还没确认,>是正常情况,总是从下一个未消费的消息开始

3.确认消息

4.查看pending-list

bash
#先插入1条数据
>XADD s1 * name jack
#创建消费者组
>XGROUP CREATE s1 s1_group 0
#从消费者组中阻塞20秒读取下一个未被消费的信息,读出name jack
>XREADGROUP GROUP s1_group a1 COUNT 1 BLOCK 20000 STREAMS s1 >
>XREADGROUP GROUP s1_group a1 COUNT 1 BLOCK 20000 STREAMS s1 >
#此时再插入1条数据,就会读取出clour blue
>xadd s1 * clour blue
#从消费者组中阻塞20秒读取下一个未被消费的信息
>XREADGROUP GROUP s1_group a1 COUNT 1 BLOCK 20000 STREAMS s1 >
#此时再插入1条数据,就会读取出age 21
>xadd s1 * age 21
#确认前两条消费过的2条消息
>XACK s1 s1_group 1773906757092-0 1773906757094-0
#此时再插入1条数据
xadd s1 * sex man
#从消费者组中阻塞20秒读取下一个未被消费的消息,会读出sex man
>XREADGROUP GROUP s1_group a1 COUNT 1 BLOCK 20000 STREAMS s1 >
#确认消息
>XACK s1 s1_group 【id】
#查看pending---list
>XPENDING s1 s1_group - + 10
#从消费者组中阻塞20秒读取下一个消费了但是还没确认的消息,会读出age 21
>XREADGROUP GROUP s1_group a1 COUNT 1 BLOCK 20000 STREAMS s1 >
#确认消息
>XACK s1 s1_group 【id】
5.Java代码的基本思路

6.总结
- 解决了内存限制 (不受JVM限制)、数据安全 (持久化机制)、消息漏读 (确认机制,确保消息至少消费一次)问题


- stream可以满足中小型企业的需求,但是如果公司业务比较庞大,对消息队列的要求更加严格,建议使用更加专业的消息队列,如RabbitMQ等,因为stream的持久化依赖于redis的持久化并不是万无一失的,还是有消息丢失的风险,并且消息确认机制只支持消费者确认,而不支持生产者的确认机制,生产者在发消息的时候丢失了就无法处理,另外还有事务机制、多消费者下的事务有序性等要解决
三、秒杀优化------基于Stream消息队列实现异步秒杀

1. 创建Stream消息队列
bash
#直接创建消费组顺便把队列创建了
> XGROUP CREATE stream.orders g1 0 MKSTREAM
OK
2. 改造Lua脚本
- 修改脚本,认定有抢购资格之后,向stream.orders添加消息
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 stockKeyif(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userIdif(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
3. 改造业务逻辑
- 修改VoucherOrderServiceImpl中的seckillVoucher方法
- 发消息
java
public Result seckillVoucher(Long voucherId) {
//1.执行Lua脚本
//优惠券id
Long userId = UserHolder.getUser().getId();
//订单id
long orderId = redisIdWorker.nextId("order");
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),//脚本的key参数为空,所以传一个空集合
voucherId.toString(),//以字符串形式传,具体看execute的参数列表
userId.toString(),
String.valueOf(orderId)
);
//2.判断结果是否为零
if(result.intValue() != 0)
//2.1不为零,没有购买资格
return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
//3.获取代理对象(因为子线程无法获取父线程的代理)
//获取代理对象(初始化)
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 4. 返回订单id
return Result.ok(orderId);
}
- 获取消息完成下单
- VoucherOrderHandler线程代码
java
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
//处理异常消息
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明pending-list中没有异常消息,结束循环
break;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理pendding-list订单异常", e);
//担心处理太频繁
try{
Thread.sleep(20);
}catch(Exception e){
e.printStackTrace();
}
}
}
}
}
- 优化后VoucherOrderServiceImpl完整代码:
java
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
/**
* 秒杀优惠券
* @param voucherId 优惠券id
* @return 订单id
*/ @Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
//注入redisson
@Resource
private RedissonClient redissonClient;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
//在静态代码块里初始化
//只会在类加载的时候执行一次,所以不会浪费资源
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
//为了避免硬编码,指定lua脚本的位置
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
//设置返回值类型为Long
SECKILL_SCRIPT.setResultType(Long.class);
}
/*创建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);*/ //创建线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//当前类初始化完毕之后就来执行
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
//创建一个runnable
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
//处理异常消息
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明pending-list中没有异常消息,结束循环
break;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理pendding-list订单异常", e);
//担心处理太频繁
try{
Thread.sleep(20);
}catch(Exception b){
b.printStackTrace();
}
}
}
}
}
private void handleVoucheOrder(VoucherOrder voucherOrder) {
Long userId =voucherOrder.getId();
//锁定的范围是用户id
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁
//这一段只是兜底,其实不做也没问题
boolean isLock = lock.tryLock();
//判断是否获取成功
if (!isLock) {
//获取锁失败,输出错误
log.info("不允许重复下单");
}
try {
//拿到那个现成的代理对象
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
//成员变量,方便子线程获取
private IVoucherOrderService proxy;
public Result seckillVoucher(Long voucherId) {
//1.执行Lua脚本
//优惠券id
Long userId = UserHolder.getUser().getId();
//订单id
long orderId = redisIdWorker.nextId("order");
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),//脚本的key参数为空,所以传一个空集合
voucherId.toString(),//以字符串形式传,具体看execute的参数列表
userId.toString(),
String.valueOf(orderId)
);
//2.判断结果是否为零
if(result.intValue() != 0)
//2.1不为零,没有购买资格
return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
//3.获取代理对象(因为子线程无法获取父线程的代理)
//获取代理对象(初始化)
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 4. 返回订单id
return Result.ok(orderId);
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
//异步的,所以不能通过ThreadLocal来获取了
Long userId =voucherOrder.getId();
//4.一人一单
//4.1 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
//4.2 判断是否存在
if(count>0)
log.info("您已经购买过一次了");
//5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock",0)
.update(); //where id = ? and stock > 0
if(!success)
//扣减失败
log.info("库存不足");
//6.订单写入数据库
save(voucherOrder);
}
}
- 压测,查看在高并发情况下的功能、性能如何
- 保证了库存不会超卖、订单不会超出、一人一单
- 解决了集群下线程安全问题
- 性能不错
以上就是黑马点评实战篇------Redis消息队列部分的学习笔记,仅供参考,多多支持!🌹