MQ消息顺序性与重试性保证

1,221 阅读10分钟

#消息队列 #消息重试 #消息顺序

1. MQ 失败重试

1.1. 消息失败的重试

对于 MQ 消费失败的消息,如果想要进行重试的话大致有两种实现方式:调用消费者消费方法进行重试;将消息扔回到 MQ 队列里进行消息重投。

这两种方式有不同的适用场景:对于方法重调,适合消息吞吐量不高,顺序性要求高的场景;对于消息重投,适合吞吐量要求高而消息消费顺序性又不是那么严格的场景;接下来将对这两种方式分别说明。

需要注意的是,不管采用哪种方式,消息处理逻辑都必须具有幂等性。否则当消息重复消费的时候就会出现数据错乱的风险。

1.1.1. 失败方法重调

对于方法重调,只要在消费者处理逻辑失败时进行方法重试即可。对于这种方式,Spring 直接提供了一种实现,不需要我们手动去捕获异常然后进行重试。

1.1.1.1. Spring 提供的重试机制

对于 Spring 自带的消息重试,可使用以下的配置


# Whether publishing retries are enabled.
spring.rabbitmq.listener.simple.retry.enabled:true

# Duration between the first and second attempt to deliver a message.
spring.rabbitmq.listener.simple.retry.initial-interval:1000ms

# Maximum number of attempts to deliver a message.
spring.rabbitmq.listener.simple.retry.max-attempts:3

# Maximum duration between attempts.
spring.rabbitmq.listener.simple.retry.max-interval:10000ms

# Multiplier to apply to the previous retry interval.
spring.rabbitmq.listener.simple.retry.multiplier:1

# Whether retries are stateless or stateful.
spring.rabbitmq.listener.simple.retry.stateless:true

当进行以上的配置后,当消费者逻辑出现异常后,Spring 就会按给定的配置规则进行重试。需要注意的是,这个重试是由 Spring 进行消费者的方法重复调用实现的,不是 RabbitMQ 进行的消息重投,这个和 MQ 没有任何关系。

1.1.1.2. 重试失败后的抉择

按上面的规则进行配置后,消息就可以进行重试了。接下来面临的另一个问题便是如果重试也失败了呢?如配置了 3 次重试,那么 3 次重试仍然失败此时这条消息该如何处理?丢掉消息?继续消费直到成功?投递到死信队列?

事实上这个策略的选择取决于消息使用者对这个消息的看重程度,如果不允许出现消息丢失的情况,那么不断重试直到成功是一种方式(需要考虑消息一直重试不成功导致的消费问题);否则失败一定次数后丢弃消息或转发到其它队列进行补偿是更为友好的一种方式。

对于这几种考虑,Spring 都给出了可用的实现,由编码人员自行决定是要丢弃消息还是继续重试:具体实现为在 Spring 项目中构造一个 MessageRecoverer 的 bean。

  • 直接丢弃消息

    当重试给定次数后仍不成功的话直接丢弃消息,这种策略适合消息一致性要求不高的场景,对应的配置为使用 ImmediateRequeueMessageRecoverer

  • 转发到死信队列

    将其转发到死信队列,由其他的消费者专门处理消费失败的消息,如进行人工介入补偿等方式,对应的配置为使用 RepublishMessageRecoverer

  • 投递回原队列

    如果对消息敏感不想丢弃消息的话可以采取投递回原队列的形式,这样消息就会一直不断的消费直到成功了,对应的配置为使用 ImmediateRequeueMessageRecoverer

    对于这种方式,需要注意的是如果是使用 Spring 提供的 ImmediateRequeueMessageRecoverer 这个类来完成投递操作的话,那么 Rabbit 的消息确认模式 AcknowledgeMode 不能设置为"none"。

    ImmediateRequeueMessageRecoverer 的实现逻辑为直接抛出了一个异常,AcknowledgeMode 设置为"auto",在成功时 ack 消息,在失败时 nack 消息;AcknowledgeMode 设置为"manual",并且此时消息进入到了 MessageRecoverer 中,这就意味着消费者代码包含未处理的异常且未进行消息确认,因此也可直接抛出异常,这是 Rabbbit 消息未收到 ack 确认从而也会触发 requeue 操作。

  • 自定义失败逻辑

    如果以上三种策略都不满足需求的话,那么我们也可以自定义消息重试失败的处理逻辑。

    由上面三种策略我们可以知道重试失败的处理逻辑是由 MessageRecoverer 这个 bean 来完成的,因此我们只要实现这个接口来进行自定义重试失败后处理逻辑即可。

1.1.2. 失败消息重投

上面介绍了消息重试,消息重试存在什么问题呢?重试时间的间隔问题,如果重试时间设置的太短,那么系统尚未从故障中恢复从而再次失败;如果设置的太长那么一直等待重试时间从而导致吞吐量下降。

因此如果对消息一致性与顺序性不敏感的话那么延迟消费消息是一种更为合理的方式。

一种实现方式是捕获消费者的异常消息,将其投递到其他地方。如 Rabbit/Rocket 的延时队列中,或者 Redis(由 Redisson 借助于 Redis 作为存储介质实现) 的延时队列中亦可。

需要注意消息重投会将消息投递到队列尾部,因此这会丢失原有消息的顺序,在多次重试的场景下更为明显。因此当使用重投机制时需要考虑顺序性是否会对数据造成扰动!

