一、概述
众所周知,redis这个强大的中间件经常被用作处理各种分布式的业务,比如分布式锁、消息队列等等,而redis用来处理消息队列的业务时应该使用哪种数据结构呢?
在学习stream之前,我的第一反应是list,理由如下
- list可以采用左进右出的方式保证消息处理的顺序性
- list可以采取阻塞读取消息方式,不浪费cpu资源
- list可以借助redis对消息进行持久化
综上所述,好像list的确可以作为一种简单的消息队列,但是它具备一种致命的缺陷:没有消息确认机制,容易导致消息丢失。此外,也没有消息回溯和广播功能。stream的出现解决了上述问题,我们来了解以下
二、Stream基础指令
(一) 向队列中添加消息:XADD
示例:XADD users * name jack
(二) 向队列中读取消息:XREAD
示例:
XREAD 1 2 STREAMS users 0 读取users队列的第一个消息(读取后队列并不会删除该消息,可重复读取)
XREAD 1 2 STREAMS users $ 读取users队列的最新消息
注意:使用XREAD读取最新消息的漏洞
当我们执行handleMessage时,又有多条消息到达消息队列时我们只会读取最新的那条
(三) 使用消费者组来读取消息
也就是说同一个消费者组中的消费者们不会重复消费同一个消息,而多个消费者组可以消费同一个消息。被消费者消费但没有确认的消息会进到pending-list中,确认后移除。
1. 消费者组的创建、增加消费者、删除消费者操作
一般我们会提前创建好消费者组,消费者可以在读取消息时创建,也可以提前创建
2. 消费者组读取消息
消费者业务伪代码
while(true){
try{
//1.获取消息
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("group1", "consumer1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("message.queue", ReadOffset.lastConsumed())
//2.判断消息是否存在
if(list == null || list.isEmpty()){
continue;
}
//3.解析消息
Map map = list.get(0).getValue();
ENtity entity = BeanUtil.fillBeanWithMap(map,new Entity(),true);
//4.处理消息
handleMsg(entity);
//5.对消息进行确认
ack(queue_name,group,id)
}catch(Exception e){
while(true){
try{
//1.获取消息
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("group1", "consumer1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("message.queue", ReadOffset.from("0"))
//2.判断消息是否存在
if(list == null || list.isEmpty()){
break;
}
//3.解析消息
Map map = list.get(0).getValue();
ENtity entity = BeanUtil.fillBeanWithMap(map,new Entity(),true);
//4.处理消息
handleMsg(entity);
//5.对消息进行确认
ack(queue_name,group,id)
}catch(e){
log.debug("消息处理异常");
}
}
}
}
}
三、业务场景
我们要实现一个秒杀商品的业务,场景如下:客户端传来商品id,我们要为该商品扣减库存,并且创建订单,要求一人一单,不能重复购买
技术点:
- 在高并发场景下正确扣减库存,不能发生超卖
- 保证一人一单,不能重复购买
- 在高并发下借用消息队列保持高性能
(一) 超卖问题
业务流程
1. 查询数据库判断库存是否大于0
2. 大于0则执行库存-1
上述流程中,当遇到高并发的场景时,多个线程同时执行到第一步时都判断成功,但执行到第二步就很有可能导致超卖,那怎么解决呢?
很多同学的第一反应是加锁,让他们串行执行,但这样的效率又太低。我们可以借助乐观锁的思想,在修改库存时判断库存是否大于0,这样既能解决超卖问题又能实现高性能。
(二) 一人一单问题
业务流程
1.得到用户id,商品id
2.使用用户id、商品id查询订单表的记录数量count
3.若count>0,表示该用户对商品下过单,返回结果,否则继续执行
4.乐观锁的方式修改商品库存,返回布尔值表示修改是否成功
5.修改成功则说明符合条件,保存订单到数据库中,若不成功则直接返回
该业务流程同样存在并发安全问题,需要加锁来解决(由于没有现成的字段可当作乐观锁的校验字段,故我们不采用乐观锁),而业务场景是一人一单,不同用户之间不存在安全问题,所以我们锁的范围不宜过大,只要锁定同一个用户就行,用户id非常合适,若在分布式系统中,使用jdk的锁无法保证安全问题,故需要使用分布式锁来解决问题,设置锁的key:lock:user:id,用redission保证线程安全问题
(三) 使用消息队列提升高并发场景的性能
在上述业务场景中,要执行相当多的数据库和加锁操作,若在高并发的场景下性能必定很低,我们需要优化业务流程,保证高并发下的性能问题
该业务流程实际包含两部分:一是购买资格校验(超卖、一人一单),二是数据库的修改(修改库存、保存订单),我们可以将资格校验部分放入redis中去做,数据库操作部分
具体流程:生产者
首先将该商品的库存和已购买用户列表添加到redis中,用户下单该商品时首先执行lua脚本判断库存是否足够和该用户id是否已经存在该商品的user_list中,若不满足则代表可以执行下列操作(此时是在redis中操作,并不是在数据库中操作)
- 库存-1
- 该用户id添加进user_list
- 将消息发送到消息队列
消费者
消费者拿到消息后只需要对数据库进行操作就行,修改商品库存和保存订单