去重 消息幂等通用解决方案,写得真好

消息中间件是分布式系统常用的组件,无论是异步化、解耦、削峰等都有广泛的应用价值 。我们通常会认为,消息中间件是一个可靠的组件——这里所谓的可靠是指,只要我把消息成功投递到了消息中间件,消息就不会丢失,即消息肯定会至少保证消息能被消费者成功消费一次,这是消息中间件最基本的特性之一,也就是我们常说的“AT LEAST ONCE”,即消息至少会被“成功消费一遍” 。
举个例子,一个消息M发送到了消息中间件,消息投递到了消费程序A,A接受到了消息,然后进行消费,但在消费到一半的时候程序重启了,这时候这个消息并没有标记为消费成功,这个消息还会继续投递给这个消费者,直到其消费成功了,消息中间件才会停止投递 。
然而这种可靠的特性导致,消息可能被多次地投递 。举个例子,还是刚刚这个例子,程序A接受到这个消息M并完成消费逻辑之后,正想通知消息中间件“我已经消费成功了”的时候,程序就重启了,那么对于消息中间件来说,这个消息并没有成功消费过,所以他还会继续投递 。这时候对于应用程序A来说,看起来就是这个消息明明消费成功了,但是消息中间件还在重复投递 。
这在RockectMQ的场景来看,就是同一个messageId的消息重复投递下来了 。
基于消息的投递可靠(消息不丢)是优先级更高的,所以消息不重的任务就会转移到应用程序自我实现,这也是为什么RocketMQ的文档里强调的,消费逻辑需要自我实现幂等 。背后的逻辑其实就是:不丢和不重是矛盾的(在分布式场景下),但消息重复是有解决方案的,而消息丢失是很麻烦的 。关注我是程序汪 回复面试宝典
简单的消息去重解决方案例如:假设我们业务的消息消费逻辑是:插入某张订单表的数据,然后更新库存:
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 ;//消息重复,直接返回 }这对于很多情况下,的确能起到不错的效果,但是在并发场景下,还是会有问题 。关注我是程序汪 回复面试宝典
并发重复消息假设这个消费的所有代码加起来需要1秒,有重复的消息在这1秒内(假设100毫秒)内到达(例如生产者快速重发,Broker重启等),那么很可能,上面去重代码里面会发现,数据依然是空的(因为上一条消息还没消费完,还没成功更新订单状态),
那么就会穿透掉检查的挡板,最后导致重复的消息消费逻辑进入到非幂等安全的业务代码中,从而引发重复消费的问题(如主键冲突抛出异常、库存被重复扣减而没释放等)
并发去重的解决方案之一要解决上面并发场景下的消息幂等问题,一个可取的方案是开启事务把select 改成 select for update语句,把记录进行锁定 。
select * from t_order where order_no = 'THIS_ORDER_NO' for update//开启事务 if(order.status != null) {return ;//消息重复,直接返回 }但这样消费的逻辑会因为引入了事务包裹而导致整个消息消费可能变长,并发度下降 。
当然还有其他更高级的解决方案,例如更新订单状态采取乐观锁,更新失败则消息重新消费之类的 。但这需要针对具体业务场景做更复杂和细致的代码开发、库表设计,不在本文讨论的范围 。
但无论是select for update,还是乐观锁这种解决方案,实际上都是基于业务表本身做去重,这无疑增加了业务开发的复杂度,一个业务系统里面很大部分的请求处理都是依赖MQ的,如果每个消费逻辑本身都需要基于业务本身而做去重/幂等的开发的话,这是繁琐的工作量 。本文希望探索出一个通用的消息幂等处理的方法,从而抽象出一定的工具类用以适用各个业务场景 。关注我是程序汪 回复面试宝典
Exactly Once在消息中间件里,有一个投递语义的概念,而这个语义里有一个叫”Exactly Once”,即消息肯定会被成功消费,并且只会被消费一次 。以下是阿里云里对Exactly Once的解释:

Exactly-Once 是指发送到消息系统的消息只能被消费端处理且仅处理一次,即使生产端重试消息发送导致某消息重复投递,该消息在消费端也只被消费一次 。
在我们业务消息幂等处理的领域内,可以认为业务消息的代码肯定会被执行,并且只被执行一次,那么我们可以认为是Exactly Once 。


推荐阅读