避免消息重复消费

简单的消息去重解决方案

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

无论是select for update, 还是乐观锁这种解决方案

Exactly Once

投递语义

即消息肯定会被成功消费,并且只会被消费一次。

  1. 在这个数据库中增加一个消息消费记录表,把消息插入到这个表,

  2. 并且把原来的订单更新和这个插入的动作放到同一个事务中一起提交,

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

阿里云的 RocketMQ 的 EXACTLY-ONCE 语义的实现上,就是类似这个方案

但是这里有它的局限性:消息的消费逻辑必须是依赖于关系型数据库事务。

  • 如果消费的消费过程中还涉及其他数据的修改,例如 Redis 这种不支持事务特性的数据源,则这些数据是不可回滚的。

  • 还有,数据库的数据必须是在一个库,跨库无法解决。

  • 由于基于事务,可能导致锁表时间过长等性能问题。

更复杂的业务场景

消息消费过程中很多子过程是不支持回滚的,

也就是说就算我们加了事务,实际上这背后的操作并不是原子性的。

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

第一条消息在经历了第二步锁库存的时候,服务重启了,这时候实际上库存是已经在另外的服务里被锁定了,这并不能被回滚。

当然消息还会再次投递下来,要保证消息能至少消费一遍,换句话说,锁库存的这个RPC接口本身依旧要支持“幂等”。

所以通常情况下,前面加 select for update,或者使用乐观锁。

更通用的解决方案

它依赖于关系型数据库的事务,且必须要把事务包裹于整个消息消费的环节。

依旧使用消息表,但是不依赖事务,而是针对消息表增加消费状态

基于消息幂等表的非事务方案

img

关键在于数据的状态,消息表本身做了状态的区分:消费中、消费完成。

  • 只有消费完成的消息才会被幂等处理掉。

  • 已有消费中的消息,后面重复的消息会触发延迟消费,比如在 RocketMQ 的场景下就是发送到 RETRY TOPIC。

  • 之所以触发延迟消费,是为了控制并发场景下,第二条消息在第一条消息没完成的过程中,去延迟消费,而不是去直接幂等,从而去控制消息不丢。

    • 如果直接幂等了,那么同一个消息 id 或者业务唯一标识,会丢失消息,
    • 因为上一条消息如果没有消费完成的时候,第二条消息你已经告诉 broker 成功了,那么第一条消息这时候失败 broker 也不会重新投递了。

这里我们回头看看我们一开始想解决的问题是否解决了:

  • 问题一:消息已经消费成功了,第二条消息将被直接幂等处理掉(消费成功)。
  • 问题二:并发场景下的消息,依旧能满足不会出现消息重复,即穿透幂等挡板的问题。
  • 问题三:支持上游业务生产者重发的业务重复的消息幂等问题。

在并发场景下我们依赖于消息状态是做并发控制使得第 2 条消息重复的消息会不断延迟消费,即重试。

但如果这时候第 1 条消息也由于一些异常原因,例如机器重启了、外部异常导致消费失败,没有消费成功呢?

也就是说这时候延迟消费实际上每次过来看到的都是消费中的状态,最后消费就会被视为消费失败而被投递到死信 Topic 中,比如 RocketMQ 默认可以重复消费 16 次。

对于此,我们解决的方法是,插入的消息表必须要带一个最长消费过期时间,例如 10 分钟。

  • 意思是如果一个消息处于消费中超过 10 分钟,就需要从消息表中删除,这一点需要程序自行实现。

img

我们这个方案实际上没有事务的,只需要一个存储的中心媒介,那么自然我们可以选择更灵活的存储媒介,例如Redis。

使用Redis有两个好处:

  • 1.性能上损耗更低
  • 2.上面我们讲到的超时时间可以直接利用Redis本身的ttl实现

当然Redis存储的数据可靠性、一致性等方面是不如MySQL的,需要用户自己取舍。

show me code

以上方案针对 RocketMQ 的 Java 实现已经开源放到 Github 中,具体的使用文档可以参考

https://github.com/Jaskey/RocketMQDedupListener ,

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

消息去重幂等的是多么简单:
//利用Redis做幂等表
//创建一个 默认的Mq push提供者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("TEST-APP1");
//订阅这个 topic
consumer.subscribe("TEST-TOPIC", "*");

//获取 group
String appName = consumer.getConsumerGroup();// 大部分情况下可直接使用consumer group名

//定义 redis工具类
StringRedisTemplate stringRedisTemplate = null;// 这里省略获取StringRedisTemplate的过程
//开启 dedupConfig
DedupConfig dedupConfig = DedupConfig.enableDedupConsumeConfig(appName, stringRedisTemplate);
//创建 简单监听
DedupConcurrentListener messageListener = new SampleListener(dedupConfig);
//注册
consumer.registerMessageListener(messageListener);
//开启
consumer.start();

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

这种实现是否一劳永逸?

这样是否就完美的完成去重的所有任务呢?很可惜,其实不是的。

因为要保证消息至少被成功消费一遍,那么消息就有机会消费到一半的时候失败触发消息重试的可能。

  • 步骤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.一些无法做到幂等的操作,至少要做到终止消费并告警。
    • 例如锁库存的操作,如果统一的业务流水锁成功了一次库存,再触发锁库存,
    • 如果做不到幂等的处理,至少要做到消息消费触发异常(例如主键冲突导致消费异常等)

在 #3 做好的前提下,做好消息的消费监控,

  • 发现消息重试不断失败的时候,手动做好 #1 的回滚,使得下次重试消费成功。

原文链接:https://jaskey.github.io/blog/2020/06/08/rocketmq-message-dedup/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值