在消息传递过程中,出现传递失败情况,发送方会重试,重试会导致产生重复消息。
因此使用消息队列的业务系统若没对重复消息处理,就可能会导致系统数据出错。
比如消费订单消息,统计下单金额的服务,就会出现重复统计,导致统计结果出错。
可能有人会问,如果消息队列本身能保证消息不重复,应用程序实现不就简单了。那有消息队列能保证这点么?
消息重复的情况必然存在
在MQTT协议中,给出了三种传递消息时能够提供的服务质量标准。质量从低到高,
At most once:最多一次,无消息可靠性,允许丢消息。一般对消息可靠性要求不高的监控场景使用。比如每分钟上报一次机房温度。
At least once:至少一次,不允许丢消息。但允许少量重复出现。
Exactly once:恰好一次,消息不允许丢,也不允许重复,最高等级。
对多有消息队列都是适用的,这个服务质量标准不仅适用于MQTT。现在常用绝大部分消息队列提供的服务质量都是At least once,包括 rabbitmq, rocketmq, kafka。也就是消息队列很难保证消息不重复。
注意,kafka的exactly once是它的事务中支持的一个特性。
既然按照标准大部分消息队列无法保证消息不重复,就需要消费代码能够接受消息是可能重复的这一个现状,然后通过一些方法来消除重复消息对业务的影响。
用幂等性解决重复消费问题
一般解决重复消息的办法是,在消费端的消费消息的操作具备幂等性。
Idempotence: 如果函数f(x) 满足 f(x) = f(f(x)), f(x)满足幂等性。
这个概念拓展到计算机领域,被用来描述一个操作、方法或者服务。
一个幂等的方法,使用同样的参数,对它进行多次调用和一次调用,对系统产生的影响是一样的。
比如设置某个账户余额=100元是幂等的,但是设置某个账户余额+100元不是。执行多次和一次对系统的影响是不一样的。
从对系统结果来说:At least once + 幂等消费 = Exactly once。
设计幂等操作的方法
1. 利用数据库的唯一约束实现幂等
刚刚提及的不具备幂等特性的转账的例子:将账户X的余额+100,改为限定每个账单每个账户只可以执行一次变更操作。
具体在分布式系统中上述限制方法较多:
方法一:建一张流水表,表有3个字段:转账单ID、账户ID、变更金额。uniq key(转账单ID、账户ID)
不仅关系DB,只要支持INSERT IF NOT EXIST 语义的存储系统都可以实现。比如Redis的SETNX命令。
2. 为更新的数据设置前置条件
以前述账户余额+100为例,改为当账户余额为500的时候+100,就保证了幂等性。
有时候更新字段并不是数字,则可以加一个版本号字段,同时对版本号进行判断和递增。
3. 记录并检查操作
通用性最强,适用范围最广的实现幂等性的方法。
在执行数据更新操作前,先检查一下是否执行过这个更新操作。
具体的实现方法是,在发送消息时,每条消息执行全局唯一ID,消费时,先根据这个ID检查这条消息,没被消费过,再执行数据更新,再讲消息状态置为已消费。
分布式系统中,要做到这点不简单。
1. 全局唯一ID的生成
2. 检查消费状态,更新数据,更新消费状态,三个操作的原子性;
第一个点,在分布式系统中,很难找到一个简单、高可用、高性能的实现方案。
第二个点, 在分布式系统中,可以用分布式事务或者分布式锁,但都不是容易的事。
小结
主要介绍通过幂等来解决消息重复消费的问题。
实现幂等的方案主要有,利用数据库的唯一性约束,为数据更新设置一次性的前置条件,记录并检查操作。
这些实现幂等的方法,不仅可以用于重复消息的问题,也适用于,
在其他场景中来解决重复请求或者重复调用的问题。
比如HTTP服务;
APP或前端重复提交表单;
也可以将微服务设计成幂等,解决RPC框架自动重试导致的重复调用问题;
思考
为什么大部分消息队列都只是 at least once,而不是excatly once的实现呢?