RabbitMQ - MQ幂等、去重的解决方案

1.场景描述
消息中间件是分布式系统常用的组件,无论是异步化、解耦、削峰等都有广泛的应用价值。我们通常会认为,消息中间件是一个可靠的组件——这里所谓的可靠是指,只要我把消息成功投递到了消息中间件,消息就不会丢失,即消息肯定会至少保证消息能被消费者成功消费一次,这是消息中间件最基本的特性之一,也就是我们常说的“AT LEAST ONCE”,即消息至少会被“成功消费一遍”。

1.1 场景1
举个例子:一个消息M发送到了消息中间件,消息投递到了消费程序A,A接受到了消息,然后进行消费,但在消费到一半的时候程序重启了,这时候这个消息并没有标记为消费成功,这个消息还会继续投递给这个消费者,直到其消费成功了,消息中间件才会停止投递。
这种情景就会出现消息可能被多次地投递。

1.2 场景2
还有一种场景是程序A接受到这个消息M并完成消费逻辑之后,正想通知消息中间件“我已经消费成功了”的时候,程序就重启了,那么对于消息中间件来说,这个消息并没有成功消费过,所以他还会继续投递。这时候对于应用程序A来说,看起来就是这个消息明明消费成功了,但是消息中间件还在重复投递。

以上两个场景对于RocketMQ来说就是同一个messageId的消息重复投递下来了。
基于消息的投递可靠(消息不丢)是优先级更高的,所以消息不重的任务就会转移到应用程序自我实现,这也是为什么RocketMQ的文档里强调的,消费逻辑需要自我实现幂等。背后的逻辑其实就是:不丢和不重是矛盾的(在分布式场景下),但消息重复是有解决方案的,而消息丢失是很麻烦的。

下面一起看看怎么实现幂等,怎么实现去重

2.什么是幂等性
在数学计算或者计算机科学中,幂等性(idempotence)是指相同操作或资源在一次或多次请求中具有同样效果的作用。
幂等性在分布式系统设计中具有十分重要的地位。
HTTP 幂等方法,是指无论调用多少次都不会有不同结果的 HTTP 方法。不管你调用一次,还是调用一百次,一千次,结果都是相同的。
举个最简单的例子,在接口开发时,不管调用多少次接口,其结果都不会改变,所以是幂等

2.1 HTTP GET 方法
HTTP GET 方法,用于获取资源,不管调用多少次接口,结果都不会改变,所以是幂等的。

GET /tickets # 获取ticket列表
GET /tickets/12 # 查看某个具体的ticket

如何判断是否幂等,有一个很重要的依据就是其操作会不会影响资源的变化。
何为资源:最简单的就是每次的操作对于数据库的变化是否有影响,其中返回的列表叫做结果,数据库就是资源。
以get方法为例,虽然每次请求返回的结果不尽相同,但是并不会在数据库中产生任何数据。
例如,有个接口是用于获取系统时间
在这个接口中,结果是每一次获取的时间,资源就是系统本身

GET /service_time # 获取服务器当前时间
该接口每次返回的结果对系统没有任何影响,所以这个接口是幂等的。

2.2 HTTP POST 方法
HTTP POST 方法是一个非幂等方法,因为调用多次,都将产生新的资源。

POST /tickets # 新建一个ticket

因为它会对资源本身产生影响,每次调用都会有新的资源产生,因此不满足幂等性。

2.3 HTTP PUT 方法

PUT /tickets/12 # 更新ticket 12

因为它直接把实体部分的数据替换到服务器的资源,我们多次调用它,只会产生一次影响,但是有相同结果的 HTTP 方法,所以满足幂等性。

2.4 HTTP PATCH 方法

PATCH /tickets/12 # 更新ticket 12

此时,服务端对方法的处理是,当调用一次方法,更新部分字段,将这条 ticket 记录的操作记录加一。也就意味着如果多次提交就会造成操作记录一直变化,所以每次调用的资源都在改变,所以它是有可能是非幂等的操作。

2.5 HTTP DELETE 方法

DELETE /tickets/12 # 删除ticekt 12

HTTP DELETE 方法用于删除资源,会将资源删除。
调用一次和多次对资源产生影响是相同的,所以也满足幂等性。什么意思呢,无论调用多少次该接口,最终产生的结果都是将12这个ticket结果删除掉,完全符合幂等性的定义即操作一次或多次最终产生的结果相同。

3.去重解决方案
3.1 简单的去重解决方案
例如:假设业务的消息消费逻辑是:插入某张订单表的数据,然后更新库存:

insert into t_order values .....
update t_inv set count = count-1 where good_id = 'good123';

要实现消息的幂等,可以尝试使用以下方案:

select * from t_order where order_no = 'order123'
if(order != null) {
return ;//消息重复,直接返回
}

