如何实现一个避免消息重复消费的方案?
问题描述
消息中间件是分布式系统常用的组件,在系统异步化、解耦以及削峰等方面有广泛的用途。通常认为消息中间件是一个可靠的组件,也就是说只要把消息投递到消息中间件,消息就不会丢失。
因此可以认为:消息肯定会至少保证消息能够消费者成功消费一次,这是消息中间件最基本的特征之一。也就是AL LEAST ONCE
,即消息至少会被"成功消费一遍"。
然而,当消费者A消费到一般的时候程序重启了,这个时候该消息并没有被标记为消费成功,因此这个消息还会继续投递给这个消费者,直到被消费成功,此时消息中间件才会停止投递。于是,这种可靠的特性会导致消息可能被多次地消费。
简而言之,程序A接收到消息M,并完成消费逻辑后,程序重启了,无法告知消息中间件“消费成功”。对于消息中间件来说,这个消息并没有被成功消费,因此会再次投递该消息。这在RocketMQ的场景中,就是同一个Message ID的消费重复投递了。
因为基于消息的投递可靠(不丢失消息)是优先级更高的,所以不重复消费的任务就由程序本身来实现,这也就是所谓的消费逻辑需要自我实现幂等性
。这背后的逻辑就是:不丢失和不重复是矛盾的(在分布式场景下),但是相对而言,消息重复是有解决方案的,而消费丢失则是相对麻烦的。
因此,RocketMQ官方对于消息重复的问题列出了三种情况:
1. 发送时消费重复
当一条消息已经被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且Message ID也相同的消息。
2. 投递时消息重复
在消息被消费的场景下,消息已经投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。为了保证消息至少被消费一次,消息队列RockeetMQ版的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且Message ID也相同的消息。
3. 负载均衡时消息重复
该场景包括但不限于网络抖动、Broker重启以及消费者应用重启。当消息队列RocketMQ版的Broker或者客户端重启、扩容或缩容时,会出发Rebalance,此时消费者可能会收到重复消息。
基于上述三种场景,解决方案如下:
方案1:简单的消息去重解决方案
假设业务的消息消费逻辑时:插入某张订单表的数据,然后更新库存。
insert into my_order values ......
update my_inv set count = count-1 where good_id = 'good_123456';
要实现消息的幂等性,可以将SQL更改如下:
select * from my_order where order_no = "order123"
if(order != null){
return; //消息重复,直接返回
}
该方案对于很多情况下还是可以起到不错的效果,但是在并发场景下,仍然存在问题。
方案2:并发重复消息
假如这个消费的所有代码总共需要1秒,有重复的消息在这1秒内(假设100毫秒)内到达。如生产者快速重发,Broker重启等。那么对于方案1的场景,数据依然是空的,因为上一条消息还没有消费完,还没有成功更新订单状态。
具体就是两个线程在间隔非常短甚至是同时执行这个逻辑:
select * from my_order where order_no = "order123"
然后发现都没有查到数据,于是进入到下面逻辑中:
if(order != null){
return; //消息重复,直接返回
}
那么就会穿透检查的挡板,导致重复的消息消费逻辑进入到非幂等安全的业务代码中,从而引发重复消费的问题。例如主键冲突抛出异常,库存被重复扣减而没释放等。
要解决这样一种并发场景下的消息幂等的问题,一个可取的方案是开启事务
,把select改为select for update语句,把记录进行锁定:
select * from my_order where order_no = 'THIS_ORDER_NO' for update //开启事务
if(order.status != null){
return; //消息重复,直接返回
}
但是这样消费的逻辑会因为引入了事务包裹而导致整个消息消费可能变长,并发度下降。
另外的更高级的方案,例如更新订单状态采取乐观锁,更新失败则消息重新消费之类的。但是更具体的方案需要针对具体业务场景进行更复杂和细致的代码开发、库表设计,这在其他博文里讨论。但是无论是select for update,还是乐观锁这种方案,实际上都是基于业务表本身做的去重,这无疑是增加了业务开发的复杂度。
一个业务系统里边很大部分的请求处理都是依赖MQ的,如果每个消费逻辑本身都需要基于业务本身而做去重/幂等的开发,这就增加了程序员的工作量。因此我们的目标是寻求一种通用的消息幂等处理的方法,从而抽象处一定的工具类以适用各种业务场景。
方案3:Exactly Once
在消息中间件里,有一个投递语义的概念,而这个语义里有一个Exactly Once
,即消息肯定会被成功消费,并且只会被消费一次。对于Exactly Once,官方的解释如下:
Exactly-Once是指发送到消息系统的消息只能被消费端处理且仅处理一次,即使生产端重试消息发送导致某消息重复投递,该消息在消费端也只被消费一次。
在业务消息幂等处理的领域中,可以认为业务消息的代码肯定会被执行,并且只被执行一次,那么就可以认为是Exactly Once。然而在分布式场景下,寻找一个通用的方案几乎不可能。但是如果是针对基于数据库事务的消费逻辑,实际上是可行的。
另外,关于Exactly-Once的其他内容如下:
- Exactly-Once语义是消息系统和流式计算系统中消息流转的最理想状态,但是在业界并没有太多理想的实现。
- 真正意义上的Exactly-Once依赖于消息系统的服务端、消息系统的客户端和用户消费逻辑这三者状态的协调,例如当消费端完成一条消息的消费处理后出现异常宕机,而消费端重启后由于消费的位点没有同步到消息系统的服务器,该消息可能被重复消费。
- 事实上,特定场景的Exactly-Once语义实现并不是非常复杂,只是因为通常没有精确地描述问题的本质。
- 如果要实现一条消息的消费结果只能在业务系统中生效一次,需要解决的只是如何保证同一条消息的消费幂等问题。
- 消息队列RocketMQ版的Exactly-Once语义就是解决业务中最常见的一条消息的消费结果(消息在消费端计算处理的结果)在数据库系统中有且仅生效一次的问题。
方案4:基于关系数据库事务插入消息表
假设业务的消息消费逻辑是:更新MySQL数据库的某张订单表的状态:
update my_order
set status = 'SUCCESS' where order_no = '0rder1234';
要实现Exactly-Once,即消息只被消费一次(并且肯定要保证能消费一次),可以这样做:在这个数据库中增加一个消息消费记录表,把消息插入到这个表,并且把原来的订单更新和这个插入的动作放到同一个事务中一起提交,就能保证消息只会被消费一遍了。具体流程如下:
-
- 开启事务
-
- 插入消息表(处理好主键冲突的问题)
-
- 更新订单表(原消费逻辑)
-
- 提交事务
这个时候如果消息消费成功且事务提交了,那么消息表就插入成功了。这时候就算RocketMQ还没有收到消费位点的更新,从而再次投递,也会插入失败而视为已经消费过,后续就直接更新消费位点了。这样就能保证我们的消费代码只会执行一次。
如果事务提交之前服务挂了(例如重启),那么对于本地事务并没有执行,所以订单没有更新,消息表也没有插入成功。而对于RocketMQ服务器来说,消费位点也没更新,所以消息还会继续投递下来,投递下来发现这个消息插入消息表也是成功的,所以可以继续消费。这样保证了消息不丢失。
事实上,阿里云的RocketMQ的Exactly-Once语义的实现上,就是类似这个方案基于数据框的事务特性实现的:
- 步骤一:添加依赖
- 步骤二:创建消费事务表
- 步骤三:生产端开启Exactly-Once投递语义
- 步骤四:消费端开启Exactly-Once投递语义
基于这种方式的方案确实可以拓展到不同的应用场景中,因为它的实现方案与具体业务本身无关——而是依赖一个消息表。但是这里有它的局限性:消息的消费逻辑必须是依赖于关系型数据库事务。
这个消息的消费过程中还涉及其他数据的修改,例如Redis这种不支持事务特性的数据源,则这些数据是不可回滚的。另外数据库的数据必须是在一个库,跨库无法解决。需要注意的是,在业务上,消息表的涉及不应该以消息ID作为标识,而应该以业务的业务主键作为标识更合理,以应对生产者的重发。
更复杂的业务场景
正如前面所讲,Exactly-Once语义的实现,实际上有很多的局限性,这种局限性使得使用Exactly-Once的方案基本不具备广泛的应用价值。而且由于基于事务,可能导致锁表时间过长等性能问题。以一个比较常见的订单申请的消息举例,可能分为以下几步:
- 检查库存(RPC)
- 锁库存(RPC)
- 开启事务,插入订单表(MySQL)
- 调用某些其他下游服务(RPC)
- 更新订单状态
- commit事务(MySQL)
这种情况下,如果采用消息表+本地事务的实现方式,消息消费过程中有很多子过程是不支持回滚的,也就是说就算加了事务,背后的操作也不是原子性的。例如:有可能第一条消息在经历了第二步锁库存的时候,服务重启了,这时候实际上库存是已经在另外的服务里被锁定了,这并不能被回滚。
当然消息还会再次投递下来,要保证消息能至少消费遍,也就是说锁库村的这个RPC接口本身依旧要支持“幂等”。而且,如果在这个比较耗时的长链条场景下加入事务的包裹,将大大的降低系统的并发。
所以通常情况下,处理这种场景的消息去重的方法还是会使用一开始说的业务自己实现去重逻辑的方式,例如前面加select for update,或者使用乐观锁。
那么有没有方法抽取出一个公共的解决方案,能够兼顾去重、通用、高性能呢?这里,首先解析一下消息的执行过程,其中一个思路是把上面的几步,拆解成几个不同的子消息,例如:
- 库存系统消息A:检查库存并做锁库存,发送消息B给订单服务
- 订单系统消费消息B:插入订单表(MySQL),发送消息C给自己(下游系统)消费
- 下游系统消费消息C:处理部分逻辑,发送消息D给订单系统
- 订单系统消费消息D:更新订单状态
上述步骤需要保证本地事务和消息是一个事务的(至少是最终一致性的),这其中涉及到分布式事务消息相关的话题。可以看到这样的处理方法会使得每一步的操作都比较原子,而原子则意味着是小事务,小事务则意味着使用消息表+事务的方案显得可行。但是,这太复杂了。把一个本来联系的代码逻辑割裂成多个系统多次消息交互,还不如业务代码层面加锁实现。
更通用的解决方案
上面消息表+本地事务的方案之所以有局限性和并发的短板,是由于它依赖于关系型数据库的事务,而且必须要把事务包裹于整个消息消费的环节。如果能够不依赖事务而实现消息的去重,那么方案就能推广到更复杂的场景,例如:RPC、跨库等。
举个例子,我们依旧使用消息表,但是不依赖事务,而是针对消息表增加消费状态,是否可以解决问题呢?
接下来就是基于消息幂等表的非事务方案了。
以上是去事务化的消息幂等方案的流程,可以看到,此方案是无事务的。关键在于数据的状态,消息表本身做了状态的区分:消费中、消费完成。只有消费完成的消息才会被幂等处理掉。而对于已有消费中的消息,后面重复的消息会触发延迟消费,比如在RocketMQ的场景下就是发送到RETRY TOPIC。
之所以触发延迟消费,是为了控制并发场景下,第二条消息在第一条消息没完成的过程中,去延迟消费,而不是直接幂等,从而去控制消息不丢。
如果直接幂等了,那么同一个消息id或者业务唯一标识,会丢失消息,因为上一条消息如果没有消费完成的时候,第二条消息已经告诉broker成功了,那么第一条消息这个时候失败broker也不会重新投递了。这里我们重新看一开始想解决的问题是否解决了:
- 问题1:消息已经消费成功,第二条消息将被直接幂等处理掉(消费成功)
- 问题2:并发场景下的消息,依旧能满足不会出现消息重复,即穿透幂等挡板的问题。
- 问题3:支持上游业务生产者重发的消息幂等问题。
第一个问题在上面的描述中已经解决了。那么对于第二个问题是如何解决的呢?主要是依靠插入消息表的这个动作做控制的,假设我们用MySQL作为消息表的存储媒介,设置消息的唯一ID为主键,那么插入的动作只有一条消息会成功。后面的消息插入会由于出现主键冲突而失败,走向延迟消费的分支,然后后面延迟消费的时候就会变成上面第一个场景的问题。关于第三个问题,只要我们涉及去重的消息见让其支持业务的主键(例如东单号、请求流水号等),而不仅仅是messageId即可。所以也不是问题。
那么,此方案是否有消息丢失的风险?其实这里实际上是有逻辑漏洞的,就是问题2。在并发场景下依赖于消息状态是做并发控制使得第2条消息重复的消息会不断延迟消费,即重试。但是,如果这个时候第一条消息也由于一些异常原因,如机器重启了、外部异常导致消费失败,没有消费成功呢?
也就是说这时候延迟消费实际上每次过来看到的都是消费中的状态,最后消费就会被视为消费失败而被投递到死信Topic中,比如RocketMQ默认可以重复消费16次。对于这个问题,我们的解决方案是插入的消息表必须要带一个最长消费过期时间,例如10分钟。意思是如果一个消息处于消费中超过10分钟,就需要从消息表中删除,这一点需要程序自行实现。
所以最后的消息的流程如下:
这个方案实际上没有事务的,只需要一个存储的中心媒介,那么自然就可以选择更灵活的存储媒介,例如Redis。这里使用Redis有两个好处:
- 性能上损耗较低
- 超时时间可以直接利用Redis本身的ttl实现
使用Redis也有缺点:数据可靠性、一致性方面不如MySQL,需要用户自己取舍。
Show Me Code
以下是利用Redis去重的适用样例,用以示意业务中如果使用此f方案加入消息去重幂等的操作:
(1)继承DedupForConcurrentListener 类,实现消费回调和去重键的设置回调。
(2)启动RocketMQ 消费者
package com.example.demo.rocketmq;
import com.example.demo.rocketmq.core.DedupConfig;
import com.example.demo.rocketmq.core.DedupForConcurrentListener;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
@Slf4j
public class MonitorListener extends DedupForConcurrentListener {
public MonitorListener(DedupConfig dedupConfig) {
super(dedupConfig);
}
//基于条件做消息去重,每一类不同的消息都可以不一样,做去重之前会尊重此方法返回的值
@Override
protected String dedupMessageKey(MessageExt messageExt) {
// 为了简单示意,直接使用消息体作为去重键
if ("TEST-TOPIC".equals(messageExt.getTopic())) {
return new String(messageExt.getTopic());
} else {
// 其他使用默认的配置(消息id)
return super.dedupMessageKey(messageExt);
}
}
@Override
protected boolean doHandleMsg(MessageExt messageExt) {
switch (messageExt.getTopic()) {
case "TEST-TOPIC":
log.info("假装消费很久....{} {}", new String(messageExt.getBody()), messageExt);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
}
return true;
}
public static void main(String[] args) throws MQClientException {
// 利用Redis做幂等操作
{
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("TEST-APP1");
consumer.subscribe("TEST-TOPIC", "*");
String appName = consumer.getConsumerGroup();
StringRedisTemplate stringRedisTemplate = null; // 省略获取StringRedisTemplate的过程
DedupConfig dedupConfig = DedupConfig.enableDedupConsumeConfig(appName, stringRedisTemplate);
DedupForConcurrentListener messageListener = new MonitorListener(dedupConfig);
consumer.registerMessageListener(messageListener);
consumer.start();
}
// 利用MySQL做幂等表
{
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("TEST-API");
consumer.subscribe("TEST-TOPIC", "*");
String appName = consumer.getConsumerGroup();
JdbcTemplate jdbcTemplate = null;
DedupConfig dedupConfig = DedupConfig.enableDedupConsumeConfig(appName, jdbcTemplate);
DedupForConcurrentListener messageListener = new MonitorListener(dedupConfig);
consumer.registerMessageListener(messageListener);
consumer.start();
}
}
}
唯一需要修改的就是创建一个DedupForConcurrentListener 示例,指明自定义的消费逻辑和去重的业务键,这里默认是messageId。
package com.example.demo.rocketmq.core;
import com.example.demo.rocketmq.strategy.ConsumeStrategy;
import com.example.demo.rocketmq.strategy.DedupConsumeStrategy;
import com.example.demo.rocketmq.strategy.NormalConsumeStrategy;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.common.message.MessageClientIDSetter;
import org.apache.rocketmq.common.message.MessageExt;
import java.util.List;
import java.util.function.Function;
/**
* 增加去重逻辑的消息消费者抽象类, 实现该类的实例需要实现doHandleMsg
* 该抽象类支持消息幂等的策略
*/
@Slf4j
public abstract class DedupForConcurrentListener implements MessageListenerConcurrently {
//默认不去重
private DedupConfig dedupConfig = DedupConfig.disableDupConsumeConfig("NOT-SET-CONSUMER-GROUP");
/**
* 默认不去重
*/
public DedupForConcurrentListener() {
log.info("Construct QBConcurrentRMQListener with default {}", dedupConfig);
}
/**
* 设置去重策略
*
* @param dedupConfig
*/
public DedupForConcurrentListener(DedupConfig dedupConfig) {
this.dedupConfig = dedupConfig;
log.info("Construct QBConcurrentRMQListener with dedupConfig {}", dedupConfig);
}
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
boolean hasConsumeFail = false;
int ackIndexIfFail = -1;
for (int i = 0; i < msgs.size(); i++) {
MessageExt msg = msgs.get(i);
try {
hasConsumeFail = !handleMsgInner(msg);
} catch (Exception ex) {
log.warn("Throw Exception when consume {}, ex", msg, ex);
hasConsumeFail = true;
}
//如果前面出现消费失败的话,后面也不用消费了,因为都会重发
if (hasConsumeFail) {
break;
} else {
//到现在都消费成功
ackIndexIfFail = i;
}
}
// 全部消费成功
if (!hasConsumeFail) {
log.info("consume [{}] msg(s) all successfully", msgs.size());
} else { // 存在失败的
context.setAckIndex(ackIndexIfFail); // 标记成功位,后面的会重发以重新消费,在这个位置之前的不会重发
log.warn("consume [{}] msg(s) fails, ackIndex = [{}] ", msgs.size(), context.getAckIndex());
}
// 无论如何最后都会返回
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
/**
* 子类实现该方法,真正处理消息
*
* @param messageExt
* @return
*/
protected abstract boolean doHandleMsg(final MessageExt messageExt);
/**
* 默认以uniqkey作为去重的标识
*/
protected String dedupMessageKey(final MessageExt messageExt) {
String uniqID = MessageClientIDSetter.getUniqID(messageExt);
if (uniqID == null) {
return messageExt.getMsgId();
} else {
return uniqID;
}
}
/**
* 消费消息,带去重的逻辑
*
* @param messageExt
* @return
*/
private boolean handleMsgInner(final MessageExt messageExt) {
ConsumeStrategy strategy = new NormalConsumeStrategy();
Function<MessageExt, String> dedupKeyFunction = messageExt1 -> dedupMessageKey(messageExt);
if (dedupConfig.getDedupStrategy() == DedupConfig.DEDUP_STRTEGY_CONSUME_LATER) {
strategy = new DedupConsumeStrategy(dedupConfig, dedupKeyFunction);
}
// 调用对应的策略
return strategy.invoke(DedupForConcurrentListener.this::doHandleMsg, messageExt);
}
}
那么问题来了:这样的方案是否就能完美地完成去重的所有任务呢?答案是否定的。因为要保证消息至少被成功消费一遍,那么消息就有机会消费到一半的时候失败触发消息重试的可能,以上面订单流程为例:
- 检查库存(RPC)
- 锁库存(RPC)
- 开启事务,插入订单表(MySQL)
- 调用某些其他下游服务(RPC)
- 更新订单状态
- COMMIT事务(MySQL)
这里,当消息消费到步骤3的时候,假设MySQL异常导致失败了,此时消息重试。因为在重试前我们会删除出幂等表的记录,所以消息重试的时候就会重新进入消费代码,那么步骤1和步骤2就会重新再执行一遍。如果步骤2本身不是幂等的,那么这个业务消息消费依旧没有做好完整的幂等处理。
那么该实现方式的价值在哪里呢?虽然这不是解决消息幂等的银弹,但是该方案能以方便的手段解决如下问题:
-
- 各种由于Broker、负载均衡等原因导致的消息重投递的重复问题
-
- 各种上游生产者导致的业务级别消息重复问题
-
- 重复消息并发消费的控制窗口问题,就算重复,重复也不可能同一时间进入消费逻辑。
其他的消息去重建议
使用上述方法能够保证正常的消费逻辑场景(无异常、无异常退出),消息的幂等工作全部都能解决,无论是业务重复还是RocketMQ特性带来的重复。而且,这已经能够解决99%的消息重复问题了。如果希望在异常场景下也能处理好幂等的问题,那么可以思考以下方案:
-
- 消息消费失败做好回滚处理。如果消息消费失败本身是带回滚机制的,那么消息重试自然就没有问题了
-
- 消费者做好优雅退出处理。这样是为了尽可能避免消息消费到一半程序退出的消息重试。
-
- 如果实在无法做到幂等的操作,至少应该要做到终止消费并告警。例如锁库存的操作,如果统一的业务流水锁成功了一次库存,再出发锁库存,如果做不到幂等的处理,至少要做到消息消费触发异常(例如主键重瞳导致消费异常等)。
另外,在要求3做好的 前提下,做好消息的消费监控,发现消息重试不断失败的时候,手动做好1的回滚,使得下次重试消费成功。