消息丢失如何排查?当我们在使用mq的时候,经常会遇到消息消费异常的问题,原因有很多种,比如:
- producer发送失败
- consumer消费异常
- consumer根本就没收到消息
「那么我们该如何排查了?」
其实借助RocketMQ-Dashboard就能高效的排查,里面有很多你想象不到的功能。
首先我们先查找期望消费的消息,查找的方式有很多种,根据消息id,时间等。
「消息没找到?」
说明proder发送异常,也有可能是消息过期了,因为rocketmq的消息默认保存72h,此时到producer端的日志进一步确认即可。
「消息找到了!」
接着看消息的消费状态,如下图消息的消费状态为NOT_ONLINE。
「NOT_ONLINE代表什么含义呢?」
别着急,我们一步步来分析,先看看TrackType到底有多少种状态。
public enum TrackType {
CONSUMED,
CONSUMED_BUT_FILTERED,
PULL,
NOT_CONSUME_YET,
NOT_ONLINE,
UNKNOWN
}
每种类型的解释如下:
类型 | 解释 |
CONSUMED | 消息已经被消费 |
CONSUMED_BUT_FILTERED | 消息已经投递但被过滤 |
PULL | 消息消费的方式是拉模式 |
NOT_CONSUME_YET | 目前没有被消费 |
NOT_ONLINE | CONSUMER不在线 |
UNKNOWN | 未知错误 |
「怎么判定消息已经被消费?」
上一节我们讲到,broker会用一个map来保存每个queue的消费进度,「如果queue的offset大于被查询消息的offset则消息被消费,否则没有被消费」(NOT_CONSUME_YET)。
我们在RocketMQ-Dashboard上其实就能看到每个队列broker端的offset(代理者位点)以及消息消费的offset(消费者位点),差值就是没有被消费的消息。
当消息都被消费时,差值为0,如下图所示:
「CONSUMED_BUT_FILTERED表示消息已经投递,但是已经被过滤掉了」。例如producer发的是topicA,tagA,但是consumer订阅的却是topicA,tagB。
「CONSUMED_BUT_FILTERED(消息已经被投递但被过滤)是怎么发生的呢?」
这个就不得不提到RocketMQ中的一个概念,「消息消费要满足订阅关系一致性,即一个consumerGroup中的所有消费者订阅的topic和tag必须保持一致,不然就会造成消息丢失」。
如下图场景,发送了4条消息,consumer1订阅了topica-taga,而consumer2订阅了topica-tab。consumer1消费q0中的数据,consumer2消费q1中的数据。
投递到q0的msg-1和msg-3只有msg-1能被正常消费,而msg-3则是CONSUMED_BUT_FILTERED。因为msg-3被投递到q0,但是consumer1不消费tagb的消息导致消息被过滤,造成消息丢失。
同理msg-2这条消息也会丢失。
「注意,还有一个非常重要的点」!
虽然消息消费失败了,但是消息的offset还会正常提交,即 「消息消费失败了,但是状态也会是CONSUMED」。
「RocketMQ认为消息消费失败需要重试的场景有哪些?」
- 返回ConsumeConcurrentlyStatus.RECONSUME_LATER
- 返回null
- 主动或被动抛出异常
「那么消费失败的消息去哪了呢?」
当消息消费失败,会被放到重试队列中,Topic名字为%RETRY% + consumerGroup。
「Consumer没订阅这个topic啊,怎么才能消费到重试消息?」
其实在Consumer启动的时候,框架内部帮你订阅了这个topic,所以重试消息能被消费到。
「另外消息不是一直重试,而是每隔1段时间进行重试」
第几次重试 | 与上次重试的间隔时间 | 第几次重试 | 与上次重试的间隔时间 |
1 | 10 秒 | 9 | 7 分钟 |
2 | 30 秒 | 10 | 8 分钟 |
3 | 1 分钟 | 11 | 9 分钟 |
4 | 2 分钟 | 12 | 10 分钟 |
5 | 3 分钟 | 13 | 20 分钟 |
6 | 4 分钟 | 14 | 30 分钟 |
7 | 5 分钟 | 15 | 1 小时 |
8 | 6 分钟 | 16 | 2 小时 |
当消息超过最大消费次数16次,会将消息投递到死信队列中,死信队列的topic名为%DLQ% + consumerGroup。
「因此当你发现消息状态为CONSUMED,但是消费失败时,去重试队列和死信队列中找就行了」。
消息消费异常排查实战
这个问题发生的背景是这样的,就是我们有2个系统,中间通过mq来保证数据的一致性,结果有一天数据不一致了,那肯定是consumer消费消息有问题,或者producer发送消息有问题。
先根据时间段找到了消息,确保了发送没有问题,接着看消息的状态为NOT_CONSUME_YET,说明consumer在线但是没有消息。
「NOT_CONSUME_YET表明消息没有被消费」,但是消息发送都过了好长时间了,consumer不应该没消费啊,查日志consumer确实没有消费。
用RocketMQ-Dashboard查看一下代理者位点和消费者位点,0队列正常消费,其他队列没有被消费。
「感觉这个负载均衡策略有点问题啊,怎么0队列这么多消息,别的队列都怎么没消息,问一波中间件的同学,是不是又改负载均衡策略了?」
确实改了!测试环境下,采用队列纬度区分多环境,0是基准环境,我们团队目前还没有用多环境,所以收发消息都会在队列0上,其他队列不会用到(「你可以简单认为测试环境发送和消费消息只会用到0队列」)。
「那么问题来了!」
首先消息的状态是NOT_CONSUME_YET,说明消息肯定被投递到0队列之外了,但是中间件的小伙伴却说消息不会被投递到0队列。
要想验证我的想法首先需要证明没有被消费的消息确实被投递到0队列之外的队列了。
中间走的弯路就不说了,直到我看了看RocketMQ-Dashboard的源码,「发现Dashboard其实返回了消息的很多信息,但是并没有在页面展示出来,直接看接口返回」。
乖乖,发现了新世界,消息的所有属性都在这了,看到queuId为14,果然验证了我的想法。
再看bornHost居然是我们办公室的网段。
「难道本地启动的负载均衡策略和测试环境的负载均衡策略不一样?」
本地debug一波代码,果然是本地的producer会往所有的队列发送消息,并且consumer也会消费所有队列的消息。
「至此找出问题了!」
producer在本地启了一个服务,注册到测试环境的zk,测试环境的部分请求打到本地,往0队列之外的队列发了消息,但是测试环境的consumer只会消费0队列中的消息,导致消息迟迟没有被消费。