这样看起来是不是完全没问题?确实没问题,但是却忽略了并发场景

3.2 并发重复消息
假设这个消费的所有代码加起来需要1秒,有重复的消息在这1秒内(假设100毫秒)内到达(例如生产者快速重发,Broker重启等),那么很可能,上面去重代码里面会发现,数据依然是空的(因为上一条消息还没消费完,还没成功更新订单状态)

那么就会穿透掉检查的挡板,最后导致重复的消息消费逻辑进入到非幂等安全的业务代码中,从而引发重复消费的问题(如主键冲突抛出异常、库存被重复扣减而没释放等)
解决上面的并发导致的问题可以采取以下几个方案:

1.通过select … for update进行锁定

select * from t_order where order_no = 'THIS_ORDER_NO' for update  //开启事务
if(order.status != null) {
    return ;//消息重复,直接返回
}

但这样消费的逻辑会因为引入了事务包裹而导致整个消息消费可能变长,并发度下降。

当然还有其他更高级的解决方案,例如更新订单状态采取乐观锁,更新失败则消息重新消费之类的。
但无论是select for update, 还是乐观锁这种解决方案,实际上都是基于业务表本身做去重,这无疑增加了业务开发的复杂度, 一个业务系统里面很大部分的请求处理都是依赖MQ的,如果每个消费逻辑本身都需要基于业务本身而做去重/幂等的开发的话,这是繁琐的工作量。

3.3 Exactly Once

在消息中间件里,有一个投递语义的概念,而这个语义里有一个叫”Exactly Once”,即消息肯定会被成功消费,并且只会被消费一次。
我们看看阿里云里对Exactly Once的解释:

  Exactly-Once 是指发送到消息系统的消息只能被消费端处理且仅处理一次,即使生产端重试消息发送导致某消息重复投递,该消息在消费端也只被消费一次。

在我们业务消息幂等处理的领域内,可以认为业务消息的代码肯定会被执行,并且只被执行一次,那么可以认为是Exactly Once。

但这在分布式的场景下想找一个通用的方案几乎是不可能的。不过如果是针对基于数据库事务的消费逻辑,实际上是可行的

3.4 基于关系数据库事务插入消息表

以一个具体的场景进行分析:
假设我们业务的消息消费逻辑是:更新MySQL数据库的某张订单表的状态:

update t_order set status = 'SUCCESS' where order_no= 'order123';

要实现Exaclty Once即这个消息只被消费一次(并且肯定要保证能消费一次),我们可以这样做:
在这个数据库中增加一个消息消费记录表,把消息插入到这个表,并且把原来的订单更新和这个插入的动作放到同一个事务中一起提交,就能保证消息只会被消费一遍了。
具体流程如下:

1)开启事务
2)插入消息表(处理好主键冲突的问题)
3)更新订单表(原消费逻辑)
4)提交事务

其本质就是通过消息表,一旦消息消费成功,就会在消息表产生一条记录,该记录的主键就是消息id,即使RocketMQ还没有收到消费位点的更新再次投递,也会因为消息表的主键重复,导致事务无法提交。这保证我们消费代码只会执行一次

其次如果事务提交之前服务挂了(例如重启),对于本地事务并没有执行所以订单没有更新,消息表也没插入成功;
而对于RocketMQ服务端来说,消费位点也没更新,所以消息还会继续投递下来,投递下来发现这个消息插入消息表也是成功的,所以可以继续消费。这保证了消息不丢失。

刚刚我们提到的Exactly Once就是类似这个方案基于数据库的事务特性实现的。
基于这种方式,的确这是有能力拓展到不同的应用场景,因为他的实现方案与具体业务本身无关——而是依赖一个消息表。
但它有一个很明显的局限性

1)、消息的消费逻辑必须是依赖于关系型数据库事务。如果消费的消费过程中还涉及其他数据的修改,例如Redis这种不支持事务特性的数据源,则这些数据是不可回滚的。
2)、数据库的数据必须是在一个库,跨库无法解决。
注:我们刚刚的设计使用的就是messageId,但是业务上,消息表的设计不应该以消息ID作为标识,而应该以业务的业务主键作为标识更为合理,以应对生产者的重发。阿里云上的消息去重只是RocketMQ的messageId,在生产者因为某些原因手动重发(例如上游针对一个交易重复请求了)的场景下起不到去重/幂等的效果(因消息id不同)。

下面看看更复杂的业务场景

3.4.1 更复杂的业务场景
这种方式Exactly Once语义的实现,实际上有很多局限性,这种局限性使得这个方案基本不具备广泛应用的价值。并且由于基于事务,可能导致锁表时间过长等性能问题。

例如以一个比较常见的一个订单申请的消息来举例,可能有以下几步(以下统称为步骤X):

