RocketMQ 消息重试分为发送重试(生产者)和消费重试(消费者)
RocketMQ 客户端连接服务端发起消息发送请求时,可能会因为网络故障、服务异常等原因导致调用失败。为保证消息的可靠性, RocketMQ 在客户端SDK中内置请求重试逻辑,尝试通过重试发送达到最终调用成功的效果。同步发送和异步发送模式均支持消息发送重试。
对于事务消息,网络超时或异常等场景不会进行重试。
生产者在初始化时设置消息发送最大重试次数,当出现上述触发条件的场景时,生产者客户端会按照设置的重试次数一直重试发送消息,直到消息发送成功或达到最大重试次数重试结束,并在最后一次重试失败后返回调用错误响应。
除服务端返回系统流控错误场景,其他触发条件触发重试后,均会立即进行重试,无等待间隔。
若由于服务端返回流控错误触发重试,系统会按照指数退避策略进行延迟重试。指数退避算法通过以下参数控制重试行为:
INITIAL_BACKOFF: 第一次失败重试前后需等待多久,默认值:1秒。
MULTIPLIER :指数退避因子,即退避倍率,默认值:1.6。
JITTER :随机抖动因子,默认值:0.2。
MAX_BACKOFF :等待间隔时间上限,默认值:120秒
MIN_CONNECT_TIMEOUT :最短重试间隔,默认值:20秒。
计算算法如下:
ConnectWithBackoff()
current_backoff = INITIAL_BACKOFF
current_deadline = now() + INITIAL_BACKOFF
while (TryConnect(Max(current_deadline, now() + MIN_CONNECT_TIMEOUT))!= SUCCESS){
SleepUntil(current_deadline)
current_backoff = Min(current_backoff * MULTIPLIER, MAX_BACKOFF)
current_deadline = now() + current_backoff + UniformRandom(-JITTER * current_backoff, JITTER * current_backoff)
}
消息肯定不能无限重试,所以生产者可以控制最大重试次数,如果最终重试还是失败,将会抛出异常。重试还可能造成消息重复(这需要根据业务逻辑自己编码处理 —— 幂等性)
消息流控指的是系统容量或水位过高, RocketMQ 服务端会通过快速失败返回流控错误来避免底层资源承受过高压力。
当前系统触发流控是,客户端一般会收到错误和异常信息如下:
- reply-code:530
- reply-text:TOO_MANY_REQUESTS
// 构建生产者
Producer producer = provider.newProducerBuilder()
// Topics 列表:生产者和主题是多对多的关系,同一个生产者可以向多个主题发送消息
.setTopics("MY_FIFO_TOPIC")
.setClientConfiguration(configuration)
// 设置消息发送重试次数(默认:3 次)
.setMaxAttempts(3)
// 构建生产者,此方法会抛出 ClientException 异常
.build();
// 设置同步发送重试次数(默认:2)
producer.setRetryTimesWhenSendFailed(2);
// 设置一般发送重试次数(默认:2)
producer.setRetryTimesWhenSendAsyncFailed(2);
消费重试指的是,消费者在消费某条消息失败后,RocketMQ 服务端会根据重试策略重新消费该消息,超过一次定数后若还未消费成功,则该消息将不再继续重试,直接被发送到死信队列中。
不同的消费者类型,重试触发条件是一样的,但 PushConsumer 和 SimpleConsumer 重试策略稍有不同。
PushConsumer 消费的消息,涉及到的状态如下(DLQ:dead letter queue 死信队列):
第几次重试 | 间隔时间 |
---|---|
1 | 10s |
2 | 30s |
3 | 1m |
4 | 2m |
5 | 3m |
6 | 4m |
7 | 5m |
8 | 6m |
9 | 7m |
10 | 8m |
11 | 9m |
12 | 10m |
13 | 20m |
14 | 30m |
15 | 1h |
16 | 2h |
>16 大于16次,后续间隔都为2h | 2h |
gRPC 协议端口的重试次数设置,以及顺序消费的设置都是设置在消费者分组创建时的元数据控制,也就是说我们在编写代码都时候不需要在代码中设置,请参考前篇《RocketMQ 消费者分类与分组》创建或修改消费者分组来设置。
PushConsumerBuilder 在 build 方法中实例化 PushConsumer 的实现的时候,会读取 MQ 服务端对应消费者分组的设置,也就是说 gRPC 协议的客户端,不允许在客户端代码中修改相关消费者分组的设置。
// 默认即为 16 次
consumer.setMaxReconsumeTimes(16);
SimpleConsumer 没有 wait retry 状态。消费失败后根据 InvisibleDuration (消费不可见时间)来计算时间间隔
消息重试间隔 = InvisibleDuration (不可见时间)- 消息实际处理时长
例如,消息不可见时间为30 ms,实际消息处理用了10 ms就返回失败响应,则距下次消息重试还需要20 ms,此时的消息重试间隔即为20 ms;若直到30 ms消息还未处理完成且未返回结果,则消息超时,立即重试,此时重试间隔即为0 ms。
为了避免 InvisibleDuration 时间小于消息实际处理时长,在消息消费过程中,我们可以动态的调整InvisibleDuration 的时长,来避免此类情况出现。
// 修改 InvisibleDuration
simpleConsumer.changeInvisibleDuration();
simpleConsumer.changeInvisibleDurationAsync()
SimpleConsumer 重试次数的修改与PushConsumer 相同
消息重试适用业务处理失败且当前消费为小概率事件的场景,是为了解决偶发情况,消费失败。如果消费重试情况经常出现,请考虑修改相应业务逻辑或修改相关代码。
顺序消息频发重试,可能导致顺序消息堆积。
RocketMQ 无法避免消息重复(Exactly-Once),所以如果业务对消费重复非常敏感,务必要在业务层面进行去重处理。可以借助关系数据库进行去重。首先需要确定消息的唯一键,可以是msgId,也可以是消息内容中的唯一标识字段,例如订单Id等。在消费之前判断唯一键是否在关系数据库中存在。如果不存在则插入,并消费,否则跳过。(实际过程要考虑原子性问题,判断是否存在可以尝试插入,如果报主键冲突,则插入失败,直接跳过)
注:msgId一定是全局唯一标识符,但是实际使用中,可能会存在相同的消息有两个不同msgId的情况(消费者主动重发、因客户端重投机制导致的重复等),这种情况就需要使业务字段进行重复消费。
$> ./mqadmin updateSubGroup -n 127.0.0.1:9876 -g MY_RETRY_GROUP -r 3 -c DefaultCluster
我们此处为了验证方便,设置重试次数为 3 次。
import com.yyoo.mq.rocket.MyMQProperties;
import org.apache.rocketmq.client.apis.ClientConfiguration;
import org.apache.rocketmq.client.apis.ClientException;
import org.apache.rocketmq.client.apis.ClientServiceProvider;
import org.apache.rocketmq.client.apis.message.Message;
import org.apache.rocketmq.client.apis.producer.Producer;
import org.apache.rocketmq.client.apis.producer.SendReceipt;
public class RetryProducerDemo {
public static void main(String[] args) throws ClientException {
// 用于提供:生产者、消费者、消息对应的构建类 Builder
ClientServiceProvider provider = ClientServiceProvider.loadService();
// 构建配置类(包含端点位置、认证以及连接超时等的配置)
ClientConfiguration configuration = ClientConfiguration.newBuilder()
// endpoints 即为 proxy 的地址,多个用分号隔开。如:xxx:8081;xxx:8081
.setEndpoints(MyMQProperties.ENDPOINTS)
.build();
// 构建生产者
Producer producer = provider.newProducerBuilder()
// Topics 列表:生产者和主题是多对多的关系,同一个生产者可以向多个主题发送消息
.setTopics("MY_NORMAL_TOPIC")
.setClientConfiguration(configuration)
// 构建生产者,此方法会抛出 ClientException 异常
.build();
// 构建消息类
Message message = provider.newMessageBuilder()
// 设置消息发送到的主题
.setTopic("MY_NORMAL_TOPIC")
// 设置消息索引键,可根据关键字精确查找某条消息。其一般为业务上的唯一值。如:订单id
.setKeys("order_id_1001")
// 设置消息Tag,用于消费端根据指定Tag过滤消息。其一般用作区分不同的业务,最好给它定义好命名规范
.setTag("RETRY_TEST")
// 消息体,单条消息的传输负载不宜过大。所以此处的字节大小最好有个限制
.setBody("{\"success\":true,\"order_id\":\"1001\",\"msg\":\"消费重试测试!\"}".getBytes())
.build();
// 发送消息(此处最好进行异常处理,对消息的状态进行一个记录)
try {
SendReceipt sendReceipt = producer.send(message);
System.out.println("Send message successfully, messageId=" + sendReceipt.getMessageId());
} catch (ClientException e) {
System.out.println("Failed to send message");
}
}
}
生产者代码和普通的消息发送代码一致。
import com.yyoo.mq.rocket.MyMQProperties;
import org.apache.rocketmq.client.apis.ClientConfiguration;
import org.apache.rocketmq.client.apis.ClientException;
import org.apache.rocketmq.client.apis.ClientServiceProvider;
import org.apache.rocketmq.client.apis.consumer.ConsumeResult;
import org.apache.rocketmq.client.apis.consumer.FilterExpression;
import org.apache.rocketmq.client.apis.consumer.FilterExpressionType;
import org.apache.rocketmq.client.apis.consumer.PushConsumer;
import java.nio.ByteBuffer;
import java.util.Collections;
public class RetryConsumerDemo {
public static void main(String[] args) throws ClientException {
// 用于提供:生产者、消费者、消息对应的构建类 Builder
ClientServiceProvider provider = ClientServiceProvider.loadService();
// 构建配置类(包含端点位置、认证以及连接超时等的配置)
ClientConfiguration configuration = ClientConfiguration.newBuilder()
// endpoints 即为 proxy 的地址,多个用分号隔开。如:xxx:8081;xxx:8081
.setEndpoints(MyMQProperties.ENDPOINTS)
.build();
// 设置过滤条件(这里为使用 tag 进行过滤)
String tag = "RETRY_TEST";
FilterExpression filterExpression = new FilterExpression(tag, FilterExpressionType.TAG);
// 构建消费者
PushConsumer pushConsumer = provider.newPushConsumerBuilder()
.setClientConfiguration(configuration)
// 设置消费者分组
.setConsumerGroup("MY_RETRY_GROUP")
// 设置主题与消费者之间的订阅关系
.setSubscriptionExpressions(Collections.singletonMap("MY_NORMAL_TOPIC", filterExpression))
.setMessageListener(messageView -> {
System.out.println("============开始消费!");
System.out.println(messageView);
ByteBuffer rs = messageView.getBody();
byte[] rsByte = new byte[rs.limit()];
rs.get(rsByte);
// 一直失败,测试消费重试机制
if(true){
return ConsumeResult.FAILURE;
}
System.out.println("Message body:" + new String(rsByte));
// 处理消息并返回消费结果。
System.out.println("Consume message successfully, messageId=" + messageView.getMessageId());
return ConsumeResult.SUCCESS;
}).build();
System.out.println(pushConsumer);
// 如果不需要再使用 PushConsumer,可关闭该实例。
// pushConsumer.close();
}
}
达到最大重试次数后,消息将被发送到死信队列。
死信队列对应的 TOPIC 名称为:%DLQ% + 消费者分组名称。我们的示例对应的死信 TOPIC 名称为:%DLQ%MY_RETRY_GROUP。我们可以通过消费死信队列的消息进行业务恢复或者进行死信消息的持久化存储。
死信消息在 MQ 中的最长存储时间为 3 天,即便我们消费了死信队列的消息,死信消息依然会存储在MQ,到期后才删除。
import com.yyoo.mq.rocket.MyMQProperties;
import org.apache.rocketmq.client.apis.ClientConfiguration;
import org.apache.rocketmq.client.apis.ClientException;
import org.apache.rocketmq.client.apis.ClientServiceProvider;
import org.apache.rocketmq.client.apis.consumer.ConsumeResult;
import org.apache.rocketmq.client.apis.consumer.FilterExpression;
import org.apache.rocketmq.client.apis.consumer.FilterExpressionType;
import org.apache.rocketmq.client.apis.consumer.PushConsumer;
import java.nio.ByteBuffer;
import java.util.Collections;
public class DLQConsumerDemo {
public static void main(String[] args) throws ClientException {
// 用于提供:生产者、消费者、消息对应的构建类 Builder
ClientServiceProvider provider = ClientServiceProvider.loadService();
// 构建配置类(包含端点位置、认证以及连接超时等的配置)
ClientConfiguration configuration = ClientConfiguration.newBuilder()
// endpoints 即为 proxy 的地址,多个用分号隔开。如:xxx:8081;xxx:8081
.setEndpoints(MyMQProperties.ENDPOINTS)
.build();
// 设置过滤条件(这里为使用 tag 进行过滤)
String tag = "RETRY_TEST";
FilterExpression filterExpression = new FilterExpression(tag, FilterExpressionType.TAG);
// 构建消费者
PushConsumer pushConsumer = provider.newPushConsumerBuilder()
.setClientConfiguration(configuration)
// 设置消费者分组
.setConsumerGroup("DLQ_CONSUMER_GROUP")
// 设置主题与消费者之间的订阅关系
.setSubscriptionExpressions(Collections.singletonMap("%DLQ%MY_RETRY_GROUP", filterExpression))
.setMessageListener(messageView -> {
System.out.println("============开始消费!");
System.out.println(messageView);
ByteBuffer rs = messageView.getBody();
byte[] rsByte = new byte[rs.limit()];
rs.get(rsByte);
// 测试消费重试机制
if(true){
return ConsumeResult.FAILURE;
}
System.out.println("死信消息消费:" + new String(rsByte));
// 处理消息并返回消费结果。
System.out.println("死信消息消费 Consume message successfully, messageId=" + messageView.getMessageId());
return ConsumeResult.SUCCESS;
}).build();
System.out.println(pushConsumer);
// 如果不需要再使用 PushConsumer,可关闭该实例。
// pushConsumer.close();
}
}
死信队列消费与普通消息消费是一样的,它同样有重试逻辑,遵循同样的规律。即死信消息消费时也有重试机制以及超过最大重试次数成为死信消息。
不仅仅是查看死信消息,Dashboard 还提供了后台管理界面,可以新增主题、消费者分组等各种 Admin Tool 支持的操作,但 Dashboard 版本几乎没有更新,比如修改消费者分组为顺序消费等功能没有,而且很多功能可能会报错,但其是我们自定义 MQ 管理功能的重要参考。