针对协议AMQP 0-9-1(开发常用协议)
消息队列工作原理分三个角色:消息生产者、消息队列服务器、消息消费者
RabbitMQ服务器内部包含多个虚拟主机,虚拟主机用于隔离不同的应用环境,每个虚拟主机都有自己的交换机、队列和绑定关系。交换机、队列和绑定关系可以通过代码或者可视化界面来构建。交换机负责接收并根据路由键将消息转发到绑定的队列,交换机和消息队列通过路由键相互绑定(路由模式下),消息队列是MQ中用于存储消息的队列(顾名思义,队列先进后出)
消息生产者和消息消费者通过TCP连接消息队列服务器,单个TCP连接对应多个信道,每个信道对应多个线程,或者说对应多个消息的生产和消费过程
消息生产者通过信道将消息发送到消息队列的交换机,交换机接收并根据路由键将消息发送并存储到消息队列,消费者通过信道将消息从消息队列中取出并完成消息
消息可靠投递是指消息从生产者发送到消息队列,再从消息队列到消费者完成消费过程,不丢失消息或重复处理消息
消息的可靠性投递分为消息可靠发送,消息可靠存储,消息可靠消费
消息可靠性投递分为上下两个半场
消息的可靠性投递分为上下半场,消息的幂等性保证也分为上下半场
消息可靠发送指生产者发送消息到消息队列,消息队列持久化并响应确认的过程。
消息可靠发送通过事务消息或者发布者确认机制保证,事务性消息不必要的繁重,更推荐发布确认机制来保证消息可靠发送。发布者确认机制通过消息发送回调接口或备用交换机实现
- 回调接口(应答机制+失败重试):配置文件开启消息确认机制,配置消息发送回调接口到`rabbitTemplate``
ConfirmCallback
接口用于确认消息是否发送到交换机(返回true
和false
来告知消息是否到达交换机)ReturnsCallback
接口用于确认消息是否发送到队列(仅在消息没有发送到队列时调用)- 备用交换机:消息队列中的消息过期、被拒绝、达到最大重试次数,或者无匹配队列等情况,就会转发到备用交换机
消息发送流程:消息发送者向MQ服务器发送消息,MQ服务器接收并落库持久化消息,并响应ACK到消息生产者。至此消息成功发送。但是我们需要考虑以下几点
异常场景一:发布者发送消息和MQ接收消息并落库持久化,前两步出现问题意味着消息发送失败。通过发布者确认机制的回调接口(ConfirmCallback
接口和ReturnsCallback
接口)就能解决,是重新发送还是记录错误。业务逻辑不会出问题
异常场景二:MQ将消息落库并响应ACK丢失。MQ落库成功意味着逻辑上消息已经发送成功,但是发布者没有收到ACK啊?似乎挺严重的(不必理会)
- 发布者检测到网络异常(心跳)未接受到ACK。内置timer尝试重新发送消息,多次失败则不再尝试返回失败(发布者会为每个消息创建Message ID,避免消息重复发送到RabbitMQ出现存储重复消息的情况,消息发送的幂等性由MQ服务端和发布者内置的机制保证)
- 发布者确认机制下,发布者和MQ服务端信道会处于确认模式,在确认模式下,Broker和发布者都会对消息进行计数。Broker成功处理消息后,Broker在信道上发送
basic.ack
,delivery-tag
字段指示已确认消息的序列号。代理也可能在basic.ack
中设置multiple
字段以指示已处理所有直到序列号消息为止的消息(对于MQ成功落库消息后个别ack响应丢失的极端情况。在下一个消息落库成功后ack响应时,multiple
字段的批量确认消息落库成功,就能解决上述的极端情况)
持久消息的延迟确认:路由到持久队列的持久消息的basic.ack
将在将消息持久化到磁盘后发送。RabbitMQ消息存储会在一段时间间隔(几百毫秒)后批量将消息持久化到磁盘。这意味着消息的确认是异步的,这意味着消息到达MQ和响应ACK确认到达的顺序并不准确一致,应用程序应尽可能避免依赖确认的顺序。
这意味着在持续负载下,
basic.ack
的延迟可能达到几百毫秒。为了提高吞吐量,强烈建议应用程序异步处理确认(作为流)或发布消息批次并等待未完成的确认。这方面的确切API在不同的客户端库之间有所不同。(有点类似Redis种AOF文件,会先将写指令记录到AOF缓存,后续通过回写策略刷到磁盘)
RabbitMQ中消息确认机制有自动确认机制和手动确认机制(建议开启手动确认机制)
自动确认机制:消费者接收到消息后自动返回ACK确认到Broker,通知RabbitMQ删除消息
自动确认机制容易出现消费者过载。而手动确认机制通常与通道预取配合使用,限制消息同时消费数量。而自动确认机制下没有限制,若消费者处理速度不及消息到达速度,消费者可能会被传递速率压垮,可能在内存中累积积压,耗尽堆内存或被操作系统终止进程。因此,应谨慎使用自动确认模式或具有无限预取的手动确认模式。建议手动确认配合通道预取使用
手动确认机制:消费者处理消息成功后显示发送ACK通知RabbitMQ删除消息。消息处理失败则发送共NACK给消息队列,消息重新投递或者直接丢弃
消息消费失败处理:有限次重试+死信队列+人工介入(需要注意消息的幂等性)
- 消费失败后有限次重新投递消息队列(比如3次),应对由临时性错误(如网络抖动)引起的消费失败(立即重试或者配合延迟队列延迟重试)
- 超过重试次数则拒绝重新入队,避免消息循环和积压。配合死信队列和人工介入处理
消息消费业务逻辑:消费者接收并处理业务逻辑,消费成功后由发送确认ACK到MQ服务器,MQ服务器删除消息。
RabbitMQ对消费者确认采取超时保护,如果消费者超时未确认(默认情况下为 30 分钟,配置文件可配置,新版的RabbitMQ还可以为队列指定超时时间),MQ服务端会关闭通道,并显示 PRECONDITION_FAILED
通道异常。
而消费者确认模式下:任何未确认的传递(消息)在发生传递的通道(或连接)关闭时都会自动重新入队。后续重复投递消息(消费者必须业务代码种做好消息幂等性的保障!)
重新传递的消息将具有一个特殊的布尔属性
redeliver
,该属性由 RabbitMQ 设置为true
。对于首次传递,它将设置为false
。这通常不会作为消息幂等性的保障,而是作为消息消费失败后,在业务种判断是否将消息重新入队的依据
考虑以下异常情况
异常场景一:MQ将消息投递消费者途中消息丢失
MQ超时关闭队列,消息重新入队,重新消费
异常场景二:消费者成功消费消息,但是响应ACK丢失
业务逻辑上消息已经消费成功,但是MQ等待超时依旧将消息重新入队。这种情况应注意消息的幂等性判断!避免后端重复消费消息(Redis幂等性表解决消息幂等性或者消息接待全局唯一id等等)
消息发送和消息消费,分别对应上下半场
- 上半场:消息生产者重新发布消息如何保障幂等性:生产者心跳检测网络异常,没有收到MQ的阔库ACK,会重新投递消息。但是生产者会为消息生产唯一Message ID,避免因发布者重复导致MQ重复存储相同消息。(上半场的消息幂等性由MQ服务器和生产者内置的机制保证)
- 下半场:MQ服务器重新投递消息到消费者如何保证幂等性:当MQ将消息投递到消费者后,超时为接收到消费者响应的ACK,会关闭通道,消息重新投递回队列。准备重新投递并消费。所以消费者可能接收到重复消息。(下半场消息幂等性由开发人员保证,那如何保障?)
幂等性保障解决方案:
消息幂等性思考:
消息幂等设计并非万能:消息幂等性保障需要保障消息消费每一个子步骤均幂等
假设消息消费的流程包含:
- 检查库存(RPC)
- 锁库存(RPC)
- 开启事务,插入订单表(MySQL)
- 调用某些其他下游服务(RPC)
- 更新订单状态
- commit 事务(MySQL)
当消息消费到第三步的时候假设 MySQL 异常导致失败,触发消息重试。由于消息前一次消费失败,所以消息重试会重新进入消费代码,那么步骤 1 和步骤 2 就会重新再执行一遍。
如果步骤 2 本身不是幂等的,那么这个业务消息消费依旧没有做好完整的幂等处理。
消息幂等设计的价值:通过消息幂等设计,绝大多数情况就能保障消息幂等性。比如上游生产者导致的业务级别消息重复问题、MQ重复投递消费等。对于之前说的消息幂等性并非万能,我们能做的就是尽量保证子步骤幂等,比如异常那就解锁库存。但是毕竟异常和宕机此类情况是极少数的情况
使用Redis消息去重表不依赖事务,用kv键值对存储消息的状态,key键可以考虑使用消息种携带的唯一标识(分布式全局唯一ID),value值对应消息消费状态:消费者共,消费过。kv键值对设置过期时间。
自定义幂等注解
/**
* 幂等注解,防止消息队列消费者重复消费消息
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMQDuplicateConsume {
/**
* 设置防重令牌 Key 前缀
*/
String keyPrefix() default "";
/**
* 通过 SpEL 表达式生成的唯一 Key
*/
String key();
/**
* 设置防重令牌 Key 过期时间,单位秒,默认 1 小时
*/
long keyTimeout() default 3600L;
}
幂等 MQ 消费状态枚举,对应消息消费中和已消费
/**
* 幂等 MQ 消费状态枚举
*/
@RequiredArgsConstructor
public enum IdempotentMQConsumeStatusEnum {
/**
* 消费中
*/
CONSUMING("0"),
/**
* 已消费
*/
CONSUMED("1");
@Getter
private final String code;
/**
* 如果消费状态等于消费中,返回失败
*
* @param consumeStatus 消费状态
* @return 是否消费失败
*/
public static boolean isError(String consumeStatus) {
return Objects.equals(CONSUMING.code, consumeStatus);
}
}
SpEL解析工具类
/**
* SpEL 表达式解析工具
*/
public final class SpELUtil {
/**
* 校验并返回实际使用的 spEL 表达式
*
* @param spEl spEL 表达式
* @return 实际使用的 spEL 表达式
*/
public static Object parseKey(String spEl, Method method, Object[] contextObj) {
List<String> spELFlag = ListUtil.of("#", "T(");
Optional<String> optional = spELFlag.stream().filter(spEl::contains).findFirst();
if (optional.isPresent()) {
return parse(spEl, method, contextObj);
}
return spEl;
}
/**
* 转换参数为字符串
*
* @param spEl spEl 表达式
* @param contextObj 上下文对象
* @return 解析的字符串值
*/
public static Object parse(String spEl, Method method, Object[] contextObj) {
DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(spEl);
String[] params = discoverer.getParameterNames(method);
StandardEvaluationContext context = new StandardEvaluationContext();
if (ArrayUtil.isNotEmpty(params)) {
for (int len = 0; len < params.length; len++) {
context.setVariable(params[len], contextObj[len]);
}
}
return exp.getValue(context);
}
}
防止消息重复提交切面类,并将其交由容器管理
/**
* 防止消息队列消费者重复消费消息切面控制器
*/
@Slf4j
@Aspect
@RequiredArgsConstructor
public final class NoMQDuplicateConsumeAspect {
private final StringRedisTemplate stringRedisTemplate;
private static final String LUA_SCRIPT = """
local key = KEYS[1]
local value = ARGV[1]
local expire_time_ms = ARGV[2]
return redis.call('SET', key, value, 'NX', 'GET', 'PX', expire_time_ms)
""";
/**
* 增强方法标记 {@link NoMQDuplicateConsume} 注解逻辑
*/
@Around("@annotation(com.nageoffer.onecoupon.framework.idempotent.NoMQDuplicateConsume)")
public Object noMQRepeatConsume(ProceedingJoinPoint joinPoint) throws Throwable {
NoMQDuplicateConsume noMQDuplicateConsume = getNoMQDuplicateConsumeAnnotation(joinPoint);
String uniqueKey = noMQDuplicateConsume.keyPrefix() + SpELUtil.parseKey(noMQDuplicateConsume.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());
String absentAndGet = stringRedisTemplate.execute(
RedisScript.of(LUA_SCRIPT, String.class),
List.of(uniqueKey),
IdempotentMQConsumeStatusEnum.CONSUMING.getCode(),
String.valueOf(TimeUnit.SECONDS.toMillis(noMQDuplicateConsume.keyTimeout()))
);
// 如果不为空证明已经有
if (Objects.nonNull(absentAndGet)) {
boolean errorFlag = IdempotentMQConsumeStatusEnum.isError(absentAndGet);
log.warn("[{}] MQ repeated consumption, {}.", uniqueKey, errorFlag ? "Wait for the client to delay consumption" : "Status is completed");
if (errorFlag) {
throw new ServiceException(String.format("消息消费者幂等异常,幂等标识:%s", uniqueKey));
}
return null;
}
Object result;
try {
// 执行标记了消息队列防重复消费注解的方法原逻辑
result = joinPoint.proceed();
// 设置防重令牌 Key 过期时间,单位秒
stringRedisTemplate.opsForValue().set(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMED.getCode(), noMQDuplicateConsume.keyTimeout(), TimeUnit.SECONDS);
} catch (Throwable ex) {
// 删除幂等 Key,让消息队列消费者重试逻辑进行重新消费
stringRedisTemplate.delete(uniqueKey);
throw ex;
}
return result;
}
/**
* @return 返回自定义防重复消费注解
*/
public static NoMQDuplicateConsume getNoMQDuplicateConsumeAnnotation(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
return targetMethod.getAnnotation(NoMQDuplicateConsume.class);
}
}
消费者添加防止消息重复消费注解
@Component
@Slf4j
public class MyMessageListener {
@NoMQDuplicateConsume(
keyPrefix = "coupon_task_execute:idempotent:",
key = "#messageWrapper.message.couponTaskId",
keyTimeout = 120
)
// 修饰监听方法
@RabbitListener(queues = STATUS_DELAYED_QUEUE_NAME)
public void processMessage(String dataString, Message message, Channel channel) throws IOException {
// 1、获取当前消息的 deliveryTag 值备用
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
// 2、正常业务操作
log.info("消费端接收到消息内容:" + dataString);
// 3、给 RabbitMQ 服务器返回 ACK 确认信息
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
// 4、获取信息,看当前消息是否曾经被投递过
Boolean redelivered = message.getMessageProperties().getRedelivered();
if (!redelivered) {
// 5、如果没有被投递过,那就重新放回队列,重新投递,再试一次
channel.basicNack(deliveryTag, false, true);
} else {
// 6、如果已经被投递过,且这一次仍然进入了 catch 块,那么返回拒绝且不再放回队列
channel.basicReject(deliveryTag, false);
}
}
}
}
为什么要设置 2 分钟幂等?10 分钟不行么?
这个 2 分钟是个经验值,也就是说你这个消息消费的时间是否能够在 2 分钟内执行完成,如果不行需要设置更长的时间。
如果 2 分钟幂等结束后有新的一模一样的请求呢?、
这是个伪命题,一般的幂等都是因为网络抖动同时到达,不太可能一个消息都执行完了挺长时间,然后又有一模一样的消息再来消费。如果面试官非要揪着这个点不放的话,可以把这个幂等标识存放到 MySQL 数据库,进行分表存储。这样存个 10 天半个月也不怕。但是要注意,MySQL 是没有到期删除机制的,还得配合定时任务删除之前的无效数据。