友情提示:全文6000多文字,预计阅读时间18分钟
一
背景
一条消息从生产者发出后,消息是否发送成功,被保存在哪个broker节点,是否被消费者正常消费都是用户在使用消息队列中间件时所需重点考虑和关注的点。如果没有一种方便和快捷的手段,用户基本上很难在一个大规模消息队列集群中搜索一条消息的当前处理状态。因此,消息队列需要提供一种机制来记录并保存集群环境中所有消息的当前状态(是否被生产者发送成功、是否被消费者正常消费、被保存的具体位置)。
消息轨迹指的是一条消息从生产方发出到消费方消费处理,整个过程中的各个相关节点的时间地点等数据汇聚而成的完整链路信息。在E-RocketMQ中,一条消息的完整链路包含生产方,服务方和消费方三个角色,每个角色在处理消息的过程中都会在轨迹链路中增加相关的信息,将这些信息汇聚即可获取任意消息当前的状态,从而为生产环境中的问题排查提供强有力的信息支持。
二
设计
消息轨迹,顾名思义就是跟踪消息发送、消息消费的轨迹,即详细记录消息在各个处理环节上的日志,从设计上需要解决如下三个核心问题:
轨迹数据的存储格式
如何记录消息轨迹
消息轨迹存储在哪
2.1 消息轨迹格式
消息轨迹数根据生产者、消费者和消息本身属性需要包含以下几部分信息,如下表所示。
Producer端 | Consumer端 | Broker端 |
生产实例信息 | 消费实例信息 | 消息的topic |
发送消息时间 | 投递时间,投递轮次 | 消息存储位置 |
消息是否发送成功 | 消息是否消费成功 | 消息的Key值 |
发送耗时 | 消费耗时 | 消息的Tag值 |
在消息发布/订阅期间,需要定义消息轨迹上下文(TuxeTraceContext)来保存所有的轨迹信息,包括Pub(消息发送)、SubBefore(消息拉取到客户端,执行业务定义的消费逻辑之前)、SubAfter(消费后)三种轨迹数据的类型、topic、发布者/订阅者、发布/订阅状态、耗时以及消息的tag、key等自身属性信息。
2.2 记录消息轨迹
消息发送和消费,其核心载体就是消息,消息轨迹(消息的流转)主要记录消息是何时发送到哪个broker节点,发送耗时多长时间,被哪个消费者消费,消费多少次等信息。记录消息的轨迹操作主要是集中在消息发送前后和消息消费前后,因此可以利用Hook机制在生产者/消费者的客户端通过增加消息拦截器来执行消息轨迹数据的收集与汇聚。
如上图所示,生产者/消费者钩子函数的实现类将收集到的发布/订阅消息的完整链路信息汇聚而成的消息轨迹数据按照特定格式封装成数据包后,放入阻塞队列(traceContextQueue)中。同时,在客户端启动异步线程(AsyncArrayDispatcher)作为数据包的消费者从阻塞队列中获取放入的消息轨迹数据包,并将其集合元素封装成异步请求任务提交至线程池进行异步执行。异步任务(AsyncAppenderRequest)遍历收集的消息轨迹数据并对其编码,最终调用客户端内部默认的生产者实例(traceProducer)向专用于存储消息轨迹的主题(topic)上发布编码后的轨迹数据包。
2.3 存储消息轨迹
通过在消息队列客户端增加发布/订阅消息的拦截器,收集消息轨迹相关信息后,将消息轨迹以普通消息的格式,使用异步线程的方式存储在broker服务器。那在broker端如何存储呢?E-RocketMQ提供了两种模式来适应用户消息队列集群中不同消息吞吐量的业务场景需求。
(1)普通模式
在普通模式下,集群中每个broker节点启动时都会创建一个系统topic(RMQ_SYS_TRACE_TOPIC),专门用来保存消息发布/订阅时的消息轨迹数据。如上图所示,生产者或消费者进程在进行发布或订阅消息时,客户端的拦截器会收集并汇聚消息轨迹数据,然后如同普通消息投递一样在经过客户端负载均衡算法计算出本次发送的broker节点后,执行异步线程发送。
(2)隔离模式
在隔离模式下,在消息队列broker集群中选择其中一个broker节点专用于存储消息轨迹数据来实现与普通消息之间的物理隔离。具体的做法是,通过配置参数的方式,在broker节点启动时候就设置该节点是否为隔离模式下的特殊节点。同时,在隔离模式下,也只有在这个专用于存储轨迹数据的特殊节点上才会默认创建保存轨迹数据的系统topic(RMQ_SYS_TRACE_TOPIC)。这样,客户端的异步线程将封装后的消息轨迹数据包只会发送至该节点,从而实现与普通消息的物理隔离。
三
实现
说完了E-RocketMQ消息轨迹的整体设计,大家应该对消息轨迹部分有了一个初步认识了,那接下来就从核心源码上来分析下消息发送时消息轨迹的实现流程。
3.1 Broker配置
通常为了避免消息轨迹的消息与正常的业务消息混合在一起,broker端存储消息轨迹使用隔离模式。在某个broker节点的broker.properties配置文件中配置traceTopicEnable=true,说明该broker节点专用于存储消息轨迹,则该broker启动时会初始化TopicConfigManager创建默认消息轨迹的topic(RMQ_SYS_TRACE_TOPIC)路由信息。
if(this.brokerController.getBrokerConfig().isTraceTopicEnable()) {
String topic = MixAll.RMQ_SYS_TRACE_TOPIC;
TopicConfig topicConfig = new TopicConfig(topic);
this.systemTopicList.add(topic);
topicConfig.setReadQueueNums(1);
topicConfig.setWriteQueueNums(1);
this.topicConfigTable.put(topicConfig.getTopicName(), topicConfig);
}
3.2 客户端发送
用户在消息发送客户端启用消息轨迹开关MsgTraceSwitch用来指定创建DefaultMQProducer时是否开启消息轨迹跟踪。开启后会在创建DefaultMQProducer对象时初始化消息轨迹任务处理器TraceDispatcher并注册记录消息轨迹的RPCHook的钩子函数TuxeClientSendMessageHookImpl。
AsyncArrayDispatcher dispatcher = new AsyncArrayDispatcher();
dispatcher.setHostProducer(defaultMQProducer.getDefaultMQProducerImpl());
// 为消息轨迹注册hook,在消息发送前后执行
this.defaultMQProducer.getDefaultMQProducerImpl().registerSendMessageHook(new TuxeClientSendMessageHookImpl(dispatcher));
消息发送钩子函数,用于在消息发送之前、发送之后执行一定的业务逻辑,是记录消息轨迹的拦截器。TraceDispatcher是消息轨迹转发处理器,其默认实现类AsyncTraceDispatcher,将异步实现消息轨迹数据的发送,类图如下。
3.3 Hook实现
在消息发送钩子函数TuxeClientSendMessageHookImpl内部有sendMessageBefore和sendMessageAfter两个方法,分别在消息发送前和发送后调用。sendMessageBefore方法在消息发送的时候,会构建一条跟踪消息TraceBean,记录原消息的topic、tags、keys、发送到broker地址、消息体长度等消息,存储在发送上下文环境(MqTraceContext)中,此时并不会发送消息轨迹数据。在消息发送后,sendMessageAfter方法从MqTraceContext中获取跟踪的TraceBean,设置消息发送的costTime(耗时)、success(是否发送成功)、 msgId(消息ID,全局唯一)、offsetMsgId(消息物理偏移量)、storeTime(消息存储时间)等信息,然后将消息轨迹追踪上下文环境加入到阻塞队列traceContextQueue中,并通过TraceDispatcher转发到broker服务器。
@Override
public void sendMessageBefore(SendMessageContext context) {
// 1.构造TuxeTraceContext消息追踪上下文对象
TuxeTraceContext tuxeContext = new TuxeTraceContext();
// 设置消息轨迹数据的类型为Pub
tuxeContext.setTraceType(TuxeTraceType.Pub);
tuxeContext.setGroupName(context.getProducerGroup());
// 2.构造TuxeTraceBean消息追踪轨迹数据bean对象
TuxeTraceBean traceBean = new TuxeTraceBean();
// 3.设置消息tag、key、bodyLength等自身属性
traceBean.setTags(context.getMessage().getTags());
......
tuxeContext.getTraceBeans().add(traceBean);
@Override
public void sendMessageAfter(SendMessageContext context) {
// 1.获取TuxeTraceContext消息追踪上下文对象
TuxeTraceContext tuxeContext = (TuxeTraceContext) context.getMqTraceContext();
// 2.获取TuxeTraceBean消息追踪轨迹数据bean对象
TuxeTraceBean traceBean = tuxeContext.getTraceBeans().get(0);
int costTime = (int) ((System.currentTimeMillis() -
tuxeContext.getTimeStamp()) / tuxeContext.getTraceBeans().size());
// 3.设置消息发送后的costTime、offsetMsgId等信息
tuxeContext.setCostTime(costTime);
......
// 4.将消息轨迹追踪上下文元素加入到阻塞队列traceContextQueue中
localDispatcher.append(tuxeContext);
}
3.4 发送轨迹
首先,TraceDispatcher构建时会初始化存放消息轨迹上下文环境的阻塞队列traceContextQueue,并创建用于发送到broker节点的异步线程池和发送消息轨迹的Producer。然后,启动DefaultMQProducer时调用Producer的start方法开启一个线程AsyncRunnable,该线程会从traceContextQueue中取出一个待提交的TraceContext,并向线程池中提交一个异步任务AsyncAppenderRequest。最后,AsyncAppenderRequest遍历收集的消息轨迹数据,获取存储消息轨迹的topic、对TraceContext进行编码并将将编码后的字符串数据发送到broker节点,各阶段核心代码如下。
// 初始化存放消息轨迹上下文环境阻塞队列traceContextQueue
ArrayBlockingQueue<TuxeTraceContext> traceContextQueue = new ArrayBlockingQueue<>(1024);
// 创建异步线程池
ThreadPoolExecutor traceExecuter = new ThreadPoolExecutor(10, 20, 1000 * 60,
TimeUnit.MILLISECONDS,
this.appenderQueue, new ThreadFactoryImpl("MQTraceSendThread_"));
// 创建Producer
DefaultMQProducer traceProducer = factory.getTraceDispatcherProducer(properties);
// 开启线程AsynRunnable
Thread worker = new Thread(new AsyncRunnable(), "AsyncTraceDispatcherThread" + dispatcherId)
...
// 获取阻塞队列traceContextQueue中的消息轨迹追踪上下文元素
TuxeTraceContext context = traceContextQueue.poll(5, TimeUnit.MILLISECONDS);
// 提交任务AsyncAppenderRequest
AsyncAppenderRequest request = new AsyncAppenderRequest(contexts);
traceExecuter.submit(request);
// 对TraceContext进行编码
TraceTransferBean traceData = TraceDataEncoder.encoderFromContextBean(context);
// 发轨迹消息到broker
flush(traceData)
消息轨迹数据在编码时使用字符串进行拼接,字段的分隔符号为字符1,整个数据以字符2结尾。编码后的发送消息轨迹数据格式如下。
经过上面的源码实现分析,消息发送端的消息轨迹跟踪流程基本就清晰了,下面我们用一张序列图来归纳一下发送消息时消息轨迹追踪的整体流程。而消息消费就是在消息消费前后执行特定的钩子函数TuxeConsumeMessageTraceHookImpl,其实现与消息发送原理类似,这里就不再详细赘述了。
可能有些读者会有这样的担心,开启消息轨迹后,消息轨迹数据需要broker去额外地处理和存储,那是否对整个消息队列集群的性能会有影响,影响正常业务消息的收发呢?答案是不会,因为消息轨迹存储使用了隔离模式,采用了一个单独的broker进行轨迹消息的处理和存储,集群内的消息轨迹数据只会发送并存储到这一台broker服务器上,并不会增加集群内原先业务broker的负载压力。其次,在存储方面,消息轨迹保存的是每条消息的发送方和消费方以及自身的一些属性信息,编码后的消息体较小,保存到broker服务器占用磁盘空间也较小。最后,在客户端,会将多条消息轨迹数据合并成一条消息进行发送,而且如果消息TPS发送过大,异步转发线程处理不过来时,会主动丢弃部分消息轨迹数据以保证正常业务消息的收发。
四
应用
随着E-RocketMQ上线移动云,消息轨迹以界面化更直观的方式展现消息发送/消费过程中的完整链路信息提供给用户查询。本节简单介绍下E-RocketMQ在移动云上消息轨迹功能的使用方法,当消息收发不符合预期时,可以查询相关的消息轨迹,找到消息的实际收发状态,帮助排查问题。
E-RocketMQ上云后,用户可以使用开源客户端无缝对接E-RocketMQ进行消息的发送和消费。收发消息后,根据消息的属性在移动云控制台可以查询消息的整个轨迹记录。消息轨迹查询功能支持三种查询方式,分别是按MessageId查询(精确查询,速度快,精确匹配)、MessageKey查询(模糊查询,适用于没有记录MessageID 但是设置了MessageKey)和topic查询(范围查询,适用于没有 MessageID和MessageKey,消息量比较小的场景),如下图。
查询成功后,可以查看消息的完整链路图,如下图所示。如果有消息失败情况,可以根据轨迹中的信息,找到对应的客户端机器和时间,查看相关日志。
五
总结
文章首先阐述了E-RocketMQ实现消息轨迹功能的作用与意义,列举功能实现需要解决的三个核心问题,着重点介绍了记录消息轨迹方法的以及存储消息轨迹的两种模式;然后以发送消息流程为例,结合相关核心代码,详细讲述了消息发送过程中消息轨迹追踪的各个步骤;最后简要介绍了消息轨迹在移动云的应用。由于写作水平有限,对本文内容如有阐述不合理之处还望留言一起探讨。
——END——
往期精选
“1、干货分享 | 玩转K8s CRD -集成Helm实践
2、【干货分享】HTTP Basic在Spring Security应用原理浅析
3、【干货分享】容灾解决方案概论
”