消息队列(Message Queue),字面意思就是存放消息的队列,是一种在不同组件或服务间进行异步通信的中间件。最简单的消息队列模型包括3个角色:
消息队列:存储和管理消息,也被称为消息代理(Message Broker)
生产者:发送消息到消息队列
消费者:从消息队列获取消息并处理消息在秒杀场景中的应用
在秒杀场景中,消息队列可用于处理高并发的下单请求,避免系统因瞬间流量过大而崩溃。具体流程如下:
生产者:用户发起秒杀请求,系统将请求封装成消息发送到消息队列。
消息队列:缓存大量秒杀请求,按顺序处理。
消费者:从消息队列中获取消息,进行库存检查、扣减库存、创建订单等操作。
list结构:基于List结构模拟消息队列
PubSub:基本的点对点消息模型
Stream:比较完善的消息队列模型
Redis 的 List
数据结构可以用来实现简单的消息队列,它提供了 LPUSH
、RPOP
等命令,能够轻松实现生产者 - 消费者模式。
生产者:使用 LPUSH 命令将消息添加到列表的左侧,模拟消息的发布。
消费者:使用 RPOP 命令从列表的右侧取出消息,模拟消息的消费。另外,为了避免消费者在列表为空时频繁轮询,可以使用 BRPOP 命令,该命令在列表为空时会阻塞,直到有新消息加入。
public void sendMessage(String message) {
stringRedisTemplate.opsForList().leftPush(MESSAGE_QUEUE_KEY, message);
}
public String receiveMessage(long timeout) {
return stringRedisTemplate.opsForList().rightPop(MESSAGE_QUEUE_KEY, timeout, TimeUnit.SECONDS);
}
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
IdWorker idWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private IVoucherOrderService proxy;
public static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("./lua/seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
public BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
public static final ExecutorService SECKILL_ORDER_EXECUTER = Executors.newSingleThreadExecutor();
@PostConstruct
private void init(){
SECKILL_ORDER_EXECUTER.submit(new voucherOrderHandler());
}
// 启动工作线程(使用局部内部类)
private class voucherOrderHandler implements Runnable {
@Override
public void run() {
while(true){
try {
//VoucherOrder order = orderTasks.take();
String json = receiveMessage(5L);
handleVoucherOrder(JSONUtil.toBean(json, VoucherOrder.class));
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
// 处理订单的逻辑
private void handleVoucherOrder(VoucherOrder order) throws InterruptedException {
// 提取一人一单,扣减库存,创建订单的代码加锁
Long userId = order.getUserId();
// 创建锁对象
RLock lock = redissonClient.getLock("shop:" + userId.toString());
// 尝试获取锁
boolean isLock = lock.tryLock(5, 10, TimeUnit.SECONDS);
if(!isLock){
return;
}
try {
proxy.createVoucherOrder(order);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
// 生产者
private static final String MESSAGE_QUEUE_KEY = "seckill_message_queue";
@Override
public Result seckillVoucher(Long voucherId) {
// 1.执行Lua脚本
long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
UserHolder.getUser().getId().toString()
);
// 2.结果为1,库存不足
if(result == 1){
return Result.fail("库存不足");
}
// 3.结果为2,重复下单
if(result == 2){
return Result.fail("不允许重复下单");
}
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
long id = idWorker.nextId("seckillVoucherOrder");
voucherOrder.setId(id);
// 4.库存为0,将订单信息保存到消息队列
sendMessage(JSONUtil.toJsonStr(voucherOrder));
// 5.返回订单id
return Result.ok(id);
}
public void sendMessage(String message) {
stringRedisTemplate.opsForList().leftPush(MESSAGE_QUEUE_KEY, message);
}
public String receiveMessage(long timeout) {
return stringRedisTemplate.opsForList().rightPop(MESSAGE_QUEUE_KEY, timeout, TimeUnit.SECONDS);
}
@Transactional
@Override
public void createVoucherOrder(VoucherOrder order) {
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", order.getVoucherId()).gt("stock", 0).update();
if (!isSuccess) {
return;
}
// 创建订单
save(order);
}
}
消息丢失:读取过的消息会直接消失。
单消费者:一条消息只支持一个消费者获取。
消息确认机制:Redis List 本身没有消息确认机制,需要业务代码自行实现。
Redis 的发布订阅(Pub/Sub)机制可以实现简单的消息队列功能。Pub/Sub 是一种消息通信模式,发送者(发布者)发送消息到特定的频道(channel),订阅该频道的客户端(订阅者)可以接收到这些消息。
PUBLISH
命令发布到指定频道。private static final String MESSAGE_CHANNEL = "seckill_message_channel";
public void sendMessage(String message) {
// 使用 PUBLISH 命令发布消息
stringRedisTemplate.convertAndSend(MESSAGE_CHANNEL, message);
}
@PostConstruct
private void init() {
// 订阅消息频道
redisMessageListenerContainer.addMessageListener(new MessageListener() {
@Override
public void onMessage(Message message, byte[] pattern) {
String json = new String(message.getBody());
handleVoucherOrder(JSONUtil.toBean(json, VoucherOrder.class));
}
}, new ChannelTopic(MESSAGE_CHANNEL));
}
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private IdWorker idWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private IVoucherOrderService proxy;
@Autowired
private RedisMessageListenerContainer redisMessageListenerContainer;
public static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("./lua/seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// 定义秒杀消息频道
private static final String MESSAGE_CHANNEL = "seckill_message_channel";
@PostConstruct
private void init() {
// 订阅消息频道
redisMessageListenerContainer.addMessageListener(new MessageListener() {
@Override
public void onMessage(Message message, byte[] pattern) {
String json = new String(message.getBody());
handleVoucherOrder(JSONUtil.toBean(json, VoucherOrder.class));
}
}, new ChannelTopic(MESSAGE_CHANNEL));
}
// 处理订单的逻辑
private void handleVoucherOrder(VoucherOrder order) {
Long userId = order.getUserId();
RLock lock = redissonClient.getLock("shop:" + userId.toString());
boolean isLock = lock.tryLock();
if (!isLock) {
return;
}
try {
proxy.createVoucherOrder(order);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
@Override
public Result seckillVoucher(Long voucherId) {
// 1.执行Lua脚本
long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
UserHolder.getUser().getId().toString()
);
// 2.结果为1,库存不足
if (result == 1) {
return Result.fail("库存不足");
}
// 3.结果为2,重复下单
if (result == 2) {
return Result.fail("不允许重复下单");
}
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
long id = idWorker.nextId("seckillVoucherOrder");
voucherOrder.setId(id);
// 4.库存充足,将订单信息发布到消息频道
sendMessage(JSONUtil.toJsonStr(voucherOrder));
// 5.返回订单id
return Result.ok(id);
}
public void sendMessage(String message) {
// 使用 PUBLISH 命令发布消息
stringRedisTemplate.convertAndSend(MESSAGE_CHANNEL, message);
}
// 创建订单的逻辑
@Transactional
@Override
public void createVoucherOrder(VoucherOrder order) {
// 扣减库存
boolean isSuccess = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", order.getVoucherId()).gt("stock", 0).update();
if (!isSuccess) {
return;
}
// 创建订单
save(order);
}
}
init
方法里,使用 RedisMessageListenerContainer
订阅指定频道,当收到消息时调用 handleVoucherOrder
处理。seckillVoucher
方法中,通过 sendMessage
方法将订单信息发布到频道。MessageListener
的 onMessage
方法中,接收消息并转换为 VoucherOrder
对象,再调用 handleVoucherOrder
处理。Redis Stream 是 Redis 5.0 版本引入的新数据结构,主要用于实现消息队列(MQ,Message Queue)。Redis 原本的发布订阅(pub/sub)虽能实现消息队列功能,但存在消息无法持久化的问题,一旦出现网络断开、Redis 宕机等情况,消息就会被丢弃。而 Redis Stream 提供了消息的持久化和主备复制功能,可让任何客户端访问任何时刻的数据,能记住每个客户端的访问位置,还能保证消息不丢失。
消息持久化:即使 Redis 重启,消息内容依然存在,不会因网络问题或 Redis 宕机导致消息丢失。
多生产者和多消费者组:可以接收多个生产者发送的消息,同时支持多个消费者组,每个消费者组内的消费者相互竞争消费消息,一条消息在消费者组只会被一个消费者消费,不同消费者组之间相互独立,都能消费到 Stream 内的所有消息。如消费者g1中的消费者c1,c2与消费者组g2中的c3,c1,c3获取则c2不能获取,c2,c3获取则c1不能获取。
消息唯一 ID:Stream 中的每条消息都有唯一的 ID,可被多个消费者独立消费。
可记录历史消息:与 Redis 的发布订阅不同,Redis Stream 可以记录历史消息,客户端可以访问任何时刻的数据。
每个 Stream 都有一个唯一的名称,它本质上就是 Redis 的 key,在首次使用 XADD 指令追加消息时会自动创建。Stream 内部有一个消息链表,将所有加入的消息串起来,每个消息都有唯一的 ID 和对应的内容。
使用 XGROUPCREATE 命令创建,一个消费组包含多个消费者(Consumer)。消费组内的消费者共同维护一个 last_delivered_id 变量,用于向前推进消费消息。每个消费者内部有一个状态数组变量 pending_ids,用于记录当前已经被客户端读取但还没有 ack(确认)的消息。
每个消费者组都有一个游标 last_delivered_id,任意一个消费者读取了消息都会使该游标往前移动。
它是消费者的状态变量,作用是维护消费者未确认的消息 ID,记录了当前已被客户端读取但还未进行 ack 操作的消息。
用于向队列添加消息,如果指定的队列不存在,则会创建一个队列。
XADD key [MAXLEN [~] count] *|id field value [field value ...]
key
:必需参数,代表 Redis Stream 的键名,用于指定要添加消息的目标 Stream。
MAXLEN [~] count
:可选参数,用于限制 Stream 的长度,防止其无限增长。
MAXLEN
:指定 Stream 最大长度。~
:可选修饰符,使用近似裁剪策略,能提升性能。Redis 不会严格保证 Stream 长度恰好为 count
,而是在接近该长度时进行裁剪。count
:具体的长度限制值。*|id
:
*
:表示让 Redis 自动生成唯一的消息 ID。生成的 ID 格式为 -
,例如 1672531200000-0
。id
:用户自定义消息 ID,格式必须符合 -
,且要大于当前 Stream 中最大的消息 ID,否则会报错。field value [field value ...]
:必需参数,用于指定消息的字段和对应的值,可添加多个键值对。
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
GROUP group consumer
group
是消费者组的名称,若该消费者组不存在,需先使用 XGROUP CREATE
命令创建。consumer
是当前消费者的名称,用于标识当前读取消息的消费者实例。COUNT count
count
是一个整数,用于指定每次最多读取的消息数量。若不指定,Redis 会返回尽可能多的消息。BLOCK milliseconds
milliseconds
表示阻塞的毫秒数,用于实现阻塞式读取。若指定该参数,当 Stream 中没有新消息时,客户端会阻塞等待,直到有新消息到来或者超时。设置为 0
表示无限期阻塞。NOACK
pending
列表移除。默认情况下,读取消息后会自动确认。STREAMS key [key ...]
key
是要读取消息的 Redis Stream 的键名,可同时指定多个 Stream 键名,以实现从多个 Stream 中读取消息。ID [ID ...]
>
:表示从 Stream 中从未被该消费者组处理过的最新消息开始读取。0-0
:表示从 Stream 的第一条消息开始读取,常用于处理历史消息或处理 pending
列表中的消息。XREADGROUP
配合 0-0
读取:从 0-0
开始读取时,会读取该消费者组里所有未确认(pending
列表里)的消息,读取完 pending
列表后,若后续还有消息,会继续读取 Stream 里的新消息。pending
列表存储的是已经被消费者读取,但还未通过 XACK
命令确认的消息。XREADGROUP GROUP mygroup consumer1 COUNT 10 STREAMS mystream 0-0
此命令使用 mygroup
消费者组中的 consumer1
消费者,从 mystream
的第一条消息开始读取,先处理 pending
列表里的消息,然后读取新消息,最多读取 10 条。
XREADGROUP
配合 >
读取:使用 >
作为消息 ID 时,消费者会从 Stream 里从未被该消费者组处理过的最新消息开始读取,会忽略 pending
列表里的消息。用于消费消息,支持阻塞读取功能。
用于消费者确认消息,当消费者处理完消息后,使用该命令进行 ack 操作,将消息从 pending_ids 中移除。
用于创建消费组。
XGROUP CREATE key groupname id [MKSTREAM]
key
:Redis Stream 的键名。groupname
:要创建的消费者组名称。id
:指定消费者组从哪个消息 ID 开始消费。常见取值:
$
:从 Stream 中最新的消息开始消费,即只消费后续新增的消息。0-0
:从 Stream 的第一条消息开始消费,会处理历史消息。MKSTREAM
:可选参数,若指定该参数,当指定的 Stream 不存在时,会自动创建该 Stream。XGROUP CREATE stream.order g1 0 MKSTREAM
判断具有下单资格后发送消息到队列中
--1.参数列表
--1.1优惠圈id
local voucherId = ARGV[1]
--1.2订单id
local userId = ARGV[2]
--1.3订单id
local id = ARGV[3]
--2.key
--2.1库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2订单key
local orderKey = 'seckill:order:' .. voucherId
--2.3订单key
local MQName ='seckill.order'
--3.脚本业务
--3.1判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
--库存不足,直接返回1
return 1
end
--3.2判断用户是否下单
if (redis.call('sismember', orderKey, userId) == 1) then
--订单已存在,重复下单,直接返回2
return 2
end
--3.3扣库存
redis.call('incrby', stockKey, '-1')
--3.4下单
redis.call('sadd', orderKey, userId)
--3.5发送消息到队列中
redis.call('xadd', MQName, '*', 'userId', userId, 'voucherId', voucherId, 'id', id)
return 0
@Override
public Result seckillVoucher(Long voucherId) {
// 1.执行Lua脚本
long id = idWorker.nextId("seckillVoucherOrder");
long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
UserHolder.getUser().getId().toString(),
String.valueOf(id)
);
// 2.结果为1,库存不足
if(result == 1){
return Result.fail("库存不足");
}
// 3.结果为2,重复下单
if(result == 2){
return Result.fail("不允许重复下单");
}
// 4.返回订单id
return Result.ok(id);
}
// 启动工作线程(使用局部内部类)
private class voucherOrderHandler implements Runnable {
public static final String MQ_NAME = "streams.order";
@Override
public void run() {
while(true){
try {
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(MQ_NAME, ReadOffset.lastConsumed())
);
// 2.判断是否获取到消息队列中的订单信息
if(list == null || list.isEmpty()){
continue;
}
// 3.如果获取到,下单
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
handleVoucherOrder(voucherOrder);
// 4.ack确认
stringRedisTemplate.opsForStream().acknowledge(MQ_NAME, "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
// 处理pending-list中的订单信息,避免消息丢失
handlePendingList();
}
}
}
// 处理pending-list中的订单
private void handlePendingList(){
while(true){
try {
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(MQ_NAME, ReadOffset.from("0"))
);
// pending-list中没有订单信息
if(list == null || list.isEmpty()){
continue;
}
// 处理订单
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
handleVoucherOrder(voucherOrder);
// ack确认订单信息
stringRedisTemplate.opsForStream().acknowledge(MQ_NAME, "g1", record.getId());
}catch (Exception e){
// 处理pending-list中的订单信息异常,继续循环处理未确认的pending-list中的订单信息,避免消息丢失
log.error("处理pending-list异常", e);
}
}
}
// 处理订单的逻辑
private void handleVoucherOrder(VoucherOrder order) throws InterruptedException {...}
}
XREADGROUPID
传0为什么从 pending
列表中读取而非 Stream 所有消息的第一个在 Redis Stream 的消费者组机制里,当使用 XREADGROUP
命令(对应 Java 代码里 stringRedisTemplate.opsForStream().read
方法),并把消息 ID 指定为 0
或者 0-0
时,会先从 pending
列表读取消息,而不是从 Stream 所有消息的第一个开始读取,这是由消费者组的设计机制决定的。
消费者组用于让多个消费者协作处理同一个 Stream 里的消息。当一个消费者从 Stream 读取消息后,这些消息会被放入该消费者所属消费者组的 pending
列表,直到消费者使用 XACK
命令确认消息处理完成。pending
列表的作用是记录已经被消费者获取但还未确认的消息,以此保证消息不会丢失。
所以,当指定从 0
位置读取时,Redis 会先检查 pending
列表,把其中未确认的消息返回给消费者,处理完 pending
列表后,若有需要才会继续读取 Stream 里的新消息。
pending
列表中移除仅读取消息并不会让消息从 pending
列表中移除。当消费者使用 XREADGROUP
命令读取消息时,消息会被标记为已被该消费者获取,同时添加到 pending
列表。要把消息从 pending
列表移除,需要消费者显式地使用 XACK
命令(对应 Java 代码里 stringRedisTemplate.opsForStream().acknowledge
方法)确认消息处理完成。
Redis Stream 支持消息持久化存储,即使 Redis 服务重启,消息也不会丢失。因为 Redis 可以将 Stream 中的数据持久化到磁盘,当服务恢复后,能从磁盘重新加载数据,保证消息的可靠性。
Redis Stream 提供了消费者组的概念,允许多个消费者共同处理同一个 Stream 中的消息,实现负载均衡。不同消费者可以从 Stream 中获取不同的消息,提高消息处理的并发能力。同时,消费者组还能记录每个消费者的消费进度,当消费者故障恢复后,可以从上次中断的位置继续消费消息。
消费者从 Stream 中读取消息后,需要显式调用 XACK
命令确认消息处理完成。若消费者在处理消息过程中发生故障,未确认的消息会保留在 pending
列表中,待消费者恢复后可以重新处理这些消息,避免消息丢失。
Redis Stream 为每条消息分配一个唯一的 ID,该 ID 包含时间戳和序列号信息。通过消息 ID,可以方便地进行范围查询,例如获取指定时间段内的消息,或者从某个消息 ID 开始继续读取消息。
5. 高性能
Redis 基于内存操作,读写速度极快,能够处理高并发的消息生产和消费请求。同时,Redis Stream 的数据结构设计经过优化,保证了消息的高效存储和读取。
List | PubSub | Stream | |
---|---|---|---|
消息持久化 | 支持 | 不支持 | 支持 |
阻塞读取 | 支持 | 支持 | 支持 |
消息堆积处理 | 受限于内存空间,可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度,可以利用消费者组提高消费速度,减少堆积 |
消息确认机制 | 不支持 | 不支持 | 支持 |
消息回溯 | 不支持 | 不支持 | 支持 |