1)检查库存(RPC)
2)锁库存(RPC)
3)开启事务,插入订单表(MySQL)
4)调用某些其他下游服务(RPC)
5)更新订单状态
6)commit 事务(MySQL)

这种情况下,如果采取消息表+本地事务的实现方式,消息消费过程中很多子过程是不支持回滚的,也就是说就算加了事务,实际上这背后的操作并不是原子性的。

有可能第一条小在经历了第二步锁库存的时候,服务重启了,这时候实际上库存是已经在另外的服务里被锁定了,这并不能被回滚。当然消息还会再次投递下来,要保证消息能至少消费一遍,换句话说,锁库存的这个RPC接口本身依旧要支持“幂等”。

再者,如果在这个比较耗时的长链条场景下加入事务的包裹,将大大的降低系统的并发。所以通常情况下,我们处理这种场景的消息去重的方法还是会使用一开始说的业务自己实现去重逻辑的方式,如前面加select for update,或者使用乐观锁。

那有没有方法抽取出一个公共的解决方案,能兼顾去重、通用、高性能呢?

3.4.2 拆解消息执行过程
大致思路如下:

1、库存系统消费A:检查库存并做锁库存,发送消息B给订单服务

2、订单系统消费消息B:插入订单表(MySQL),发送消息C给自己(下游系统)消费

3、下游系统消费消息C:处理部分逻辑,发送消息D给订单系统

4、订单系统消费消息D:更新订单状态

这个过程中需要确保一件事就是保证本地事务和消息是一个事务的(至少是最终一致性的)。
可以看到这样的处理方法会使得每一步的操作都比较原子,而原子则意味着是小事务,小事务则意味着使用消息表+事务的方案显得可行。
然而这个过程拆解的让一个很简单的逻辑变得相对复杂起来,而且让一个本来就连续的代码逻辑割裂成多个系统多次消息交互。

3.5 更通用的方案
上面研究的消息表+本地事务的方案之所以有其局限性和并发的短板,究其根本是因为它依赖于关系型数据库的事务,且必须要把事务包裹于整个消息消费的环节。
我们能不依赖事务而实现消息的去重,那么方案就能推广到更复杂的场景例如:RPC、跨库等。

例如,我们依旧使用消息表,但是不依赖事务,而是针对消息表增加消费状态,是否可以解决问题呢?
基于消息幂等表的非事务方案
大致流程如下图所示:

