当我们在使用RocketMQ发送消息时,为了实现百分百消息可靠投递,那么重复消息就不可避免。
发送消息(同步方式)一般经过三个步骤:
首先:将消息从生产端发送到broker,生产端继续等待这条消息的处理结果(broker是否能够正常接收);
然后:broker做一些处理(比如同步到从节点以及持久化等);
最后:将应答返还给客户端,客户端收到该消息的应答后,做下一步处理。
我们考虑一下这样的情况:
a. 客户端将一条消息发送(同步方式)到broker,broker成功接收,当broker将成功接收的结果返回给客户端时,这个确认消息在网络传输的过程中,由于某些原因丢失了;
b. 那么对于客户端来说,为了实现高可用机制,在超时后默认有2次重试机会,将这条消息会再次发送给broker;
c. 当broker处理完这条消息后,将成功接收的结果返回给客户端,这次客户端成功的接收到了来自broker发来的确认消息,然后进行下一步的处理;
此时,broker就有两条重复的消息。
RocketMQ为了简化功能上的设计,允许重复消息的存在,将消息去重的任务交给我们自己去处理。在我公司,我的做法如下:
a. 在消息处理之前,先把这条消息的订单号放入redis中(有过期时间),即代表这条消息正在处理中,不允许在同一时间处理两条相同的消息;
b. 根据订单号,在消息消费记录表中查询是否存在(兜底方案,当redis失效时);
b1. 若存在且处理状态为已成功处理,则返回处理成功并把redis中订单号删除;
b2. 若存在但处理状态为处理失败,则消费这条消息;
b2.1 若这条消息消费成功:
b2.1.1 在消息消费记录表中的状态置为已成功处理;
b2.1.2 将redis中的订单号删除;
b2.1.3 返回处理成功;
b2.2 若这条消息处理失败:
b2.2.1 在消息消费记录表中的状态置为处理失败;
b2.2.2 将redis中的订单号删除;
b2.2.3 返回稍后重试;
b3. 若不存在,则:
b3.1 将这条消息插入消费记录表中,处理状态置为处理中;
b3.2 消费这条消息,若消费成功,则走 b2.1,若消费失败,则走 b2.2
伪代码如下:
public class PkgConcurrentlyListener implements MessageListenerConcurrently {
protected static Logger logger = LoggerFactory.getLogger(PkgConcurrentlyListener.class);
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
MessageExt me = list.get(0);
String topic = me.getTopic();
String tags = me.getTags();
String keys = me.getKeys();
boolean result = getLock30Second(keys,keys);
if(!result){
//正在处理中,请稍后再试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
try {
String msgBody = new String(me.getBody(), RemotingHelper.DEFAULT_CHARSET);
logger.info("消费者消费消息 topic = 【" + topic + "】keys = 【" + keys + "】,msgBody = 【" + msgBody + "】");
//根据keys在消费记录表中查询数据
//业务的处理逻辑
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (Exception e) {
e.printStackTrace();
return ConsumeConcurrentlyStatus.RECONSUME_LATER;//消息处理失败,过会再试
} finally {
//无论消费成功与否,都删除
RedisPoolUtil.del(keys);
}
}
/**
* 锁住某个key值30S,需要解锁时删除即可
* 注意:不要使用这种方式,因为这种做法不能防死锁,使用这篇文章
* https://blog.csdn.net/zhaoming19870124/article/details/91041855介绍的优化版本
*/
public static boolean getLock30Second(final String key, final String value) {
Jedis jedis = RedisPoolConfig.getJedis();
try {
Long flag = jedis.setnx(key, value);
System.out.println("将key = 【" + key + "】,放入redis中的结果 = 【" + flag + "】");
if (1 == flag) {
//如果在redis中不存在,则设置有效期,防止永久存在;
//当这个keys永久存在时,若一条消息在第一次消费时,消费失败了,则永远不会被消费了。
// 因为接下来的每次消费,都被redis拦下来了
flag = jedis.expire(key, 30);
}
return flag == 1L ? true : false;
} catch (Exception e) {
System.out.println(e);
return false;
}
}
}