1.2. MQ 的消息消费的顺序性

对于 MQ 的使用,重试、幂等、顺序三个问题是不可避免也必须考虑的。在上面我们考虑了重试的解决,幂等操作依赖业务逻辑的编写。许多业务场景对消息顺序有严格要求,那么顺序性又该如何保证呢?特别是在消息失败重试的情况下!

特别说明的是 Rabbit 提供的强顺序性保证是在 2.7.0 版本后支持的,在之前顺序性无法得到保证,消息会在队列尾部重新排队。因此接下来的介绍都将基于 2.7.0 及之后更高的版本。

1.2.1. Rabbit 的 requeue 操作

在讨论消息的顺序性之前,我们需要了解消息 requeue 的操作。理解 requeue 时会发生什么以及如何影响队列里消息的消费顺序至关重要。

当消息消费失败时可以将消息重新放回原队列,Rabbit 对于放回原队列的消息提供了不同的几种方式。对于任意一种方式,消息回到队列都能始终保持发布时的顺序

  • basic-reject

    basic-reject 用于拒绝一条消息的消费并将其放回到队列中。该策略只能拒绝一条消息。

  • basic-nack

    basic-nackbasic-reject 的作用类似,都是拒绝消息并将其放回到队列中。与 basic-reject 不同的是 basic-nack 支持批量操作,可以拒绝当前消息位置前所有的消息。除此扩展外 basic-nackbasic-reject 没有其它区别

    Manual acknowledgements can be batched to reduce network traffic. This is done by setting the multiple field of acknowledgement methods (see above) to true. Note that basic. reject doesn't historically have the field and that's why basic. nack was introduced by RabbitMQ as a protocol extension.

  • basic-recover

    basic-recover 并非用于拒绝消息,而是让 MQ 节点重发所有 unack 的消息给消费者。对于那些尚未确认到消息,basic-recover 提供了两种模式:将 unack 的消息均衡的发送给所有消费者;将 unack 的消息发送给原来消费者(消费者仍存在的情况下)。

对于以上三种方式,不管选择哪一种方式,消息回到队列中都会保持发布时的顺序,而不会在队尾重新排队。

对于 requeue 有了认知,首先先来考虑正常情况下的顺序性,或者说对于实际场景中,我们能做到的最大程度的顺序保证是什么?

1.2.2. Rabbit 的顺序性支持

Rabbit 的官方文档中介绍,它们提供了一种高顺序性的保证,即使在消息发生了 requeue 操作时,仍能保证原来消息的顺序。

实际上,官方文档也同样指出这个顺序保证只在队列存在单个消费者的情况下。如果某个队列存在多个消费者,那么这些消费者 requeue 的消息顺序将得不到保证。对于单个队列多个消费者消费,如果要保证顺序性那么势必要违反一些定律与进行必要的事务回滚。

以一个例子说明:

  1. consumerA 与 consumerB 同时消费队列消息,当前 consumerA 正在消费 msg1,consumerB 正在消费 msg2

  2. 此时 consumerA 发生了错误而崩溃,那么 unack 的消息 msg1 就要重回到队列中

那么此时的顺序性要如何保证呢?

如果要保证严格的顺序性,那么 msg1 就需要在 msg2 前得到消费,可此时仅有的消费者 consumerB 正在消费 msg2。msg2 要停止消费转而消费 msg1 么?那么此时的代价就相当的大了,不仅需要 consumerB 停止执行当前逻辑,并且需要回滚所有的操作(这些操作包含各种逻辑,也许并不支持回滚)!

所以基于以上的考虑,Rabbit 的顺序性保证只在单个队列单个消费者上面。其实不只是 Rabbit,对于 Rocket/Kafak 都是类似的,都只能保证单个队列上消息的顺序而无法保证多个队列或者多个消费者的顺序!

1.2.3. 严格的顺序性保证

在 MQ 提供的顺序性保障有限的前提下,我们该如何确保业务消息的顺序呢?如果对于消息的顺序有极为严格的要求,那么下面提供一种方案来保证消息的顺序性。在使用之前,最好思考清除这么严格的顺序是否真的有必要?能不能通过其他的方式来解决!

  1. 对于有顺序性要求的消息投递到一个队列

    将需要顺序性保证的消息投递到同一个队列,实际中可以有两种做法:

    • 所有消息只投递到一个队列

    • 构建多个队列,将有顺序性要求的消息通过一致性哈希算法投递到相同队列。

  2. 对该队列保持消费者数量为 1

    上面已经介绍过了,对于同一个队列存在多个消费者的前提下,requeue 发生时消息的顺序无法保证。因此如果严格的顺序消息那么就只能限制为只存在一个消费者(消息的吞吐量会降低)。

  3. 对于消息预取数量设置为 1

    对于 Rabbit 的消费者,消息采取拉模式,每次消费者都会从 MQ 节点拉取消息到本地消息。

    对于拉取到本地的消息数量大小,Rabbit 采取 prefetch_count 控制。对于 prefetch_count,用于限制 unack 的消息数量,默认值为 0 不限制。当设置为 1 时即可限制为只有 1 个消息为 unack 状态,当存在消息未 ack 时后面的消息无法消费从而保证顺序。

  4. 对于失败的消息,设置严格的重试

    对于当前消费的消息,进行不断的重试,直到成功为止,确保消费当前消息时前序所有消息都已消费成功。