聊一聊如何保证RocketMQ使用中如何保证消费幂等性
之所以想聊一聊这个话题,是因为在刚开始使用rocketmq时,Consumer服务写的有问题的情况下,消息队列会重发,这是因为消费失败会导致消息被放入RETRY重试队列,根据用户配置的重试次数(默认16次)进行重试,这部分我们已经在之前的 RocketMQ存储机制与确认重传机制一文中讨论过,这个情况引起了我探究“什么情况下消息队里会进行重试,会不会导致重复消费?”这一问题的好奇心。
为什么会出现消息重复的问题?
对于 Producer
我们知道,RocketMQ提供了三种发送消息模式:
1、同步发送
Producer 向 broker 发送消息,阻塞当前线程等待 broker 响应 发送结果。
DefaultMQProducerImpl中设置如果超时没有响应或者发送失败,会重发。
2、异步发送
Producer 首先构建一个向 broker 发送消息的任务,把该任务提交给线程池,等执行完该任务时,回调用户自定义的回调函数,执行处理结果。
3、Oneway 发送
Oneway 方式只负责发送请求,不等待应答,Producer 只负责把请求发出去,而不处理响应结果。
上述前两种发送方式,如果成功发送到消息队列,但消息队列返回结果时出现网络问题,则会导致消息已经发送成功而生产者认为发送失败,重新发送,导致出现多条重复消息。
对于 Consumer
1、可能因为 Broker 的消息进度丢失,导致消息重复投递给 Consumer 。
2、Consumer 消费成功,但是因为网络问题/ JVM 异常崩溃,导致消息消息队列没能收到消费成功确认,以为消费失败,导致重复推送 。
注:对于大多数消息队列,考虑到性能,消费进度是异步定时同步给 Broker 。
如何解决
消费者实现幂等性,一般是在框架层统一封装或者业务层自己实现。
框架层统一封装
首先,需要有一个消息排重的唯一标识,该编号只能由 Producer 生成,例如说使用 uuid、或者其它唯一编号的算法 。
对于RocketMQ来说,Producer 在发送消息时,默认会生成消息编号( msgId ),可见org.apache.rocketmq.common.message.MessageClientExt 类。Broker 在存储消息时,会生成结合 offset 的消息编号( offsetMsgId ) 。Consumer 在消费消息失败后,将该消息发回 Broker 后,会产生新的 offsetMsgId 编号,但是 msgId 不变。
然后,就需要有一个排重的存储器,例如说:
使用关系数据库,增加一个排重表,使用消息编号作为唯一主键。
使用 KV 数据库,KEY 存储消息编号,VALUE 任一。此处,暂时不考虑 KV 数据库持久化的问题。
那么,我们要什么时候插入这条排重记录呢?
在消息消费执行业务逻辑之前,插入这条排重记录。但是,此时会有可能 JVM 异常崩溃。那么 JVM 重启后,这条消息就无法被消费了。因为,已经存在这条排重记录。
在消息消费执行业务逻辑之后,插入这条排重记录。
如果业务逻辑执行失败,显然,我们不能插入这条排重记录,因为我们后续要消费重试。
如果业务逻辑执行成功,此时,我们可以插入这条排重记录。但是,万一插入这条排重记录失败呢?那么,需要让插入记录和业务逻辑在同一个事务当中,此时,我们只能使用数据库。
业务层自己实现
方式很多,这个和 HTTP 请求实现幂等是一样的逻辑:
先查询数据库,判断数据是否已经被更新过。如果是,则直接返回消费完成,否则执行消费。
在并发量比较高的系统下,我们可以使用redis来进行幂等性判断。
正常情况下,出现重复消息的概率其实很小,如果由框架层统一封装来实现的话,肯定会对消息系统的吞吐量和高可用有影响,所以最好还是由业务层自己实现处理消息重复的问题。
我的做法
我的项目中主要是针对“任务”提交时进行幂等性判断,我的做法是在下派任务时把任务id放到redis中,然后再任务提交时从redis中删除,但如果消费事务执行失败,redis会重新把任务id添加回来作出补偿。从而完成幂等性判断。