可以看到,此方案是无事务的,而是针对消息表本身做了状态的区分:

  • 消费中
  • 消费完成。

    只有消费完成的消息才会被幂等处理掉。
    而对于已有消费中的消息,后面重复的消息会触发延迟消费(在RocketMQ的场景下即发送到RETRY TOPIC),之所以触发延迟消费是为了控制并发场景下,第二条消息在第一条消息没完成的过程中,去控制消息不丢(如果直接幂等,那么会丢失消息(同一个消息id的话),因为上一条消息如果没有消费完成的时候,第二条消息你已经告诉broker成功了,那么第一条消息这时候失败broker也不会重新投递了)
    通过这个方案看看有没有解决我们第一章中描述的场景所出现的问题。

    场景1: 一个消息M发送到了消息中间件,消息投递到了消费程序A,A接受到了消息,然后进行消费,但在消费到一半的时候程序重启了,这时候这个消息并没有标记为消费成功,这个消息还会继续投递给这个消费者,直到其消费成功了,消息中间件才会停止投递。
    这种情景就会出现消息可能被多次地投递。

    1、 消息已经消费成功了,第二条消息将被直接幂等处理掉(消费成功)

    2、 并发场景下的消息,依旧能满足不会出现消息重复,即穿透幂等挡板的问题。

    3、 支持上游业务生产者重发的业务重复的消息幂等问题。

    很明显该场景所带来的问题已经解决
    再看看下一个场景

    程序A接受到这个消息M并完成消费逻辑之后,正想通知消息中间件“我已经消费成功了”的时候,程序就重启了,那么对于消息中间件来说,这个消息并没有成功消费过,所以他还会继续投递。这时候对于应用程序A来说,看起来就是这个消息明明消费成功了,但是消息中间件还在重复投递。

    关于第二个问题是如何解决的?主要是依靠插入消息表的这个动作做控制的,假设我们用MySQL作为消息表的存储媒介(设置消息的唯一ID为主键),那么插入的动作只有一条消息会成功,后面的消息插入会由于主键冲突而失败,走向延迟消费的分支,然后后面延迟消费的时候就会变成上面第一个场景的问题。

    同时要设计去重的消息键让其支持业务的主键(例如订单号、请求流水号等),而不仅仅是messageId即可。所以也不是问题。
    这种方案就万无一失啦?

    在并发场景下我们依赖于消息状态是做并发控制使得第2条消息重复的消息会不断延迟消费(重试)。但如果这时候第1条消息也由于一些异常原因(例如机器重启了、外部异常导致消费失败)没有成功消费成功呢?也就是说这时候延迟消费实际上每次下来看到的都是消费中的状态,最后消费就会被视为消费失败而被投递到死信Topic中(RocketMQ默认可以重复消费16次)
    对于此,解决方法是,插入的消息表必须要带一个最长消费过期时间,例如10分钟,意思是如果一个消息处于消费中超过10分钟,就需要从消息表中删除(需要程序自行实现)。所以最后这个消息的流程会是这样的:

    这个方案实际上没有事务的,只需要一个存储的中心媒介,那可以选择更灵活的存储媒介,例如Redis。使用Redis有两个好处:

    1、性能上损耗更低

    2、上面讲到的超时时间可以直接利用Redis本身的ttl实现

    当然Redis存储的数据可靠性、一致性等方面是不如MySQL的,做好取舍即可。

    源码:RocketMQDedupListener
    以上方案针对RocketMQ的Java实现已经开源放到Github中,具体的使用文档可以参考https://github.com/Jaskey/RocketMQDedupListener

    以下仅贴一个Readme中利用Redis去重的使用样例,如果使用此工具加入消息去重幂等的是多么简单:

    //利用Redis做幂等表
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("TEST-APP1");
    consumer.subscribe("TEST-TOPIC", "*");
    String appName = consumer.getConsumerGroup();// 大部分情况下可直接使用consumer group名
    StringRedisTemplate stringRedisTemplate = null;// 这里省略获取StringRedisTemplate的过程
    DedupConfig dedupConfig = DedupConfig.enableDedupConsumeConfig(appName, stringRedisTemplate);
    DedupConcurrentListener messageListener = new SampleListener(dedupConfig);
    consumer.registerMessageListener(messageListener);
    consumer.start();

    以上代码大部分是原始RocketMQ的必须代码,唯一需要修改的仅仅是创建一个DedupConcurrentListener示例,在这个示例中指明你的消费逻辑和去重的业务键(默认是messageId)。

    更多使用详情请参考Github上的说明。

    这种实现是否一劳永逸?
    实现到这里,似乎方案挺完美的,所有的消息都能快速的接入去重,且与具体业务实现也完全解耦。那么这样是否就完美的完成去重的所有任务呢?

    很可惜,其实不是的。原因很简单:因为要保证消息至少被成功消费一遍,那么消息就有机会消费到一半的时候失败触发消息重试的可能。还是以上面的订单流程X:

    1)检查库存(RPC)
    2)锁库存(RPC)
    3)开启事务,插入订单表(MySQL)
    4)调用某些其他下游服务(RPC)
    5)更新订单状态
    6)commit 事务(MySQL)

    当消息消费到步骤3的时候,我们假设MySQL异常导致失败了,触发消息重试。因为在重试前我们会删除幂等表的记录,所以消息重试的时候就会重新进入消费代码,那么步骤1和步骤2就会重新再执行一遍。如果步骤2本身不是幂等的,那么这个业务消息消费依旧没有做好完整的幂等处理。

    那么既然这个并不能完整的完成消息幂等,还有什么价值呢?价值可就大了!虽然这不是解决消息幂等的银弹(事实上,软件工程领域里基本没有银弹),但是能以便捷的手段解决:

    1.各种由于Broker、负载均衡等原因导致的消息重投递的重复问题

    2.各种上游生产者导致的业务级别消息重复问题

    3.重复消息并发消费的控制窗口问题,就算重复,重复也不可能同一时间进入消费逻辑

    一些其他的消息去重的建议
    也就是说,使用这个方法能保证正常的消费逻辑场景下(无异常,无异常退出),消息的幂等工作全部都能解决,无论是业务重复,还是rocketmq特性带来的重复。

    事实上,这已经能解决99%的消息重复问题了,毕竟异常的场景肯定是少数的。那么如果希望异常场景下也能处理好幂等的问题,可以做以下工作降低问题率:

    1)、消息消费失败做好回滚处理。如果消息消费失败本身是带回滚机制的,那么消息重试自然就没有副作用了。

    2)、消费者做好优雅退出处理。这是为了尽可能避免消息消费到一半程序退出导致的消息重试。

    3)、一些无法做到幂等的操作,至少要做到终止消费并告警。例如锁库存的操作,如果统一的业务流水锁成功了一次库存,再触发锁库存,如果做不到幂等的处理,至少要做到消息消费触发异常(例如主键冲突导致消费异常等)

    4)、在做好3)的前提下,做好消息的消费监控,发现消息重试不断失败的时候,手动做好1)的回滚,使得下次重试消费成功。

posted @ 2023-07-31 13:58  李若盛开  阅读(704)  评论(0编辑  收藏  举报